Merge branch 'rearchitect-nav-model'
# Conflicts: # src/utils/navigation.ts
This commit is contained in:
@@ -17,7 +17,6 @@ import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
|||||||
import {
|
import {
|
||||||
useNavigation,
|
useNavigation,
|
||||||
NavMode,
|
NavMode,
|
||||||
SIDEBAR_PANE,
|
|
||||||
DEPTH_CENTER_PANE,
|
DEPTH_CENTER_PANE,
|
||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
@@ -26,7 +25,8 @@ import { useFeedStore } from "@/stores/feed";
|
|||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { emit } from "@/utils/event-bus";
|
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> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
[TABS.FEED]: "Feed",
|
[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,
|
/** Movement actions the sidebar pane handled itself when the tab-list pane
|
||||||
* length TabsCount). Routed through the standard move/gotoIndex API. */
|
* 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([
|
const SIDEBAR_ACTIONS: ReadonlySet<KeybindActionName> = new Set([
|
||||||
"move-down",
|
"move-down",
|
||||||
"move-up",
|
"move-up",
|
||||||
@@ -282,30 +285,11 @@ export function Shell() {
|
|||||||
nav.setActiveTab(dt);
|
nav.setActiveTab(dt);
|
||||||
break;
|
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 ──
|
// ── pane swipe / depth nav ──
|
||||||
// h/l always uses the unified swipe() (clamped to the sidebar on
|
// h/l use swipe() on fixed-pane tabs (clamped to [0,
|
||||||
// the left). Depth-tabs additionally: l at the center drills in
|
// paneCount-1] — there is no sidebar pane). Depth-tabs: l
|
||||||
// (open), h at the center pops a depth (or swipes to sidebar at
|
// at the center drills in (open); h at the center pops a
|
||||||
// root). Fixed-pane tabs just swipe between their panes.
|
// depth (noop at depth 0).
|
||||||
if (action === "swipe-prev") {
|
if (action === "swipe-prev") {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
if (
|
if (
|
||||||
@@ -432,22 +416,22 @@ export function Shell() {
|
|||||||
>
|
>
|
||||||
{(tab) => {
|
{(tab) => {
|
||||||
const active = () => nav.activeTab() === tab;
|
const active = () => nav.activeTab() === tab;
|
||||||
const focused = () =>
|
// The sidebar pane is gone (task 01); the tab strip stays
|
||||||
active() && nav.activePane() === SIDEBAR_PANE;
|
// rendered for now (removed in task 05) and highlights the
|
||||||
|
// active tab. Clicking just switches tabs — no pane focus.
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
focused() ? t.accent : active() ? t.primary : t.background
|
active() ? t.primary : t.background
|
||||||
}
|
}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(SIDEBAR_PANE);
|
|
||||||
nav.setActiveTab(tab);
|
nav.setActiveTab(tab);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focused() || active() ? t.surface : t.textMuted}>
|
<text fg={active() ? t.surface : t.textMuted}>
|
||||||
{focused() ? "❯ " : " "}
|
{active() ? "❯ " : " "}
|
||||||
{tab}. {TAB_LABEL[tab]}
|
{tab}. {TAB_LABEL[tab]}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -484,9 +468,7 @@ export function Shell() {
|
|||||||
</text>
|
</text>
|
||||||
<text fg={t.textMuted} paddingLeft={1}>
|
<text fg={t.textMuted} paddingLeft={1}>
|
||||||
{TAB_LABEL[nav.activeTab()]} ·{" "}
|
{TAB_LABEL[nav.activeTab()]} ·{" "}
|
||||||
{nav.activePane() === SIDEBAR_PANE
|
{nav.isDepthTab()
|
||||||
? "tabs"
|
|
||||||
: nav.isDepthTab()
|
|
||||||
? `depth ${nav.currentDepth()}`
|
? `depth ${nav.currentDepth()}`
|
||||||
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
|
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
|
||||||
</text>
|
</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 { createSimpleContext } from "./helper";
|
||||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
import { createNavigation } from "./navigation-store";
|
||||||
|
|
||||||
// ── Yazi-style navigation state ──────────────────────────────────────────────
|
// Re-export the entire nav model surface so existing imports from
|
||||||
// Two pane models coexist:
|
// `@/context/NavigationContext` keep resolving.
|
||||||
//
|
export * from "./navigation-store";
|
||||||
// • 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;
|
|
||||||
|
|
||||||
export const { use: useNavigation, provider: NavigationProvider } =
|
export const { use: useNavigation, provider: NavigationProvider } =
|
||||||
createSimpleContext({
|
createSimpleContext({
|
||||||
name: "Navigation",
|
name: "Navigation",
|
||||||
init: () => {
|
init: () => createNavigation(),
|
||||||
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
456
src/context/navigation-store.ts
Normal file
456
src/context/navigation-store.ts
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
/**
|
||||||
|
* 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 { createSignal, batch } from "solid-js";
|
||||||
|
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||||
|
|
||||||
|
export enum NavMode {
|
||||||
|
NORMAL = "NORMAL",
|
||||||
|
VISUAL = "VISUAL",
|
||||||
|
COMMAND = "COMMAND",
|
||||||
|
INPUT = "INPUT",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current 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)] }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Apply all tab-switch side effects synchronously. Done here in the
|
||||||
|
* imperative `switchTab` path rather than a reactive `createEffect`
|
||||||
|
* because this module is exercised by unit tests without the OpenTUI
|
||||||
|
* JSX runtime, and in that environment Solid's `createEffect` is a
|
||||||
|
* no-op (server build). Routing every tab change through this helper
|
||||||
|
* keeps the behavior identical under both runtimes.
|
||||||
|
*
|
||||||
|
* - seed a root frame for fresh depth-tabs
|
||||||
|
* - reset focus to the current/center pane (no sidebar pane)
|
||||||
|
* - clear mode/command/visual/count state */
|
||||||
|
const applyTabSwitch = (tab: TABS) => {
|
||||||
|
ensureStack(tab);
|
||||||
|
batch(() => {
|
||||||
|
setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
setMode(NavMode.NORMAL);
|
||||||
|
setCount(null);
|
||||||
|
setCommandBuffer("");
|
||||||
|
setCommandError(null);
|
||||||
|
setVisualAnchor(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── depth stack accessors ──────────────────────────────────────────────
|
||||||
|
// Plain functions (not createMemo) so they recompute on every read.
|
||||||
|
// On the client build these are read inside reactive JSX contexts so
|
||||||
|
// their underlying signal reads are still tracked; on the server build
|
||||||
|
// (used by unit tests) createMemo is a no-op that freezes at creation,
|
||||||
|
// so a plain function is the only option that stays correct in tests.
|
||||||
|
const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
|
||||||
|
const currentDepth = (): number =>
|
||||||
|
Math.max(0, depthStack().length - 1);
|
||||||
|
const topFrame = (): DepthFrame | undefined =>
|
||||||
|
depthStack()[depthStack().length - 1];
|
||||||
|
const isDepthTab = () => DEPTH_TABS.has(activeTab());
|
||||||
|
|
||||||
|
/** Focus within a given depth's frame (default = current/top). */
|
||||||
|
const depthFocus = (d: number = currentDepth()) =>
|
||||||
|
depthStack()[d]?.focus ?? 0;
|
||||||
|
|
||||||
|
const setDepthFocus = (i: number, d: number = currentDepth()) =>
|
||||||
|
setStacks((s) => {
|
||||||
|
const st = s[activeTab()];
|
||||||
|
if (!st || d < 0 || d >= st.length) return s;
|
||||||
|
const next = st.slice();
|
||||||
|
next[d] = { ...next[d], focus: i };
|
||||||
|
return { ...s, [activeTab()]: next };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Push a child frame (drill in). */
|
||||||
|
const pushDepth = (frame: DepthFrame) =>
|
||||||
|
setStacks((s) => {
|
||||||
|
const st = s[activeTab()] ?? [];
|
||||||
|
return { ...s, [activeTab()]: [...st, frame] };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Pop the top frame (go back up a depth). No-op at root. Returns
|
||||||
|
* true if a frame was popped. */
|
||||||
|
const popDepth = (): boolean => {
|
||||||
|
let popped = false;
|
||||||
|
setStacks((s) => {
|
||||||
|
const st = s[activeTab()] ?? [];
|
||||||
|
if (st.length <= 1) return s;
|
||||||
|
popped = true;
|
||||||
|
return { ...s, [activeTab()]: st.slice(0, -1) };
|
||||||
|
});
|
||||||
|
return popped;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── tab switching ──────────────────────────────────────────────────────
|
||||||
|
/** Internal: set activeTab + run all side effects (root-frame seed,
|
||||||
|
* pane/mode/command reset). Called by gotoTab/nextTab/prevTab so every
|
||||||
|
* tab change — programmatic or key-driven — goes through one path. */
|
||||||
|
const switchTab = (tab: TABS) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
applyTabSwitch(tab);
|
||||||
|
};
|
||||||
|
const gotoTab = (tab: TABS) => {
|
||||||
|
if (tab < 1 || tab > TabsCount) return;
|
||||||
|
switchTab(tab);
|
||||||
|
};
|
||||||
|
const nextTab = () => {
|
||||||
|
const t = activeTab() >= TabsCount ? 1 : ((activeTab() + 1) as TABS);
|
||||||
|
switchTab(t);
|
||||||
|
};
|
||||||
|
const prevTab = () => {
|
||||||
|
const t = activeTab() <= 1 ? TabsCount : ((activeTab() - 1) as TABS);
|
||||||
|
switchTab(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── pane focus ──────────────────────────────────────────────────────────
|
||||||
|
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||||
|
|
||||||
|
/** 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 = () => [...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 {
|
export enum DIRECTION {
|
||||||
Increment,
|
Increment,
|
||||||
Decrement,
|
Decrement,
|
||||||
@@ -49,40 +42,31 @@ export function rootFrameFor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayerGraph = {
|
// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`,
|
||||||
[TABS.FEED]: FeedPage,
|
// split out so this module stays free of `.tsx`/JSX imports (unit-testable).
|
||||||
[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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Yazi-style pane grow ratios (parent : current : preview) = [1, 3, 3].
|
// Yazi-style pane grow ratios (parent : current : preview). Panes use
|
||||||
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
|
// flexGrow (Yoga) so columns always sum to the row width regardless of
|
||||||
// of terminal size — more robust than fixed percentages and exactly mirrors
|
// 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).
|
// 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 = {
|
export const PANE_RATIO = {
|
||||||
parent: 1,
|
parent: 1,
|
||||||
current: 3,
|
current: 3,
|
||||||
preview: 3,
|
preview: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/
|
// Number of *focusable* content panes per tab. The three visible columns
|
||||||
// Settings) now have a single focusable content pane (the center/current
|
// (parent | current | preview) are a *render* concern, NOT three panes — for
|
||||||
// column at depth 0..N); prev and preview are derived, not focusable. Search
|
// depth-tabs only the current column (index 0) is focusable, so this is 1.
|
||||||
// keeps its 3 fixed panes; Player is single-pane. The Shell's h/l dispatch
|
// Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop
|
||||||
// routes depth-tabs to push/pop instead of pane swipe. Defined here (after
|
// with `h` (noop at depth 0) via the Shell dispatch — they never call swipe.
|
||||||
// TABS) to avoid re-introducing the old NavigationContext top-level-init
|
// Search keeps its 3 fixed focusable panes; Player is single-pane. Defined
|
||||||
// circular deadlock.
|
// here (after TABS) to avoid re-introducing the old NavigationContext
|
||||||
|
// top-level-init circular deadlock.
|
||||||
export const TabPaneCount: Record<TABS, number> = {
|
export const TabPaneCount: Record<TABS, number> = {
|
||||||
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
||||||
[TABS.MYSHOWS]: 1, // depth: shows → 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