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
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.harness/
|
.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"]
|
preload = ["@opentui/solid/preload"]
|
||||||
|
|
||||||
|
[test]
|
||||||
|
preload = "@opentui/solid/preload"
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Shell — yazi-style application chrome.
|
* Shell — yazi-style application chrome.
|
||||||
*
|
*
|
||||||
* Renders the tabs as a vertical sidebar on the left (the root pane), the
|
* Renders the active page (which owns its own three-column parent | current |
|
||||||
* active page (which owns its own panes) to the right of it, and a bottom
|
* preview panes) full-width, with a bottom status/command bar that also
|
||||||
* status/command bar spanning the full width. A single `useKeyboard` router
|
* carries the tab strip. A single `useKeyboard` router translates keystrokes
|
||||||
* translates keystrokes (via the sequence-aware keybind matcher) into actions:
|
* (via the sequence-aware keybind matcher) into actions: the unified router
|
||||||
* global ones (tabs, modes, audio, quit, help, command) are handled here;
|
* 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`
|
* 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 { createSignal, Show, For } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { useKeyboard } from "@opentui/solid";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||||
import {
|
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||||
useNavigation,
|
|
||||||
NavMode,
|
|
||||||
SIDEBAR_PANE,
|
|
||||||
DEPTH_CENTER_PANE,
|
|
||||||
} from "@/context/NavigationContext";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { emit } from "@/utils/event-bus";
|
import { emit } from "@/utils/event-bus";
|
||||||
import { TABS, TabsCount, TabPaneCount, LayerGraph } from "@/utils/navigation";
|
import { LayerGraph } from "@/utils/layer-graph";
|
||||||
|
import { TABS, 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> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
[TABS.FEED]: "Feed",
|
[TABS.FEED]: "Feed",
|
||||||
@@ -37,54 +37,6 @@ const TAB_LABEL: Record<TABS, string> = {
|
|||||||
[TABS.SETTINGS]: "Settings",
|
[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() {
|
export function Shell() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const t = theme.theme;
|
const t = theme.theme;
|
||||||
@@ -141,7 +93,7 @@ export function Shell() {
|
|||||||
case "q":
|
case "q":
|
||||||
case "quit":
|
case "quit":
|
||||||
case "exit":
|
case "exit":
|
||||||
process.exit(0);
|
return process.exit(0);
|
||||||
case "refresh":
|
case "refresh":
|
||||||
case "r":
|
case "r":
|
||||||
emit("nav.action", {
|
emit("nav.action", {
|
||||||
@@ -235,148 +187,16 @@ export function Shell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Unified router (normal + visual) ───────────────────────────────────────
|
// ── Unified router (normal + visual) ───────────────────────────────────────
|
||||||
function dispatch(action: KeybindActionName, evt: any) {
|
const { dispatch } = createDispatcher({
|
||||||
const tab = nav.activeTab();
|
nav,
|
||||||
const pane = nav.activePane();
|
audio: {
|
||||||
switch (action) {
|
togglePlayback: audio.togglePlayback,
|
||||||
// ── modes ──
|
seekRelative: audio.seekRelative,
|
||||||
case "escape":
|
},
|
||||||
evt.preventDefault();
|
k,
|
||||||
if (nav.mode() === NavMode.VISUAL) {
|
setShowHelp,
|
||||||
nav.toNormal();
|
advanceEpisode,
|
||||||
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(),
|
|
||||||
});
|
|
||||||
} 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(
|
useKeyboard(
|
||||||
(evt: any) => {
|
(evt: any) => {
|
||||||
@@ -414,60 +234,36 @@ export function Shell() {
|
|||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={t.surface}
|
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%">
|
<box flexDirection="row" flexGrow={1} width="100%">
|
||||||
{/* ── Left tab sidebar ─────────────────────────────────────────────── */}
|
<Show
|
||||||
<box
|
when={nav.atRootTab()}
|
||||||
flexDirection="column"
|
fallback={
|
||||||
width={14}
|
<box flexGrow={1} width="100%">
|
||||||
height="100%"
|
{LayerGraph[nav.activeTab()]()}
|
||||||
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>
|
</box>
|
||||||
</Show>
|
}
|
||||||
</box>
|
>
|
||||||
|
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
||||||
{/* ── Active page (owns its panes) ────────────────────────────────── */}
|
<YaziPaneRow
|
||||||
<box flexDirection="column" flexGrow={1} height="100%">
|
parent={
|
||||||
{LayerGraph[nav.activeTab()]()}
|
<box padding={1}>
|
||||||
</box>
|
<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>
|
</box>
|
||||||
|
|
||||||
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
|
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
@@ -483,24 +279,30 @@ export function Shell() {
|
|||||||
{modeLabel()}
|
{modeLabel()}
|
||||||
</text>
|
</text>
|
||||||
<text fg={t.textMuted} paddingLeft={1}>
|
<text fg={t.textMuted} paddingLeft={1}>
|
||||||
{TAB_LABEL[nav.activeTab()]} ·{" "}
|
{nav.atRootTab()
|
||||||
{nav.activePane() === SIDEBAR_PANE
|
? "Tabs · root"
|
||||||
? "tabs"
|
: `${TAB_LABEL[nav.activeTab()]} · ${
|
||||||
: nav.isDepthTab()
|
nav.isDepthTab()
|
||||||
? `depth ${nav.currentDepth()}`
|
? `depth ${nav.currentDepth()}`
|
||||||
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
|
: `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}`
|
||||||
|
}`}
|
||||||
</text>
|
</text>
|
||||||
<Show when={nav.selectedIds().length > 0}>
|
<Show when={nav.selectedIds().length > 0}>
|
||||||
<text fg={t.warning} paddingLeft={1}>
|
<text fg={t.warning} paddingLeft={1}>
|
||||||
● {nav.selectedIds().length}
|
● {nav.selectedIds().length}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={nowPlaying()}>
|
||||||
|
<text fg={t.primary} paddingLeft={1}>
|
||||||
|
{nowPlaying()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
<text fg={t.textMuted} paddingRight={1}>
|
<text fg={t.textMuted} paddingRight={1}>
|
||||||
{pendingLabel()}
|
{pendingLabel()}
|
||||||
</text>
|
</text>
|
||||||
<text fg={t.textMuted} paddingRight={1}>
|
<text fg={t.textMuted} paddingRight={1}>
|
||||||
:cmd ~help q quit
|
~
|
||||||
</text>
|
</text>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -517,7 +319,6 @@ export function Shell() {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
|
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
|
||||||
<Show when={showHelp()}>
|
<Show when={showHelp()}>
|
||||||
<HelpOverlay
|
<HelpOverlay
|
||||||
@@ -545,7 +346,9 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
|
|||||||
{
|
{
|
||||||
group: "Panes",
|
group: "Panes",
|
||||||
items: [
|
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"],
|
["1-6 / [ ]", "switch tabs"],
|
||||||
[":", "command"],
|
[":", "command"],
|
||||||
["~", "help"],
|
["~", "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 { createSimpleContext } from "./helper";
|
||||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
import { createNavigation } from "./navigation-store";
|
||||||
|
|
||||||
// ── Yazi-style navigation state ──────────────────────────────────────────────
|
// Re-export the entire nav model surface so existing imports from
|
||||||
// Two pane models coexist:
|
// `@/context/NavigationContext` keep resolving.
|
||||||
//
|
export * from "./navigation-store";
|
||||||
// • Depth-stack tabs (Feed, MyShows, Discover, Settings) use a yazi-style
|
|
||||||
// depth stack. The three content columns render as:
|
|
||||||
// left = the previous depth's list (empty at depth 0)
|
|
||||||
// center = the current depth's list (always where focus lives)
|
|
||||||
// right = preview of the hovered item in center
|
|
||||||
// `l`/Enter drills in (push); `h` pops back (or yields to the sidebar at
|
|
||||||
// depth 0). Depth is unbounded — each page decides per-item whether an
|
|
||||||
// item is drillable and what child list kind to push.
|
|
||||||
//
|
|
||||||
// • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
|
|
||||||
// old indexed pane model (`focusedIndex(pane)` + `swipe`).
|
|
||||||
//
|
|
||||||
// The Shell's left tab sidebar is a special pane that sits *before* the
|
|
||||||
// content area. It uses SIDEBAR_PANE (-1) so the h/l chain naturally lands on
|
|
||||||
// it as the leftmost/root pane.
|
|
||||||
|
|
||||||
export enum NavMode {
|
|
||||||
NORMAL = "NORMAL",
|
|
||||||
VISUAL = "VISUAL",
|
|
||||||
COMMAND = "COMMAND",
|
|
||||||
INPUT = "INPUT",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tab sidebar (chrome) pane. Always the leftmost focus target. */
|
|
||||||
export const SIDEBAR_PANE = -1 as PaneId;
|
|
||||||
|
|
||||||
/** For depth-tabs, the current-depth (center) pane is the only focusable
|
|
||||||
* content pane — index 0. The prev/preview columns are derived, not focused. */
|
|
||||||
export const DEPTH_CENTER_PANE = 0 as PaneId;
|
|
||||||
|
|
||||||
/** The sidebar pane's "list" is the tab list itself: its focus cursor is the
|
|
||||||
* active tab (1-based) minus 1, and moving/setting it switches tabs via the
|
|
||||||
* standard focusedIndex/move/gotoIndex API — no special-cased nextTab. */
|
|
||||||
|
|
||||||
/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */
|
|
||||||
export enum PaneSlot {
|
|
||||||
PARENT = 0, // depth-tabs: center/current; Search: input
|
|
||||||
CURRENT = 1, // Search: results
|
|
||||||
PREVIEW = 2, // Search: detail
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PaneId = number; // 0-based index into the active tab's pane list
|
|
||||||
|
|
||||||
// ── Depth stack ──────────────────────────────────────────────────────────────
|
|
||||||
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
|
|
||||||
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
|
|
||||||
* focused row index within that list. `ctx` optionally carries an id or
|
|
||||||
* payload the page needs to derive the list (e.g. a feed id). */
|
|
||||||
export type DepthFrame = {
|
|
||||||
kind: string;
|
|
||||||
ctx?: string;
|
|
||||||
focus: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Selection store ───────────────────────────────────────────────────────────
|
|
||||||
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
|
|
||||||
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range
|
|
||||||
// selection anchored at the focused index.
|
|
||||||
|
|
||||||
type SelectionMap = Record<string, Set<string>>;
|
|
||||||
|
|
||||||
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
|
|
||||||
|
|
||||||
export const { use: useNavigation, provider: NavigationProvider } =
|
export const { use: useNavigation, provider: NavigationProvider } =
|
||||||
createSimpleContext({
|
createSimpleContext({
|
||||||
name: "Navigation",
|
name: "Navigation",
|
||||||
init: () => {
|
init: () => createNavigation(),
|
||||||
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
|
||||||
// App focus starts on the left tab sidebar (root pane); tab switches
|
|
||||||
// also return focus there.
|
|
||||||
const [activePane, setActivePane] = createSignal<PaneId>(SIDEBAR_PANE);
|
|
||||||
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
|
|
||||||
const [count, setCount] = createSignal<number | null>(null);
|
|
||||||
const [inputFocused, setInputFocused] = createSignal(false);
|
|
||||||
|
|
||||||
// per-tab depth stack. Depth-tabs get a root frame on first visit.
|
|
||||||
const [stacks, setStacks] = createSignal<
|
|
||||||
Partial<Record<TABS, DepthFrame[]>>
|
|
||||||
>({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] });
|
|
||||||
|
|
||||||
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed
|
|
||||||
// by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
|
|
||||||
// for pane 0 (DEPTH_CENTER_PANE) instead.
|
|
||||||
const [paneIndices, setPaneIndices] = createSignal<
|
|
||||||
Record<string, number>
|
|
||||||
>({});
|
|
||||||
const [selections, setSelections] = createSignal<SelectionMap>({});
|
|
||||||
const [visualAnchor, setVisualAnchor] = createSignal<{
|
|
||||||
paneKey: string;
|
|
||||||
index: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [commandBuffer, setCommandBuffer] = createSignal("");
|
|
||||||
const [commandError, setCommandError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
/** Depth stack for a tab (empty for fixed-pane tabs). */
|
|
||||||
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
|
|
||||||
|
|
||||||
const ensureStack = (tab: TABS) => {
|
|
||||||
if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
|
|
||||||
setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// On tab change: ensure a root frame exists (depth-tabs) + reset
|
|
||||||
// focus to the sidebar, clear modes/command/visual state.
|
|
||||||
createEffect(
|
|
||||||
on(activeTab, (tab) => {
|
|
||||||
ensureStack(tab);
|
|
||||||
batch(() => {
|
|
||||||
setActivePane(SIDEBAR_PANE);
|
|
||||||
setMode(NavMode.NORMAL);
|
|
||||||
setCount(null);
|
|
||||||
setCommandBuffer("");
|
|
||||||
setCommandError(null);
|
|
||||||
setVisualAnchor(null);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── depth stack accessors ──────────────────────────────────────────────
|
|
||||||
const depthStack = createMemo<DepthFrame[]>(() =>
|
|
||||||
depthStackFor(activeTab()),
|
|
||||||
);
|
|
||||||
const currentDepth = createMemo(() =>
|
|
||||||
Math.max(0, depthStack().length - 1),
|
|
||||||
);
|
|
||||||
const topFrame = createMemo<DepthFrame | undefined>(
|
|
||||||
() => depthStack()[depthStack().length - 1],
|
|
||||||
);
|
|
||||||
const isDepthTab = () => DEPTH_TABS.has(activeTab());
|
|
||||||
|
|
||||||
/** Focus within a given depth's frame (default = current/top). */
|
|
||||||
const depthFocus = (d: number = currentDepth()) =>
|
|
||||||
depthStack()[d]?.focus ?? 0;
|
|
||||||
|
|
||||||
const setDepthFocus = (i: number, d: number = currentDepth()) =>
|
|
||||||
setStacks((s) => {
|
|
||||||
const st = s[activeTab()];
|
|
||||||
if (!st || d < 0 || d >= st.length) return s;
|
|
||||||
const next = st.slice();
|
|
||||||
next[d] = { ...next[d], focus: i };
|
|
||||||
return { ...s, [activeTab()]: next };
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Push a child frame (drill in). */
|
|
||||||
const pushDepth = (frame: DepthFrame) =>
|
|
||||||
setStacks((s) => {
|
|
||||||
const st = s[activeTab()] ?? [];
|
|
||||||
return { ...s, [activeTab()]: [...st, frame] };
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Pop the top frame (go back up a depth). No-op at root. Returns
|
|
||||||
* true if a frame was popped. */
|
|
||||||
const popDepth = (): boolean => {
|
|
||||||
let popped = false;
|
|
||||||
setStacks((s) => {
|
|
||||||
const st = s[activeTab()] ?? [];
|
|
||||||
if (st.length <= 1) return s;
|
|
||||||
popped = true;
|
|
||||||
return { ...s, [activeTab()]: st.slice(0, -1) };
|
|
||||||
});
|
|
||||||
return popped;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── tab switching ──────────────────────────────────────────────────────
|
|
||||||
const gotoTab = (tab: TABS) => {
|
|
||||||
if (tab < 1 || tab > TabsCount) return;
|
|
||||||
setActiveTab(tab);
|
|
||||||
};
|
|
||||||
const nextTab = () =>
|
|
||||||
setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS)));
|
|
||||||
const prevTab = () =>
|
|
||||||
setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS)));
|
|
||||||
|
|
||||||
// ── pane focus ──────────────────────────────────────────────────────────
|
|
||||||
const setPane = (pane: PaneId) => setActivePane(pane);
|
|
||||||
|
|
||||||
/** Move focus to the adjacent pane (fixed-pane tabs only). `dir` =
|
|
||||||
* -1 (left, toward sidebar) or +1 (right, toward preview). Clamped to
|
|
||||||
* [SIDEBAR_PANE, paneCount-1]. */
|
|
||||||
const swipe = (dir: -1 | 1, paneCount: number) => {
|
|
||||||
setActivePane((p) => {
|
|
||||||
const n = Math.max(SIDEBAR_PANE, Math.min(paneCount - 1, p + dir));
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── per-pane focus index ────────────────────────────────────────────────
|
|
||||||
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
|
||||||
|
|
||||||
/** For depth-tabs, pane 0 (center) reads/writes the top frame's
|
|
||||||
* focus. The sidebar pane's focus IS the active tab. Other panes
|
|
||||||
* (and fixed-pane tabs) use the per-pane map. */
|
|
||||||
const focusedIndex = (pane: PaneId = activePane()): number => {
|
|
||||||
if (pane === SIDEBAR_PANE) return activeTab() - 1;
|
|
||||||
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
|
||||||
return topFrame()?.focus ?? 0;
|
|
||||||
}
|
|
||||||
return paneIndices()[paneKey(pane)] ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setFocusedIndex = (pane: PaneId, index: number) => {
|
|
||||||
if (pane === SIDEBAR_PANE) {
|
|
||||||
gotoTab(((index + TabsCount) % TabsCount) + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
|
|
||||||
setDepthFocus(index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPaneIndices((m) => ({
|
|
||||||
...m,
|
|
||||||
[`${activeTab()}:${pane}`]: index,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Apply a clamped relative motion to the active pane's focus. Returns
|
|
||||||
* the new index so callers can update their own scroll state. */
|
|
||||||
const move = (
|
|
||||||
delta: number,
|
|
||||||
listLen: number,
|
|
||||||
countOverride?: number,
|
|
||||||
): number => {
|
|
||||||
if (listLen <= 0) return 0;
|
|
||||||
const steps = countOverride ?? count() ?? 1;
|
|
||||||
const pane = activePane();
|
|
||||||
const cur = focusedIndex(pane);
|
|
||||||
let next = cur + delta * steps;
|
|
||||||
// wrap-around like yazi (arrow wraps top<->bottom)
|
|
||||||
next = ((next % listLen) + listLen) % listLen;
|
|
||||||
setFocusedIndex(pane, next);
|
|
||||||
// visual-mode range selection: add newly-traversed items to selection
|
|
||||||
if (HAS_VISUAL(mode()) && visualAnchor()) {
|
|
||||||
growVisualSelection(next);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const gotoIndex = (index: number, listLen: number): number => {
|
|
||||||
if (listLen <= 0) return 0;
|
|
||||||
const pane = activePane();
|
|
||||||
const next = Math.max(0, Math.min(listLen - 1, index));
|
|
||||||
setFocusedIndex(pane, next);
|
|
||||||
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── selection ───────────────────────────────────────────────────────────
|
|
||||||
const selSet = (key: string): Set<string> =>
|
|
||||||
selections()[key] ?? new Set();
|
|
||||||
|
|
||||||
const toggleSelected = (id: string) => {
|
|
||||||
const key = paneKey();
|
|
||||||
setSelections((m) => {
|
|
||||||
const set = new Set(m[key] ?? []);
|
|
||||||
if (set.has(id)) set.delete(id);
|
|
||||||
else set.add(id);
|
|
||||||
return { ...m, [key]: set };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSelected = (id: string) => selSet(paneKey()).has(id);
|
|
||||||
|
|
||||||
const clearSelection = (key?: string) => {
|
|
||||||
const k = key ?? paneKey();
|
|
||||||
setSelections((m) => {
|
|
||||||
if (!(k in m)) return m;
|
|
||||||
const next = { ...m };
|
|
||||||
delete next[k];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedIds = createMemo(() => [...selSet(paneKey())]);
|
|
||||||
|
|
||||||
/** Enter visual mode, anchoring range selection at the current focus. */
|
|
||||||
const enterVisual = () => {
|
|
||||||
const pane = activePane();
|
|
||||||
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
|
|
||||||
setMode(NavMode.VISUAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Grow selection between the visual anchor and `index` for the active
|
|
||||||
* pane. Callers pass item ids aligned to indices; we store ids via the
|
|
||||||
* resolve callback registered per-pane (see registerResolver). */
|
|
||||||
let resolvers: Record<string, (index: number) => string | undefined> = {};
|
|
||||||
const registerResolver = (
|
|
||||||
key: string,
|
|
||||||
fn: (i: number) => string | undefined,
|
|
||||||
) => {
|
|
||||||
resolvers[key] = fn;
|
|
||||||
};
|
|
||||||
const growVisualSelection = (index: number) => {
|
|
||||||
const anchor = visualAnchor();
|
|
||||||
if (!anchor) return;
|
|
||||||
const resolve = resolvers[anchor.paneKey];
|
|
||||||
if (!resolve) return;
|
|
||||||
const lo = Math.min(anchor.index, index);
|
|
||||||
const hi = Math.max(anchor.index, index);
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (let i = lo; i <= hi; i++) {
|
|
||||||
const id = resolve(i);
|
|
||||||
if (id) ids.push(id);
|
|
||||||
}
|
|
||||||
const key = anchor.paneKey;
|
|
||||||
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── modes ────────────────────────────────────────────────────────────────
|
|
||||||
const enterCommand = () => {
|
|
||||||
setMode(NavMode.COMMAND);
|
|
||||||
setCommandBuffer("");
|
|
||||||
setCommandError(null);
|
|
||||||
};
|
|
||||||
const enterInput = () => setMode(NavMode.INPUT);
|
|
||||||
const exitCommand = () => {
|
|
||||||
batch(() => {
|
|
||||||
setMode(NavMode.NORMAL);
|
|
||||||
setCommandBuffer("");
|
|
||||||
setCommandError(null);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const exitVisual = () => {
|
|
||||||
batch(() => {
|
|
||||||
setMode(NavMode.NORMAL);
|
|
||||||
setVisualAnchor(null);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const toNormal = () => {
|
|
||||||
if (mode() === NavMode.VISUAL) {
|
|
||||||
clearSelection();
|
|
||||||
exitVisual();
|
|
||||||
} else {
|
|
||||||
setMode(NavMode.NORMAL);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── command buffer ───────────────────────────────────────────────────────
|
|
||||||
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
|
|
||||||
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
|
|
||||||
const submitCommand = (): string => {
|
|
||||||
const cmd = commandBuffer().trim();
|
|
||||||
exitCommand();
|
|
||||||
return cmd;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── count register ───────────────────────────────────────────────────────
|
|
||||||
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
|
|
||||||
const consumeCount = (): number => {
|
|
||||||
const c = count();
|
|
||||||
setCount(null);
|
|
||||||
return c ?? 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeTab,
|
|
||||||
activePane,
|
|
||||||
mode,
|
|
||||||
count,
|
|
||||||
inputFocused,
|
|
||||||
commandBuffer,
|
|
||||||
commandError,
|
|
||||||
visualAnchor,
|
|
||||||
selections,
|
|
||||||
selectedIds,
|
|
||||||
// depth stack
|
|
||||||
depthStack,
|
|
||||||
currentDepth,
|
|
||||||
topFrame,
|
|
||||||
depthFocus,
|
|
||||||
setDepthFocus,
|
|
||||||
pushDepth,
|
|
||||||
popDepth,
|
|
||||||
isDepthTab,
|
|
||||||
// tab
|
|
||||||
setActiveTab: gotoTab,
|
|
||||||
nextTab,
|
|
||||||
prevTab,
|
|
||||||
// pane focus
|
|
||||||
setActivePane: setPane,
|
|
||||||
swipe,
|
|
||||||
// focus index
|
|
||||||
focusedIndex,
|
|
||||||
setFocusedIndex,
|
|
||||||
move,
|
|
||||||
gotoIndex,
|
|
||||||
// selection
|
|
||||||
isSelected,
|
|
||||||
toggleSelected,
|
|
||||||
clearSelection,
|
|
||||||
selectedIdsFor: (key: string) => [...selSet(key)],
|
|
||||||
registerResolver,
|
|
||||||
enterVisual,
|
|
||||||
exitVisual,
|
|
||||||
// modes
|
|
||||||
setActiveTabSignal: setActiveTab,
|
|
||||||
setActiveDepth: setPane, // legacy alias
|
|
||||||
activeDepth: activePane, // legacy alias
|
|
||||||
setInputFocused,
|
|
||||||
nextPane: () => {}, // legacy noop; swipe() replaces this
|
|
||||||
prevPane: () => {},
|
|
||||||
setMode,
|
|
||||||
enterCommand,
|
|
||||||
enterInput,
|
|
||||||
exitCommand,
|
|
||||||
toNormal,
|
|
||||||
// command buffer
|
|
||||||
setCommandBuffer,
|
|
||||||
appendCommand,
|
|
||||||
backspaceCommand,
|
|
||||||
submitCommand,
|
|
||||||
setCommandError,
|
|
||||||
// count
|
|
||||||
pushCountDigit,
|
|
||||||
consumeCount,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
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.
|
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — category list. Left pane empty at root.
|
* depth 0 (current) — category list. Parent pane shows the muted
|
||||||
* depth 1 (current) — podcast results for the drilled category.
|
* placeholder (1/7 slot kept).
|
||||||
* right (preview) — detail of the hovered item (category summary, or
|
* 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).
|
* podcast detail + subscribe action).
|
||||||
*
|
*
|
||||||
* `l`/Enter drills in (category → results) or subscribes (on a podcast);
|
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* `h` pops back (or yields to the sidebar at depth 0). j/k move within the
|
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
||||||
* current column. Moving through categories at depth 0 updates the store's
|
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||||||
* selected category so the preview follows.
|
* 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";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
@@ -25,7 +28,8 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
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;
|
export const DiscoverPaneCount = 1;
|
||||||
|
|
||||||
@@ -132,269 +136,218 @@ function DiscoverPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
|
||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.surface : theme.text;
|
i === lf && active ? theme.surface : theme.text;
|
||||||
const headerBg = theme.background;
|
|
||||||
|
|
||||||
return (
|
const currentLabel = () =>
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
depth() === 0
|
||||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
? "Categories"
|
||||||
<box
|
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||||||
flexDirection="column"
|
|
||||||
flexGrow={PANE_RATIO.parent}
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
flexShrink={1}
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||||
flexBasis={0}
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||||
height="100%"
|
// mounts/unmounts cleanly on depth change.
|
||||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
const parentContent = () => (
|
||||||
overflow="hidden"
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||||
>
|
<For each={categories()}>
|
||||||
<Show when={depth() >= 1}>
|
{(cat, index) => (
|
||||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
<box
|
||||||
<text fg={theme.textSecondary}>Categories</text>
|
flexDirection="row"
|
||||||
</box>
|
gap={1}
|
||||||
<scrollbox
|
paddingLeft={1}
|
||||||
height="100%"
|
paddingRight={1}
|
||||||
border
|
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
||||||
borderColor={theme.border}
|
|
||||||
backgroundColor={theme.background}
|
|
||||||
>
|
>
|
||||||
<For each={categories()}>
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
{(cat, index) => (
|
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
|
{cat.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
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())}>
|
||||||
|
{index() === lf ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={focusFg(index(), lf, isActive())}>{cat.name}</text>
|
||||||
|
<Show when={selected()}>
|
||||||
|
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||||
|
*
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
{/* depth ≥1: results */}
|
||||||
|
<Show when={depth() >= 1}>
|
||||||
|
<Show
|
||||||
|
when={podcasts().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={podcasts()}>
|
||||||
|
{(podcast, index) => {
|
||||||
|
const lf = focusedPodIdx();
|
||||||
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="column"
|
||||||
gap={1}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
backgroundColor={focusBg(index(), lf, isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
<box flexDirection="row" gap={1}>
|
||||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
<text fg={focusFg(index(), lf, isActive())}>
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
|
||||||
{cat.name}
|
|
||||||
</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
|
|
||||||
? "Categories"
|
|
||||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<scrollbox
|
|
||||||
height="100%"
|
|
||||||
focused={isActive}
|
|
||||||
border
|
|
||||||
borderColor={border(isActive)}
|
|
||||||
backgroundColor={theme.background}
|
|
||||||
>
|
|
||||||
{/* depth 0: categories */}
|
|
||||||
<Show when={depth() === 0}>
|
|
||||||
<For each={categories()}>
|
|
||||||
{(cat, index) => {
|
|
||||||
const lf = focusedCatIdx();
|
|
||||||
const selected = () =>
|
|
||||||
cat.id === discoverStore.selectedCategory();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
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)}>
|
|
||||||
{index() === lf ? "❯" : " "}
|
{index() === lf ? "❯" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
|
<text fg={focusFg(index(), lf, isActive())}>
|
||||||
<Show when={selected()}>
|
{podcast.title}
|
||||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
</text>
|
||||||
*
|
<Show when={podcast.isSubscribed}>
|
||||||
|
<text fg={index() === lf ? theme.surface : theme.success}>
|
||||||
|
[+]
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
);
|
<Show when={podcast.author}>
|
||||||
}}
|
<text
|
||||||
</For>
|
fg={index() === lf ? theme.surface : muted()}
|
||||||
</Show>
|
paddingLeft={2}
|
||||||
|
|
||||||
{/* depth ≥1: results */}
|
|
||||||
<Show when={depth() >= 1}>
|
|
||||||
<Show
|
|
||||||
when={podcasts().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={podcasts()}>
|
|
||||||
{(podcast, index) => {
|
|
||||||
const lf = focusedPodIdx();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={focusBg(index(), lf, isActive)}
|
|
||||||
onMouseDown={() => {
|
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
||||||
nav.setDepthFocus(index(), 1);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
by {podcast.author}
|
||||||
<text fg={focusFg(index(), lf, isActive)}>
|
</text>
|
||||||
{index() === lf ? "❯" : " "}
|
</Show>
|
||||||
</text>
|
</box>
|
||||||
<text fg={focusFg(index(), lf, isActive)}>
|
);
|
||||||
{podcast.title}
|
}}
|
||||||
</text>
|
</For>
|
||||||
<Show when={podcast.isSubscribed}>
|
</Show>
|
||||||
<text
|
</Show>
|
||||||
fg={index() === lf ? theme.surface : theme.success}
|
</>
|
||||||
>
|
);
|
||||||
[+]
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<Show when={podcast.author}>
|
|
||||||
<text
|
|
||||||
fg={index() === lf ? theme.surface : muted()}
|
|
||||||
paddingLeft={2}
|
|
||||||
>
|
|
||||||
by {podcast.author}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* ── right: preview ────────────────────────────────────────────────── */}
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
<box
|
const previewContent = () =>
|
||||||
flexDirection="column"
|
depth() === 0 ? (
|
||||||
flexGrow={PANE_RATIO.preview}
|
// depth 0 preview: hovered category
|
||||||
flexShrink={1}
|
<Show
|
||||||
flexBasis={0}
|
when={focusedCategory()}
|
||||||
height="100%"
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No category focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
{(cat) => (
|
||||||
<text fg={theme.textSecondary}>Preview</text>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
</box>
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
<scrollbox
|
<strong>{cat().name}</strong>
|
||||||
height="100%"
|
</text>
|
||||||
border
|
<text fg={theme.textSecondary}>
|
||||||
borderColor={theme.border}
|
{(cat() as any).description ??
|
||||||
backgroundColor={theme.background}
|
`Browse top podcasts in ${cat().name}.`}
|
||||||
>
|
</text>
|
||||||
{/* depth 0 preview: hovered category */}
|
<box height={1} />
|
||||||
<Show when={depth() === 0}>
|
<text fg={muted()}>enter/l: open · h: back</text>
|
||||||
<Show
|
</box>
|
||||||
when={focusedCategory()}
|
)}
|
||||||
fallback={
|
</Show>
|
||||||
<box padding={1}>
|
) : (
|
||||||
<text fg={muted()}>No category focused</text>
|
// depth ≥1 preview: hovered podcast + subscribe
|
||||||
</box>
|
<Show
|
||||||
}
|
when={focusedPodcast()}
|
||||||
>
|
fallback={
|
||||||
{(cat) => (
|
<box padding={1}>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<text fg={muted()}>No podcast focused</text>
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
</box>
|
||||||
<strong>{cat().name}</strong>
|
}
|
||||||
</text>
|
>
|
||||||
<text fg={theme.textSecondary}>
|
{(pod) => (
|
||||||
{(cat() as any).description ??
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
`Browse top podcasts in ${cat().name}.`}
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
</text>
|
<strong>{pod().title}</strong>
|
||||||
<box height={1} />
|
</text>
|
||||||
<text fg={muted()}>enter/l: open · h: back</text>
|
<Show when={pod().author}>
|
||||||
</box>
|
<text fg={muted()}>by {pod().author}</text>
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
<Show when={pod().isSubscribed}>
|
||||||
|
<text fg={theme.success}>✓ Subscribed</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={!pod().isSubscribed}>
|
||||||
|
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{pod().description?.slice(0, 400) ?? "No description available."}
|
||||||
|
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<Show when={(pod().categories ?? []).length > 0}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={(pod().categories ?? []).slice(0, 4)}>
|
||||||
|
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={pod().feedUrl}>
|
||||||
|
<text fg={muted()}>Feed: {pod().feedUrl}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
{/* depth ≥1 preview: hovered podcast + subscribe */}
|
return (
|
||||||
<Show when={depth() >= 1}>
|
<YaziPaneRow
|
||||||
<Show
|
parent={parentContent}
|
||||||
when={focusedPodcast()}
|
current={currentContent}
|
||||||
fallback={
|
preview={previewContent}
|
||||||
<box padding={1}>
|
parentLabel={() => (depth() >= 1 ? "Categories" : "Up")}
|
||||||
<text fg={muted()}>No podcast focused</text>
|
currentLabel={currentLabel}
|
||||||
</box>
|
previewLabel="Detail"
|
||||||
}
|
focused={isActive}
|
||||||
>
|
/>
|
||||||
{(pod) => (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
|
||||||
<strong>{pod().title}</strong>
|
|
||||||
</text>
|
|
||||||
<Show when={pod().author}>
|
|
||||||
<text fg={muted()}>by {pod().author}</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={pod().isSubscribed}>
|
|
||||||
<text fg={theme.success}>✓ Subscribed</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={!pod().isSubscribed}>
|
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
|
||||||
</Show>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{pod().description?.slice(0, 400) ??
|
|
||||||
"No description available."}
|
|
||||||
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<Show when={(pod().categories ?? []).length > 0}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<For each={(pod().categories ?? []).slice(0, 4)}>
|
|
||||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<Show when={pod().feedUrl}>
|
|
||||||
<text fg={muted()}>Feed: {pod().feedUrl}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={muted()}>
|
|
||||||
Updated: {formatDate(pod().lastUpdated)}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>
|
|
||||||
enter: subscribe · h: back · r: refresh
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
|
* 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
|
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
||||||
* chronological). Left pane = the feeds list (prev).
|
* chronological). Parent pane = the feeds list (prev).
|
||||||
* right (preview) — detail of the hovered item in the current column.
|
* preview — detail of the hovered item in the current column.
|
||||||
*
|
*
|
||||||
* `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the
|
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
|
||||||
* sidebar at depth 0). j/k move within the current column. The Shell router
|
* primitive); no bespoke 3-column flexbox JSX remains. `l`/Enter drills in
|
||||||
* drives everything over nav.action; this page only handles list/preview data.
|
* (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";
|
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 { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
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;
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
@@ -200,8 +204,8 @@ function FeedPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
// Row highlight within a list. `active=true` only for the current pane.
|
||||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||||
i === listFocus && active
|
i === listFocus && active
|
||||||
? theme.primary
|
? theme.primary
|
||||||
@@ -210,7 +214,6 @@ function FeedPage() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||||
i === listFocus && active ? theme.surface : theme.text;
|
i === listFocus && active ? theme.surface : theme.text;
|
||||||
const headerBg = theme.background;
|
|
||||||
|
|
||||||
const feedLabel = (item: FeedListItem) =>
|
const feedLabel = (item: FeedListItem) =>
|
||||||
item.kind === "all"
|
item.kind === "all"
|
||||||
@@ -221,309 +224,260 @@ function FeedPage() {
|
|||||||
? feedStore.getAllEpisodesChronological().length
|
? feedStore.getAllEpisodesChronological().length
|
||||||
: item.feed.episodes.length;
|
: item.feed.episodes.length;
|
||||||
|
|
||||||
return (
|
const currentLabel = () =>
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
depth() === 0
|
||||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
? `Feeds · ${feedList().length - 1}`
|
||||||
<box
|
: `${(() => {
|
||||||
flexDirection="column"
|
const fi = focusedFeedItem();
|
||||||
flexGrow={PANE_RATIO.parent}
|
return fi?.kind === "feed"
|
||||||
flexShrink={1}
|
? fi.feed.customName || fi.feed.podcast.title
|
||||||
flexBasis={0}
|
: "All Episodes";
|
||||||
height="100%"
|
})()} · ${episodes().length}`;
|
||||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
|
||||||
overflow="hidden"
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||||
>
|
// Wrap in a stable <Show> (the sibling-Show pattern) so the parent list
|
||||||
<Show when={depth() >= 1}>
|
// mounts/unmounts cleanly on depth change instead of swapping roots.
|
||||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
const parentContent = () => (
|
||||||
<text fg={theme.textSecondary}>
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||||
Feeds · {feedList().length - 1}
|
<For each={feedList()}>
|
||||||
</text>
|
{(item, index) => {
|
||||||
</box>
|
const lf = nav.depthFocus(0);
|
||||||
<scrollbox
|
return (
|
||||||
height="100%"
|
<box
|
||||||
border
|
flexDirection="row"
|
||||||
borderColor={theme.border}
|
gap={1}
|
||||||
backgroundColor={theme.background}
|
paddingLeft={1}
|
||||||
>
|
paddingRight={1}
|
||||||
<For each={feedList()}>
|
backgroundColor={focusBg(index(), lf, false)}
|
||||||
{(item, index) => (
|
>
|
||||||
|
<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}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>
|
||||||
|
No feeds. Subscribe from Discover/Search.
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={feedList()}>
|
||||||
|
{(item, index) => {
|
||||||
|
const fi = focusedFeedIdx();
|
||||||
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
backgroundColor={focusBg(index(), fi, isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
<text fg={focusFg(index(), fi, isActive())}>
|
||||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
{index() === fi ? "❯" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
<text fg={focusFg(index(), fi, isActive())}>
|
||||||
{feedLabel(item)}
|
{feedLabel(item)}
|
||||||
</text>
|
</text>
|
||||||
<text fg={muted()}>({feedCount(item)})</text>
|
<text fg={index() === fi ? theme.surface : muted()}>
|
||||||
</box>
|
({feedCount(item)})
|
||||||
)}
|
|
||||||
</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
|
|
||||||
? `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}
|
|
||||||
>
|
|
||||||
{/* depth 0: feeds */}
|
|
||||||
<Show when={depth() === 0}>
|
|
||||||
<Show
|
|
||||||
when={feedList().length > 1}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>
|
|
||||||
No feeds. Subscribe from Discover/Search.
|
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
);
|
||||||
>
|
}}
|
||||||
<For each={feedList()}>
|
</For>
|
||||||
{(item, index) => {
|
</Show>
|
||||||
const fi = focusedFeedIdx();
|
</Show>
|
||||||
return (
|
<Show when={depth() >= 1}>
|
||||||
<box
|
{/* depth ≥1: episodes */}
|
||||||
flexDirection="row"
|
<Show
|
||||||
gap={1}
|
when={episodes().length > 0}
|
||||||
paddingLeft={1}
|
fallback={
|
||||||
paddingRight={1}
|
<box padding={1}>
|
||||||
backgroundColor={focusBg(index(), fi, isActive)}
|
<text fg={muted()}>No episodes. :refresh</text>
|
||||||
onMouseDown={() => {
|
</box>
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
}
|
||||||
nav.setDepthFocus(index(), 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={focusFg(index(), fi, isActive)}>
|
|
||||||
{index() === fi ? "❯" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), fi, isActive)}>
|
|
||||||
{feedLabel(item)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi ? theme.surface : muted()}>
|
|
||||||
({feedCount(item)})
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* depth ≥1: episodes */}
|
|
||||||
<Show when={depth() >= 1}>
|
|
||||||
<Show
|
|
||||||
when={episodes().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No episodes. :refresh</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={episodes()}>
|
|
||||||
{(item, index) => {
|
|
||||||
const fi = focusedEpIdx();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
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)}>
|
|
||||||
{index() === fi ? "❯" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), fi, isActive)}>
|
|
||||||
{item.episode.episodeNumber
|
|
||||||
? `#${item.episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{item.episode.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
|
||||||
{formatDate(item.episode.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi ? theme.surface : muted()}>
|
|
||||||
{formatDuration(item.episode.duration)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === fi ? theme.surface : muted()}>
|
|
||||||
{item.feed.customName || item.feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(item.episode.id)}>
|
|
||||||
<text fg={theme.warning}>●</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(item.episode.id)}>
|
|
||||||
<text fg={downloadColor(item.episode.id)}>
|
|
||||||
{downloadLabel(item.episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</box>
|
|
||||||
</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 */}
|
<For each={episodes()}>
|
||||||
<Show when={depth() === 0}>
|
{(item, index) => {
|
||||||
<Show
|
const fi = focusedEpIdx();
|
||||||
when={focusedFeedItem()}
|
return (
|
||||||
fallback={
|
<box
|
||||||
<box padding={1}>
|
flexDirection="column"
|
||||||
<text fg={muted()}>No feed focused</text>
|
gap={0}
|
||||||
</box>
|
paddingLeft={1}
|
||||||
}
|
paddingRight={1}
|
||||||
>
|
backgroundColor={focusBg(index(), fi, isActive())}
|
||||||
{(item) => {
|
onMouseDown={() => {
|
||||||
const it = item();
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
return (
|
nav.setDepthFocus(index(), 1);
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
}}
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
>
|
||||||
<strong>{feedLabel(it)}</strong>
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={focusFg(index(), fi, isActive())}>
|
||||||
|
{index() === fi ? "❯" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={muted()}>
|
<text fg={focusFg(index(), fi, isActive())}>
|
||||||
{it.kind === "feed"
|
{item.episode.episodeNumber
|
||||||
? `by ${it.feed.podcast.author ?? "unknown"}`
|
? `#${item.episode.episodeNumber} `
|
||||||
: ""}
|
: ""}
|
||||||
|
{item.episode.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{it.kind === "all"
|
|
||||||
? `${feedCount(it)} episodes across all feeds`
|
|
||||||
: `${feedCount(it)} episodes`}
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{it.kind === "feed"
|
|
||||||
? (it.feed.podcast.description?.slice(0, 400) ??
|
|
||||||
"No description.")
|
|
||||||
: "Drill in to see episodes across every feed."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter/l: open · h: back</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
}}
|
<text fg={index() === fi ? theme.surface : theme.info}>
|
||||||
</Show>
|
{formatDate(item.episode.pubDate)}
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* depth ≥1 preview: hovered episode */}
|
|
||||||
<Show when={depth() >= 1}>
|
|
||||||
<Show
|
|
||||||
when={focusedItem()}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No episode focused</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(item) => {
|
|
||||||
const it = item();
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
|
||||||
<strong>
|
|
||||||
{it.episode.episodeNumber
|
|
||||||
? `#${it.episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{it.episode.title}
|
|
||||||
</strong>
|
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="row" gap={2}>
|
<text fg={index() === fi ? theme.surface : muted()}>
|
||||||
<text fg={theme.info}>
|
{formatDuration(item.episode.duration)}
|
||||||
{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)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{it.feed.customName || it.feed.podcast.title}
|
|
||||||
</text>
|
</text>
|
||||||
<Show when={it.feed.podcast.author}>
|
<text fg={index() === fi ? theme.surface : muted()}>
|
||||||
<text fg={muted()}>by {it.feed.podcast.author}</text>
|
{item.feed.customName || item.feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={nav.isSelected(item.episode.id)}>
|
||||||
|
<text fg={theme.warning}>●</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={downloadLabel(item.episode.id)}>
|
||||||
|
<text fg={downloadColor(item.episode.id)}>
|
||||||
|
{downloadLabel(item.episode.id)}
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{it.episode.description?.slice(0, 400) ??
|
|
||||||
"No description available."}
|
|
||||||
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>
|
|
||||||
enter: play · space: select · h: back
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
</box>
|
||||||
}}
|
);
|
||||||
</Show>
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</Show>
|
||||||
</box>
|
</Show>
|
||||||
</box>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── preview pane: hovered-item detail ──────────────────────────────────────
|
||||||
|
const previewContent = () =>
|
||||||
|
depth() === 0 ? (
|
||||||
|
// depth 0 preview: hovered feed
|
||||||
|
<Show
|
||||||
|
when={focusedFeedItem()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No feed focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => {
|
||||||
|
const it = item();
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{feedLabel(it)}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{it.kind === "feed"
|
||||||
|
? `by ${it.feed.podcast.author ?? "unknown"}`
|
||||||
|
: ""}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{it.kind === "all"
|
||||||
|
? `${feedCount(it)} episodes across all feeds`
|
||||||
|
: `${feedCount(it)} episodes`}
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{it.kind === "feed"
|
||||||
|
? (it.feed.podcast.description?.slice(0, 400) ??
|
||||||
|
"No description.")
|
||||||
|
: "Drill in to see episodes across every feed."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter/l: open · h: back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
) : (
|
||||||
|
// depth ≥1 preview: hovered episode
|
||||||
|
<Show
|
||||||
|
when={focusedItem()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => {
|
||||||
|
const it = item();
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{it.episode.episodeNumber
|
||||||
|
? `#${it.episode.episodeNumber} `
|
||||||
|
: ""}
|
||||||
|
{it.episode.title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<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)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{it.feed.customName || it.feed.podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={it.feed.podcast.author}>
|
||||||
|
<text fg={muted()}>by {it.feed.podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{it.episode.description?.slice(0, 400) ??
|
||||||
|
"No description available."}
|
||||||
|
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: play · space: select · h: back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
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.
|
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — subscribed shows. Left pane empty at root.
|
* depth 0 (current) — subscribed shows. Parent pane shows the muted
|
||||||
* depth 1 (current) — episodes of the drilled show. Left pane = shows (prev).
|
* placeholder (1/7 slot kept).
|
||||||
* right (preview) — detail of the hovered item in the current column.
|
* 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
|
* Renders entirely through `<YaziPaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* sidebar at depth 0). j/k move within the current column.
|
* 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";
|
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 { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
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;
|
export const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
@@ -181,289 +184,238 @@ export function MyShowsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
|
||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.surface : theme.text;
|
i === lf && active ? theme.surface : theme.text;
|
||||||
const headerBg = theme.background;
|
|
||||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||||
|
|
||||||
return (
|
const currentLabel = () =>
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
depth() === 0
|
||||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
? `Shows (${shows().length})`
|
||||||
<box
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||||
flexDirection="column"
|
|
||||||
flexGrow={PANE_RATIO.parent}
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
flexShrink={1}
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
||||||
flexBasis={0}
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||||
height="100%"
|
// mounts/unmounts cleanly on depth change.
|
||||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
const parentContent = () => (
|
||||||
overflow="hidden"
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||||
>
|
<For each={shows()}>
|
||||||
<Show when={depth() >= 1}>
|
{(feed, index) => {
|
||||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
const lf = nav.depthFocus(0);
|
||||||
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
|
return (
|
||||||
</box>
|
<box
|
||||||
<scrollbox
|
flexDirection="row"
|
||||||
height="100%"
|
gap={1}
|
||||||
border
|
paddingLeft={1}
|
||||||
borderColor={theme.border}
|
paddingRight={1}
|
||||||
backgroundColor={theme.background}
|
backgroundColor={focusBg(index(), lf, false)}
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<text fg={focusFg(index(), lf, false)}>
|
||||||
{(feed, index) => {
|
{index() === lf ? "❯" : " "}
|
||||||
const lf = nav.depthFocus(0);
|
</text>
|
||||||
return (
|
<text fg={focusFg(index(), lf, false)}>{showTitle(feed)}</text>
|
||||||
<box
|
<text fg={muted()}>({feed.episodes.length})</text>
|
||||||
flexDirection="row"
|
</box>
|
||||||
gap={1}
|
);
|
||||||
paddingLeft={1}
|
}}
|
||||||
paddingRight={1}
|
</For>
|
||||||
backgroundColor={focusBg(index(), lf, false)}
|
</Show>
|
||||||
>
|
);
|
||||||
<text fg={focusFg(index(), lf, false)}>
|
|
||||||
|
// ── 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}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>
|
||||||
|
No shows. Subscribe from Discover/Search.
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={shows()}>
|
||||||
|
{(feed, index) => {
|
||||||
|
const lf = focusedShowIdx();
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), lf, isActive())}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(index(), lf, isActive())}>
|
||||||
|
{index() === lf ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={focusFg(index(), lf, isActive())}>
|
||||||
|
{showTitle(feed)}
|
||||||
|
</text>
|
||||||
|
<text fg={index() === lf ? theme.surface : muted()}>
|
||||||
|
({feed.episodes.length})
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
{/* depth ≥1: episodes */}
|
||||||
|
<Show when={depth() >= 1}>
|
||||||
|
<Show
|
||||||
|
when={episodes().length > 0}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episodes. :refresh</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={episodes()}>
|
||||||
|
{(ep, index) => {
|
||||||
|
const lf = focusedEpIdx();
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
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())}>
|
||||||
{index() === lf ? "❯" : " "}
|
{index() === lf ? "❯" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf, false)}>
|
<text fg={focusFg(index(), lf, isActive())}>
|
||||||
{showTitle(feed)}
|
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||||
|
{ep.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={muted()}>({feed.episodes.length})</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
}}
|
<text fg={index() === lf ? theme.surface : theme.info}>
|
||||||
</For>
|
{formatDate(ep.pubDate)}
|
||||||
</scrollbox>
|
</text>
|
||||||
</Show>
|
<text fg={index() === lf ? theme.surface : muted()}>
|
||||||
</box>
|
{formatDuration(ep.duration)}
|
||||||
|
</text>
|
||||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
<Show when={nav.isSelected(ep.id)}>
|
||||||
<box
|
<text fg={theme.warning}>●</text>
|
||||||
flexDirection="column"
|
</Show>
|
||||||
flexGrow={PANE_RATIO.current}
|
<Show when={downloadLabel(ep.id)}>
|
||||||
flexShrink={1}
|
<text fg={downloadColor(ep.id)}>
|
||||||
flexBasis={0}
|
{downloadLabel(ep.id)}
|
||||||
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 */}
|
|
||||||
<Show when={depth() === 0}>
|
|
||||||
<Show
|
|
||||||
when={shows().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>
|
|
||||||
No shows. Subscribe from Discover/Search.
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={shows()}>
|
|
||||||
{(feed, index) => {
|
|
||||||
const lf = focusedShowIdx();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={focusBg(index(), lf, isActive)}
|
|
||||||
onMouseDown={() => {
|
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
||||||
nav.setDepthFocus(index(), 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={focusFg(index(), lf, isActive)}>
|
|
||||||
{index() === lf ? "❯" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), lf, isActive)}>
|
|
||||||
{showTitle(feed)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === lf ? theme.surface : muted()}>
|
|
||||||
({feed.episodes.length})
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* depth ≥1: episodes */}
|
|
||||||
<Show when={depth() >= 1}>
|
|
||||||
<Show
|
|
||||||
when={episodes().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No episodes. :refresh</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={episodes()}>
|
|
||||||
{(ep, index) => {
|
|
||||||
const lf = focusedEpIdx();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
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)}>
|
|
||||||
{index() === lf ? "❯" : " "}
|
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), lf, isActive)}>
|
|
||||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
|
||||||
{ep.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
|
||||||
{formatDate(ep.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === lf ? theme.surface : muted()}>
|
|
||||||
{formatDuration(ep.duration)}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(ep.id)}>
|
|
||||||
<text fg={theme.warning}>●</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(ep.id)}>
|
|
||||||
<text fg={downloadColor(ep.id)}>
|
|
||||||
{downloadLabel(ep.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</box>
|
|
||||||
</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}>
|
|
||||||
<Show
|
|
||||||
when={selectedShow()}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No show focused</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(show) => (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
|
||||||
<strong>{showTitle(show())}</strong>
|
|
||||||
</text>
|
|
||||||
<Show when={show().podcast.author}>
|
|
||||||
<text fg={muted()}>by {show().podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{show().episodes.length} episodes
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{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}>
|
|
||||||
<Show
|
|
||||||
when={focusedEpisode()}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={muted()}>No episode focused</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(ep) => (
|
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
|
||||||
<strong>
|
|
||||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
|
||||||
{ep().title}
|
|
||||||
</strong>
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
|
||||||
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
|
||||||
<Show when={downloadLabel(ep().id)}>
|
|
||||||
<text fg={downloadColor(ep().id)}>
|
|
||||||
{downloadLabel(ep().id)}
|
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={selectedShow()?.podcast.author}>
|
|
||||||
<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?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>
|
|
||||||
enter: play · space: select · h: back
|
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
);
|
||||||
</Show>
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={feedStore.isLoadingMore()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</Show>
|
||||||
</box>
|
</Show>
|
||||||
</box>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
|
const previewContent = () =>
|
||||||
|
depth() === 0 ? (
|
||||||
|
// depth 0 preview: hovered show
|
||||||
|
<Show
|
||||||
|
when={selectedShow()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No show focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(show) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{showTitle(show())}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={show().podcast.author}>
|
||||||
|
<text fg={muted()}>by {show().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{show().episodes.length} episodes
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter/l: open · h: back</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
) : (
|
||||||
|
// depth ≥1 preview: hovered episode
|
||||||
|
<Show
|
||||||
|
when={focusedEpisode()}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ep) => (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
||||||
|
{ep().title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
||||||
|
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
||||||
|
<Show when={downloadLabel(ep().id)}>
|
||||||
|
<text fg={downloadColor(ep().id)}>
|
||||||
|
{downloadLabel(ep().id)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={selectedShow()?.podcast.author}>
|
||||||
|
<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?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: play · space: select · h: back</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
|
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.
|
* SearchPage — yazi-style 3-pane view.
|
||||||
*
|
*
|
||||||
* pane 0 (parent) — query input with recent-search history (clickable)
|
* pane 1 (parent) — query input with recent-search history (clickable)
|
||||||
* pane 1 (current) — search results list (navigate j/k)
|
* pane 2 (current) — search results list (navigate j/k)
|
||||||
* pane 2 (preview) — detail of the focused search result
|
* pane 3 (preview) — detail of the focused search result
|
||||||
*
|
*
|
||||||
* The Shell resets activePane to CURRENT(1) on tab enter so the user lands on
|
* (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2)
|
||||||
* the results pane. Swipe left (h) to pane 0 to type a query — the Shell
|
* 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>`
|
* router skips keys while `nav.inputFocused()` is true so the `<input>`
|
||||||
* element captures typing natively. Press Enter (onSubmit) to search and
|
* element captures typing natively. Press Enter (onSubmit) to search and
|
||||||
* auto-swipe to the results pane.
|
* auto-swipe to the results pane.
|
||||||
@@ -44,9 +45,9 @@ function SearchPage() {
|
|||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
|
||||||
const INPUT = PaneSlot.PARENT; // 0
|
const INPUT = PaneSlot.PARENT; // 1 (input row)
|
||||||
const RESULTS = PaneSlot.CURRENT; // 1
|
const RESULTS = PaneSlot.CURRENT; // 2 (results list)
|
||||||
const DETAIL = PaneSlot.PREVIEW; // 2
|
const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview)
|
||||||
|
|
||||||
const results = () => searchStore.results();
|
const results = () => searchStore.results();
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@
|
|||||||
* depth 1 — the focused section's items as a navigable list
|
* depth 1 — the focused section's items as a navigable list
|
||||||
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
||||||
*
|
*
|
||||||
* Columns render as yazi's prev | current | preview:
|
* Renders entirely through `<YaziPaneRow>` (parent | current | preview):
|
||||||
* left = previous depth's list (empty at depth 0)
|
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
||||||
* right = preview/help text for the hovered item in center
|
* 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,
|
* 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 —
|
* Enter/l drill, h back). Panels no longer register their own useKeyboard —
|
||||||
@@ -24,12 +27,13 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
|
||||||
import type { SettingItem, SettingsSectionDef } from "./types";
|
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||||
import { usePreferencesItems } from "./PreferencesPanel";
|
import { usePreferencesItems } from "./PreferencesPanel";
|
||||||
import { useVisualizerItems } from "./VisualizerSettings";
|
import { useVisualizerItems } from "./VisualizerSettings";
|
||||||
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||||
import { useSourceItems } from "./SourceManager";
|
import { useSourceItems } from "./SourceManager";
|
||||||
|
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
||||||
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
export const SettingsPaneCount = 1;
|
export const SettingsPaneCount = 1;
|
||||||
|
|
||||||
@@ -224,9 +228,7 @@ export function SettingsPage() {
|
|||||||
onCleanup(() => closeSyncEditor());
|
onCleanup(() => closeSyncEditor());
|
||||||
|
|
||||||
// ── render helpers ───────────────────────────────────────────────────────
|
// ── render helpers ───────────────────────────────────────────────────────
|
||||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
|
||||||
const headerBg = theme.background;
|
|
||||||
|
|
||||||
// preview text for the right column
|
// preview text for the right column
|
||||||
const previewText = createMemo<string>(() => {
|
const previewText = createMemo<string>(() => {
|
||||||
@@ -245,120 +247,86 @@ export function SettingsPage() {
|
|||||||
: "No editor.";
|
: "No editor.";
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── column content builders ──────────────────────────────────────────────
|
// ── column label ───────────────────────────────────────────────────────────
|
||||||
// left = previous depth (read-only list), or empty at depth 0
|
const currentLabel = () => {
|
||||||
const LeftCol = () => (
|
const d = depth();
|
||||||
<box
|
if (d === 0) return "Settings";
|
||||||
flexDirection="column"
|
if (d === 1) return sectionForDepth1()?.label ?? "Items";
|
||||||
flexGrow={PANE_RATIO.parent}
|
return editorItem()?.label ?? "Editor";
|
||||||
flexShrink={1}
|
};
|
||||||
flexBasis={0}
|
const parentLabel = () => {
|
||||||
height="100%"
|
const d = depth();
|
||||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
if (d === 1) return "Sections";
|
||||||
overflow="hidden"
|
if (d === 2) return sectionForDepth1()?.label ?? "";
|
||||||
>
|
return "Up";
|
||||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
};
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
<Show when={depth() >= 1} fallback=" ">
|
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
|
||||||
{depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")}
|
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
|
||||||
</Show>
|
// mounts every branch once and toggles children on depth change — the
|
||||||
</text>
|
// known-good opentui disposal pattern. A ternary returning different
|
||||||
</box>
|
// 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>
|
||||||
<Show when={depth() === 1}>
|
<Show when={depth() === 1}>
|
||||||
<scrollbox
|
{/* previous depth = sections list (read-only) */}
|
||||||
height="100%"
|
<For each={SECTIONS}>
|
||||||
border
|
{(section, index) => (
|
||||||
borderColor={theme.border}
|
<Row
|
||||||
backgroundColor={theme.background}
|
label={`${section.id + 1}. ${section.label}`}
|
||||||
>
|
focused={index() === focusedSectionIdx()}
|
||||||
<For each={SECTIONS}>
|
active={false}
|
||||||
{(section, index) => (
|
/>
|
||||||
<Row
|
)}
|
||||||
label={`${section.id + 1}. ${section.label}`}
|
</For>
|
||||||
focused={index() === focusedSectionIdx()}
|
|
||||||
active={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={depth() === 2}>
|
<Show when={depth() === 2}>
|
||||||
<scrollbox
|
{/* previous depth = items list (read-only) */}
|
||||||
height="100%"
|
<For each={items()}>
|
||||||
border
|
{(it, index) => (
|
||||||
borderColor={theme.border}
|
<Row
|
||||||
backgroundColor={theme.background}
|
label={`${it.label} ${it.display()}`}
|
||||||
>
|
focused={index() === focusedItemIdx()}
|
||||||
<For each={items()}>
|
active={false}
|
||||||
{(it, index) => (
|
/>
|
||||||
<Row
|
)}
|
||||||
label={`${it.label} ${it.display()}`}
|
</For>
|
||||||
focused={index() === focusedItemIdx()}
|
|
||||||
active={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// center = current depth
|
// ── current pane: current-depth list (or editor at depth 2) ───────────────
|
||||||
const CenterCol = () => (
|
const currentContent = () => (
|
||||||
<box
|
<>
|
||||||
flexDirection="column"
|
<Show when={depth() === 0}>
|
||||||
flexGrow={PANE_RATIO.current}
|
<For each={SECTIONS}>
|
||||||
flexShrink={1}
|
{(section, index) => (
|
||||||
flexBasis={0}
|
<Row
|
||||||
height="100%"
|
label={`${section.id + 1}. ${section.label}`}
|
||||||
>
|
focused={index() === focusedSectionIdx()}
|
||||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
active={isActive()}
|
||||||
<text fg={theme.textSecondary}>
|
onMouseDown={() => {
|
||||||
<Show
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
when={depth() === 0}
|
nav.setDepthFocus(index(), 0);
|
||||||
fallback={
|
}}
|
||||||
<Show
|
/>
|
||||||
when={depth() === 1}
|
)}
|
||||||
fallback={editorItem()?.label ?? "Editor"}
|
</For>
|
||||||
>
|
</Show>
|
||||||
{sectionForDepth1()?.label ?? "Items"}
|
<Show when={depth() === 1}>
|
||||||
</Show>
|
<box flexDirection="column">
|
||||||
}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<scrollbox
|
|
||||||
height="100%"
|
|
||||||
focused={isActive}
|
|
||||||
border
|
|
||||||
borderColor={border(isActive)}
|
|
||||||
backgroundColor={theme.background}
|
|
||||||
>
|
|
||||||
<Show when={depth() === 0}>
|
|
||||||
<For each={SECTIONS}>
|
|
||||||
{(section, index) => (
|
|
||||||
<Row
|
|
||||||
label={`${section.id + 1}. ${section.label}`}
|
|
||||||
focused={index() === focusedSectionIdx()}
|
|
||||||
active={isActive}
|
|
||||||
onMouseDown={() => {
|
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
|
||||||
nav.setDepthFocus(index(), 0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
<Show when={depth() === 1}>
|
|
||||||
<For each={items()}>
|
<For each={items()}>
|
||||||
{(it, index) => (
|
{(it, index) => (
|
||||||
<Row
|
<Row
|
||||||
label={`${it.label}`}
|
label={`${it.label}`}
|
||||||
value={it.display()}
|
value={it.display()}
|
||||||
focused={index() === focusedItemIdx()}
|
focused={index() === focusedItemIdx()}
|
||||||
active={isActive}
|
active={isActive()}
|
||||||
hint={hintFor(it)}
|
hint={hintFor(it)}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
@@ -372,50 +340,37 @@ export function SettingsPage() {
|
|||||||
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
|
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={depth() === 2}>
|
||||||
|
{/* depth 2: editor */}
|
||||||
|
<Show
|
||||||
|
when={editorItem()?.renderEditor}
|
||||||
|
fallback={<GenericEditor item={editorItem()!} />}
|
||||||
|
>
|
||||||
|
{editorItem()!.renderEditor!()}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={depth() === 2}>
|
</Show>
|
||||||
<Show
|
</>
|
||||||
when={editorItem()?.renderEditor}
|
|
||||||
fallback={<GenericEditor item={editorItem()!} />}
|
|
||||||
>
|
|
||||||
{editorItem()!.renderEditor!()}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// right = preview / help
|
// ── preview pane ──────────────────────────────────────────────────────────
|
||||||
const RightCol = () => (
|
const previewContent = () => (
|
||||||
<box
|
<box padding={1}>
|
||||||
flexDirection="column"
|
<MultiLine text={previewText()} />
|
||||||
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}
|
|
||||||
>
|
|
||||||
<box padding={1}>
|
|
||||||
<MultiLine text={previewText()} />
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
<YaziPaneRow
|
||||||
{LeftCol()}
|
parent={parentContent}
|
||||||
{CenterCol()}
|
current={currentContent}
|
||||||
{RightCol()}
|
preview={previewContent}
|
||||||
</box>
|
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 {
|
export enum DIRECTION {
|
||||||
Increment,
|
Increment,
|
||||||
Decrement,
|
Decrement,
|
||||||
@@ -49,40 +42,31 @@ export function rootFrameFor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayerGraph = {
|
// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`,
|
||||||
[TABS.FEED]: FeedPage,
|
// split out so this module stays free of `.tsx`/JSX imports (unit-testable).
|
||||||
[TABS.MYSHOWS]: MyShowsPage,
|
|
||||||
[TABS.DISCOVER]: DiscoverPage,
|
|
||||||
[TABS.SEARCH]: SearchPage,
|
|
||||||
[TABS.PLAYER]: PlayerPage,
|
|
||||||
[TABS.SETTINGS]: SettingsPage,
|
|
||||||
};
|
|
||||||
export const LayerDepths = {
|
|
||||||
[TABS.FEED]: FeedPaneCount,
|
|
||||||
[TABS.MYSHOWS]: MyShowsPaneCount,
|
|
||||||
[TABS.DISCOVER]: DiscoverPaneCount,
|
|
||||||
[TABS.SEARCH]: SearchPaneCount,
|
|
||||||
[TABS.PLAYER]: PlayerPaneCount,
|
|
||||||
[TABS.SETTINGS]: SettingsPaneCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
|
// Yazi-style pane grow ratios (parent : current : preview). Panes use
|
||||||
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
|
// flexGrow (Yoga) so columns always sum to the row width regardless of
|
||||||
// of terminal size — more robust than fixed percentages and exactly mirrors
|
// terminal size — more robust than fixed percentages and exactly mirrors
|
||||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||||
|
//
|
||||||
|
// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT
|
||||||
|
// touch these values. Task 02 re-tunes them to the remake target ratios
|
||||||
|
// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there.
|
||||||
export const PANE_RATIO = {
|
export const PANE_RATIO = {
|
||||||
parent: 1,
|
parent: 1,
|
||||||
current: 4,
|
current: 3,
|
||||||
preview: 3,
|
preview: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/
|
// Number of *focusable* content panes per tab. The three visible columns
|
||||||
// Settings) now have a single focusable content pane (the center/current
|
// (parent | current | preview) are a *render* concern, NOT three panes — for
|
||||||
// column at depth 0..N); prev and preview are derived, not focusable. Search
|
// depth-tabs only the current column (index 0) is focusable, so this is 1.
|
||||||
// keeps its 3 fixed panes; Player is single-pane. The Shell's h/l dispatch
|
// Depth-tabs (Feed/MyShows/Discover/Settings) drill with `l` (push) and pop
|
||||||
// routes depth-tabs to push/pop instead of pane swipe. Defined here (after
|
// with `h` (noop at depth 0) via the Shell dispatch — they never call swipe.
|
||||||
// TABS) to avoid re-introducing the old NavigationContext top-level-init
|
// Search keeps its 3 fixed focusable panes; Player is single-pane. Defined
|
||||||
// circular deadlock.
|
// here (after TABS) to avoid re-introducing the old NavigationContext
|
||||||
|
// top-level-init circular deadlock.
|
||||||
export const TabPaneCount: Record<TABS, number> = {
|
export const TabPaneCount: Record<TABS, number> = {
|
||||||
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
||||||
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
|
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
|
||||||
|
|||||||
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
|
priority: P1
|
||||||
depends_on: [yazi-remake-03, yazi-remake-04, yazi-remake-05, yazi-remake-06]
|
depends_on: [yazi-remake-03, yazi-remake-04, yazi-remake-05, yazi-remake-06]
|
||||||
tags: [verification, tests-required]
|
tags: [verification, tests-required]
|
||||||
|
status: BLOCKED # see .harness/verification-07.md + tasks/yazi-remake/07-blocker-task-04-player-search.md
|
||||||
|
|
||||||
objective:
|
objective:
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
|
- [x] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
|
||||||
- [ ] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
|
- [x] 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`
|
- [x] 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`
|
- [x] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
|
||||||
- [ ] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
|
- [x] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
|
||||||
- [ ] 06 — rewire-keybinds → `06-rewire-keybinds.md`
|
- [x] 06 — rewire-keybinds → `06-rewire-keybinds.md`
|
||||||
- [ ] 07 — verify-remake → `07-verify-remake.md`
|
- [x] 07 — verify-remake → `07-verify-remake.md`
|
||||||
|
|
||||||
## Dependencies
|
## 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