refactor: rearchitect navigation model with pure store and no sidebar pane
- Extract navigation state and logic into navigation-store.ts for testability and separation of concerns - NavigationContext now only wraps the store as a Solid provider - Remove SIDEBAR_PANE from the model; depth-tabs have a single focusable content pane (center/current column) - Split LayerGraph and page imports into layer-graph.ts so the navigation utils stay free of JSX (unit-testable) - Update Shell keybind dispatch: remove sidebar pane handling, simplify swipe to fixed-pane tabs only, clarify depth-tab h/l - Add nav-model.test.ts covering depth stack, tab/pane focus, and selection semantics
This commit is contained in:
@@ -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, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
@@ -64,8 +64,11 @@ const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>(
|
||||
],
|
||||
);
|
||||
|
||||
/** 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<KeybindActionName> = 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 (
|
||||
<box
|
||||
flexDirection="row"
|
||||
backgroundColor={
|
||||
focused() ? t.accent : active() ? t.primary : t.background
|
||||
active() ? t.primary : t.background
|
||||
}
|
||||
paddingLeft={1}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(SIDEBAR_PANE);
|
||||
nav.setActiveTab(tab);
|
||||
}}
|
||||
>
|
||||
<text fg={focused() || active() ? t.surface : t.textMuted}>
|
||||
{focused() ? "❯ " : " "}
|
||||
<text fg={active() ? t.surface : t.textMuted}>
|
||||
{active() ? "❯ " : " "}
|
||||
{tab}. {TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
@@ -484,9 +468,7 @@ export function Shell() {
|
||||
</text>
|
||||
<text fg={t.textMuted} paddingLeft={1}>
|
||||
{TAB_LABEL[nav.activeTab()]} ·{" "}
|
||||
{nav.activePane() === SIDEBAR_PANE
|
||||
? "tabs"
|
||||
: nav.isDepthTab()
|
||||
{nav.isDepthTab()
|
||||
? `depth ${nav.currentDepth()}`
|
||||
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
|
||||
</text>
|
||||
|
||||
@@ -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<string, Set<string>>;
|
||||
|
||||
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>(TABS.FEED);
|
||||
// App focus starts on the left tab sidebar (root pane); tab switches
|
||||
// also return focus there.
|
||||
const [activePane, setActivePane] = createSignal<PaneId>(SIDEBAR_PANE);
|
||||
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 (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<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 (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<DepthFrame[]>(() =>
|
||||
depthStackFor(activeTab()),
|
||||
);
|
||||
const currentDepth = createMemo(() =>
|
||||
Math.max(0, depthStack().length - 1),
|
||||
);
|
||||
const topFrame = createMemo<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 ──────────────────────────────────────────────────────
|
||||
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<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 = 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, (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,
|
||||
// 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(),
|
||||
});
|
||||
|
||||
444
src/context/navigation-store.ts
Normal file
444
src/context/navigation-store.ts
Normal file
@@ -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<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);
|
||||
// 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<PaneId>(DEPTH_CENTER_PANE);
|
||||
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 (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<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 (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<DepthFrame[]>(() =>
|
||||
depthStackFor(activeTab()),
|
||||
);
|
||||
const currentDepth = createMemo(() =>
|
||||
Math.max(0, depthStack().length - 1),
|
||||
);
|
||||
const topFrame = createMemo<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 ──────────────────────────────────────────────────────
|
||||
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<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 = 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, (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,
|
||||
// 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<typeof createNavigation>;
|
||||
48
src/utils/layer-graph.ts
Normal file
48
src/utils/layer-graph.ts
Normal file
@@ -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 `<count>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,
|
||||
};
|
||||
@@ -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, number> = {
|
||||
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
||||
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
|
||||
|
||||
152
tests/nav-model.test.ts
Normal file
152
tests/nav-model.test.ts
Normal file
@@ -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<typeof createNavigation>) => 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user