514 lines
19 KiB
TypeScript
514 lines
19 KiB
TypeScript
/**
|
|
* navigation-store — the yazi-style navigation model, as a plain Solid store.
|
|
*
|
|
* This module is deliberately free of JSX and of any `.tsx` page imports so it
|
|
* can be exercised directly by unit tests (`bun test`) without the OpenTUI JSX
|
|
* runtime (which is supplied only by the build-time @opentui/solid bun-plugin).
|
|
* The Solid provider wrapper (`useNavigation` / `NavigationProvider`) and the
|
|
* simple-context plumbing live in `NavigationContext.tsx`; the app imports the
|
|
* provider from there, tests import `createNavigation` directly from here.
|
|
*
|
|
* ── Model ────────────────────────────────────────────────────────────────
|
|
* The app horizontally lays out three columns per tab:
|
|
*
|
|
* parent | current | preview
|
|
*
|
|
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in
|
|
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
|
* nav model — which column is focused and where its list cursor lives. The
|
|
* parent/preview columns are always derived, never focused.
|
|
*
|
|
* The tab list is the app's ROOT and participates in the same pane flow as
|
|
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:
|
|
*
|
|
* • At launch the tab list is the CURRENT pane, with nothing in UP (`atRootTab`).
|
|
* • Opening a tab (j/k to hover, `l`/Enter) slides it into the UP/parent pane;
|
|
* that tab's content becomes CURRENT and its hovered item PREVIEW
|
|
* (`enterTabContent`).
|
|
* • Drilling deeper (`l`/Enter in content) pushes frames; once past the tab's
|
|
* own root the UP/CURRENT/PREVIEW columns are all content, and the tab drops
|
|
* OUT of the 3-pane view.
|
|
* • `popDepth`/`h` walks back up: at content depth 0 `h` returns to the tab
|
|
* root (`backToTabRoot`, the tab becomes CURRENT again); `h` at the root
|
|
* stays (out of the panes — no-op).
|
|
*
|
|
* Depth-stack tabs (Feed, MyShows, Discover, Search, Player, Settings):
|
|
* ONE focusable content pane — the current column (DEPTH_CENTER_PANE = 1);
|
|
* the parent/preview are derived. Search drills query→results; Player is a
|
|
* single now-playing pane under the tab list (2-pane, no preview). Every
|
|
* tab returns to the root via `h` at depth 0 (`backToTabRoot`).
|
|
*
|
|
* Tabs switch via the tab list (j/k + l/Enter), digit keys `1`-`6`, and
|
|
* `[`/`]`, each re-syncing the tab cursor (`tabCursor`).
|
|
*/
|
|
import { createSignal, batch } from "solid-js";
|
|
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
|
|
|
export enum NavMode {
|
|
NORMAL = "NORMAL",
|
|
VISUAL = "VISUAL",
|
|
COMMAND = "COMMAND",
|
|
INPUT = "INPUT",
|
|
}
|
|
|
|
/** The current content pane of the active tab, i.e. the focusable column
|
|
* (index 1) for every depth-tab. Content panes occupy 1..n; the tab list is
|
|
* pane 0. A tab switch made while focused on content resets `activePane` to
|
|
* this pane (unless already on the tab list). */
|
|
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
|
|
|
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
|
* left of the active tab's content (1..n). It is the app's outermost pane:
|
|
* starting focus lives here, tab switches made from it keep focus on it, and
|
|
* swiping left past the first content pane returns to it. Swiping left again
|
|
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
|
|
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
|
|
|
|
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
|
|
|
|
// ── Depth stack ──────────────────────────────────────────────────────────────
|
|
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
|
|
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
|
|
* focused row index within that list. `ctx` optionally carries an id or
|
|
* payload the page needs to derive the list (e.g. a feed id). */
|
|
export type DepthFrame = {
|
|
kind: string;
|
|
ctx?: string;
|
|
focus: number;
|
|
};
|
|
|
|
// ── Selection store ───────────────────────────────────────────────────────────
|
|
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
|
|
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
|
|
// selection anchored at the focused index.
|
|
|
|
type SelectionMap = Record<string, Set<string>>;
|
|
|
|
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
|
|
|
|
/**
|
|
* Construct a fresh, self-contained navigation state graph.
|
|
*
|
|
* Exported (not just inlined into the Solid provider) so unit tests can build
|
|
* a nav graph inside a `createRoot` without rendering any provider tree.
|
|
*/
|
|
export function createNavigation() {
|
|
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
|
// The root tab panel's cursor — which tab j/k is currently hovering. It is
|
|
// independent of `activeTab` until l/Enter activates it (activateTabCursor)
|
|
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
|
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
|
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
|
// App focus starts on the tab list (the app root). `activePane` is always
|
|
// DEPTH_CENTER_PANE for the active depth-tab; the per-tab depth stack plus
|
|
// the `atRootTab` flag describe where focus sits (the tab is the CURRENT
|
|
// pane when at the root, and slides into the UP/parent pane once content is
|
|
// opened).
|
|
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
|
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
|
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
|
const [atRootTabSignal, setAtTabRoot] = createSignal(true);
|
|
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
|
|
const [count, setCount] = createSignal<number | null>(null);
|
|
const [inputFocused, setInputFocused] = createSignal(false);
|
|
|
|
// per-tab depth stack. Depth-tabs get a root frame on first visit.
|
|
const [stacks, setStacks] = createSignal<Partial<Record<TABS, DepthFrame[]>>>(
|
|
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
|
|
);
|
|
|
|
// per-pane focused index map (unused by depth-tabs, which read/write the
|
|
// top frame's focus for DEPTH_CENTER_PANE; kept for any future fixed-pane
|
|
// pages). Keyed by `${tab}:${pane}`.
|
|
const [paneIndices, setPaneIndices] = createSignal<Record<string, number>>(
|
|
{},
|
|
);
|
|
const [selections, setSelections] = createSignal<SelectionMap>({});
|
|
const [visualAnchor, setVisualAnchor] = createSignal<{
|
|
paneKey: string;
|
|
index: number;
|
|
} | null>(null);
|
|
|
|
const [commandBuffer, setCommandBuffer] = createSignal("");
|
|
const [commandError, setCommandError] = createSignal<string | null>(null);
|
|
|
|
/** Depth stack for a tab (always non-empty — every tab is a depth-tab). */
|
|
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
|
|
|
|
const ensureStack = (tab: TABS) => {
|
|
if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
|
|
setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
|
|
}
|
|
};
|
|
|
|
/** Apply all tab-switch side effects synchronously. Done here in the
|
|
* imperative `switchTab` path rather than a reactive `createEffect`
|
|
* because this module is exercised by unit tests without the OpenTUI
|
|
* JSX runtime, and in that environment Solid's `createEffect` is a
|
|
* no-op (server build). Routing every tab change through this helper
|
|
* keeps the behavior identical under both runtimes.
|
|
*
|
|
* - a depth-tab switch from the root keeps the root (the tab list stays
|
|
* CURRENT); switches made from inside content drop into the new tab's
|
|
* current/center pane.
|
|
* - clear mode/command/visual/count state */
|
|
const applyTabSwitch = (tab: TABS) => {
|
|
ensureStack(tab);
|
|
batch(() => {
|
|
if (atRootTabSignal()) {
|
|
// a depth-tab switch from the root keeps the root (focus stays on
|
|
// the tab list); only entering content (enterTabContent) leaves it.
|
|
} else {
|
|
setActivePane(DEPTH_CENTER_PANE);
|
|
}
|
|
setMode(NavMode.NORMAL);
|
|
setCount(null);
|
|
setCommandBuffer("");
|
|
setCommandError(null);
|
|
setVisualAnchor(null);
|
|
});
|
|
};
|
|
|
|
// ── depth stack accessors ──────────────────────────────────────────────
|
|
// Plain functions (not createMemo) so they recompute on every read.
|
|
// On the client build these are read inside reactive JSX contexts so
|
|
// their underlying signal reads are still tracked; on the server build
|
|
// (used by unit tests) createMemo is a no-op that freezes at creation,
|
|
// so a plain function is the only option that stays correct in tests.
|
|
const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
|
|
const currentDepth = (): number => Math.max(0, depthStack().length - 1);
|
|
const topFrame = (): DepthFrame | undefined =>
|
|
depthStack()[depthStack().length - 1];
|
|
const isDepthTab = () => DEPTH_TABS.has(activeTab());
|
|
|
|
/** Focus within a given depth's frame (default = current/top). */
|
|
const depthFocus = (d: number = currentDepth()) =>
|
|
depthStack()[d]?.focus ?? 0;
|
|
|
|
const setDepthFocus = (i: number, d: number = currentDepth()) =>
|
|
setStacks((s) => {
|
|
const st = s[activeTab()];
|
|
if (!st || d < 0 || d >= st.length) return s;
|
|
const next = st.slice();
|
|
next[d] = { ...next[d], focus: i };
|
|
return { ...s, [activeTab()]: next };
|
|
});
|
|
|
|
/** Push a child frame (drill in). */
|
|
const pushDepth = (frame: DepthFrame) =>
|
|
setStacks((s) => {
|
|
const st = s[activeTab()] ?? [];
|
|
return { ...s, [activeTab()]: [...st, frame] };
|
|
});
|
|
|
|
/** Pop the top frame (go back up a depth). No-op at root. Returns
|
|
* true if a frame was popped. */
|
|
const popDepth = (): boolean => {
|
|
let popped = false;
|
|
setStacks((s) => {
|
|
const st = s[activeTab()] ?? [];
|
|
if (st.length <= 1) return s;
|
|
popped = true;
|
|
return { ...s, [activeTab()]: st.slice(0, -1) };
|
|
});
|
|
return popped;
|
|
};
|
|
|
|
// ── tab switching ──────────────────────────────────────────────────────
|
|
/** Internal: set activeTab + run all side effects (root-frame seed,
|
|
* pane/mode/command reset). Called by gotoTab/nextTab/prevTab so every
|
|
* tab change — programmatic or key-driven — goes through one path. */
|
|
const switchTab = (tab: TABS) => {
|
|
setActiveTab(tab);
|
|
// a direct tab switch re-syncs the root panel's cursor so the panel
|
|
// reflects what is actually active.
|
|
setTabCursor(tab);
|
|
applyTabSwitch(tab);
|
|
};
|
|
const gotoTab = (tab: TABS) => {
|
|
if (tab < 1 || tab > TabsCount) return;
|
|
switchTab(tab);
|
|
};
|
|
const nextTab = () => {
|
|
const t = activeTab() >= TabsCount ? 1 : ((activeTab() + 1) as TABS);
|
|
switchTab(t);
|
|
};
|
|
const prevTab = () => {
|
|
const t = activeTab() <= 1 ? TabsCount : ((activeTab() - 1) as TABS);
|
|
switchTab(t);
|
|
};
|
|
|
|
// ── pane focus ──────────────────────────────────────────────────────────
|
|
const setPane = (pane: PaneId) => setActivePane(pane);
|
|
|
|
// (no fixed-pane swipe — every tab is a depth-tab; h/l drill/pop instead.)
|
|
|
|
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
|
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
|
* with nothing above it. Applies to every tab: a depth-tab switch from
|
|
* the root keeps it; entering content (`enterTabContent`) clears it; `h`
|
|
* at content depth 0 regains it via `backToTabRoot`. */
|
|
const atRootTab = (): boolean => atRootTabSignal();
|
|
|
|
/** Open the active tab's content: the tab slides from CURRENT into the
|
|
* UP/parent pane and focus lands on the content's current pane. */
|
|
const enterTabContent = () => {
|
|
setAtTabRoot(false);
|
|
setActivePane(DEPTH_CENTER_PANE);
|
|
};
|
|
|
|
/** Move focus back to the tab list root (UP -> CURRENT), e.g. `h` popping
|
|
* out of content at depth 0. */
|
|
const backToTabRoot = () => {
|
|
setAtTabRoot(true);
|
|
setActivePane(DEPTH_CENTER_PANE);
|
|
};
|
|
|
|
/** The tab the root's cursor is hovering (independent of activeTab). */
|
|
const tabCursor = (): TABS => tabCursorSignal();
|
|
|
|
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
|
|
const moveTabCursor = (dir: -1 | 1) => {
|
|
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
|
};
|
|
|
|
/** Open the hovered tab (switch to it and enter its content) from the root.
|
|
* The yazi "open" of a tab row. */
|
|
const activateTabCursor = () => {
|
|
switchTab(tabCursorSignal());
|
|
enterTabContent();
|
|
};
|
|
|
|
// ── per-pane focus index ────────────────────────────────────────────────
|
|
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
|
|
|
/** For depth-tabs (every tab), pane 1 (DEPTH_CENTER_PANE) reads/writes
|
|
* the top frame's focus. Other panes fall back to the per-pane index
|
|
* map (unused by current pages). */
|
|
const focusedIndex = (pane: PaneId = activePane()): number => {
|
|
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
|
return topFrame()?.focus ?? 0;
|
|
}
|
|
return paneIndices()[paneKey(pane)] ?? 0;
|
|
};
|
|
|
|
const setFocusedIndex = (pane: PaneId, index: number) => {
|
|
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
|
setDepthFocus(index);
|
|
return;
|
|
}
|
|
setPaneIndices((m) => ({
|
|
...m,
|
|
[`${activeTab()}:${pane}`]: index,
|
|
}));
|
|
};
|
|
|
|
/** Apply a clamped relative motion to the active pane's focus. Returns
|
|
* the new index so callers can update their own scroll state. */
|
|
const move = (
|
|
delta: number,
|
|
listLen: number,
|
|
countOverride?: number,
|
|
): number => {
|
|
if (listLen <= 0) return 0;
|
|
const steps = countOverride ?? count() ?? 1;
|
|
const pane = activePane();
|
|
const cur = focusedIndex(pane);
|
|
let next = cur + delta * steps;
|
|
// wrap-around like yazi (arrow wraps top<->bottom)
|
|
next = ((next % listLen) + listLen) % listLen;
|
|
setFocusedIndex(pane, next);
|
|
// visual-mode range selection: add newly-traversed items to selection
|
|
if (HAS_VISUAL(mode()) && visualAnchor()) {
|
|
growVisualSelection(next);
|
|
}
|
|
return next;
|
|
};
|
|
|
|
const gotoIndex = (index: number, listLen: number): number => {
|
|
if (listLen <= 0) return 0;
|
|
const pane = activePane();
|
|
const next = Math.max(0, Math.min(listLen - 1, index));
|
|
setFocusedIndex(pane, next);
|
|
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
|
|
return next;
|
|
};
|
|
|
|
// ── selection ───────────────────────────────────────────────────────────
|
|
const selSet = (key: string): Set<string> => selections()[key] ?? new Set();
|
|
|
|
const toggleSelected = (id: string) => {
|
|
const key = paneKey();
|
|
setSelections((m) => {
|
|
const set = new Set(m[key] ?? []);
|
|
if (set.has(id)) set.delete(id);
|
|
else set.add(id);
|
|
return { ...m, [key]: set };
|
|
});
|
|
};
|
|
|
|
const isSelected = (id: string) => selSet(paneKey()).has(id);
|
|
|
|
const clearSelection = (key?: string) => {
|
|
const k = key ?? paneKey();
|
|
setSelections((m) => {
|
|
if (!(k in m)) return m;
|
|
const next = { ...m };
|
|
delete next[k];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const selectedIds = () => [...selSet(paneKey())];
|
|
|
|
/** Enter visual mode, anchoring range selection at the current focus. */
|
|
const enterVisual = () => {
|
|
const pane = activePane();
|
|
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
|
|
setMode(NavMode.VISUAL);
|
|
};
|
|
|
|
/** Grow selection between the visual anchor and `index` for the active
|
|
* pane. Callers pass item ids aligned to indices; we store ids via the
|
|
* resolve callback registered per-pane (see registerResolver). */
|
|
let resolvers: Record<string, (index: number) => string | undefined> = {};
|
|
const registerResolver = (
|
|
key: string,
|
|
fn: (i: number) => string | undefined,
|
|
) => {
|
|
resolvers[key] = fn;
|
|
};
|
|
const growVisualSelection = (index: number) => {
|
|
const anchor = visualAnchor();
|
|
if (!anchor) return;
|
|
const resolve = resolvers[anchor.paneKey];
|
|
if (!resolve) return;
|
|
const lo = Math.min(anchor.index, index);
|
|
const hi = Math.max(anchor.index, index);
|
|
const ids: string[] = [];
|
|
for (let i = lo; i <= hi; i++) {
|
|
const id = resolve(i);
|
|
if (id) ids.push(id);
|
|
}
|
|
const key = anchor.paneKey;
|
|
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
|
|
};
|
|
|
|
// ── modes ────────────────────────────────────────────────────────────────
|
|
const enterCommand = () => {
|
|
setMode(NavMode.COMMAND);
|
|
setCommandBuffer("");
|
|
setCommandError(null);
|
|
};
|
|
const enterInput = () => setMode(NavMode.INPUT);
|
|
const exitCommand = () => {
|
|
batch(() => {
|
|
setMode(NavMode.NORMAL);
|
|
setCommandBuffer("");
|
|
setCommandError(null);
|
|
});
|
|
};
|
|
const exitVisual = () => {
|
|
batch(() => {
|
|
setMode(NavMode.NORMAL);
|
|
setVisualAnchor(null);
|
|
});
|
|
};
|
|
const toNormal = () => {
|
|
if (mode() === NavMode.VISUAL) {
|
|
clearSelection();
|
|
exitVisual();
|
|
} else {
|
|
setMode(NavMode.NORMAL);
|
|
}
|
|
};
|
|
|
|
// ── command buffer ───────────────────────────────────────────────────────
|
|
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
|
|
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
|
|
const submitCommand = (): string => {
|
|
const cmd = commandBuffer().trim();
|
|
exitCommand();
|
|
return cmd;
|
|
};
|
|
|
|
// ── count register ───────────────────────────────────────────────────────
|
|
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
|
|
const consumeCount = (): number => {
|
|
const c = count();
|
|
setCount(null);
|
|
return c ?? 1;
|
|
};
|
|
|
|
return {
|
|
activeTab,
|
|
activePane,
|
|
mode,
|
|
count,
|
|
inputFocused,
|
|
commandBuffer,
|
|
commandError,
|
|
visualAnchor,
|
|
selections,
|
|
selectedIds,
|
|
// depth stack
|
|
depthStack,
|
|
currentDepth,
|
|
topFrame,
|
|
depthFocus,
|
|
setDepthFocus,
|
|
pushDepth,
|
|
popDepth,
|
|
isDepthTab,
|
|
// tab
|
|
setActiveTab: gotoTab,
|
|
nextTab,
|
|
prevTab,
|
|
// tab root (app's outermost pane)
|
|
atRootTab,
|
|
enterTabContent,
|
|
backToTabRoot,
|
|
tabCursor,
|
|
moveTabCursor,
|
|
activateTabCursor,
|
|
// pane focus
|
|
setActivePane: setPane,
|
|
// focus index
|
|
focusedIndex,
|
|
setFocusedIndex,
|
|
move,
|
|
gotoIndex,
|
|
// selection
|
|
isSelected,
|
|
toggleSelected,
|
|
clearSelection,
|
|
selectedIdsFor: (key: string) => [...selSet(key)],
|
|
registerResolver,
|
|
enterVisual,
|
|
exitVisual,
|
|
// modes
|
|
setActiveTabSignal: setActiveTab,
|
|
setActiveDepth: setPane, // legacy alias
|
|
activeDepth: activePane, // legacy alias
|
|
setInputFocused,
|
|
nextPane: () => {}, // legacy noop; swipe() replaces this
|
|
prevPane: () => {},
|
|
setMode,
|
|
enterCommand,
|
|
enterInput,
|
|
exitCommand,
|
|
toNormal,
|
|
// command buffer
|
|
setCommandBuffer,
|
|
appendCommand,
|
|
backspaceCommand,
|
|
submitCommand,
|
|
setCommandError,
|
|
// count
|
|
pushCountDigit,
|
|
consumeCount,
|
|
};
|
|
}
|
|
|
|
export type NavigationState = ReturnType<typeof createNavigation>;
|