diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 21cbbcc..7f343d0 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -17,7 +17,6 @@ import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useNavigation, NavMode, - SIDEBAR_PANE, DEPTH_CENTER_PANE, } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; @@ -26,7 +25,8 @@ import { useFeedStore } from "@/stores/feed"; import type { Episode } from "@/types/episode"; import { useToast } from "@/ui/toast"; import { emit } from "@/utils/event-bus"; -import { TABS, TabsCount, TabPaneCount, LayerGraph } from "@/utils/navigation"; +import { LayerGraph } from "@/utils/layer-graph"; +import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation"; const TAB_LABEL: Record = { [TABS.FEED]: "Feed", @@ -64,8 +64,11 @@ const PAGE_ACTIONS: ReadonlySet = new Set( ], ); -/** Movement actions the sidebar pane handles itself (its list = the tabs, - * length TabsCount). Routed through the standard move/gotoIndex API. */ +/** Movement actions the sidebar pane handled itself when the tab-list pane + * still existed (its list = the tabs, length TabsCount). The sidebar pane was + * removed in the yazi remake nav rework (task 01); these now fall through + * to the active page's PAGE_ACTIONS dispatch. Retained here and fully + * removed in task 06's keybind rewrite. */ const SIDEBAR_ACTIONS: ReadonlySet = new Set([ "move-down", "move-up", @@ -282,30 +285,11 @@ export function Shell() { nav.setActiveTab(dt); break; } - // ── sidebar pane: j/k/jump/goto move through the tab list via the - // standard move/gotoIndex API (list length = TabsCount). No - // special-cased nextTab/prevTab — the sidebar is a normal pane. - if (nav.activePane() === SIDEBAR_PANE && SIDEBAR_ACTIONS.has(action)) { - evt.preventDefault(); - if (action === "goto-top") nav.gotoIndex(0, TabsCount); - else if (action === "goto-bottom") - nav.gotoIndex(TabsCount - 1, TabsCount); - else { - const dir = action.endsWith("down") ? 1 : -1; - const step = action.startsWith("jump") - ? 5 - : action.startsWith("page") - ? 10 - : 1; - nav.move(dir, TabsCount, step); - } - break; - } // ── pane swipe / depth nav ── - // h/l always uses the unified swipe() (clamped to the sidebar on - // the left). Depth-tabs additionally: l at the center drills in - // (open), h at the center pops a depth (or swipes to sidebar at - // root). Fixed-pane tabs just swipe between their panes. + // h/l use swipe() on fixed-pane tabs (clamped to [0, + // paneCount-1] — there is no sidebar pane). Depth-tabs: l + // at the center drills in (open); h at the center pops a + // depth (noop at depth 0). if (action === "swipe-prev") { evt.preventDefault(); if ( @@ -432,22 +416,22 @@ export function Shell() { > {(tab) => { const active = () => nav.activeTab() === tab; - const focused = () => - active() && nav.activePane() === SIDEBAR_PANE; + // The sidebar pane is gone (task 01); the tab strip stays + // rendered for now (removed in task 05) and highlights the + // active tab. Clicking just switches tabs — no pane focus. return ( { - nav.setActivePane(SIDEBAR_PANE); nav.setActiveTab(tab); }} > - - {focused() ? "❯ " : " "} + + {active() ? "❯ " : " "} {tab}. {TAB_LABEL[tab]} @@ -484,11 +468,9 @@ export function Shell() { {TAB_LABEL[nav.activeTab()]} ·{" "} - {nav.activePane() === SIDEBAR_PANE - ? "tabs" - : nav.isDepthTab() - ? `depth ${nav.currentDepth()}` - : `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`} + {nav.isDepthTab() + ? `depth ${nav.currentDepth()}` + : `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`} 0}> diff --git a/src/context/NavigationContext.tsx b/src/context/NavigationContext.tsx index 61b6a76..7ce8f8d 100644 --- a/src/context/NavigationContext.tsx +++ b/src/context/NavigationContext.tsx @@ -1,426 +1,22 @@ -import { createEffect, createSignal, on, batch, createMemo } from "solid-js"; +/** + * NavigationContext — Solid provider wrapper around the pure nav store in + * `./navigation-store`. Re-exports the nav model (`createNavigation`, the + * enums/types, `DEPTH_CENTER_PANE`, etc.) so the rest of the app keeps + * importing everything from `@/context/NavigationContext`, and binds the + * store into a Solid context (`useNavigation` / `NavigationProvider`). + * + * See `./navigation-store` for the model documentation (parent | current | + * preview, depth-stack vs fixed-pane tabs, no sidebar pane). + */ import { createSimpleContext } from "./helper"; -import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation"; +import { createNavigation } from "./navigation-store"; -// ── Yazi-style navigation state ────────────────────────────────────────────── -// Two pane models coexist: -// -// • Depth-stack tabs (Feed, MyShows, Discover, Settings) use a yazi-style -// depth stack. The three content columns render as: -// left = the previous depth's list (empty at depth 0) -// center = the current depth's list (always where focus lives) -// right = preview of the hovered item in center -// `l`/Enter drills in (push); `h` pops back (or yields to the sidebar at -// depth 0). Depth is unbounded — each page decides per-item whether an -// item is drillable and what child list kind to push. -// -// • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the -// old indexed pane model (`focusedIndex(pane)` + `swipe`). -// -// The Shell's left tab sidebar is a special pane that sits *before* the -// content area. It uses SIDEBAR_PANE (-1) so the h/l chain naturally lands on -// it as the leftmost/root pane. - -export enum NavMode { - NORMAL = "NORMAL", - VISUAL = "VISUAL", - COMMAND = "COMMAND", - INPUT = "INPUT", -} - -/** The tab sidebar (chrome) pane. Always the leftmost focus target. */ -export const SIDEBAR_PANE = -1 as PaneId; - -/** For depth-tabs, the current-depth (center) pane is the only focusable - * content pane — index 0. The prev/preview columns are derived, not focused. */ -export const DEPTH_CENTER_PANE = 0 as PaneId; - -/** The sidebar pane's "list" is the tab list itself: its focus cursor is the - * active tab (1-based) minus 1, and moving/setting it switches tabs via the - * standard focusedIndex/move/gotoIndex API — no special-cased nextTab. */ - -/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */ -export enum PaneSlot { - PARENT = 0, // depth-tabs: center/current; Search: input - CURRENT = 1, // Search: results - PREVIEW = 2, // Search: detail -} - -export type PaneId = number; // 0-based index into the active tab's pane list - -// ── 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>; - -const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL; +// Re-export the entire nav model surface so existing imports from +// `@/context/NavigationContext` keep resolving. +export * from "./navigation-store"; export const { use: useNavigation, provider: NavigationProvider } = createSimpleContext({ name: "Navigation", - init: () => { - const [activeTab, setActiveTab] = createSignal(TABS.FEED); - // App focus starts on the left tab sidebar (root pane); tab switches - // also return focus there. - const [activePane, setActivePane] = createSignal(SIDEBAR_PANE); - const [mode, setMode] = createSignal(NavMode.NORMAL); - const [count, setCount] = createSignal(null); - const [inputFocused, setInputFocused] = createSignal(false); - - // per-tab depth stack. Depth-tabs get a root frame on first visit. - const [stacks, setStacks] = createSignal< - Partial> - >({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] }); - - // per-pane focused index (for j/k movement in fixed-pane tabs). Keyed - // by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus` - // for pane 0 (DEPTH_CENTER_PANE) instead. - const [paneIndices, setPaneIndices] = createSignal< - Record - >({}); - const [selections, setSelections] = createSignal({}); - const [visualAnchor, setVisualAnchor] = createSignal<{ - paneKey: string; - index: number; - } | null>(null); - - const [commandBuffer, setCommandBuffer] = createSignal(""); - const [commandError, setCommandError] = createSignal(null); - - /** Depth stack for a tab (empty for fixed-pane tabs). */ - 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)] })); - } - }; - - // On tab change: ensure a root frame exists (depth-tabs) + reset - // focus to the sidebar, clear modes/command/visual state. - createEffect( - on(activeTab, (tab) => { - ensureStack(tab); - batch(() => { - setActivePane(SIDEBAR_PANE); - setMode(NavMode.NORMAL); - setCount(null); - setCommandBuffer(""); - setCommandError(null); - setVisualAnchor(null); - }); - }), - ); - - // ── depth stack accessors ────────────────────────────────────────────── - const depthStack = createMemo(() => - depthStackFor(activeTab()), - ); - const currentDepth = createMemo(() => - Math.max(0, depthStack().length - 1), - ); - const topFrame = createMemo( - () => 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 ────────────────────────────────────────────────────── - const gotoTab = (tab: TABS) => { - if (tab < 1 || tab > TabsCount) return; - setActiveTab(tab); - }; - const nextTab = () => - setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS))); - const prevTab = () => - setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS))); - - // ── pane focus ────────────────────────────────────────────────────────── - const setPane = (pane: PaneId) => setActivePane(pane); - - /** Move focus to the adjacent pane (fixed-pane tabs only). `dir` = - * -1 (left, toward sidebar) or +1 (right, toward preview). Clamped to - * [SIDEBAR_PANE, paneCount-1]. */ - const swipe = (dir: -1 | 1, paneCount: number) => { - setActivePane((p) => { - const n = Math.max(SIDEBAR_PANE, Math.min(paneCount - 1, p + dir)); - return n; - }); - }; - - // ── per-pane focus index ──────────────────────────────────────────────── - const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; - - /** For depth-tabs, pane 0 (center) reads/writes the top frame's - * focus. The sidebar pane's focus IS the active tab. Other panes - * (and fixed-pane tabs) use the per-pane map. */ - const focusedIndex = (pane: PaneId = activePane()): number => { - if (pane === SIDEBAR_PANE) return activeTab() - 1; - if (isDepthTab() && pane === DEPTH_CENTER_PANE) { - return topFrame()?.focus ?? 0; - } - return paneIndices()[paneKey(pane)] ?? 0; - }; - - const setFocusedIndex = (pane: PaneId, index: number) => { - if (pane === SIDEBAR_PANE) { - gotoTab(((index + TabsCount) % TabsCount) + 1); - return; - } - 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 => - 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 = createMemo(() => [...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 | 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, - // pane focus - setActivePane: setPane, - swipe, - // 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, - }; - }, + init: () => createNavigation(), }); diff --git a/src/context/navigation-store.ts b/src/context/navigation-store.ts new file mode 100644 index 0000000..f9d00d5 --- /dev/null +++ b/src/context/navigation-store.ts @@ -0,0 +1,444 @@ +/** + * 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. + * + * Two pane models coexist: + * + * • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE + * focusable pane — the current column (DEPTH_CENTER_PANE = 0). The parent + * column renders the previous depth's list (blank at depth 0); the preview + * column renders the hovered item. `l`/Enter drills in (push a frame); + * `h` pops a depth (a noop at depth 0). Depth is unbounded — each page + * decides per-item whether an item is drillable and what child list to + * push. Drill/pop is dispatched by the Shell, never via swipe. + * + * • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the + * indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the + * parent/current/preview columns with `h`/`l`, clamped to [0, paneCount-1]. + * + * Tabs switch only via digit keys `1`-`6`, `[`/`]`, or (later) a bottom tab + * strip. There is NO sidebar pane: `activePane` is plain tab pane state and is + * never a chrome/tab-list pane. + */ +import { + createEffect, + createSignal, + on, + batch, + createMemo, +} 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 pane. For depth-tabs this is the single focusable pane (the + * center column, index 0); for fixed-pane tabs it's the default landing pane + * on tab-enter. Every tab-enter resets `activePane` to this value. */ +export const DEPTH_CENTER_PANE = 0 as PaneId; + +/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */ +export enum PaneSlot { + PARENT = 0, // depth-tabs: center/current; Search: input + CURRENT = 1, // Search: results + PREVIEW = 2, // Search: detail +} + +export type PaneId = number; // 0-based index into the active tab's pane list + +// ── 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>; + +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.FEED); + // App focus starts on the current pane (center, idx 0); every tab + // switch also resets here. There is no sidebar pane. + const [activePane, setActivePane] = + createSignal(DEPTH_CENTER_PANE); + const [mode, setMode] = createSignal(NavMode.NORMAL); + const [count, setCount] = createSignal(null); + const [inputFocused, setInputFocused] = createSignal(false); + + // per-tab depth stack. Depth-tabs get a root frame on first visit. + const [stacks, setStacks] = createSignal< + Partial> + >({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] }); + + // per-pane focused index (for j/k movement in fixed-pane tabs). Keyed + // by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus` + // for pane 0 (DEPTH_CENTER_PANE) instead. + const [paneIndices, setPaneIndices] = createSignal< + Record + >({}); + const [selections, setSelections] = createSignal({}); + const [visualAnchor, setVisualAnchor] = createSignal<{ + paneKey: string; + index: number; + } | null>(null); + + const [commandBuffer, setCommandBuffer] = createSignal(""); + const [commandError, setCommandError] = createSignal(null); + + /** Depth stack for a tab (empty for fixed-pane tabs). */ + 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)] })); + } + }; + + // On tab change: ensure a root frame exists (depth-tabs) + reset + // focus to the current/center pane, clear modes/command/visual. + createEffect( + on(activeTab, (tab) => { + ensureStack(tab); + batch(() => { + setActivePane(DEPTH_CENTER_PANE); + setMode(NavMode.NORMAL); + setCount(null); + setCommandBuffer(""); + setCommandError(null); + setVisualAnchor(null); + }); + }), + ); + + // ── depth stack accessors ────────────────────────────────────────────── + const depthStack = createMemo(() => + depthStackFor(activeTab()), + ); + const currentDepth = createMemo(() => + Math.max(0, depthStack().length - 1), + ); + const topFrame = createMemo( + () => 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 ────────────────────────────────────────────────────── + const gotoTab = (tab: TABS) => { + if (tab < 1 || tab > TabsCount) return; + setActiveTab(tab); + }; + const nextTab = () => + setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS))); + const prevTab = () => + setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS))); + + // ── pane focus ────────────────────────────────────────────────────────── + const setPane = (pane: PaneId) => setActivePane(pane); + + /** Move focus to the adjacent pane (fixed-pane tabs only). `dir` = + * -1 (left, toward parent) or +1 (right, toward preview). Clamped to + * [0, paneCount-1] — there is no sidebar pane to land on. */ + const swipe = (dir: -1 | 1, paneCount: number) => { + setActivePane((p) => { + const n = Math.max(0, Math.min(paneCount - 1, p + dir)); + return n; + }); + }; + + // ── per-pane focus index ──────────────────────────────────────────────── + const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; + + /** For depth-tabs, pane 0 (the center/current pane) reads/writes + * the top frame's focus. Other panes and fixed-pane tabs use the + * per-pane index map. */ + 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 => + 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 = createMemo(() => [...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 | 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, + // pane focus + setActivePane: setPane, + swipe, + // 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; diff --git a/src/utils/layer-graph.ts b/src/utils/layer-graph.ts new file mode 100644 index 0000000..18cd258 --- /dev/null +++ b/src/utils/layer-graph.ts @@ -0,0 +1,48 @@ +/** + * layer-graph — maps each TAB id to its page component + pane count. + * + * Split out of `navigation.ts` so that the nav-model primitives (TABS, + * TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount, PANE_RATIO) in + * `navigation.ts` stay free of any `.tsx` / JSX imports. This lets unit tests + * import the pure navigation store without pulling the OpenTUI JSX runtime + * (which is only provided by the build-time @opentui/solid bun-plugin). + * + * The page modules live alongside their pages and export `PaneCount` + * constants describing how many focusable panes each fixed page owns. + */ +import { + DiscoverPage, + DiscoverPaneCount, +} from "@/pages/Discover/DiscoverPage"; +import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage"; +import { + MyShowsPage, + MyShowsPaneCount, +} from "@/pages/MyShows/MyShowsPage"; +import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage"; +import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage"; +import { + SettingsPage, + SettingsPaneCount, +} from "@/pages/Settings/SettingsPage"; +import { TABS } from "@/utils/navigation"; + +/** Maps a TAB id to the page component that renders it. */ +export const LayerGraph = { + [TABS.FEED]: FeedPage, + [TABS.MYSHOWS]: MyShowsPage, + [TABS.DISCOVER]: DiscoverPage, + [TABS.SEARCH]: SearchPage, + [TABS.PLAYER]: PlayerPage, + [TABS.SETTINGS]: SettingsPage, +}; + +/** Per-tab focusable-pane counts (forwarded from each page's `*PaneCount`). */ +export const LayerDepths = { + [TABS.FEED]: FeedPaneCount, + [TABS.MYSHOWS]: MyShowsPaneCount, + [TABS.DISCOVER]: DiscoverPaneCount, + [TABS.SEARCH]: SearchPaneCount, + [TABS.PLAYER]: PlayerPaneCount, + [TABS.SETTINGS]: SettingsPaneCount, +}; diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index f1ec5d8..d8e9147 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -1,10 +1,3 @@ -import { DiscoverPage, DiscoverPaneCount } from "@/pages/Discover/DiscoverPage"; -import { FeedPage, FeedPaneCount } from "@/pages/Feed/FeedPage"; -import { MyShowsPage, MyShowsPaneCount } from "@/pages/MyShows/MyShowsPage"; -import { PlayerPage, PlayerPaneCount } from "@/pages/Player/PlayerPage"; -import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage"; -import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage"; - export enum DIRECTION { Increment, Decrement, @@ -49,40 +42,31 @@ export function rootFrameFor( } } -export const LayerGraph = { - [TABS.FEED]: FeedPage, - [TABS.MYSHOWS]: MyShowsPage, - [TABS.DISCOVER]: DiscoverPage, - [TABS.SEARCH]: SearchPage, - [TABS.PLAYER]: PlayerPage, - [TABS.SETTINGS]: SettingsPage, -}; -export const LayerDepths = { - [TABS.FEED]: FeedPaneCount, - [TABS.MYSHOWS]: MyShowsPaneCount, - [TABS.DISCOVER]: DiscoverPaneCount, - [TABS.SEARCH]: SearchPaneCount, - [TABS.PLAYER]: PlayerPaneCount, - [TABS.SETTINGS]: SettingsPaneCount, -}; +// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`, +// split out so this module stays free of `.tsx`/JSX imports (unit-testable). -// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3]. -// Panes use flexGrow (Yoga) so columns always sum to the row width regardless -// of terminal size — more robust than fixed percentages and exactly mirrors +// Yazi-style pane grow ratios (parent : current : preview). Panes use +// flexGrow (Yoga) so columns always sum to the row width regardless of +// terminal size — more robust than fixed percentages and exactly mirrors // yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs). +// +// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT +// touch these values. Task 02 re-tunes them to the remake target ratios +// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there. export const PANE_RATIO = { parent: 1, current: 4, preview: 3, } as const; -// Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/ -// Settings) now have a single focusable content pane (the center/current -// column at depth 0..N); prev and preview are derived, not focusable. Search -// keeps its 3 fixed panes; Player is single-pane. The Shell's h/l dispatch -// routes depth-tabs to push/pop instead of pane swipe. Defined here (after -// TABS) to avoid re-introducing the old NavigationContext top-level-init -// circular deadlock. +// Number of *focusable* content panes per tab. The three visible columns +// (parent | current | preview) are a *render* concern, NOT three panes — for +// depth-tabs only the current column (index 0) is focusable, so this is 1. +// Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop +// with `h` (noop at depth 0) via the Shell dispatch — they never call swipe. +// Search keeps its 3 fixed focusable panes; Player is single-pane. Defined +// here (after TABS) to avoid re-introducing the old NavigationContext +// top-level-init circular deadlock. export const TabPaneCount: Record = { [TABS.FEED]: 1, // depth: feeds → episodes → preview [TABS.MYSHOWS]: 1, // depth: shows → episodes → preview diff --git a/tests/nav-model.test.ts b/tests/nav-model.test.ts new file mode 100644 index 0000000..1dad726 --- /dev/null +++ b/tests/nav-model.test.ts @@ -0,0 +1,152 @@ +/** + * nav-model.test.ts — yazi remake task 01 unit/integration tests. + * + * Covers the removal of the SIDEBAR_PANE concept: + * • createNavigation() exposes the nav factory directly (no Solid render + * needed), wrapped in a createRoot so effects register/dispose. + * • depth-tab focusedIndex depth-current read/writes the top frame's focus. + * • the tab-switch effect resets activePane to DEPTH_CENTER_PANE (0), not -1. + * • swipe() clamps to [0, paneCount-1] (no -1 sidebar slot). + */ +import { test, expect } from "bun:test"; +import { createRoot } from "solid-js"; +import { + createNavigation, + DEPTH_CENTER_PANE, + NavMode, +} from "../src/context/navigation-store"; +import { TABS, TabPaneCount } from "../src/utils/navigation"; + +/** Build a fresh nav graph inside a reactive root and run `fn` against it. + * Disposes the root afterwards so effects/signals don't leak between tests. */ +function withNav(fn: (nav: ReturnType) => void) { + createRoot((dispose) => { + const nav = createNavigation(); + fn(nav); + dispose(); + }); +} + +test("createNavigation initial activePane is DEPTH_CENTER_PANE (0), not -1", () => { + withNav((nav) => { + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.activePane()).toBe(0); + }); +}); + +// ── depth-tab focus: reads/writes the top frame's focus ─────────────────────── +test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => { + withNav((nav) => { + // FEED is a depth-tab; its root frame is { kind: "feeds", focus: 0 }. + nav.setActiveTab(TABS.FEED); + expect(nav.isDepthTab()).toBe(true); + expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0); + // setFocusedIndex writes to the *top* frame, not a pane map. + nav.setFocusedIndex(DEPTH_CENTER_PANE, 7); + expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(7); + // topFrame focus reflects the write. + expect(nav.topFrame()?.focus).toBe(7); + }); +}); + +test("setFocusedIndex on a 2-frame stack writes only the top frame", () => { + withNav((nav) => { + nav.setActiveTab(TABS.FEED); + // root frame focus 3, then push a child frame whose focus is 5. + nav.setFocusedIndex(DEPTH_CENTER_PANE, 3); + nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 5 }); + expect(nav.currentDepth()).toBe(1); + // writing the current pane updates only the top (child) frame. + nav.setFocusedIndex(DEPTH_CENTER_PANE, 9); + expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(9); + // the previous depth's focus is untouched. + expect(nav.depthFocus(0)).toBe(3); + // popping restores the parent frame's focus. + expect(nav.popDepth()).toBe(true); + expect(nav.currentDepth()).toBe(0); + expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(3); + }); +}); + +// ── popDepth is a noop at depth 0 ──────────────────────────────────────────── +test("popDepth at depth 0 is a noop (returns false, no frame lost)", () => { + withNav((nav) => { + nav.setActiveTab(TABS.MYSHOWS); + expect(nav.currentDepth()).toBe(0); + expect(nav.popDepth()).toBe(false); + expect(nav.currentDepth()).toBe(0); + expect(nav.depthStack().length).toBe(1); + }); +}); + +// ── tab-switch resets focus to DEPTH_CENTER_PANE, not a sidebar ────────────── +test("tab-switch effect resets activePane to DEPTH_CENTER_PANE", () => { + withNav((nav) => { + // start on a depth-tab, land on the current pane. + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + // move pane focus away (swipe is a noop for depth-tabs count=1, so + // instead prove the effect resets on tab change). + nav.setActiveTab(TABS.SEARCH); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + // switch to another tab; the effect must reset to 0, never -1. + nav.setActiveTab(TABS.SETTINGS); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.activePane()).toBeGreaterThanOrEqual(0); + }); +}); + +test("tab-switch resets mode/visual/command state", () => { + withNav((nav) => { + nav.enterVisual(); + expect(nav.mode()).toBe(NavMode.VISUAL); + nav.setActiveTab(TABS.DISCOVER); + expect(nav.mode()).toBe(NavMode.NORMAL); + expect(nav.visualAnchor()).toBeNull(); + }); +}); + +// ── swipe clamps to [0, paneCount-1] (no sidebar slot) ──────────────────────── +test("swipe(-1, 3) on a fixed-pane tab clamps to 0, not -1", () => { + withNav((nav) => { + nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3 + expect(TabPaneCount[TABS.SEARCH]).toBe(3); + // tab-switch effect lands us on pane 0 (DEPTH_CENTER_PANE). + expect(nav.activePane()).toBe(0); + nav.swipe(-1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(0); // lower bound, never -1 + // swipe right twice then back: clamps to [0, 2]. + nav.swipe(1, 3); + nav.swipe(1, 3); + expect(nav.activePane()).toBe(2); // upper bound + nav.swipe(1, 3); + expect(nav.activePane()).toBe(2); // never exceeds count-1 + nav.swipe(-1, 3); + expect(nav.activePane()).toBe(1); + nav.swipe(-1, 3); + expect(nav.activePane()).toBe(0); + }); +}); + +test("swipe on a single-pane fixed tab stays at 0", () => { + withNav((nav) => { + nav.setActiveTab(TABS.PLAYER); // single-pane + expect(TabPaneCount[TABS.PLAYER]).toBe(1); + expect(nav.activePane()).toBe(0); + nav.swipe(1, TabPaneCount[TABS.PLAYER]); + expect(nav.activePane()).toBe(0); + nav.swipe(-1, TabPaneCount[TABS.PLAYER]); + expect(nav.activePane()).toBe(0); + }); +}); + +// ── ensureStack seeds a root frame on first visit to a depth-tab ───────────── +test("switching to a fresh depth-tab seeds its root frame", () => { + withNav((nav) => { + nav.setActiveTab(TABS.DISCOVER); + expect(nav.isDepthTab()).toBe(true); + expect(nav.depthStack().length).toBe(1); + expect(nav.topFrame()?.kind).toBe("discover:categories"); + nav.setActiveTab(TABS.SETTINGS); + expect(nav.topFrame()?.kind).toBe("settings:sections"); + }); +});