Files
PodTui/src/context/navigation-store.ts
2026-08-07 13:38:32 -04:00

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>;