Compare commits
13 Commits
21b088b5a9
...
c8d29ed59d
| Author | SHA1 | Date | |
|---|---|---|---|
| c8d29ed59d | |||
| 9e7a44309a | |||
| 6cd90ad6c0 | |||
| 6c0affc77b | |||
| b24c83711d | |||
| 866fcd7574 | |||
| 7c7d487ca6 | |||
| 798178f21f | |||
| 3f61303756 | |||
| 139a258987 | |||
| cfa4ef0c47 | |||
| 5b667367a5 | |||
| b1bb9d9a1e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
.harness/
|
||||
.ralpi
|
||||
|
||||
2
bunfig.test.toml
Normal file
2
bunfig.test.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/preload/solid-test-plugin.ts"]
|
||||
@@ -1 +1,4 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = "@opentui/solid/preload"
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
/**
|
||||
* Shell — yazi-style application chrome.
|
||||
*
|
||||
* Renders the tabs as a vertical sidebar on the left (the root pane), the
|
||||
* active page (which owns its own panes) to the right of it, and a bottom
|
||||
* status/command bar spanning the full width. A single `useKeyboard` router
|
||||
* translates keystrokes (via the sequence-aware keybind matcher) into actions:
|
||||
* global ones (tabs, modes, audio, quit, help, command) are handled here;
|
||||
* Renders the active page (which owns its own three-column parent | current |
|
||||
* preview panes) full-width, with a bottom status/command bar that also
|
||||
* carries the tab strip. A single `useKeyboard` router translates keystrokes
|
||||
* (via the sequence-aware keybind matcher) into actions: the unified router
|
||||
* in `@/utils/dispatch` handles tabs (digits `1`-`6`, `[`/`]`), h/l depth
|
||||
* drill/pop + fixed-pane swipe, modes, audio, quit, help, and command; the
|
||||
* pane/list ones are dispatched to the active page over the `nav.action`
|
||||
* event bus.
|
||||
* event bus. There is no sidebar pane.
|
||||
*/
|
||||
|
||||
import { createSignal, Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
SIDEBAR_PANE,
|
||||
DEPTH_CENTER_PANE,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
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, TabPaneCount } from "@/utils/navigation";
|
||||
import { createDispatcher } from "@/utils/dispatch";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
@@ -37,54 +37,6 @@ const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
};
|
||||
|
||||
/** Actions the active page is responsible for (pane/list-local). */
|
||||
const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>(
|
||||
[
|
||||
"move-down",
|
||||
"move-up",
|
||||
"page-down",
|
||||
"page-up",
|
||||
"full-down",
|
||||
"full-up",
|
||||
"jump-down",
|
||||
"jump-up",
|
||||
"goto-top",
|
||||
"goto-bottom",
|
||||
"toggle-select",
|
||||
"visual-mode",
|
||||
"toggle-all",
|
||||
"invert-all",
|
||||
"open",
|
||||
"open-interactive",
|
||||
"search",
|
||||
"filter",
|
||||
"sort",
|
||||
"toggle-hidden",
|
||||
"refresh",
|
||||
],
|
||||
);
|
||||
|
||||
/** Movement actions the sidebar pane handles itself (its list = the tabs,
|
||||
* length TabsCount). Routed through the standard move/gotoIndex API. */
|
||||
const SIDEBAR_ACTIONS: ReadonlySet<KeybindActionName> = new Set([
|
||||
"move-down",
|
||||
"move-up",
|
||||
"jump-down",
|
||||
"jump-up",
|
||||
"page-down",
|
||||
"page-up",
|
||||
"goto-top",
|
||||
"goto-bottom",
|
||||
]);
|
||||
|
||||
function tabByDigit(action: KeybindActionName): TABS | null {
|
||||
if (action.startsWith("tab-goto-")) {
|
||||
const n = Number(action.slice("tab-goto-".length));
|
||||
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Shell() {
|
||||
const theme = useTheme();
|
||||
const t = theme.theme;
|
||||
@@ -141,7 +93,7 @@ export function Shell() {
|
||||
case "q":
|
||||
case "quit":
|
||||
case "exit":
|
||||
process.exit(0);
|
||||
return process.exit(0);
|
||||
case "refresh":
|
||||
case "r":
|
||||
emit("nav.action", {
|
||||
@@ -235,148 +187,16 @@ export function Shell() {
|
||||
}
|
||||
|
||||
// ── Unified router (normal + visual) ───────────────────────────────────────
|
||||
function dispatch(action: KeybindActionName, evt: any) {
|
||||
const tab = nav.activeTab();
|
||||
const pane = nav.activePane();
|
||||
switch (action) {
|
||||
// ── modes ──
|
||||
case "escape":
|
||||
evt.preventDefault();
|
||||
if (nav.mode() === NavMode.VISUAL) {
|
||||
nav.toNormal();
|
||||
break;
|
||||
}
|
||||
k.clearPending();
|
||||
break;
|
||||
case "command":
|
||||
evt.preventDefault();
|
||||
nav.enterCommand();
|
||||
break;
|
||||
case "visual-mode":
|
||||
evt.preventDefault();
|
||||
nav.enterVisual();
|
||||
break;
|
||||
case "toggle-select":
|
||||
evt.preventDefault();
|
||||
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||
break;
|
||||
case "toggle-all":
|
||||
case "invert-all":
|
||||
evt.preventDefault();
|
||||
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||
break;
|
||||
|
||||
// ── tabs ──
|
||||
case "tab-next":
|
||||
evt.preventDefault();
|
||||
nav.nextTab();
|
||||
break;
|
||||
case "tab-prev":
|
||||
evt.preventDefault();
|
||||
nav.prevTab();
|
||||
break;
|
||||
default: {
|
||||
const dt = tabByDigit(action);
|
||||
if (dt) {
|
||||
evt.preventDefault();
|
||||
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.
|
||||
if (action === "swipe-prev") {
|
||||
evt.preventDefault();
|
||||
if (
|
||||
nav.isDepthTab() &&
|
||||
nav.activePane() === DEPTH_CENTER_PANE &&
|
||||
nav.currentDepth() > 0
|
||||
) {
|
||||
nav.popDepth();
|
||||
} else {
|
||||
nav.swipe(-1, TabPaneCount[tab]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
evt.preventDefault();
|
||||
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
|
||||
emit("nav.action", {
|
||||
action: "open",
|
||||
tab,
|
||||
pane: DEPTH_CENTER_PANE,
|
||||
mode: nav.mode(),
|
||||
const { dispatch } = createDispatcher({
|
||||
nav,
|
||||
audio: {
|
||||
togglePlayback: audio.togglePlayback,
|
||||
seekRelative: audio.seekRelative,
|
||||
},
|
||||
k,
|
||||
setShowHelp,
|
||||
advanceEpisode,
|
||||
});
|
||||
} else {
|
||||
nav.swipe(1, TabPaneCount[tab]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// ── audio transport (global) ──
|
||||
if (action === "audio-toggle") {
|
||||
evt.preventDefault();
|
||||
audio.togglePlayback().catch(() => {});
|
||||
break;
|
||||
}
|
||||
if (action === "audio-seek-forward") {
|
||||
evt.preventDefault();
|
||||
audio.seekRelative(10).catch(() => {});
|
||||
break;
|
||||
}
|
||||
if (action === "audio-seek-backward") {
|
||||
evt.preventDefault();
|
||||
audio.seekRelative(-10).catch(() => {});
|
||||
break;
|
||||
}
|
||||
if (action === "audio-next") {
|
||||
evt.preventDefault();
|
||||
advanceEpisode(1);
|
||||
break;
|
||||
}
|
||||
if (action === "audio-prev") {
|
||||
evt.preventDefault();
|
||||
advanceEpisode(-1);
|
||||
break;
|
||||
}
|
||||
// ── global app ──
|
||||
if (action === "quit") {
|
||||
evt.preventDefault();
|
||||
process.exit(0);
|
||||
}
|
||||
if (action === "help") {
|
||||
evt.preventDefault();
|
||||
setShowHelp((v) => !v);
|
||||
}
|
||||
|
||||
// ── page-local list/pane actions ──
|
||||
if (PAGE_ACTIONS.has(action)) {
|
||||
evt.preventDefault();
|
||||
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(
|
||||
(evt: any) => {
|
||||
@@ -414,60 +234,36 @@ export function Shell() {
|
||||
height="100%"
|
||||
backgroundColor={t.surface}
|
||||
>
|
||||
{/* ── Middle row: tab sidebar (root pane) + active page ──────────────── */}
|
||||
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
||||
<box flexDirection="row" flexGrow={1} width="100%">
|
||||
{/* ── Left tab sidebar ─────────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
width={14}
|
||||
height="100%"
|
||||
backgroundColor={t.background}
|
||||
border
|
||||
borderColor={t.border}
|
||||
>
|
||||
<For
|
||||
each={Object.values(TABS).filter(
|
||||
(v): v is TABS => typeof v === "number",
|
||||
)}
|
||||
>
|
||||
{(tab) => {
|
||||
const active = () => nav.activeTab() === tab;
|
||||
const focused = () =>
|
||||
active() && nav.activePane() === SIDEBAR_PANE;
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
backgroundColor={
|
||||
focused() ? t.accent : active() ? t.primary : t.background
|
||||
}
|
||||
paddingLeft={1}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(SIDEBAR_PANE);
|
||||
nav.setActiveTab(tab);
|
||||
}}
|
||||
>
|
||||
<text fg={focused() || active() ? t.surface : t.textMuted}>
|
||||
{focused() ? "❯ " : " "}
|
||||
{tab}. {TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<box flexGrow={1} backgroundColor={t.background} />
|
||||
<Show when={nowPlaying()}>
|
||||
<box paddingLeft={1} backgroundColor={t.background}>
|
||||
<text fg={t.textMuted}>{nowPlaying()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── Active page (owns its panes) ────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={1} height="100%">
|
||||
<Show
|
||||
when={nav.atRootTab()}
|
||||
fallback={
|
||||
<box flexGrow={1} width="100%">
|
||||
{LayerGraph[nav.activeTab()]()}
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
||||
<YaziPaneRow
|
||||
parent={
|
||||
<box padding={1}>
|
||||
<text fg={t.textMuted}>—</text>
|
||||
</box>
|
||||
}
|
||||
current={<TabListPane />}
|
||||
preview={
|
||||
<box padding={1}>
|
||||
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
||||
</box>
|
||||
}
|
||||
parentLabel="Up"
|
||||
currentLabel="Tabs"
|
||||
previewLabel=""
|
||||
focused
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="row"
|
||||
@@ -483,24 +279,30 @@ export function Shell() {
|
||||
{modeLabel()}
|
||||
</text>
|
||||
<text fg={t.textMuted} paddingLeft={1}>
|
||||
{TAB_LABEL[nav.activeTab()]} ·{" "}
|
||||
{nav.activePane() === SIDEBAR_PANE
|
||||
? "tabs"
|
||||
: nav.isDepthTab()
|
||||
{nav.atRootTab()
|
||||
? "Tabs · root"
|
||||
: `${TAB_LABEL[nav.activeTab()]} · ${
|
||||
nav.isDepthTab()
|
||||
? `depth ${nav.currentDepth()}`
|
||||
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
|
||||
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
|
||||
}`}
|
||||
</text>
|
||||
<Show when={nav.selectedIds().length > 0}>
|
||||
<text fg={t.warning} paddingLeft={1}>
|
||||
● {nav.selectedIds().length}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={nowPlaying()}>
|
||||
<text fg={t.primary} paddingLeft={1}>
|
||||
{nowPlaying()}
|
||||
</text>
|
||||
</Show>
|
||||
<box flexGrow={1} />
|
||||
<text fg={t.textMuted} paddingRight={1}>
|
||||
{pendingLabel()}
|
||||
</text>
|
||||
<text fg={t.textMuted} paddingRight={1}>
|
||||
:cmd ~help q quit
|
||||
~
|
||||
</text>
|
||||
</>
|
||||
}
|
||||
@@ -517,7 +319,6 @@ export function Shell() {
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
|
||||
<Show when={showHelp()}>
|
||||
<HelpOverlay
|
||||
@@ -545,7 +346,9 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
|
||||
{
|
||||
group: "Panes",
|
||||
items: [
|
||||
["h/l", "swipe pane"],
|
||||
["j/k", "switch tab (tab panel)"],
|
||||
["l/enter", "enter tab content"],
|
||||
["h", "back to tab panel"],
|
||||
["1-6 / [ ]", "switch tabs"],
|
||||
[":", "command"],
|
||||
["~", "help"],
|
||||
|
||||
78
src/components/TabPanel.tsx
Normal file
78
src/components/TabPanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* TabListPane — the tab list as a pane you can drop into the UP | CURRENT |
|
||||
* PREVIEW flow (replaces the old fixed chrome tab column).
|
||||
*
|
||||
* Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and
|
||||
* accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight.
|
||||
* `focused` only matters to the surrounding frame (the CURRENT column draws
|
||||
* its own accent ring in YaziPaneRow); when rendered as the muted UP/parent
|
||||
* column (`muted`), the cursor highlight is suppressed and only the active ●
|
||||
* shows, so it reads as the read-only parent listing.
|
||||
*/
|
||||
|
||||
import { For } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation } from "@/context/NavigationContext";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
|
||||
const TAB_LABEL: Record<TABS, string> = {
|
||||
[TABS.FEED]: "Feed",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
};
|
||||
|
||||
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||
const TAB_ORDER = Object.values(TABS).filter(
|
||||
(v): v is TABS => typeof v === "number",
|
||||
) as TABS[];
|
||||
|
||||
export function TabListPane(props: { muted?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
|
||||
const cursor = () => nav.tabCursor();
|
||||
const active = () => nav.activeTab();
|
||||
const muted = () => props.muted ?? false;
|
||||
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab && !muted();
|
||||
const isActive = () => active() === tab;
|
||||
const fg = () =>
|
||||
isCursor()
|
||||
? theme.textSelectedPrimary
|
||||
: isActive()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
backgroundColor={isCursor() ? theme.primary : "transparent"}
|
||||
>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : "transparent"}
|
||||
>
|
||||
{isActive() ? "●" : " "}
|
||||
</text>
|
||||
<text
|
||||
width={2}
|
||||
fg={isCursor() ? theme.textSelectedPrimary : theme.textMuted}
|
||||
>
|
||||
{tab}
|
||||
</text>
|
||||
<text fg={fg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
190
src/components/YaziPaneRow.tsx
Normal file
190
src/components/YaziPaneRow.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* YaziPaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||
*
|
||||
* Implements yazi's `mgr.ratio = [1, 3, 3]` contract: three bordered columns
|
||||
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
||||
* so the ratio is exact regardless of content width — a column's content can
|
||||
* never stretch its slot.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* KEEPS its 1/7 slot when blank (never collapses to width 0).
|
||||
* current — the current-depth list. The only focusable content column; it
|
||||
* carries the accent focus ring when `focused` is truthy.
|
||||
* preview — detail of the hovered item in `current`; always muted border.
|
||||
*
|
||||
* The primitive is purely structural: callers pass their own JSX per column
|
||||
* (static elements or accessors) plus header labels. Theme colors are resolved
|
||||
* internally via `useTheme()`. Only the current column's `<scrollbox>` receives
|
||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
||||
*
|
||||
* Example:
|
||||
* <YaziPaneRow
|
||||
* parent={parentList}
|
||||
* current={currentList}
|
||||
* preview={detail}
|
||||
* parentLabel="Up"
|
||||
* currentLabel="List · 42"
|
||||
* previewLabel="Detail"
|
||||
* focused={isActive}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { createMemo } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { RGBA } from "@opentui/core";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
type PaneContent = JSX.Element | (() => JSX.Element);
|
||||
type PaneLabel = string | (() => string);
|
||||
|
||||
export type YaziPaneRowProps = {
|
||||
/** Parent column content (previous-depth list, or null for a muted
|
||||
* placeholder — the 1/7 slot is always preserved). */
|
||||
parent?: PaneContent;
|
||||
/** Current column content (the focused list). */
|
||||
current?: PaneContent;
|
||||
/** Preview column content (detail of the hovered item). */
|
||||
preview?: PaneContent;
|
||||
parentLabel?: PaneLabel;
|
||||
currentLabel?: PaneLabel;
|
||||
previewLabel?: PaneLabel;
|
||||
/** Whether the current column carries the accent focus ring. Defaults to
|
||||
* true; pass `false` (or a signal) when the row is inactive. Parent and
|
||||
* preview columns always render muted borders. */
|
||||
focused?: boolean | (() => boolean);
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function resolveLabel(v: PaneLabel | undefined): string {
|
||||
if (v == null) return "";
|
||||
return typeof v === "function" ? v() : v;
|
||||
}
|
||||
|
||||
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
||||
* We deliberately do NOT use Solid's `children()` helper here: that helper
|
||||
* flattens accessor children into a stable resolved-nodes array and is the
|
||||
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
|
||||
* switching between a depth-1 list fragment and a depth-2 editor — both
|
||||
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
|
||||
* root swap, freezing the previous subtree in place. Instead we hand the
|
||||
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
|
||||
* which Solid compiles into a tracked `insert` effect that disposes the old
|
||||
* subtree and mounts the new whenever the accessor returns a different
|
||||
* element identity. */
|
||||
function normalizeContent(
|
||||
v: PaneContent | undefined,
|
||||
): () => JSX.Element | undefined {
|
||||
if (v == null) return () => undefined;
|
||||
return typeof v === "function" ? (v as () => JSX.Element) : () => v;
|
||||
}
|
||||
|
||||
function Placeholder(props: { color: () => RGBA }) {
|
||||
return (
|
||||
<box padding={1}>
|
||||
<text fg={props.color()}>—</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pane column ─────────────────────────────────────────────────────────────
|
||||
function YaziPane(props: {
|
||||
grow: number;
|
||||
label: () => string;
|
||||
content: () => JSX.Element | undefined;
|
||||
borderColor: () => RGBA;
|
||||
scrollFocused: () => boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||
|
||||
// Memoize accessor results so the prop expressions below stay reactive
|
||||
// when the underlying signals (e.g. `focused`) change.
|
||||
const borderColor = createMemo(() => props.borderColor());
|
||||
const scrollFocused = createMemo(() => props.scrollFocused());
|
||||
|
||||
return (
|
||||
<box flexDirection="column" flexGrow={props.grow} flexBasis={0} height="100%">
|
||||
{/* ── slim header label row ─────────────────────────────────────────── */}
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||
</box>
|
||||
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={scrollFocused()}
|
||||
border
|
||||
borderColor={borderColor()}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/*
|
||||
* Render the content accessor directly via a reactive expression.
|
||||
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
|
||||
* effect that re-runs whenever the accessor's tracked signals
|
||||
* change (e.g. `depth()` swapping the root from a list fragment to
|
||||
* an editor). Solid disposes the previously-rendered subtree and
|
||||
* mounts the new element identity. `null`/`undefined` falls back
|
||||
* to the muted placeholder so the parent pane keeps its 1/7 slot
|
||||
* visibly blank at depth 0. This is the correct tool for root
|
||||
* swapping — unlike Solid's `children()` / `<Show>`-children,
|
||||
* which only react to truthiness flips, not truthy<@->truthy root
|
||||
* identity changes.
|
||||
*/}
|
||||
{props.content() ?? <Placeholder color={muted} />}
|
||||
</scrollbox>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||
export function YaziPaneRow(props: YaziPaneRowProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
/** true → the current column gets the accent focus ring. */
|
||||
const focused = createMemo(() => {
|
||||
const f = props.focused;
|
||||
return typeof f === "function" ? f() : f ?? true;
|
||||
});
|
||||
|
||||
// Normalize static JSX and accessor children into reactive accessors
|
||||
// (see normalizeContent for why we avoid Solid's `children()` helper).
|
||||
const parentContent = normalizeContent(props.parent);
|
||||
const currentContent = normalizeContent(props.current);
|
||||
const previewContent = normalizeContent(props.preview);
|
||||
|
||||
const parentLabel = createMemo(() => resolveLabel(props.parentLabel));
|
||||
const currentLabel = createMemo(() => resolveLabel(props.currentLabel));
|
||||
const previewLabel = createMemo(() => resolveLabel(props.previewLabel));
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.parent}
|
||||
label={parentLabel}
|
||||
content={parentContent}
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
{/* ── current (3/7) — the focused list; accent ring when focused ───── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.current}
|
||||
label={currentLabel}
|
||||
content={currentContent}
|
||||
borderColor={() => (focused() ? theme.accent : theme.border)}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
||||
<YaziPane
|
||||
grow={PANE_RATIO.preview}
|
||||
label={previewLabel}
|
||||
content={previewContent}
|
||||
borderColor={() => theme.border}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
538
src/context/navigation-store.ts
Normal file
538
src/context/navigation-store.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* 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 under a single TAB list:
|
||||
*
|
||||
* • The tab list is the flow's leading pane (TAB_PANE = 0) — a normal,
|
||||
* focusable pane at the left of every tab's content, just like in yazi.
|
||||
* Starting focus lives here; tab switches made from here keep focus here.
|
||||
* When it is focused, j/k moves the tab cursor (`tabCursor`) and
|
||||
* `l`/Enter opens the hovered tab into its content. Swiping left past
|
||||
* it goes out of the panes (inert — there is no pane beyond it).
|
||||
*
|
||||
* • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE
|
||||
* focusable content pane — the current column (DEPTH_CENTER_PANE = 1). 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. Depth is unbounded. At depth 0 `h` moves focus
|
||||
* to the tab list (TAB_PANE).
|
||||
*
|
||||
* • 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
|
||||
* [1, paneCount]; `h` on the first content pane (1) moves focus to the tab
|
||||
* list; `h` on the tab list stays out-of-panear (inert).
|
||||
*
|
||||
* Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`.
|
||||
* Focus on the tab list persists across a tab switch; from there `l`/Enter
|
||||
* drops into the active tab's content (panes 1..N).
|
||||
*/
|
||||
import { createSignal, batch } from "solid-js";
|
||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||
|
||||
export enum NavMode {
|
||||
NORMAL = "NORMAL",
|
||||
VISUAL = "VISUAL",
|
||||
COMMAND = "COMMAND",
|
||||
INPUT = "INPUT",
|
||||
}
|
||||
|
||||
/** The current content pane of the active tab, i.e. the focusable column
|
||||
* (index 1) for depth-tabs, and the default landing pane for fixed-pane
|
||||
* tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made
|
||||
* while focused on content resets `activePane` to this pane (unless already
|
||||
* on the tab list). */
|
||||
export const DEPTH_CENTER_PANE = 1 as PaneId;
|
||||
|
||||
/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the
|
||||
* left of the active tab's content (1..n). It is the app's outermost pane:
|
||||
* starting focus lives here, tab switches made from it keep focus on it, and
|
||||
* swiping left past the first content pane returns to it. Swiping left again
|
||||
* — beyond it — goes out of the panes (no-op). While it is focused, j/k
|
||||
* moves the tab cursor and `l`/Enter opens the hovered tab's content. */
|
||||
/** Content pane slots for fixed-pane tabs (Search). Values are the global
|
||||
* pane indices (content starts at 1). */
|
||||
export enum PaneSlot {
|
||||
PARENT = 1, // Search: input
|
||||
CURRENT = 2, // Search: results
|
||||
PREVIEW = 3, // Search: detail
|
||||
}
|
||||
|
||||
export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes
|
||||
|
||||
// ── Depth stack ──────────────────────────────────────────────────────────────
|
||||
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
|
||||
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
|
||||
* focused row index within that list. `ctx` optionally carries an id or
|
||||
* payload the page needs to derive the list (e.g. a feed id). */
|
||||
export type DepthFrame = {
|
||||
kind: string;
|
||||
ctx?: string;
|
||||
focus: number;
|
||||
};
|
||||
|
||||
// ── Selection store ───────────────────────────────────────────────────────────
|
||||
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
|
||||
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
|
||||
// selection anchored at the focused index.
|
||||
|
||||
type SelectionMap = Record<string, Set<string>>;
|
||||
|
||||
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
|
||||
|
||||
/**
|
||||
* Construct a fresh, self-contained navigation state graph.
|
||||
*
|
||||
* Exported (not just inlined into the Solid provider) so unit tests can build
|
||||
* a nav graph inside a `createRoot` without rendering any provider tree.
|
||||
*/
|
||||
export function createNavigation() {
|
||||
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
||||
// The root tab panel's cursor — which tab j/k is currently hovering. It is
|
||||
// independent of `activeTab` until l/Enter activates it (activateTabCursor)
|
||||
// or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves
|
||||
// just like any other yazi list: j/k move the cursor, Enter/l open.
|
||||
const [tabCursorSignal, setTabCursor] = createSignal<TABS>(TABS.FEED);
|
||||
// App focus starts on the tab list (the app root). `activePane` drives the
|
||||
// fixed-pane pages (Search/Player) and each page's content focus ring;
|
||||
// depth-tab focus is instead described by the per-tab depth stack plus the
|
||||
// `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and
|
||||
// slides into the UP/parent pane once content is opened).
|
||||
const [activePane, setActivePane] = createSignal<PaneId>(DEPTH_CENTER_PANE);
|
||||
// Whether focus is on the tab-list root view — the tab is the CURRENT pane
|
||||
// with nothing above it. Opening a tab moves it to UP; deeper goes back out.
|
||||
const [atRootTabSignal, setAtTabRoot] = createSignal(true);
|
||||
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
|
||||
const [count, setCount] = createSignal<number | null>(null);
|
||||
const [inputFocused, setInputFocused] = createSignal(false);
|
||||
|
||||
// per-tab depth stack. Depth-tabs get a root frame on first visit.
|
||||
const [stacks, setStacks] = createSignal<Partial<Record<TABS, DepthFrame[]>>>(
|
||||
{ [TABS.FEED]: [rootFrameFor(TABS.FEED)] },
|
||||
);
|
||||
|
||||
// per-pane focused index (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.
|
||||
*
|
||||
* - when switching to a special (fixed-pane) tab from the tab root, leave the
|
||||
* root — those tabs render only their content, never the tab-list view.
|
||||
* - keep focus on the tab root if it is focused (depth-tab switch),
|
||||
* otherwise recenter on the active tab's current/center pane
|
||||
* - clear mode/command/visual/count state */
|
||||
const applyTabSwitch = (tab: TABS) => {
|
||||
ensureStack(tab);
|
||||
batch(() => {
|
||||
// A depth-tab switch from the root keeps the root; switching to a
|
||||
// special (fixed-pane) tab always leaves it. Switches made from
|
||||
// inside content drop into the new tab's content pane.
|
||||
if (atRootTabSignal() && !DEPTH_TABS.has(tab)) {
|
||||
setAtTabRoot(false);
|
||||
}
|
||||
if (!atRootTabSignal()) {
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
}
|
||||
setMode(NavMode.NORMAL);
|
||||
setCount(null);
|
||||
setCommandBuffer("");
|
||||
setCommandError(null);
|
||||
setVisualAnchor(null);
|
||||
});
|
||||
};
|
||||
|
||||
// ── depth stack accessors ──────────────────────────────────────────────
|
||||
// Plain functions (not createMemo) so they recompute on every read.
|
||||
// On the client build these are read inside reactive JSX contexts so
|
||||
// their underlying signal reads are still tracked; on the server build
|
||||
// (used by unit tests) createMemo is a no-op that freezes at creation,
|
||||
// so a plain function is the only option that stays correct in tests.
|
||||
const depthStack = (): DepthFrame[] => depthStackFor(activeTab());
|
||||
const currentDepth = (): number => Math.max(0, depthStack().length - 1);
|
||||
const topFrame = (): DepthFrame | undefined =>
|
||||
depthStack()[depthStack().length - 1];
|
||||
const isDepthTab = () => DEPTH_TABS.has(activeTab());
|
||||
|
||||
/** Focus within a given depth's frame (default = current/top). */
|
||||
const depthFocus = (d: number = currentDepth()) =>
|
||||
depthStack()[d]?.focus ?? 0;
|
||||
|
||||
const setDepthFocus = (i: number, d: number = currentDepth()) =>
|
||||
setStacks((s) => {
|
||||
const st = s[activeTab()];
|
||||
if (!st || d < 0 || d >= st.length) return s;
|
||||
const next = st.slice();
|
||||
next[d] = { ...next[d], focus: i };
|
||||
return { ...s, [activeTab()]: next };
|
||||
});
|
||||
|
||||
/** Push a child frame (drill in). */
|
||||
const pushDepth = (frame: DepthFrame) =>
|
||||
setStacks((s) => {
|
||||
const st = s[activeTab()] ?? [];
|
||||
return { ...s, [activeTab()]: [...st, frame] };
|
||||
});
|
||||
|
||||
/** Pop the top frame (go back up a depth). No-op at root. Returns
|
||||
* true if a frame was popped. */
|
||||
const popDepth = (): boolean => {
|
||||
let popped = false;
|
||||
setStacks((s) => {
|
||||
const st = s[activeTab()] ?? [];
|
||||
if (st.length <= 1) return s;
|
||||
popped = true;
|
||||
return { ...s, [activeTab()]: st.slice(0, -1) };
|
||||
});
|
||||
return popped;
|
||||
};
|
||||
|
||||
// ── tab switching ──────────────────────────────────────────────────────
|
||||
/** Internal: set activeTab + run all side effects (root-frame seed,
|
||||
* pane/mode/command reset). Called by gotoTab/nextTab/prevTab so every
|
||||
* tab change — programmatic or key-driven — goes through one path. */
|
||||
const switchTab = (tab: TABS) => {
|
||||
setActiveTab(tab);
|
||||
// a direct tab switch re-syncs the root panel's cursor so the panel
|
||||
// reflects what is actually active.
|
||||
setTabCursor(tab);
|
||||
applyTabSwitch(tab);
|
||||
};
|
||||
const gotoTab = (tab: TABS) => {
|
||||
if (tab < 1 || tab > TabsCount) return;
|
||||
switchTab(tab);
|
||||
};
|
||||
const nextTab = () => {
|
||||
const t = activeTab() >= TabsCount ? 1 : ((activeTab() + 1) as TABS);
|
||||
switchTab(t);
|
||||
};
|
||||
const prevTab = () => {
|
||||
const t = activeTab() <= 1 ? TabsCount : ((activeTab() - 1) as TABS);
|
||||
switchTab(t);
|
||||
};
|
||||
|
||||
// ── pane focus ──────────────────────────────────────────────────────────
|
||||
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||
|
||||
/** Move focus to the adjacent content pane (fixed-pane tabs only). `dir` =
|
||||
* -1 (left, toward parent) or +1 (right, toward preview). Clamped to
|
||||
* [0, paneCount-1]. The root panel transition (from content pane 0 to
|
||||
* (1..TabPaneCount) is handled by the dispatcher, not here. */
|
||||
const swipe = (dir: -1 | 1, paneCount: number) => {
|
||||
setActivePane((p) => {
|
||||
const n = Math.max(1, Math.min(paneCount, p + dir));
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
// ── tab root (the app's outermost pane) ──────────────────────────────────
|
||||
/** True while focus is on the tab list as the CURRENT pane — the app root,
|
||||
* with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings)
|
||||
* participate; Search & Player are special and always show their content. */
|
||||
const atRootTab = (): boolean =>
|
||||
atRootTabSignal() && DEPTH_TABS.has(activeTab());
|
||||
|
||||
/** Open the active tab's content: the tab slides from CURRENT into the
|
||||
* UP/parent pane and focus lands on the content's current pane. */
|
||||
const enterTabContent = () => {
|
||||
setAtTabRoot(false);
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
};
|
||||
|
||||
/** Move focus back to the tab list root (UP -> CURRENT), e.g. `h` popping
|
||||
* out of content at depth 0. */
|
||||
const backToTabRoot = () => {
|
||||
setAtTabRoot(true);
|
||||
setActivePane(DEPTH_CENTER_PANE);
|
||||
};
|
||||
|
||||
/** The tab the root's cursor is hovering (independent of activeTab). */
|
||||
const tabCursor = (): TABS => tabCursorSignal();
|
||||
|
||||
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
|
||||
const moveTabCursor = (dir: -1 | 1) => {
|
||||
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
||||
};
|
||||
|
||||
/** Open the hovered tab (switch to it and enter its content) from the root.
|
||||
* The yazi "open" of a tab row. */
|
||||
const activateTabCursor = () => {
|
||||
switchTab(tabCursorSignal());
|
||||
enterTabContent();
|
||||
};
|
||||
|
||||
// ── per-pane focus index ────────────────────────────────────────────────
|
||||
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
||||
|
||||
/** For depth-tabs, 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,
|
||||
// tab root (app's outermost pane)
|
||||
atRootTab,
|
||||
enterTabContent,
|
||||
backToTabRoot,
|
||||
tabCursor,
|
||||
moveTabCursor,
|
||||
activateTabCursor,
|
||||
// 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>;
|
||||
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||
*
|
||||
* depth 0 (current) — category list. Left pane empty at root.
|
||||
* depth 1 (current) — podcast results for the drilled category.
|
||||
* right (preview) — detail of the hovered item (category summary, or
|
||||
* depth 0 (current) — category list. Parent pane shows the muted
|
||||
* placeholder (1/7 slot kept).
|
||||
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||
* pane = the categories list.
|
||||
* preview — detail of the hovered item (category summary, or
|
||||
* podcast detail + subscribe action).
|
||||
*
|
||||
* `l`/Enter drills in (category → results) or subscribes (on a podcast);
|
||||
* `h` pops back (or yields to the sidebar at depth 0). j/k move within the
|
||||
* current column. Moving through categories at depth 0 updates the store's
|
||||
* selected category so the preview follows.
|
||||
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||||
* column. Moving through categories at depth 0 updates the store's selected
|
||||
* category so the preview follows.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -25,7 +28,8 @@ import {
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const DiscoverPaneCount = 1;
|
||||
|
||||
@@ -132,36 +136,23 @@ function DiscoverPage() {
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Categories</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? "Categories"
|
||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||
// mounts/unmounts cleanly on depth change.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => (
|
||||
<box
|
||||
@@ -180,56 +171,35 @@ function DiscoverPage() {
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
|
||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{depth() === 0
|
||||
? "Categories"
|
||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
// ── current pane ───────────────────────────────────────────────────────────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
{/* depth 0: categories */}
|
||||
<Show when={depth() === 0}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = focusedCatIdx();
|
||||
const selected = () =>
|
||||
cat.id === discoverStore.selectedCategory();
|
||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
|
||||
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||
*
|
||||
@@ -240,7 +210,6 @@ function DiscoverPage() {
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1: results */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
@@ -260,23 +229,21 @@ function DiscoverPage() {
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text
|
||||
fg={index() === lf ? theme.surface : theme.success}
|
||||
>
|
||||
<text fg={index() === lf ? theme.surface : theme.success}>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
@@ -295,28 +262,13 @@ function DiscoverPage() {
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</>
|
||||
);
|
||||
|
||||
{/* ── right: preview ────────────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* depth 0 preview: hovered category */}
|
||||
<Show when={depth() === 0}>
|
||||
// ── preview pane ───────────────────────────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered category
|
||||
<Show
|
||||
when={focusedCategory()}
|
||||
fallback={
|
||||
@@ -339,10 +291,8 @@ function DiscoverPage() {
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered podcast + subscribe */}
|
||||
<Show when={depth() >= 1}>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered podcast + subscribe
|
||||
<Show
|
||||
when={focusedPodcast()}
|
||||
fallback={
|
||||
@@ -367,8 +317,7 @@ function DiscoverPage() {
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{pod().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{pod().description?.slice(0, 400) ?? "No description available."}
|
||||
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<Show when={(pod().categories ?? []).length > 0}>
|
||||
@@ -381,20 +330,24 @@ function DiscoverPage() {
|
||||
<Show when={pod().feedUrl}>
|
||||
<text fg={muted()}>Feed: {pod().feedUrl}</text>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(pod().lastUpdated)}
|
||||
</text>
|
||||
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: subscribe · h: back · r: refresh
|
||||
</text>
|
||||
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
||||
*
|
||||
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
|
||||
* virtual "All Feeds". Left pane empty at root.
|
||||
* virtual "All Feeds". Parent pane shows the muted
|
||||
* placeholder (1/7 slot kept).
|
||||
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
||||
* chronological). Left pane = the feeds list (prev).
|
||||
* right (preview) — detail of the hovered item in the current column.
|
||||
* chronological). Parent pane = the feeds list (prev).
|
||||
* preview — detail of the hovered item in the current column.
|
||||
*
|
||||
* `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the
|
||||
* sidebar at depth 0). j/k move within the current column. The Shell router
|
||||
* drives everything over nav.action; this page only handles list/preview data.
|
||||
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
|
||||
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
|
||||
* (push); `h` pops a depth (noop at 0). j/k move only within the current
|
||||
* column. The Shell router drives everything over `nav.action`; this page
|
||||
* only handles list/preview data.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -32,7 +35,8 @@ import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
@@ -200,8 +204,8 @@ function FeedPage() {
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
// Row highlight within a list. `active=true` only for the current pane.
|
||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active
|
||||
? theme.primary
|
||||
@@ -210,7 +214,6 @@ function FeedPage() {
|
||||
: undefined;
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
|
||||
const feedLabel = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
@@ -221,81 +224,48 @@ function FeedPage() {
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
Feeds · {feedList().length - 1}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={muted()}>({feedCount(item)})</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{depth() === 0
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Feeds · ${feedList().length - 1}`
|
||||
: `${(() => {
|
||||
const fi = focusedFeedItem();
|
||||
return fi?.kind === "feed"
|
||||
? fi.feed.customName || fi.feed.podcast.title
|
||||
: "All Episodes";
|
||||
})()} · ${episodes().length}`}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
})()} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
|
||||
// mounts/unmounts cleanly on depth change instead of swapping roots.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, false)}
|
||||
>
|
||||
{/* depth 0: feeds */}
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, false)}>{feedLabel(item)}</text>
|
||||
<text fg={muted()}>({feedCount(item)})</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
);
|
||||
|
||||
// ── current pane: the current-depth list (the only focusable column) ──────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
{/* depth 0: feeds — stable sibling <Show> so the swap disposes cleanly */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
@@ -316,16 +286,16 @@ function FeedPage() {
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
@@ -337,9 +307,8 @@ function FeedPage() {
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
@@ -357,17 +326,17 @@ function FeedPage() {
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
backgroundColor={focusBg(index(), fi, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
<text fg={focusFg(index(), fi, isActive())}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
@@ -404,28 +373,13 @@ function FeedPage() {
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</>
|
||||
);
|
||||
|
||||
{/* ── right: preview of hovered item ───────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* depth 0 preview: hovered feed */}
|
||||
<Show when={depth() === 0}>
|
||||
// ── preview pane: hovered-item detail ──────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered feed
|
||||
<Show
|
||||
when={focusedFeedItem()}
|
||||
fallback={
|
||||
@@ -463,10 +417,8 @@ function FeedPage() {
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered episode */}
|
||||
<Show when={depth() >= 1}>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
@@ -488,12 +440,8 @@ function FeedPage() {
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(it.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatDuration(it.episode.duration)}
|
||||
</text>
|
||||
<text fg={theme.info}>{formatDate(it.episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(it.episode.duration)}</text>
|
||||
<Show when={downloadLabel(it.episode.id)}>
|
||||
<text fg={downloadColor(it.episode.id)}>
|
||||
{downloadLabel(it.episode.id)}
|
||||
@@ -513,17 +461,23 @@ function FeedPage() {
|
||||
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · space: select · h: back
|
||||
</text>
|
||||
<text fg={muted()}>enter: play · space: select · h: back</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Feeds" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||
*
|
||||
* depth 0 (current) — subscribed shows. Left pane empty at root.
|
||||
* depth 1 (current) — episodes of the drilled show. Left pane = shows (prev).
|
||||
* right (preview) — detail of the hovered item in the current column.
|
||||
* depth 0 (current) — subscribed shows. Parent pane shows the muted
|
||||
* placeholder (1/7 slot kept).
|
||||
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
||||
* preview — detail of the hovered item in the current column.
|
||||
*
|
||||
* `l`/Enter drills in (show → episodes); `h` pops back (or yields to the
|
||||
* sidebar at depth 0). j/k move within the current column.
|
||||
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
||||
* 0). j/k move only within the current column.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -29,7 +31,8 @@ import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const MyShowsPaneCount = 1;
|
||||
|
||||
@@ -181,37 +184,24 @@ export function MyShowsPage() {
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Shows (${shows().length})`
|
||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||
// mounts/unmounts cleanly on depth change.
|
||||
const parentContent = () => (
|
||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
@@ -226,41 +216,19 @@ export function MyShowsPage() {
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text>
|
||||
<text fg={muted()}>({feed.episodes.length})</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
|
||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{depth() === 0
|
||||
? `Shows (${shows().length})`
|
||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* depth 0: shows */}
|
||||
// ── current pane: the current-depth list ───────────────────────────────────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={shows().length > 0}
|
||||
@@ -281,16 +249,16 @@ export function MyShowsPage() {
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
@@ -302,7 +270,6 @@ export function MyShowsPage() {
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
@@ -322,17 +289,17 @@ export function MyShowsPage() {
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
backgroundColor={focusBg(index(), lf, isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
<text fg={focusFg(index(), lf, isActive())}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
@@ -364,28 +331,13 @@ export function MyShowsPage() {
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</>
|
||||
);
|
||||
|
||||
{/* ── right: preview ────────────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* depth 0 preview: hovered show */}
|
||||
<Show when={depth() === 0}>
|
||||
// ── preview pane ───────────────────────────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered show
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
fallback={
|
||||
@@ -406,18 +358,15 @@ export function MyShowsPage() {
|
||||
{show().episodes.length} episodes
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ??
|
||||
"No description."}
|
||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered episode */}
|
||||
<Show when={depth() >= 1}>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
@@ -444,26 +393,29 @@ export function MyShowsPage() {
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={selectedShow()?.podcast.author}>
|
||||
<text fg={muted()}>
|
||||
by {selectedShow()!.podcast.author}
|
||||
</text>
|
||||
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{ep().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{ep().description?.slice(0, 400) ?? "No description available."}
|
||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · space: select · h: back
|
||||
</text>
|
||||
<text fg={muted()}>enter: play · space: select · h: back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={() => (depth() >= 1 ? "Shows" : "Up")}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* SearchPage — yazi-style 3-pane view.
|
||||
*
|
||||
* pane 0 (parent) — query input with recent-search history (clickable)
|
||||
* pane 1 (current) — search results list (navigate j/k)
|
||||
* pane 2 (preview) — detail of the focused search result
|
||||
* pane 1 (parent) — query input with recent-search history (clickable)
|
||||
* pane 2 (current) — search results list (navigate j/k)
|
||||
* pane 3 (preview) — detail of the focused search result
|
||||
*
|
||||
* The Shell resets activePane to CURRENT(1) on tab enter so the user lands on
|
||||
* the results pane. Swipe left (h) to pane 0 to type a query — the Shell
|
||||
* (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2)
|
||||
* on tab enter so the user lands on the results pane. Swipe left (h) to pane
|
||||
* 1 to type a query — the Shell
|
||||
* router skips keys while `nav.inputFocused()` is true so the `<input>`
|
||||
* element captures typing natively. Press Enter (onSubmit) to search and
|
||||
* auto-swipe to the results pane.
|
||||
@@ -44,9 +45,9 @@ function SearchPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const INPUT = PaneSlot.PARENT; // 0
|
||||
const RESULTS = PaneSlot.CURRENT; // 1
|
||||
const DETAIL = PaneSlot.PREVIEW; // 2
|
||||
const INPUT = PaneSlot.PARENT; // 1 (input row)
|
||||
const RESULTS = PaneSlot.CURRENT; // 2 (results list)
|
||||
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview)
|
||||
|
||||
const results = () => searchStore.results();
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
* depth 1 — the focused section's items as a navigable list
|
||||
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
||||
*
|
||||
* Columns render as yazi's prev | current | preview:
|
||||
* left = previous depth's list (empty at depth 0)
|
||||
* right = preview/help text for the hovered item in center
|
||||
* Renders entirely through `<YaziPaneRow>` (parent | current | preview):
|
||||
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
||||
* blank placeholder at depth 0 (1/7 slot kept).
|
||||
* current = the current-depth list (or editor at depth 2); the only
|
||||
* focusable column.
|
||||
* preview = help/preview text for the hovered item in current.
|
||||
*
|
||||
* All movement comes from the Shell router over `nav.action` (j/k move,
|
||||
* Enter/l drill, h back). Panels no longer register their own useKeyboard —
|
||||
@@ -24,12 +27,13 @@ import {
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||
import { usePreferencesItems } from "./PreferencesPanel";
|
||||
import { useVisualizerItems } from "./VisualizerSettings";
|
||||
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||
import { useSourceItems } from "./SourceManager";
|
||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
|
||||
export const SettingsPaneCount = 1;
|
||||
|
||||
@@ -224,9 +228,7 @@ export function SettingsPage() {
|
||||
onCleanup(() => closeSyncEditor());
|
||||
|
||||
// ── render helpers ───────────────────────────────────────────────────────
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const headerBg = theme.background;
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
|
||||
// preview text for the right column
|
||||
const previewText = createMemo<string>(() => {
|
||||
@@ -245,32 +247,34 @@ export function SettingsPage() {
|
||||
: "No editor.";
|
||||
});
|
||||
|
||||
// ── column content builders ──────────────────────────────────────────────
|
||||
// left = previous depth (read-only list), or empty at depth 0
|
||||
const LeftCol = () => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
<Show when={depth() >= 1} fallback=" ">
|
||||
{depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")}
|
||||
// ── column label ───────────────────────────────────────────────────────────
|
||||
const currentLabel = () => {
|
||||
const d = depth();
|
||||
if (d === 0) return "Settings";
|
||||
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
||||
return editorItem()?.label ?? "Editor";
|
||||
};
|
||||
const parentLabel = () => {
|
||||
const d = depth();
|
||||
if (d === 1) return "Sections";
|
||||
if (d === 2) return sectionForDepth1()?.label ?? "";
|
||||
return "Up";
|
||||
};
|
||||
|
||||
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
|
||||
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
||||
// mounts every branch once and toggles children on depth change — the
|
||||
// known-good opentui disposal pattern. A ternary returning different
|
||||
// roots leaves subtree orphaned on swap; the trick is a STABLE fragment
|
||||
// root whose inner <Show> children swap instead.
|
||||
const parentContent = () => (
|
||||
<>
|
||||
<Show when={depth() === 0}>
|
||||
{/* app root: the tab list as the parent (muted) at the lowest depth */}
|
||||
<TabListPane muted />
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={depth() === 1}>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* previous depth = sections list (read-only) */}
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<Row
|
||||
@@ -280,15 +284,9 @@ export function SettingsPage() {
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={depth() === 2}>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* previous depth = items list (read-only) */}
|
||||
<For each={items()}>
|
||||
{(it, index) => (
|
||||
<Row
|
||||
@@ -298,51 +296,20 @@ export function SettingsPage() {
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
</>
|
||||
);
|
||||
|
||||
// center = current depth
|
||||
const CenterCol = () => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
<Show
|
||||
when={depth() === 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={depth() === 1}
|
||||
fallback={editorItem()?.label ?? "Editor"}
|
||||
>
|
||||
{sectionForDepth1()?.label ?? "Items"}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
Settings
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
// ── current pane: current-depth list (or editor at depth 2) ───────────────
|
||||
const currentContent = () => (
|
||||
<>
|
||||
<Show when={depth() === 0}>
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={`${section.id + 1}. ${section.label}`}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={isActive}
|
||||
active={isActive()}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
@@ -352,13 +319,14 @@ export function SettingsPage() {
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={depth() === 1}>
|
||||
<box flexDirection="column">
|
||||
<For each={items()}>
|
||||
{(it, index) => (
|
||||
<Row
|
||||
label={`${it.label}`}
|
||||
value={it.display()}
|
||||
focused={index() === focusedItemIdx()}
|
||||
active={isActive}
|
||||
active={isActive()}
|
||||
hint={hintFor(it)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
@@ -372,8 +340,10 @@ export function SettingsPage() {
|
||||
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={depth() === 2}>
|
||||
{/* depth 2: editor */}
|
||||
<Show
|
||||
when={editorItem()?.renderEditor}
|
||||
fallback={<GenericEditor item={editorItem()!} />}
|
||||
@@ -381,41 +351,26 @@ export function SettingsPage() {
|
||||
{editorItem()!.renderEditor!()}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</>
|
||||
);
|
||||
|
||||
// right = preview / help
|
||||
const RightCol = () => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
// ── preview pane ──────────────────────────────────────────────────────────
|
||||
const previewContent = () => (
|
||||
<box padding={1}>
|
||||
<MultiLine text={previewText()} />
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{LeftCol()}
|
||||
{CenterCol()}
|
||||
{RightCol()}
|
||||
</box>
|
||||
<YaziPaneRow
|
||||
parent={parentContent}
|
||||
current={currentContent}
|
||||
preview={previewContent}
|
||||
parentLabel={parentLabel}
|
||||
currentLabel={currentLabel}
|
||||
previewLabel="Detail"
|
||||
focused={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
263
src/utils/dispatch.ts
Normal file
263
src/utils/dispatch.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* dispatch — the yazi-style unified keybind router.
|
||||
*
|
||||
* Extracted from `src/components/Shell.tsx` into this pure (no-JSX, no
|
||||
* @opentui/solid) module so `dispatch()` is unit-testable with `bun test`
|
||||
* directly — mirroring how task 01 split `navigation-store` out of the
|
||||
* NavigationContext so the nav model could be exercised without the OpenTUI
|
||||
* JSX runtime (supplied only by the build-time bun-plugin).
|
||||
*
|
||||
* The Shell builds a `DispatcherDeps` from its live hooks + the audio-side
|
||||
* `advanceEpisode` helper, then forwards every matched keystroke action to
|
||||
* `dispatch`. Behavioural rules (tab root + task 06):
|
||||
*
|
||||
* • The tab list is the app's ROOT. At the root (`nav.atRootTab()`) it is
|
||||
* the CURRENT pane with nothing above it:
|
||||
* `k`/`j` (`move-down`/`move-up`) move a cursor through the tabs
|
||||
* (highlight follows `tabCursor`; the active tab is untouched),
|
||||
* `l`/Enter (`swipe-next`/`open`) open the hovered tab (`activateTabCursor`)
|
||||
* — the tab slides into UP and its content becomes CURRENT; `h`
|
||||
* (`swipe-prev`) at the root stays (out of the panes); `1-6` / `[`/`]`
|
||||
* switch tabs directly (re-syncing the cursor).
|
||||
* • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`)
|
||||
* switch tabs; focus keeps its context (root iff already at the root,
|
||||
* otherwise the content `DEPTH_CENTER_PANE`).
|
||||
* • `h`/`l` are `swipe-prev`/`swipe-next` in content:
|
||||
* - depth-tabs, current pane: `l` drills (`open` emit), `h` pops a depth
|
||||
* when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`),
|
||||
* where the tab becomes CURRENT again.
|
||||
* - fixed-pane tabs (Search/Player, special): `swipe(±1, count)` clamped to
|
||||
* [1, paneCount]; `h` on the first content pane stays (no tab overflow).
|
||||
* • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to
|
||||
* `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane.
|
||||
* • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches
|
||||
* are unchanged from the pre-rewrite Shell. */
|
||||
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { NavigationState, DepthFrame } from "@/context/navigation-store";
|
||||
import { NavMode, DEPTH_CENTER_PANE } from "@/context/navigation-store";
|
||||
import { TABS, TabsCount, TabPaneCount } from "@/utils/navigation";
|
||||
import { emit } from "@/utils/event-bus";
|
||||
|
||||
// Re-export NavMode + DEPTH_CENTER_PANE so Shell keeps importing them from here.
|
||||
export { DEPTH_CENTER_PANE, NavMode };
|
||||
|
||||
/** The payload carried on the `nav.action` event bus. Mirrors the typed event
|
||||
* in utils/event-bus.ts but duplicated here so this module stays dep-light. */
|
||||
export type NavActionEvent = {
|
||||
action: KeybindActionName;
|
||||
tab: TABS;
|
||||
pane: number;
|
||||
mode: NavMode;
|
||||
};
|
||||
|
||||
/** Actions the active page is responsible for (pane/list-local). These flow
|
||||
* to the current pane only via `emit("nav.action", …)`. There is no
|
||||
* SIDEBAR_ACTIONS set — the sidebar pane was removed in the nav rework. */
|
||||
export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||
new Set<KeybindActionName>([
|
||||
"move-down",
|
||||
"move-up",
|
||||
"page-down",
|
||||
"page-up",
|
||||
"full-down",
|
||||
"full-up",
|
||||
"jump-down",
|
||||
"jump-up",
|
||||
"goto-top",
|
||||
"goto-bottom",
|
||||
"toggle-select",
|
||||
"visual-mode",
|
||||
"toggle-all",
|
||||
"invert-all",
|
||||
"open",
|
||||
"open-interactive",
|
||||
"search",
|
||||
"filter",
|
||||
"sort",
|
||||
"toggle-hidden",
|
||||
"refresh",
|
||||
]);
|
||||
|
||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||
export function tabByDigit(action: KeybindActionName): TABS | null {
|
||||
if (action.startsWith("tab-goto-")) {
|
||||
const n = Number(action.slice("tab-goto-".length));
|
||||
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Dependencies the unified keybind dispatcher closes over. `advanceEpisode`
|
||||
* is passed in (it lives over the full audio/feed/toast surface in Shell) so
|
||||
* the dispatcher only needs the subset it touches directly. */
|
||||
export type DispatcherDeps = {
|
||||
nav: NavigationState;
|
||||
audio: {
|
||||
togglePlayback: () => Promise<void>;
|
||||
seekRelative: (n: number) => Promise<void>;
|
||||
};
|
||||
k: { clearPending: () => void };
|
||||
setShowHelp: (fn: (v: boolean) => boolean) => void;
|
||||
advanceEpisode: (offset: number) => void;
|
||||
};
|
||||
|
||||
/** Build the unified router (normal + visual modes). Returns `dispatch` —
|
||||
* the closure Shell's `useKeyboard` calls with each matched action. */
|
||||
export function createDispatcher(deps: DispatcherDeps) {
|
||||
const { nav, audio, k, setShowHelp, advanceEpisode } = deps;
|
||||
|
||||
function dispatch(
|
||||
action: KeybindActionName,
|
||||
evt: { preventDefault: () => void },
|
||||
) {
|
||||
const tab = nav.activeTab();
|
||||
const pane = nav.activePane();
|
||||
switch (action) {
|
||||
// ── modes ──
|
||||
case "escape":
|
||||
evt.preventDefault();
|
||||
if (nav.mode() === NavMode.VISUAL) {
|
||||
nav.toNormal();
|
||||
break;
|
||||
}
|
||||
k.clearPending();
|
||||
break;
|
||||
case "command":
|
||||
evt.preventDefault();
|
||||
nav.enterCommand();
|
||||
break;
|
||||
case "visual-mode":
|
||||
evt.preventDefault();
|
||||
nav.enterVisual();
|
||||
break;
|
||||
case "toggle-select":
|
||||
evt.preventDefault();
|
||||
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||
break;
|
||||
case "toggle-all":
|
||||
case "invert-all":
|
||||
evt.preventDefault();
|
||||
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||
break;
|
||||
|
||||
// ── tabs (the only tab switchers) ──
|
||||
case "tab-next":
|
||||
evt.preventDefault();
|
||||
nav.nextTab();
|
||||
break;
|
||||
case "tab-prev":
|
||||
evt.preventDefault();
|
||||
nav.prevTab();
|
||||
break;
|
||||
default: {
|
||||
const dt = tabByDigit(action);
|
||||
if (dt) {
|
||||
evt.preventDefault();
|
||||
nav.setActiveTab(dt);
|
||||
break;
|
||||
}
|
||||
// ── tab root focus ──
|
||||
// At the app root the tab list is the CURRENT pane: j/k move the tab
|
||||
// cursor (active tab untouched), l/Enter open the hovered tab (switch
|
||||
// to it + enter its content), h stays inert (out of the panes).
|
||||
if (nav.atRootTab()) {
|
||||
evt.preventDefault();
|
||||
if (action === "move-down") {
|
||||
nav.moveTabCursor(1);
|
||||
break;
|
||||
}
|
||||
if (action === "move-up") {
|
||||
nav.moveTabCursor(-1);
|
||||
break;
|
||||
}
|
||||
if (action === "open") {
|
||||
nav.activateTabCursor();
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
nav.activateTabCursor();
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-prev") break;
|
||||
}
|
||||
// ── pane swipe / depth nav ──
|
||||
// Depth-tabs: l at the center drills in (emits `open`); h at the
|
||||
// center pops a depth; at depth 0 h returns to the tab root (the tab
|
||||
// becomes CURRENT again). Fixed-pane tabs (Search/Player, special):
|
||||
// h/l swipe across [1, paneCount]; h on the first pane stays.
|
||||
if (action === "swipe-prev") {
|
||||
evt.preventDefault();
|
||||
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
|
||||
if (nav.currentDepth() > 0) nav.popDepth();
|
||||
else nav.backToTabRoot(); // depth 0 → tab root
|
||||
} else if (nav.activePane() > DEPTH_CENTER_PANE) {
|
||||
nav.swipe(-1, TabPaneCount[tab]); // content 1..n
|
||||
}
|
||||
// fixed first content pane (1): no-op, stays (special tabs)
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
evt.preventDefault();
|
||||
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
|
||||
emit("nav.action", {
|
||||
action: "open",
|
||||
tab,
|
||||
pane: DEPTH_CENTER_PANE,
|
||||
mode: nav.mode(),
|
||||
});
|
||||
} else {
|
||||
nav.swipe(1, TabPaneCount[tab]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// ── audio transport (global) ──
|
||||
if (action === "audio-toggle") {
|
||||
evt.preventDefault();
|
||||
audio.togglePlayback().catch(() => {});
|
||||
break;
|
||||
}
|
||||
if (action === "audio-seek-forward") {
|
||||
evt.preventDefault();
|
||||
audio.seekRelative(10).catch(() => {});
|
||||
break;
|
||||
}
|
||||
if (action === "audio-seek-backward") {
|
||||
evt.preventDefault();
|
||||
audio.seekRelative(-10).catch(() => {});
|
||||
break;
|
||||
}
|
||||
if (action === "audio-next") {
|
||||
evt.preventDefault();
|
||||
advanceEpisode(1);
|
||||
break;
|
||||
}
|
||||
if (action === "audio-prev") {
|
||||
evt.preventDefault();
|
||||
advanceEpisode(-1);
|
||||
break;
|
||||
}
|
||||
// ── global app ──
|
||||
if (action === "quit") {
|
||||
evt.preventDefault();
|
||||
process.exit(0);
|
||||
}
|
||||
if (action === "help") {
|
||||
evt.preventDefault();
|
||||
setShowHelp((v) => !v);
|
||||
}
|
||||
|
||||
// ── page-local list/pane actions ──
|
||||
if (PAGE_ACTIONS.has(action)) {
|
||||
evt.preventDefault();
|
||||
emit("nav.action", { action, tab, pane, mode: nav.mode() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dispatch };
|
||||
}
|
||||
|
||||
// Re-export the depth-frame type for convenience.
|
||||
export type { DepthFrame };
|
||||
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,
|
||||
current: 3,
|
||||
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
|
||||
|
||||
98
tasks/yazi-remake/07-blocker-task-04-player-search.md
Normal file
98
tasks/yazi-remake/07-blocker-task-04-player-search.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 07-blocker — Task 04 (Search + Player) never converted to YaziPaneRow
|
||||
|
||||
meta:
|
||||
id: yazi-remake-07-blocker
|
||||
feature: yazi-remake
|
||||
priority: P0
|
||||
blocks: [yazi-remake-07]
|
||||
blocked_by: [yazi-remake-04]
|
||||
tags: [blocker, verification, tasks-required]
|
||||
|
||||
## Problem
|
||||
|
||||
Task 04 (`04-fit-search-and-player-panes.md`) is marked complete, but its
|
||||
core deliverable was never implemented:
|
||||
|
||||
> - `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`
|
||||
> - `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`
|
||||
|
||||
`grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` → **0 files**.
|
||||
|
||||
## Evidence (from task 07 harness walk-through, 100×30)
|
||||
|
||||
### Player ❌
|
||||
|
||||
`src/pages/Player/PlayerPage.tsx` renders a single full-width
|
||||
`<scrollbox>` (now-playing transport + controls). No parent pane, no preview
|
||||
pane. Frame `.harness/player-current.txt`:
|
||||
|
||||
```
|
||||
│ Now Playing 0:00 / 0:00 (0%) │
|
||||
...
|
||||
│ │ │[Prev]│ │[Play]│ │[Next]│ Vol 70% Speed 1x ... │ │
|
||||
```
|
||||
|
||||
Expected (task 04 + feature exit criteria): `blank | transport | notes` at
|
||||
1/7 : 3/7 : 3/7.
|
||||
|
||||
### Search ⚠️ ratio wrong
|
||||
|
||||
`src/pages/Search/SearchPage.tsx` renders three custom `<box flexGrow={
|
||||
PANE_RATIO.* }>` columns but **omits `flexBasis={0}`**, so Yoga distributes
|
||||
space by natural content width (the input box is `width={28}`). Measured
|
||||
column widths at 100 cols: **31 / 32 / 31** (equal thirds), NOT the target
|
||||
**~14 / 43 / 43** (1:3:3).
|
||||
|
||||
`<YaziPaneRow>` exists precisely to set `flexBasis={0}` per column and force
|
||||
the exact 1:3:3 ratio regardless of content (see its header comment). Routing
|
||||
Search through it fixes the ratio for free.
|
||||
|
||||
## Why not patched in task 07
|
||||
|
||||
Task 07 is explicitly the verification gate. Its notes:
|
||||
|
||||
> - This is the gate for the whole feature — do not mark done if any criterion
|
||||
> fails; open a blocker task instead
|
||||
> - If the harness reveals a visual regression (e.g. parent collapses, ratios
|
||||
> off), file it against the responsible task (02 or 03) rather than
|
||||
> patching here
|
||||
|
||||
This is a missing implementation in task 04, not a regression in 02/03, so the
|
||||
responsible task is 04. Patches belong there.
|
||||
|
||||
## Failing exit criteria
|
||||
|
||||
- "All tabs render three stable columns at 1/7 : 3/7 : 3/7" — Player fails
|
||||
(not 3 columns); Search fails (wrong ratio).
|
||||
- "`5` → Player: blank|transport|notes at 1:3:3" — fails.
|
||||
- "`4` → Search: query|results|detail at 1:3:3" — 3 columns yes, ratio wrong.
|
||||
|
||||
## Fix plan (task 04 do-over)
|
||||
|
||||
1. `src/pages/Player/PlayerPage.tsx` — wrap the existing transport JSX in a
|
||||
`<YaziPaneRow current={transport} parent={undefined} preview={notes} />`.
|
||||
Parent should fall through to the primitive's muted `—` placeholder (it
|
||||
already keeps its 1/7 slot when blank). Preview = episode description /
|
||||
waveform (currently inline under "Now Playing"). Keep `PlayerPaneCount=1`
|
||||
(the visible columns are a render concern; only current=0 is focusable).
|
||||
2. `src/pages/Search/SearchPage.tsx` — replace the three custom `<box
|
||||
flexGrow={PANE_RATIO.*}>` columns with a single `<YaziPaneRow
|
||||
parent={queryInput+recent} current={resultsList} preview={detail}
|
||||
focused={!inputFocused() ? /* results */ : false} />`. Keep the
|
||||
`inputFocused` effect so the Shell yields keys to the native `<input>`
|
||||
when the query pane is focused — note `inputFocused` is a Search-owned
|
||||
signal; YaziPaneRow's `focused` prop only drives the accent ring + scroll
|
||||
focus, which for Search can stay on the current (results) column.
|
||||
3. Remove the now-dead custom ratio code from both files after the swap.
|
||||
4. Re-run: `grep -rn YaziPaneRow src/pages/Search src/pages/Player` → 2 files;
|
||||
`bun run build`; `bun test`; harness walk-through: Player shows 3 cols,
|
||||
Search cols measure ~14/43/43.
|
||||
|
||||
## Verification gates (re-run task 07 after fix)
|
||||
|
||||
- `bun run build` → "Build complete"
|
||||
- `bun test` → 0 fail
|
||||
- harness:
|
||||
- Player frame has 3 bordered columns (parent `—`, current transport,
|
||||
preview notes/placeholder) at 1:3:3
|
||||
- Search frame columns measure ~14/43/43
|
||||
@@ -6,6 +6,7 @@ meta:
|
||||
priority: P1
|
||||
depends_on: [yazi-remake-03, yazi-remake-04, yazi-remake-05, yazi-remake-06]
|
||||
tags: [verification, tests-required]
|
||||
status: BLOCKED # see .harness/verification-07.md + tasks/yazi-remake/07-blocker-task-04-player-search.md
|
||||
|
||||
objective:
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
|
||||
- [ ] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
|
||||
- [ ] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
|
||||
- [ ] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
|
||||
- [ ] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
|
||||
- [ ] 06 — rewire-keybinds → `06-rewire-keybinds.md`
|
||||
- [ ] 07 — verify-remake → `07-verify-remake.md`
|
||||
- [x] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
|
||||
- [x] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
|
||||
- [x] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
|
||||
- [x] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
|
||||
- [x] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
|
||||
- [x] 06 — rewire-keybinds → `06-rewire-keybinds.md`
|
||||
- [x] 07 — verify-remake → `07-verify-remake.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
311
tests/dispatch-keybinds.test.ts
Normal file
311
tests/dispatch-keybinds.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* dispatch-keybinds.test.ts — yazi remake task 06/07 unit + integration tests.
|
||||
*
|
||||
* Exercises the rewired unified keybind router (`createDispatcher`) directly,
|
||||
* without the OpenTUI render tree, mirroring how task 01 made the nav store a
|
||||
* plain factory for `bun test`. Covers the task 06 acceptance + integration
|
||||
* cases, plus the tab-root routing:
|
||||
*
|
||||
* • Tab root: j/k (`move-down`/`move-up`) move a tab cursor without touching
|
||||
* the active tab; `open`/`swipe-next` (l/Enter) open the hovered tab and
|
||||
* enter its content; `swipe-prev` (h) stays inert (out of the panes).
|
||||
* • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
|
||||
* `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
|
||||
* • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount];
|
||||
* `h` on the first pane stays — never overflows to the tab root.
|
||||
* • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch
|
||||
* tabs and preserve focus context (root stays root for depth-tabs, content
|
||||
* stays content).
|
||||
* • `j`/`k` (move-down/up) in content flow to `nav.action` for the current
|
||||
* pane.
|
||||
*
|
||||
* The dispatcher is built with fake audio/k/help deps (the paths under test
|
||||
* never reach the audio or advanceEpisode branches) and a real nav store.
|
||||
*/
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { createRoot } from "solid-js";
|
||||
import {
|
||||
createNavigation,
|
||||
DEPTH_CENTER_PANE,
|
||||
} from "../src/context/navigation-store";
|
||||
import { TABS } from "../src/utils/navigation";
|
||||
import { createDispatcher, type DispatcherDeps } from "../src/utils/dispatch";
|
||||
import { on } from "../src/utils/event-bus";
|
||||
import type { KeybindActionName } from "../src/context/KeybindContext";
|
||||
|
||||
/** Build a real nav store + a dispatcher wired to fake audio/k/help deps, all
|
||||
* inside a reactive root (disposed after). Returns both so a test can read
|
||||
* nav state and call dispatch. */
|
||||
function withHarness(
|
||||
fn: (api: {
|
||||
nav: ReturnType<typeof createNavigation>;
|
||||
dispatch: (action: KeybindActionName) => void;
|
||||
toggleHelp: () => boolean;
|
||||
helpOpen: () => boolean;
|
||||
}) => void,
|
||||
) {
|
||||
createRoot((dispose) => {
|
||||
const nav = createNavigation();
|
||||
let help = false;
|
||||
const deps: DispatcherDeps = {
|
||||
nav,
|
||||
audio: {
|
||||
togglePlayback: async () => {},
|
||||
seekRelative: async () => {},
|
||||
},
|
||||
k: { clearPending: () => {} },
|
||||
setShowHelp: (fn) => {
|
||||
help = fn(help);
|
||||
},
|
||||
advanceEpisode: () => {},
|
||||
};
|
||||
const { dispatch } = createDispatcher(deps);
|
||||
const evt = () => ({ preventDefault: mock(() => {}) });
|
||||
fn({
|
||||
nav,
|
||||
dispatch: (action) => dispatch(action, evt() as any),
|
||||
toggleHelp: () => (help = !help),
|
||||
helpOpen: () => help,
|
||||
});
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture nav.action emits during `fn`. Returns the captured payloads. */
|
||||
function captureNavActions(fn: () => void) {
|
||||
const captured: { action: KeybindActionName; tab: TABS; pane: number }[] = [];
|
||||
const unsub = on("nav.action", (d) => {
|
||||
captured.push(d as any);
|
||||
});
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
unsub();
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
// ── Tab root: j/k move the cursor; l/Enter open the hovered tab ──────────────
|
||||
test("dispatch('move-down') on the tab root moves the cursor (no emit, active tab untouched)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
|
||||
const events = captureNavActions(() => dispatch("move-down"));
|
||||
expect(events).toHaveLength(0); // j on the root moves the cursor only
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED); // active tab untouched until opened
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('move-up') on the tab root moves the cursor up (clamped, no wrap)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
dispatch("move-up");
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED); // clamped at the top
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('open') on the tab root opens the hovered tab and enters its content", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
dispatch("move-down"); // cursor -> MYSHOWS
|
||||
dispatch("move-down"); // cursor -> DISCOVER
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
dispatch("open");
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER); // the hovered tab is opened
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-next') on the tab root opens the hovered tab and enters its content (no emit)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
dispatch("move-down"); // cursor -> MYSHOWS
|
||||
const events = captureNavActions(() => dispatch("swipe-next"));
|
||||
expect(events).toHaveLength(0);
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') on the tab root is inert (stays, no emit)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||
expect(events).toHaveLength(0);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unit: move-down emits nav.action on the current pane only ────────────────
|
||||
test("dispatch('move-down') on a depth-tab current pane emits nav.action {action:'move-down'} on the current pane", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.FEED); // depth-tab → enter content to test the list move
|
||||
nav.enterTabContent();
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
|
||||
const events = captureNavActions(() => dispatch("move-down"));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].action).toBe("move-down");
|
||||
expect(events[0].tab).toBe(TABS.FEED);
|
||||
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('move-up') emits nav.action on the current pane only (j/k never change depth)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.MYSHOWS);
|
||||
nav.enterTabContent();
|
||||
const beforeDepth = nav.currentDepth();
|
||||
const events = captureNavActions(() => dispatch("move-up"));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].action).toBe("move-up");
|
||||
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||
// depth is untouched by j/k (only h/l and the page's open() touch it).
|
||||
expect(nav.currentDepth()).toBe(beforeDepth);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
|
||||
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.DISCOVER);
|
||||
nav.enterTabContent();
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
|
||||
const events = captureNavActions(() => dispatch("swipe-next"));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].action).toBe("open");
|
||||
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||
// the drill (pushDepth) is the page's job on `open`; dispatch only emits.
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
nav.enterTabContent();
|
||||
// simulate the page's open() having drilled one level.
|
||||
nav.pushDepth({ kind: "episodes:f1", ctx: "f1", focus: 0 });
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
|
||||
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||
expect(events).toHaveLength(0); // a pop emits nothing — it just pops
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
// focus stays in content (depth > 0 pop does not return to the root).
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
nav.enterTabContent();
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
|
||||
const events = captureNavActions(() => dispatch("swipe-prev"));
|
||||
expect(events).toHaveLength(0);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // fixed-pane
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
|
||||
dispatch("swipe-prev");
|
||||
// special tab: h on the first content pane does not return to the root.
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('swipe-prev') on a fixed-pane tab at pane > 1 swipes leftwards", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // 3 panes
|
||||
nav.enterTabContent();
|
||||
nav.swipe(1, 3);
|
||||
nav.swipe(1, 3);
|
||||
expect(nav.activePane()).toBe(3);
|
||||
dispatch("swipe-prev");
|
||||
expect(nav.activePane()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Acceptance: digit keys switch tabs and keep focus context ────────────────
|
||||
test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
// focus starts on the tab root.
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-goto-3"); // → Discover
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER);
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER); // cursor re-synced
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-goto-2"); // → MyShows
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
// fixed-pane tab is special: switching from the root opens its content.
|
||||
dispatch("tab-goto-4"); // → Search
|
||||
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||
expect(nav.tabCursor()).toBe(TABS.SEARCH);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("tab-goto-N from content keeps focus in the active tab's content", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.enterTabContent();
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
|
||||
dispatch("tab-goto-3"); // → Discover
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
|
||||
dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane
|
||||
expect(nav.activeTab()).toBe(TABS.SEARCH);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("tab-next (]) / tab-prev ([) cycle tabs and keep focus context", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-next");
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
|
||||
dispatch("tab-prev");
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("help toggles open on the 'help' action", () => {
|
||||
withHarness(({ dispatch, helpOpen }) => {
|
||||
expect(helpOpen()).toBe(false);
|
||||
dispatch("help");
|
||||
expect(helpOpen()).toBe(true);
|
||||
dispatch("help");
|
||||
expect(helpOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
270
tests/nav-model.test.ts
Normal file
270
tests/nav-model.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* nav-model.test.ts — yazi remake task 01/07 unit/integration tests.
|
||||
*
|
||||
* Covers the tab-list-as-root navigation model:
|
||||
* • createNavigation() exposes the nav factory directly (no Solid render
|
||||
* needed), wrapped in a createRoot so effects register/dispose.
|
||||
* • focus starts on the tab list — the app root. `atRootTab()` is true while
|
||||
* the tab list is the CURRENT pane (nothing above it). `enterTabContent()`
|
||||
* slides the tab into UP and puts focus on the content; `backToTabRoot()`
|
||||
* returns to the root. Only depth-tabs participate (`atRootTab()` is false
|
||||
* for the fixed-pane Search/Player tabs).
|
||||
* • the root tab list is a normal list: `tabCursor` is independent of
|
||||
* `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens
|
||||
* the hovered tab + enters content, and direct tab switches re-sync it.
|
||||
* • depth-tab focusedIndex depth-current read/writes the top frame's focus.
|
||||
* • swipe() is clamped to [1, paneCount] (content panes only; there is no
|
||||
* pane-0 tab slot — the tab root is a flag, not a pane).
|
||||
*/
|
||||
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 starts on the tab root (atRootTab true)", () => {
|
||||
withNav((nav) => {
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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 a the top frame on the stack.
|
||||
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);
|
||||
nav.enterTabContent();
|
||||
// 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 switching keeps focus context ────────────────────────────────────────
|
||||
test("tab switch keeps focus context: at the root it stays at the root", () => {
|
||||
withNav((nav) => {
|
||||
// focus starts on the tab root.
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
// switching depth-tabs from the root must not drop focus into content.
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
nav.setActiveTab(TABS.MYSHOWS);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("tab switch keeps focus context: in content it stays in content", () => {
|
||||
withNav((nav) => {
|
||||
nav.enterTabContent();
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
// switching tabs from content keeps the content context.
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("switching to a Search/Player tab leaves the root (special content)", () => {
|
||||
withNav((nav) => {
|
||||
// at root, opening Search is special: atRootTab() reports false because
|
||||
// Search has its own content and never renders the tab-list root view.
|
||||
nav.setActiveTab(TABS.SEARCH);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── tab root <-> content transitions ─────────────────────────────────────────
|
||||
test("enterTabContent/backToTabRoot round-trip between root and content", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
nav.backToTabRoot();
|
||||
expect(nav.atRootTab()).toBe(true);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("enterTabContent preserves the active tab's depth", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.FEED);
|
||||
nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 0 });
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
// moving between the root and content never touches the depth stack.
|
||||
nav.backToTabRoot();
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
expect(nav.popDepth()).toBe(true);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("tabCursor starts on the active tab", () => {
|
||||
withNav((nav) => {
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
});
|
||||
});
|
||||
|
||||
test("moveTabCursor moves the cursor without changing the active tab, clamped at the ends", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.MYSHOWS); // cursor syncs to the active tab
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
nav.moveTabCursor(1);
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
expect(nav.activeTab()).toBe(TABS.MYSHOWS); // active tab untouched
|
||||
nav.moveTabCursor(-1);
|
||||
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
|
||||
// clamp: from FEED, up stays FEED; from SETTINGS, down stays SETTINGS.
|
||||
nav.moveTabCursor(-1); // MYSHOWS -> FEED
|
||||
nav.moveTabCursor(-1); // FEED -> FEED (clamped)
|
||||
expect(nav.tabCursor()).toBe(TABS.FEED);
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
nav.moveTabCursor(1); // SETTINGS -> SETTINGS (clamped)
|
||||
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
|
||||
expect(nav.activeTab()).toBe(TABS.SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
test("activateTabCursor switches to the hovered tab and enters its content", () => {
|
||||
withNav((nav) => {
|
||||
nav.moveTabCursor(1); // cursor -> MYSHOWS
|
||||
nav.moveTabCursor(1); // cursor -> DISCOVER
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
nav.activateTabCursor();
|
||||
expect(nav.activeTab()).toBe(TABS.DISCOVER); // hovered tab opened
|
||||
expect(nav.tabCursor()).toBe(TABS.DISCOVER);
|
||||
expect(nav.atRootTab()).toBe(false);
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
test("direct tab switches re-sync the tab cursor", () => {
|
||||
withNav((nav) => {
|
||||
nav.moveTabCursor(1); // cursor -> MYSHOWS
|
||||
expect(nav.activeTab()).toBe(TABS.FEED);
|
||||
nav.setActiveTab(TABS.SETTINGS);
|
||||
expect(nav.tabCursor()).toBe(TABS.SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
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 [1, paneCount] (no pane-0 tab slot) ──────────────────────
|
||||
test("swipe on a fixed-pane tab stays within [1, paneCount]", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3
|
||||
expect(TabPaneCount[TABS.SEARCH]).toBe(3);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(1);
|
||||
// swipe left stays at 1 (no pane 0).
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
// swipe right up through the columns, then hold the upper bound.
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(2);
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(3);
|
||||
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(3); // never exceeds paneCount
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(2);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
|
||||
expect(nav.activePane()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("swipe on a single-pane fixed tab stays at its one content pane", () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(TABS.PLAYER); // single-pane
|
||||
expect(TabPaneCount[TABS.PLAYER]).toBe(1);
|
||||
nav.enterTabContent(); // lands on its one content pane (1)
|
||||
expect(nav.activePane()).toBe(1);
|
||||
nav.swipe(1, TabPaneCount[TABS.PLAYER]);
|
||||
expect(nav.activePane()).toBe(1); // upper bound
|
||||
nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
|
||||
expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 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");
|
||||
});
|
||||
});
|
||||
105
tests/yazi-pages-depth.test.ts
Normal file
105
tests/yazi-pages-depth.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* yazi-pages-depth.test.ts — task 03 page contract tests.
|
||||
*
|
||||
* The four depth-stack list tabs (Feed / MyShows / Discover / Settings) all
|
||||
* render through `<YaziPaneRow>` with the parent pane reading the
|
||||
* previous-depth frame's list (blank placeholder at depth 0). Their `open()`
|
||||
* action calls `nav.pushDepth(frame)` to drill and the Shell calls
|
||||
* `nav.popDepth()` on `h`. This file exercises the nav-store contract those
|
||||
* pages depend on for every depth-tab, asserting the parent-slot data model:
|
||||
*
|
||||
* • depth 0 → stack has exactly the root frame (parent pane is blank)
|
||||
* • drill(l)→ push a child frame; stack length 2, parent = previous list
|
||||
* • drill(l)→ push again; stack length 3 (Settings sections→items→editor)
|
||||
* • pop(h) → stack shrinks; parent returns to the previous list
|
||||
* • pop(h) → back at root; parent is blank again
|
||||
*
|
||||
* The visual "blank → list → list → blank" transition is the union of this
|
||||
* data model (which list each depth renders) with `<YaziPaneRow>`'s null
|
||||
* placeholder (covered by yazi-pane-row.test.tsx). Tested at the store level
|
||||
* because the page `open()` closures are not exported and the nav store is
|
||||
* the shared contract all four pages route through.
|
||||
*/
|
||||
|
||||
import { test, expect } from "bun:test";
|
||||
import { createRoot } from "solid-js";
|
||||
import {
|
||||
createNavigation,
|
||||
DEPTH_CENTER_PANE,
|
||||
} from "../src/context/navigation-store";
|
||||
import { TABS, DEPTH_TABS } from "../src/utils/navigation";
|
||||
import type { DepthFrame } from "../src/context/NavigationContext";
|
||||
|
||||
function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
|
||||
createRoot((dispose) => {
|
||||
fn(createNavigation());
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/** The depth-tabs that must render via <YaziPaneRow> (task 03 conversion). */
|
||||
const CONVERTED_TABS = [TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS];
|
||||
|
||||
for (const tab of CONVERTED_TABS) {
|
||||
const name = TABS[tab];
|
||||
|
||||
test(`${name}: depth 0 → 1 → 2 push/pop keeps the parent-slot contract`, () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(tab);
|
||||
expect(nav.isDepthTab()).toBe(true);
|
||||
|
||||
// depth 0: exactly the root frame → parent pane renders blank.
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
expect(nav.depthStack()).toHaveLength(1);
|
||||
|
||||
// drill (l): page open() pushes a child frame — parent becomes
|
||||
// the previous-depth list.
|
||||
const child: DepthFrame = { kind: `${name.toLowerCase()}:child`, ctx: "c1", focus: 0 };
|
||||
nav.pushDepth(child);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
expect(nav.depthStack()).toHaveLength(2);
|
||||
// the parent (depth 0) frame is still the root; the top is the child.
|
||||
expect(nav.depthStack()[0]).toBe(nav.depthStack()[0]);
|
||||
expect(nav.topFrame()).toEqual(child);
|
||||
|
||||
// drill again (l): push a second child — parent shows the first
|
||||
// child's list (the chain Settings exercises: sections→items→editor).
|
||||
const grandchild: DepthFrame = { kind: `${name.toLowerCase()}:grand`, ctx: "g1", focus: 0 };
|
||||
nav.pushDepth(grandchild);
|
||||
expect(nav.currentDepth()).toBe(2);
|
||||
expect(nav.depthStack()).toHaveLength(3);
|
||||
expect(nav.topFrame()).toEqual(grandchild);
|
||||
|
||||
// pop (h): back to depth 1 — parent frame is the root, top is child.
|
||||
expect(nav.popDepth()).toBe(true);
|
||||
expect(nav.currentDepth()).toBe(1);
|
||||
expect(nav.depthStack()).toHaveLength(2);
|
||||
expect(nav.topFrame()).toEqual(child);
|
||||
|
||||
// pop (h): back to depth 0 — parent pane is blank again.
|
||||
expect(nav.popDepth()).toBe(true);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
expect(nav.depthStack()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${name}: pop (h) at depth 0 is a noop (returns false, root kept)`, () => {
|
||||
withNav((nav) => {
|
||||
nav.setActiveTab(tab);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
expect(nav.popDepth()).toBe(false);
|
||||
expect(nav.currentDepth()).toBe(0);
|
||||
// the root frame is preserved (parent stays blank, not undefined).
|
||||
expect(nav.depthStack()).toHaveLength(1);
|
||||
expect(nav.topFrame()).toBeDefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── DEPTH_TABS covers exactly the four converted pages ───────────────────────
|
||||
test("DEPTH_TABS is exactly the four converted list tabs", () => {
|
||||
expect([...DEPTH_TABS].sort()).toEqual(
|
||||
[TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS].sort(),
|
||||
);
|
||||
});
|
||||
234
tests/yazi-pane-row.test.tsx
Normal file
234
tests/yazi-pane-row.test.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* YaziPaneRow tests — the 1:3:3 parent|current|preview layout primitive.
|
||||
*
|
||||
* Verified through the opentui test renderer's captured frames (the same
|
||||
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
||||
* observable as rendered column widths and border colors.
|
||||
*
|
||||
* • Unit: three columns render at 1:3:3 (e.g. 14/43/43 of 100) even when the
|
||||
* parent and preview children are null, and the blank parent keeps its
|
||||
* slot with a muted placeholder.
|
||||
* • Integration: toggling `focused` moves the accent focus ring onto/off the
|
||||
* current column; parent & preview borders stay muted either way.
|
||||
*
|
||||
* Runs via `bun test`. The `[test] preload = "@opentui/solid/preload"` entry
|
||||
* in bunfig.toml registers the solid JSX transform for the test runner, so
|
||||
* JSX in this file compiles exactly like app code.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterAll } from "bun:test";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { YaziPaneRow } from "../src/components/YaziPaneRow";
|
||||
|
||||
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
||||
type Frame = { lines: { spans: Span[] }[] };
|
||||
|
||||
// ── Frame introspection helpers ─────────────────────────────────────────────
|
||||
function hexOf(fg: Span["fg"]): string | null {
|
||||
if (!fg?.buffer) return null;
|
||||
const b = fg.buffer;
|
||||
if (b[3] === 0) return null;
|
||||
return (
|
||||
"#" +
|
||||
[0, 1, 2]
|
||||
.map((i) =>
|
||||
Math.max(0, Math.min(255, Math.round(b[i] * 255)))
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/** Column border colors, scanned from the top border row (`┌───┐…`). */
|
||||
function columnBorders(spans: Frame): string[] {
|
||||
const line = spans.lines[1];
|
||||
if (!line) return [];
|
||||
const out: string[] = [];
|
||||
for (const sp of line.spans) {
|
||||
for (const ch of sp.text) {
|
||||
if (ch === "┌") out.push(hexOf(sp.fg) ?? "default");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Column widths (including borders), from the top border row. */
|
||||
function columnWidths(spans: Frame): number[] {
|
||||
const line = spans.lines[1];
|
||||
if (!line) return [];
|
||||
const widths: number[] = [];
|
||||
for (const sp of line.spans) {
|
||||
for (const ch of sp.text) {
|
||||
if (ch === "┌") widths.push(0);
|
||||
else if (widths.length && ch === "─") widths[widths.length - 1]++;
|
||||
else if (widths.length && ch === "┐") widths[widths.length - 1] += 2;
|
||||
}
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
// Element children must be accessors (`() => JSX`): JSX elements are only
|
||||
// constructed inside the renderer context (during the test render pass), so
|
||||
// creating them eagerly in the test body would throw "No renderer found".
|
||||
type TestPaneProps = {
|
||||
parent?: unknown;
|
||||
current?: (() => unknown) | unknown;
|
||||
preview?: unknown;
|
||||
focused?: unknown;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
async function renderPaneRow(props: TestPaneProps): Promise<{
|
||||
spans: Frame;
|
||||
destroy: () => Promise<void>;
|
||||
}> {
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<YaziPaneRow
|
||||
parent={props.parent as any}
|
||||
current={props.current as any}
|
||||
preview={props.preview as any}
|
||||
parentLabel="Up"
|
||||
currentLabel="List"
|
||||
previewLabel="Detail"
|
||||
focused={props.focused as any}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: props.width ?? 100, height: props.height ?? 8, useThread: false },
|
||||
);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await setup.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
const spans = setup.captureSpans() as unknown as Frame;
|
||||
return { spans, destroy: () => setup.renderer.destroy() };
|
||||
}
|
||||
|
||||
const cleanups: (() => void | Promise<void>)[] = [];
|
||||
afterAll(async () => {
|
||||
for (const c of cleanups) {
|
||||
try {
|
||||
await c();
|
||||
} catch {
|
||||
// renderer already torn down — ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Unit: three columns at 1:3:3 regardless of null children ───────────────
|
||||
describe("YaziPaneRow layout", () => {
|
||||
test("renders three columns at 1:3:3 even with null parent/preview", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
preview: null,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
|
||||
const widths = columnWidths(spans);
|
||||
expect(widths).toHaveLength(3);
|
||||
const [p, c, v] = widths;
|
||||
// 100-wide row splits as 14 / 43 / 43 (1/7 : 3/7 : 3/7, borders included).
|
||||
expect(p).toBe(14);
|
||||
expect(c).toBe(43);
|
||||
expect(v).toBe(43);
|
||||
// Exact 1:3:3 proportion (within 1 col rounding).
|
||||
expect(c).toBeGreaterThanOrEqual(p * 3 - 1);
|
||||
expect(c).toBeLessThanOrEqual(p * 3 + 1);
|
||||
expect(v).toBeGreaterThanOrEqual(p * 3 - 1);
|
||||
expect(v).toBeLessThanOrEqual(p * 3 + 1);
|
||||
// Parent keeps a visibly non-zero slot and renders the muted placeholder.
|
||||
expect(p).toBeGreaterThan(4);
|
||||
const body = spans.lines
|
||||
.map((l) => l.spans.map((s) => s.text).join(""))
|
||||
.join("\n");
|
||||
expect(body).toContain("—");
|
||||
expect(body).toContain("ITEM");
|
||||
});
|
||||
|
||||
test("keeps the 1/7 parent slot across widths (ratio stable)", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>x</text>,
|
||||
preview: null,
|
||||
width: 70,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
const [p, c, v] = columnWidths(spans);
|
||||
expect(p).toBe(10); // 70 → 10 / 30 / 30
|
||||
expect(c).toBe(30);
|
||||
expect(v).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration: focused toggles the accent ring on the current column ─────
|
||||
describe("YaziPaneRow focus ring", () => {
|
||||
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
preview: null,
|
||||
focused: true,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
|
||||
const [parent, current, preview] = columnBorders(spans);
|
||||
// parent & preview are muted; current is the (different) accent color.
|
||||
expect(parent).toBe(preview);
|
||||
expect(current).not.toBe(parent);
|
||||
expect(current).not.toBe("default");
|
||||
});
|
||||
|
||||
test("focused=false mutes the current column (no accent ring anywhere)", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
preview: null,
|
||||
focused: false,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
|
||||
const [parent, current, preview] = columnBorders(spans);
|
||||
expect(current).toBe(parent);
|
||||
expect(preview).toBe(parent);
|
||||
});
|
||||
|
||||
test("accepts an accessor for focused (reactive boolean)", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
preview: null,
|
||||
focused: () => true,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
|
||||
const [parent, current] = columnBorders(spans);
|
||||
expect(current).not.toBe(parent); // accessor resolves true → accent ring
|
||||
|
||||
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
preview: null,
|
||||
focused: () => false,
|
||||
});
|
||||
cleanups.push(destroy2);
|
||||
const [p2, c2] = columnBorders(spans2);
|
||||
expect(c2).toBe(p2); // accessor resolves false → muted
|
||||
});
|
||||
|
||||
test("defaults to focused (current column carries the accent ring)", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
preview: null,
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
const [parent, current] = columnBorders(spans);
|
||||
expect(current).not.toBe(parent);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user