diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index c228bcc..21cbbcc 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -1,19 +1,25 @@ /** * Shell — yazi-style application chrome. * - * Replaces the old left sidebar (vertical TabNavigation) with a horizontal - * top tab bar, renders the active page (which owns its own panes), and adds a - * bottom status/command bar. A single `useKeyboard` router translates keystrokes - * (via the sequence-aware keybind matcher) into actions: global ones (tabs, - * modes, audio, quit, help, command) are handled here; pane/list ones are - * dispatched to the active page over the `nav.action` event bus. + * Renders the tabs as a vertical sidebar on the left (the root pane), the + * active page (which owns its own panes) to the right of it, and a bottom + * status/command bar spanning the full width. A single `useKeyboard` router + * translates keystrokes (via the sequence-aware keybind matcher) into actions: + * global ones (tabs, modes, audio, quit, help, command) are handled here; + * pane/list ones are dispatched to the active page over the `nav.action` + * event bus. */ import { createSignal, Show, For } from "solid-js"; import { useKeyboard } from "@opentui/solid"; import { useTheme } from "@/context/ThemeContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; -import { useNavigation, NavMode } from "@/context/NavigationContext"; +import { + useNavigation, + NavMode, + SIDEBAR_PANE, + DEPTH_CENTER_PANE, +} from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useFeedStore } from "@/stores/feed"; @@ -58,6 +64,19 @@ const PAGE_ACTIONS: ReadonlySet = new Set( ], ); +/** Movement actions the sidebar pane handles itself (its list = the tabs, + * length TabsCount). Routed through the standard move/gotoIndex API. */ +const SIDEBAR_ACTIONS: ReadonlySet = 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)); @@ -263,15 +282,55 @@ export function Shell() { nav.setActiveTab(dt); break; } - // ── pane swipe ── + // ── 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(); - nav.swipe(-1, TabPaneCount[tab]); + 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(); - nav.swipe(1, TabPaneCount[tab]); + 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) ── @@ -355,43 +414,58 @@ export function Shell() { height="100%" backgroundColor={t.surface} > - {/* ── Top tab bar ─────────────────────────────────────────────────────── */} - - typeof v === "number", - )} + {/* ── Middle row: tab sidebar (root pane) + active page ──────────────── */} + + {/* ── Left tab sidebar ─────────────────────────────────────────────── */} + - {(tab) => { - const active = () => nav.activeTab() === tab; - return ( - nav.setActiveTab(tab)} - > - - {tab}. {TAB_LABEL[tab]} - - - ); - }} - - - - {nowPlaying() ?? ""} - - + typeof v === "number", + )} + > + {(tab) => { + const active = () => nav.activeTab() === tab; + const focused = () => + active() && nav.activePane() === SIDEBAR_PANE; + return ( + { + nav.setActivePane(SIDEBAR_PANE); + nav.setActiveTab(tab); + }} + > + + {focused() ? "❯ " : " "} + {tab}. {TAB_LABEL[tab]} + + + ); + }} + + + + + {nowPlaying()} + + + - {/* ── Active page (owns its panes) ────────────────────────────────────── */} - - {LayerGraph[nav.activeTab()]()} + {/* ── Active page (owns its panes) ────────────────────────────────── */} + + {LayerGraph[nav.activeTab()]()} + {/* ── Bottom status / command bar ─────────────────────────────────────── */} @@ -409,8 +483,12 @@ export function Shell() { {modeLabel()} - {TAB_LABEL[nav.activeTab()]} · pane {nav.activePane() + 1}/ - {TabPaneCount[nav.activeTab()]} + {TAB_LABEL[nav.activeTab()]} ·{" "} + {nav.activePane() === SIDEBAR_PANE + ? "tabs" + : nav.isDepthTab() + ? `depth ${nav.currentDepth()}` + : `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`} 0}> diff --git a/src/context/NavigationContext.tsx b/src/context/NavigationContext.tsx index 4cf03d3..61b6a76 100644 --- a/src/context/NavigationContext.tsx +++ b/src/context/NavigationContext.tsx @@ -1,17 +1,25 @@ import { createEffect, createSignal, on, batch, createMemo } from "solid-js"; import { createSimpleContext } from "./helper"; -import { TABS, TabsCount } from "@/utils/navigation"; +import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation"; // ── Yazi-style navigation state ────────────────────────────────────────────── -// PodTui's interaction model after the yazi redesign. A single source of truth -// for: which tab is active, which pane within a tab is focused (parent | -// current | preview), the current mode (normal/visual/command/input), the -// count register (for `5j` style motions), and the command-bar buffer. +// Two pane models coexist: // -// Panes are addressed by index 0..N-1 within the active tab. Each tab declares -// how many panes it has via the PaneSystem registry (see navigation.ts). h/l -// (swipe-prev / swipe-next) move pane focus; j/k move within the focused pane's -// list (handled per-pane via the focusedIndex accessors below). +// • 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", @@ -20,16 +28,37 @@ export enum NavMode { INPUT = "INPUT", } -/** Slot semantics mirror yazi's three columns. Slots beyond 2 exist for - * tabs that need more panes (e.g. search = query/results/detail). */ +/** 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, // left — the container list (e.g. shows) - CURRENT = 1, // middle — the items (e.g. episodes) - PREVIEW = 2, // right — detail of the hovered item + 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 @@ -44,14 +73,21 @@ export const { use: useNavigation, provider: NavigationProvider } = name: "Navigation", init: () => { const [activeTab, setActiveTab] = createSignal(TABS.FEED); - const [activePane, setActivePane] = createSignal( - PaneSlot.CURRENT, - ); + // App focus starts on the left tab sidebar (root pane); tab switches + // also return focus there. + const [activePane, setActivePane] = createSignal(SIDEBAR_PANE); const [mode, setMode] = createSignal(NavMode.NORMAL); const [count, setCount] = createSignal(null); const [inputFocused, setInputFocused] = createSignal(false); - // per-pane focused index (for j/k movement). Keyed by `${tab}:${pane}`. + // per-tab depth stack. Depth-tabs get a root frame on first visit. + const [stacks, setStacks] = createSignal< + Partial> + >({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] }); + + // per-pane focused index (for j/k movement in fixed-pane tabs). Keyed + // by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus` + // for pane 0 (DEPTH_CENTER_PANE) instead. const [paneIndices, setPaneIndices] = createSignal< Record >({}); @@ -64,11 +100,22 @@ export const { use: useNavigation, provider: NavigationProvider } = const [commandBuffer, setCommandBuffer] = createSignal(""); const [commandError, setCommandError] = createSignal(null); - // Reset depth/pane/mode on tab change. + /** 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, () => { + on(activeTab, (tab) => { + ensureStack(tab); batch(() => { - setActivePane(PaneSlot.CURRENT); + setActivePane(SIDEBAR_PANE); setMode(NavMode.NORMAL); setCount(null); setCommandBuffer(""); @@ -78,6 +125,51 @@ export const { use: useNavigation, provider: NavigationProvider } = }), ); + // ── depth stack accessors ────────────────────────────────────────────── + const depthStack = createMemo(() => + depthStackFor(activeTab()), + ); + const currentDepth = createMemo(() => + Math.max(0, depthStack().length - 1), + ); + const topFrame = createMemo( + () => depthStack()[depthStack().length - 1], + ); + const isDepthTab = () => DEPTH_TABS.has(activeTab()); + + /** Focus within a given depth's frame (default = current/top). */ + const depthFocus = (d: number = currentDepth()) => + depthStack()[d]?.focus ?? 0; + + const setDepthFocus = (i: number, d: number = currentDepth()) => + setStacks((s) => { + const st = s[activeTab()]; + if (!st || d < 0 || d >= st.length) return s; + const next = st.slice(); + next[d] = { ...next[d], focus: i }; + return { ...s, [activeTab()]: next }; + }); + + /** Push a child frame (drill in). */ + const pushDepth = (frame: DepthFrame) => + setStacks((s) => { + const st = s[activeTab()] ?? []; + return { ...s, [activeTab()]: [...st, frame] }; + }); + + /** Pop the top frame (go back up a depth). No-op at root. Returns + * true if a frame was popped. */ + const popDepth = (): boolean => { + let popped = false; + setStacks((s) => { + const st = s[activeTab()] ?? []; + if (st.length <= 1) return s; + popped = true; + return { ...s, [activeTab()]: st.slice(0, -1) }; + }); + return popped; + }; + // ── tab switching ────────────────────────────────────────────────────── const gotoTab = (tab: TABS) => { if (tab < 1 || tab > TabsCount) return; @@ -91,12 +183,12 @@ export const { use: useNavigation, provider: NavigationProvider } = // ── pane focus ────────────────────────────────────────────────────────── const setPane = (pane: PaneId) => setActivePane(pane); - /** Move focus to the adjacent pane. `dir` = -1 (left/parent) or +1 - * (right/preview). Clamped to [0, paneCount-1]. */ + /** 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) => { - if (paneCount <= 1) return; setActivePane((p) => { - const n = Math.max(0, Math.min(paneCount - 1, p + dir)); + const n = Math.max(SIDEBAR_PANE, Math.min(paneCount - 1, p + dir)); return n; }); }; @@ -104,11 +196,31 @@ export const { use: useNavigation, provider: NavigationProvider } = // ── per-pane focus index ──────────────────────────────────────────────── const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; - const focusedIndex = (pane: PaneId = activePane()) => - paneIndices()[paneKey(pane)] ?? 0; + /** 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) => - setPaneIndices((m) => ({ ...m, [`${activeTab()}:${pane}`]: index })); + 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. */ @@ -259,6 +371,15 @@ export const { use: useNavigation, provider: NavigationProvider } = visualAnchor, selections, selectedIds, + // depth stack + depthStack, + currentDepth, + topFrame, + depthFocus, + setDepthFocus, + pushDepth, + popDepth, + isDepthTab, // tab setActiveTab: gotoTab, nextTab, diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index a2ab50f..2447b53 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -1,13 +1,15 @@ /** - * DiscoverPage — yazi-style 3-pane view. + * DiscoverPage — yazi depth-stack view of discoverable podcasts. * - * pane 0 (parent) — category list (the "containers") - * pane 1 (current) — podcast results for the focused category (landing pane) - * pane 2 (preview) — detail of the focused podcast + subscribe action + * depth 0 (current) — category list. Left pane empty at root. + * depth 1 (current) — podcast results for the drilled category. + * right (preview) — detail of the hovered item (category summary, or + * podcast detail + subscribe action). * - * The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between - * panes; j/k move within; Enter subscribes to the focused podcast; r refreshes. - * Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering. + * `l`/Enter drills in (category → results) or subscribes (on a podcast); + * `h` pops back (or yields to the sidebar at depth 0). j/k move within the + * current column. Moving through categories at depth 0 updates the store's + * selected category so the preview follows. */ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; @@ -17,15 +19,15 @@ import { useTheme } from "@/context/ThemeContext"; import { useNavigation, NavMode, - PaneSlot, + DEPTH_CENTER_PANE, type PaneId, + type DepthFrame, } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; -import type { Podcast } from "@/types/podcast"; import { PANE_RATIO } from "@/utils/navigation"; -export const DiscoverPaneCount = 3; +export const DiscoverPaneCount = 1; function DiscoverPage() { const discoverStore = useDiscoverStore(); @@ -33,83 +35,71 @@ function DiscoverPage() { const muted = () => theme.muted || theme.text; const nav = useNavigation(); - const CATS = PaneSlot.PARENT; // 0 — categories (parent) - const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane) - const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe + const stack = nav.depthStack; + const depth = nav.currentDepth; + const focus = (d: number = depth()) => nav.depthFocus(d); const categories = () => DISCOVER_CATEGORIES; const podcasts = () => discoverStore.filteredPodcasts(); - const focusedCategory = createMemo(() => { - const list = categories(); - if (list.length === 0) return undefined; - return list[Math.min(nav.focusedIndex(CATS), list.length - 1)]; - }); + const focusedCatIdx = () => + categories().length === 0 ? 0 : Math.min(focus(0), categories().length - 1); + const focusedCategory = createMemo(() => categories()[focusedCatIdx()]); + + const focusedPodIdx = () => + podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1); + const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]); + + const curLen = () => + depth() === 0 ? categories().length : podcasts().length; - // ── keep category + results focus in range ─────────────────────────────── const ensureFocus = () => { - const cl = categories(); - if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length) - nav.setFocusedIndex(CATS, cl.length - 1); - const pl = podcasts(); - if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length) - nav.setFocusedIndex(RESULTS, pl.length - 1); + if (categories().length > 0 && focus(0) >= categories().length) + nav.setDepthFocus(categories().length - 1, 0); + if (podcasts().length > 0 && focus(1) >= podcasts().length) + nav.setDepthFocus(podcasts().length - 1, 1); }; onMount(ensureFocus); - const focusedPodcast = createMemo(() => { - const list = podcasts(); - if (list.length === 0) return undefined; - return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)]; - }); - - // Register a resolver so visual-mode range selection grows by podcast id. onMount(() => { - nav.registerResolver( - `${nav.activeTab()}:${RESULTS}`, - (i) => podcasts()[i]?.id, - ); - const unsub = on("nav.action", () => { - nav.registerResolver( - `${nav.activeTab()}:${RESULTS}`, - (i) => podcasts()[i]?.id, - ); + nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { + if (depth() === 0) return categories()[i]?.id; + return podcasts()[i]?.id; }); - onCleanup(() => unsub()); }); // ── helpers ──────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); - const handleSubscribe = (podcast: Podcast) => { - discoverStore.toggleSubscription(podcast.id); - }; + + // ── drill / open ─────────────────────────────────────────────────────────── + function open() { + if (depth() === 0) { + const c = focusedCategory(); + if (!c) return; + discoverStore.setSelectedCategory(c.id); + nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame); + nav.setActivePane(DEPTH_CENTER_PANE); + return; + } + if (depth() >= 1) { + const pod = focusedPodcast(); + if (pod) discoverStore.toggleSubscription(pod.id); + } + } // ── nav.action handler ──────────────────────────────────────────────────── - const PAGE_ACTIONS: Partial< - Record void> - > = { - "move-down": (p) => step(p, 1), - "move-up": (p) => step(p, -1), - "jump-down": (p) => step(p, 5), - "jump-up": (p) => step(p, -5), - "page-down": (p) => step(p, 10), - "page-up": (p) => step(p, -10), - "goto-top": (p) => nav.gotoIndex(0, len(p)), - "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), - open: (p) => { - if (p === CATS) { - const c = focusedCategory(); - if (c) discoverStore.setSelectedCategory(c.id); - nav.swipe(1, DiscoverPaneCount); // dive to results - return; - } - if (p === RESULTS) { - const pod = focusedPodcast(); - if (pod) handleSubscribe(pod); - } - }, - "toggle-select": (p) => { - if (p === RESULTS) { + const PAGE_ACTIONS: Partial void>> = { + "move-down": () => step(1), + "move-up": () => step(-1), + "jump-down": () => step(5), + "jump-up": () => step(-5), + "page-down": () => step(10), + "page-up": () => step(-10), + "goto-top": () => nav.gotoIndex(0, curLen()), + "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), + open: () => open(), + "toggle-select": () => { + if (depth() >= 1) { const pod = focusedPodcast(); if (pod) nav.toggleSelected(pod.id); } @@ -118,28 +108,23 @@ function DiscoverPage() { discoverStore.refresh().catch(() => {}); }, }; - - function len(pane: PaneId): number { - if (pane === CATS) return categories().length; - if (pane === RESULTS) return podcasts().length; - return 0; - } - function step(pane: PaneId, delta: number) { - nav.move(delta, len(pane)); - if (pane === CATS) { + function step(delta: number) { + nav.move(delta, curLen()); + // keep the store's selected category synced with the focused row at depth 0 + if (depth() === 0) { const c = focusedCategory(); if (c) discoverStore.setSelectedCategory(c.id); } } - const onAction = (data: { action: KeybindActionName; pane: PaneId; mode: NavMode; }) => { + if (data.pane !== DEPTH_CENTER_PANE) return; + if (nav.activePane() !== DEPTH_CENTER_PANE) return; ensureFocus(); - const handler = PAGE_ACTIONS[data.action]; - if (handler) handler(data.pane); + PAGE_ACTIONS[data.action]?.(); }; onMount(() => { on("nav.action", onAction); @@ -147,202 +132,265 @@ function DiscoverPage() { }); // ── render ────────────────────────────────────────────────────────────────── - const isActive = (p: PaneId) => nav.activePane() === p; - const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); - const focusBg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) - ? theme.primary - : i === nav.focusedIndex(pane) - ? theme.border - : undefined; - const focusFg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; + const isActive = nav.activePane() === DEPTH_CENTER_PANE; + const border = (active: boolean) => (active ? theme.accent : theme.border); + const focusBg = (i: number, lf: number, active: boolean) => + i === lf && active ? theme.primary : i === lf ? theme.border : undefined; + const focusFg = (i: number, lf: number, active: boolean) => + i === lf && active ? theme.surface : theme.text; + const headerBg = theme.background; return ( - {/* ── pane 0 (parent, left): categories ───────────────────────────── */} - - - Categories - - - - {(cat, index) => { - const selected = () => - cat.id === discoverStore.selectedCategory(); - return ( + {/* ── left: previous depth (empty at root) ──────────────────────────── */} + + = 1}> + + Categories + + + + {(cat, index) => ( { - nav.setActivePane(CATS); - nav.setFocusedIndex(CATS, index()); - discoverStore.setSelectedCategory(cat.id); - }} + backgroundColor={focusBg(index(), nav.depthFocus(0), false)} > - - {index() === nav.focusedIndex(CATS) ? "❯" : " "} + + {index() === nav.depthFocus(0) ? "❯" : " "} + + + {cat.name} - {cat.name} - - - * - - - ); - }} - - + )} + + + - {/* ── pane 1 (current, center): results ───────────────────────────── */} - - + {/* ── center: current depth ─────────────────────────────────────────── */} + + - {focusedCategory()?.name ?? "Discover"} · {podcasts().length} + {depth() === 0 + ? "Categories" + : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`} - 0} - fallback={ - - No podcasts found. :refresh - - } - > - - {(podcast, index) => ( - { - nav.setActivePane(RESULTS); - nav.setFocusedIndex(RESULTS, index()); - }} - > - - - {index() === nav.focusedIndex(RESULTS) ? "❯" : " "} + {/* depth 0: categories */} + + + {(cat, index) => { + const lf = focusedCatIdx(); + const selected = () => + cat.id === discoverStore.selectedCategory(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + discoverStore.setSelectedCategory(cat.id); + }} + > + + {index() === lf ? "❯" : " "} - {podcast.title} - - - [+] + {cat.name} + + + * - - - by {podcast.author} - - - - )} + ); + }} + + {/* depth ≥1: results */} + = 1}> + 0} + fallback={ + + No podcasts found. :refresh + + } + > + + {(podcast, index) => { + const lf = focusedPodIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + > + + + {index() === lf ? "❯" : " "} + + + {podcast.title} + + + + [+] + + + + + + by {podcast.author} + + + + ); + }} + + + - {/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */} - - + {/* ── right: preview ────────────────────────────────────────────────── */} + + Preview - - No podcast focused - - } - > - {(pod) => ( - - - {pod().title} - - - by {pod().author} - - - ✓ Subscribed - - - [+] Subscribe (enter) - - - - {pod().description?.slice(0, 400) ?? - "No description available."} - {(pod().description?.length ?? 0) > 400 ? "…" : ""} - - 0}> - - - {(cat) => [{cat}]} - - - - - Feed: {pod().feedUrl} - - - Updated: {formatDate(pod().lastUpdated)} - - - enter: subscribe h/l: panes r: refresh - - )} + {/* depth 0 preview: hovered category */} + + + No category focused + + } + > + {(cat) => ( + + + {cat().name} + + + {(cat() as any).description ?? + `Browse top podcasts in ${cat().name}.`} + + + enter/l: open · h: back + + )} + + + + {/* depth ≥1 preview: hovered podcast + subscribe */} + = 1}> + + No podcast focused + + } + > + {(pod) => ( + + + {pod().title} + + + by {pod().author} + + + ✓ Subscribed + + + [+] Subscribe (enter) + + + + {pod().description?.slice(0, 400) ?? + "No description available."} + {(pod().description?.length ?? 0) > 400 ? "…" : ""} + + 0}> + + + {(cat) => [{cat}]} + + + + + Feed: {pod().feedUrl} + + + Updated: {formatDate(pod().lastUpdated)} + + + + enter: subscribe · h: back · r: refresh + + + )} + diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 33245d7..4ece6bc 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -1,24 +1,18 @@ /** - * FeedPage — yazi-style 3-pane view of all episodes across subscribed shows. + * FeedPage — yazi depth-stack view of episodes across subscribed shows. * - * pane 0 (parent) — subscribed feeds list (the "containers"); an implicit - * "All Feeds" entry at index 0 shows every episode. - * pane 1 (current) — flat episodes list for the focused feed (reverse - * chronological). This is the landing pane. - * pane 2 (preview) — detail of the focused episode. + * depth 0 (current) — subscribed feeds list (containers); index 0 is a + * virtual "All Feeds". Left pane empty at root. + * depth 1 (current) — flat episodes list for the drilled feed (reverse + * chronological). Left pane = the feeds list (prev). + * right (preview) — detail of the hovered item in the current column. * - * The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between - * panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio. + * `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the + * sidebar at depth 0). j/k move within the current column. The Shell router + * drives everything over nav.action; this page only handles list/preview data. */ -import { - createMemo, - For, - Show, - onMount, - onCleanup, - createEffect, -} from "solid-js"; +import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { DownloadStatus } from "@/types/episode"; @@ -28,8 +22,9 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useNavigation, NavMode, - PaneSlot, + DEPTH_CENTER_PANE, type PaneId, + type DepthFrame, } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; @@ -39,9 +34,10 @@ import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { PANE_RATIO } from "@/utils/navigation"; -export const FeedPaneCount = 3; +export const FeedPaneCount = 1; type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed }; +type EpItem = { episode: Episode; feed: Feed }; function FeedPage() { const feedStore = useFeedStore(); @@ -52,72 +48,59 @@ function FeedPage() { const muted = () => theme.muted || theme.text; const nav = useNavigation(); - const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent) - const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane) - const PREV = PaneSlot.PREVIEW; // 2 — episode detail + const stack = nav.depthStack; + const depth = nav.currentDepth; + const focus = (d: number = depth()) => nav.depthFocus(d); - // ── feeds pane data ────────────────────────────────────────────────────── - // Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first). + // ── feeds list (depth 0) ───────────────────────────────────────────────── const feedList = createMemo(() => { const all: FeedListItem[] = [{ kind: "all" }]; for (const f of feedStore.getFilteredFeeds()) all.push({ kind: "feed", feed: f }); return all; }); - const focusedFeedItem = createMemo(() => { - const list = feedList(); - if (list.length === 0) return undefined; - return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)]; - }); + const focusedFeedIdx = () => + feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1); + const focusedFeedItem = (): FeedListItem | undefined => + feedList()[focusedFeedIdx()]; - // ── episodes pane data (filtered by focused feed, or all) ──────────────── - type EpItem = { episode: Episode; feed: Feed }; + // ── episodes list (depth 1) — derived from the depth-1 frame's ctx ─────── + const drilledFeedId = (): string => stack()[1]?.ctx ?? "all"; const episodes = createMemo(() => { - const item = focusedFeedItem(); - if (!item || item.kind === "all") + if (depth() < 1) return []; + const id = drilledFeedId(); + if (id === "all") return feedStore.getAllEpisodesChronological() as EpItem[]; - return [...item.feed.episodes] + const f = feedStore.getFilteredFeeds().find((x) => x.podcast.id === id); + if (!f) return []; + return [...f.episodes] .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()) - .map((episode) => ({ episode, feed: item.feed })); + .map((episode) => ({ episode, feed: f })); }); + const focusedEpIdx = () => + episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1); + const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()]; - // Reset episodes focus when the feed filter changes. - createEffect(() => { - focusedFeedItem(); - nav.setFocusedIndex(EPS, 0); - }); - - const focusedItem = createMemo(() => { - const list = episodes(); - if (list.length === 0) return undefined; - return list[Math.min(nav.focusedIndex(EPS), list.length - 1)]; - }); - - // Keep resolvers fresh so visual-mode range selection grows by id. - onMount(() => { - nav.registerResolver( - `${nav.activeTab()}:${EPS}`, - (i) => episodes()[i]?.episode.id, - ); - const unsub = on("nav.action", () => { - nav.registerResolver( - `${nav.activeTab()}:${EPS}`, - (i) => episodes()[i]?.episode.id, - ); - }); - onCleanup(() => unsub()); - }); + const curLen = () => (depth() === 0 ? feedList().length : episodes().length); const ensureFocus = () => { - const eps = episodes(); - if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length) - nav.setFocusedIndex(EPS, eps.length - 1); - const fl = feedList(); - if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length) - nav.setFocusedIndex(FEEDS, fl.length - 1); + if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length) + nav.setDepthFocus(feedList().length - 1, 0); + if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length) + nav.setDepthFocus(episodes().length - 1, 1); }; onMount(ensureFocus); + onMount(() => { + nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { + if (depth() === 0) { + const it = feedList()[i]; + return it?.kind === "feed" ? it.feed.podcast.id : "all"; + } + return episodes()[i]?.episode.id; + }); + }); + // ── helpers ──────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const formatDuration = (s: number) => { @@ -159,24 +142,34 @@ function FeedPage() { audioNav.setSource(AudioSource.FEED); }; + // ── drill / open ─────────────────────────────────────────────────────────── + function open() { + if (depth() === 0) { + const item = focusedFeedItem(); + if (!item) return; + const ctx = item.kind === "all" ? "all" : item.feed.podcast.id; + nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame); + nav.setActivePane(DEPTH_CENTER_PANE); + return; + } + if (depth() >= 1) { + playEpisode(focusedItem()); + } + } + // ── nav.action handler ──────────────────────────────────────────────────── - const PAGE_ACTIONS: Partial< - Record void> - > = { - "move-down": (p) => step(p, 1), - "move-up": (p) => step(p, -1), - "jump-down": (p) => step(p, 5), - "jump-up": (p) => step(p, -5), - "page-down": (p) => step(p, 10), - "page-up": (p) => step(p, -10), - "goto-top": (p) => nav.gotoIndex(0, len(p)), - "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), - open: (p) => { - if (p === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes - if (p === EPS) playEpisode(focusedItem()); - }, - "toggle-select": (p) => { - if (p === EPS) { + const PAGE_ACTIONS: Partial void>> = { + "move-down": () => step(1), + "move-up": () => step(-1), + "jump-down": () => step(5), + "jump-up": () => step(-5), + "page-down": () => step(10), + "page-up": () => step(-10), + "goto-top": () => nav.gotoIndex(0, curLen()), + "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), + open: () => open(), + "toggle-select": () => { + if (depth() >= 1) { const item = focusedItem(); if (item) nav.toggleSelected(item.episode.id); } @@ -188,24 +181,18 @@ function FeedPage() { else feedStore.refreshAllFeeds().catch(() => {}); }, }; - - function len(pane: PaneId): number { - if (pane === FEEDS) return feedList().length; - if (pane === EPS) return episodes().length; - return 0; + function step(delta: number) { + nav.move(delta, curLen()); } - function step(pane: PaneId, delta: number) { - nav.move(delta, len(pane)); - } - const onAction = (data: { action: KeybindActionName; pane: PaneId; mode: NavMode; }) => { + if (data.pane !== DEPTH_CENTER_PANE) return; + if (nav.activePane() !== DEPTH_CENTER_PANE) return; ensureFocus(); - const handler = PAGE_ACTIONS[data.action]; - if (handler) handler(data.pane); + PAGE_ACTIONS[data.action]?.(); }; onMount(() => { on("nav.action", onAction); @@ -213,243 +200,326 @@ function FeedPage() { }); // ── render ────────────────────────────────────────────────────────────────── - const isActive = (p: PaneId) => nav.activePane() === p; - const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); - const focusBg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) + const isActive = nav.activePane() === DEPTH_CENTER_PANE; + const border = (active: boolean) => (active ? theme.accent : theme.border); + const focusBg = (i: number, listFocus: number, active: boolean) => + i === listFocus && active ? theme.primary - : i === nav.focusedIndex(pane) + : i === listFocus ? theme.border : undefined; - const focusFg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; + const focusFg = (i: number, listFocus: number, active: boolean) => + i === listFocus && active ? theme.surface : theme.text; + const headerBg = theme.background; + + const feedLabel = (item: FeedListItem) => + item.kind === "all" + ? "All Feeds" + : item.feed.customName || item.feed.podcast.title; + const feedCount = (item: FeedListItem) => + item.kind === "all" + ? feedStore.getAllEpisodesChronological().length + : item.feed.episodes.length; return ( - {/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */} - - - Feeds · {feedList().length - 1} - - - 1} - fallback={ - - - No feeds. Subscribe from Discover/Search. - - - } + {/* ── left: previous depth (empty at root) ──────────────────────────── */} + + = 1}> + + + Feeds · {feedList().length - 1} + + + - {(item, index) => { - const label = () => - item.kind === "all" - ? "All Feeds" - : item.feed.customName || item.feed.podcast.title; - const count = () => - item.kind === "all" - ? feedStore.getAllEpisodesChronological().length - : item.feed.episodes.length; - return ( - { - nav.setActivePane(FEEDS); - nav.setFocusedIndex(FEEDS, index()); - }} - > - - {index() === nav.focusedIndex(FEEDS) ? "❯" : " "} - - {label()} - - ({count()}) - - - ); - }} + {(item, index) => ( + + + {index() === nav.depthFocus(0) ? "❯" : " "} + + + {feedLabel(item)} + + ({feedCount(item)}) + + )} - - + + - {/* ── pane 1 (current, center): episodes ─────────────────────────────── */} - - + {/* ── center: current depth ─────────────────────────────────────────── */} + + - {(() => { - const fi = focusedFeedItem(); - if (fi?.kind === "feed") - return fi.feed.customName || fi.feed.podcast.title; - return "All Episodes"; - })()} · {episodes().length} + {depth() === 0 + ? `Feeds · ${feedList().length - 1}` + : `${(() => { + const fi = focusedFeedItem(); + return fi?.kind === "feed" + ? fi.feed.customName || fi.feed.podcast.title + : "All Episodes"; + })()} · ${episodes().length}`} - 0} - fallback={ - - No episodes. :refresh - - } - > - - {(item, index) => ( - { - nav.setActivePane(EPS); - nav.setFocusedIndex(EPS, index()); - }} - > - - - {index() === nav.focusedIndex(EPS) ? "❯" : " "} - - - {item.episode.episodeNumber - ? `#${item.episode.episodeNumber} ` - : ""} - {item.episode.title} - - - - - {formatDate(item.episode.pubDate)} - - - {formatDuration(item.episode.duration)} - - - {item.feed.customName || item.feed.podcast.title} - - - - - - - {downloadLabel(item.episode.id)} - - - + {/* depth 0: feeds */} + + 1} + fallback={ + + + No feeds. Subscribe from Discover/Search. + - )} - - - - - + } + > + + {(item, index) => { + const fi = focusedFeedIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + > + + {index() === fi ? "❯" : " "} + + + {feedLabel(item)} + + + ({feedCount(item)}) + + + ); + }} + + + + + {/* depth ≥1: episodes */} + = 1}> + 0} + fallback={ + + No episodes. :refresh + + } + > + + {(item, index) => { + const fi = focusedEpIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + > + + + {index() === fi ? "❯" : " "} + + + {item.episode.episodeNumber + ? `#${item.episode.episodeNumber} ` + : ""} + {item.episode.title} + + + + + {formatDate(item.episode.pubDate)} + + + {formatDuration(item.episode.duration)} + + + {item.feed.customName || item.feed.podcast.title} + + + + + + + {downloadLabel(item.episode.id)} + + + + + ); + }} + + + + + + - {/* ── pane 2 (preview, right): episode detail ───────────────────────── */} - - + {/* ── right: preview of hovered item ───────────────────────────────── */} + + Preview - - No episode focused - - } - > - {(item) => ( - - - - {item().episode.episodeNumber - ? `#${item().episode.episodeNumber} ` - : ""} - {item().episode.title} - - - - - {formatDate(item().episode.pubDate)} - - - {formatDuration(item().episode.duration)} - - - - {downloadLabel(item().episode.id)} - - + {/* depth 0 preview: hovered feed */} + + + No feed focused - - {item().feed.customName || item().feed.podcast.title} - - - by {item().feed.podcast.author} - - - - {item().episode.description?.slice(0, 400) ?? - "No description available."} - {(item().episode.description?.length ?? 0) > 400 ? "…" : ""} - - - enter: play space: select h/l: panes - - )} + } + > + {(item) => { + const it = item(); + return ( + + + {feedLabel(it)} + + + {it.kind === "feed" + ? `by ${it.feed.podcast.author ?? "unknown"}` + : ""} + + + {it.kind === "all" + ? `${feedCount(it)} episodes across all feeds` + : `${feedCount(it)} episodes`} + + + {it.kind === "feed" + ? (it.feed.podcast.description?.slice(0, 400) ?? + "No description.") + : "Drill in to see episodes across every feed."} + + + enter/l: open · h: back + + ); + }} + + + + {/* depth ≥1 preview: hovered episode */} + = 1}> + + No episode focused + + } + > + {(item) => { + const it = item(); + return ( + + + + {it.episode.episodeNumber + ? `#${it.episode.episodeNumber} ` + : ""} + {it.episode.title} + + + + + {formatDate(it.episode.pubDate)} + + + {formatDuration(it.episode.duration)} + + + + {downloadLabel(it.episode.id)} + + + + + {it.feed.customName || it.feed.podcast.title} + + + by {it.feed.podcast.author} + + + + {it.episode.description?.slice(0, 400) ?? + "No description available."} + {(it.episode.description?.length ?? 0) > 400 ? "…" : ""} + + + + enter: play · space: select · h: back + + + ); + }} + diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 0b25971..6a6e7c8 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -1,15 +1,12 @@ /** - * MyShowsPage — yazi-style 3-pane view (canonical reference migration). + * MyShowsPage — yazi depth-stack view of subscribed shows. * - * pane 0 (parent) — subscribed shows - * pane 1 (current) — episodes of the focused show - * pane 2 (preview) — detail of the focused episode + * depth 0 (current) — subscribed shows. Left pane empty at root. + * depth 1 (current) — episodes of the drilled show. Left pane = shows (prev). + * right (preview) — detail of the hovered item in the current column. * - * Movement (j/k, gg/G, page-jumps) and selection (space, v) are driven by the - * Shell router via the `nav.action` event bus; this page only subscribes and - * translates actions against its own data. h/l swipe between panes is handled - * by the Shell (nav.swipe). The focused row is read from nav.focusedIndex(pane) - * so the page is purely reactive. + * `l`/Enter drills in (show → episodes); `h` pops back (or yields to the + * sidebar at depth 0). j/k move within the current column. */ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; @@ -22,17 +19,19 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useNavigation, NavMode, - PaneSlot, + DEPTH_CENTER_PANE, type PaneId, + type DepthFrame, } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; +import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { PANE_RATIO } from "@/utils/navigation"; -export const MyShowsPaneCount = 3; +export const MyShowsPaneCount = 1; export function MyShowsPage() { const feedStore = useFeedStore(); @@ -43,65 +42,46 @@ export function MyShowsPage() { const muted = () => theme.muted || theme.text; const nav = useNavigation(); - const SHOWS = PaneSlot.PARENT; - const EPS = PaneSlot.CURRENT; - const PREV = PaneSlot.PREVIEW; + const stack = nav.depthStack; + const depth = nav.currentDepth; + const focus = (d: number = depth()) => nav.depthFocus(d); const shows = () => feedStore.getFilteredFeeds(); - // The selected show tracks the focused row of pane 0. - const selectedShow = createMemo(() => { - const list = shows(); - if (list.length === 0) return undefined; - const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1); - return list[idx]; - }); + const focusedShowIdx = () => + shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); + const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()]; - const episodes = createMemo(() => { - const show = selectedShow(); - if (!show) return [] as Episode[]; + // depth-1 frame ctx = the drilled feed id + const drilledShowId = (): string => stack()[1]?.ctx ?? ""; + const episodes = createMemo(() => { + if (depth() < 1) return []; + const id = drilledShowId(); + const show = shows().find((s) => s.id === id); + if (!show) return []; return [...show.episodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), ); }); + const focusedEpIdx = () => + episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1); + const focusedEpisode = () => episodes()[focusedEpIdx()]; + + const curLen = () => (depth() === 0 ? shows().length : episodes().length); + + const ensureFocus = () => { + if (shows().length > 0 && focus(0) >= shows().length) + nav.setDepthFocus(shows().length - 1, 0); + if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length) + nav.setDepthFocus(episodes().length - 1, 1); + }; + onMount(ensureFocus); - // Register a resolver so visual-mode range selection grows by episode id. onMount(() => { - nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id); - // keep the resolver fresh as the episode list changes - const unsub = on("nav.action", () => { - nav.registerResolver( - `${nav.activeTab()}:${EPS}`, - (i) => episodes()[i]?.id, - ); + nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { + if (depth() === 0) return shows()[i]?.id; + return episodes()[i]?.id; }); - onCleanup(() => unsub()); - }); - - // Keep shows-focus in range after feeds load/change. - const ensureShowsFocus = () => { - const list = shows(); - if (list.length === 0) return; - const cur = nav.focusedIndex(SHOWS); - if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1); - }; - onMount(ensureShowsFocus); - - // When the show changes, reset episode focus + set audio-nav source + show count. - const onShowChanged = () => { - const show = selectedShow(); - if (!show) return; - if (nav.focusedIndex(EPS) > episodes().length - 1) - nav.setFocusedIndex(EPS, 0); - audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id); - }; - onMount(onShowChanged); - - const focusedEpisode = createMemo(() => { - const eps = episodes(); - if (eps.length === 0) return undefined; - const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1); - return eps[idx]; }); // ── helpers ───────────────────────────────────────────────────────────────── @@ -139,35 +119,40 @@ export function MyShowsPage() { return muted(); } }; - const playEpisode = (ep: Episode) => { audio.play(ep).catch(() => {}); audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id); }; - // ── nav.action handler ────────────────────────────────────────────────────── - const PAGE_ACTIONS: Partial< - Record void> - > = { - "move-down": (p) => step(p, 1), - "move-up": (p) => step(p, -1), - "jump-down": (p) => step(p, 5), - "jump-up": (p) => step(p, -5), - "page-down": (p) => step(p, 10), - "page-up": (p) => step(p, -10), - "goto-top": (p) => nav.gotoIndex(0, len(p)), - "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), - open: (p) => { - if (p === SHOWS) { - nav.swipe(1, MyShowsPaneCount); - onShowChanged(); - } else if (p === EPS) { - const ep = focusedEpisode(); - if (ep) playEpisode(ep); - } - }, - "toggle-select": (p) => { - if (p === EPS) { + // ── drill / open ─────────────────────────────────────────────────────────── + function open() { + if (depth() === 0) { + const show = selectedShow(); + if (!show) return; + nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame); + nav.setActivePane(DEPTH_CENTER_PANE); + audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id); + return; + } + if (depth() >= 1) { + const ep = focusedEpisode(); + if (ep) playEpisode(ep); + } + } + + // ── nav.action ────────────────────────────────────────────────────────────── + const PAGE_ACTIONS: Partial void>> = { + "move-down": () => step(1), + "move-up": () => step(-1), + "jump-down": () => step(5), + "jump-up": () => step(-5), + "page-down": () => step(10), + "page-up": () => step(-10), + "goto-top": () => nav.gotoIndex(0, curLen()), + "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), + open: () => open(), + "toggle-select": () => { + if (depth() >= 1) { const ep = focusedEpisode(); if (ep) nav.toggleSelected(ep.id); } @@ -177,252 +162,305 @@ export function MyShowsPage() { if (show) feedStore.refreshFeed(show.id).catch(() => {}); }, }; - - function len(pane: PaneId): number { - if (pane === SHOWS) return shows().length; - if (pane === EPS) return episodes().length; - return 0; + function step(delta: number) { + nav.move(delta, curLen()); } - function step(pane: PaneId, delta: number) { - nav.move(delta, len(pane)); - if (pane === SHOWS) { - // clamp episode focus + re-resolve after show change - nav.setFocusedIndex( - EPS, - Math.min(nav.focusedIndex(EPS), Math.max(0, episodes().length - 1)), - ); - onShowChanged(); - } - } - const onAction = (data: { action: KeybindActionName; pane: PaneId; mode: NavMode; }) => { - // Only react when our tab is active. - // (Shell always emits; router guarantees our tab is active.) - ensureShowsFocus(); - const handler = PAGE_ACTIONS[data.action]; - if (handler) handler(data.pane); - // visual selection growth is handled inside nav.move/registerResolver + if (data.pane !== DEPTH_CENTER_PANE) return; + if (nav.activePane() !== DEPTH_CENTER_PANE) return; + ensureFocus(); + PAGE_ACTIONS[data.action]?.(); }; - onMount(() => { on("nav.action", onAction); onCleanup(() => off("nav.action", onAction)); }); // ── render ────────────────────────────────────────────────────────────────── - const isActive = (p: PaneId) => nav.activePane() === p; - const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); - - const focusBg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) - ? theme.primary - : i === nav.focusedIndex(pane) - ? theme.border - : undefined; - const focusFg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; + const isActive = nav.activePane() === DEPTH_CENTER_PANE; + const border = (active: boolean) => (active ? theme.accent : theme.border); + const focusBg = (i: number, lf: number, active: boolean) => + i === lf && active ? theme.primary : i === lf ? theme.border : undefined; + const focusFg = (i: number, lf: number, active: boolean) => + i === lf && active ? theme.surface : theme.text; + const headerBg = theme.background; + const showTitle = (f: Feed) => f.customName || f.podcast.title; return ( - {/* ── pane 0: shows ─────────────────────────────────────────────────────── */} - - - Shows ({shows().length}) - - - 0} - fallback={ - - - No shows. Subscribe from Discover/Search. - - - } + {/* ── left: previous depth (empty at root) ──────────────────────────── */} + + = 1}> + + Shows ({shows().length}) + + - {(feed, index) => ( - { - nav.setActivePane(SHOWS); - nav.setFocusedIndex(SHOWS, index()); - onShowChanged(); - }} - > - - {index() === nav.focusedIndex(SHOWS) ? "❯" : " "} - - - {feed.customName || feed.podcast.title} - - { + const lf = nav.depthFocus(0); + return ( + - ({feed.episodes.length}) - - - )} + + {index() === lf ? "❯" : " "} + + + {showTitle(feed)} + + ({feed.episodes.length}) + + ); + }} - - + + - {/* ── pane 1: episodes ──────────────────────────────────────────────────── */} - - + {/* ── center: current depth ─────────────────────────────────────────── */} + + - {selectedShow()?.customName || - selectedShow()?.podcast.title || - "Episodes"}{" "} - · {episodes().length} + {depth() === 0 + ? `Shows (${shows().length})` + : `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`} - 0} - fallback={ - - No episodes. :refresh - - } - > - - {(ep, index) => ( - { - nav.setActivePane(EPS); - nav.setFocusedIndex(EPS, index()); - }} - > - - - {index() === nav.focusedIndex(EPS) ? "❯" : " "} - - - {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} - {ep.title} - - - - - {formatDate(ep.pubDate)} - - - {formatDuration(ep.duration)} - - - - - - - {downloadLabel(ep.id)} - - - + {/* depth 0: shows */} + + 0} + fallback={ + + + No shows. Subscribe from Discover/Search. + - )} - - - - - + } + > + + {(feed, index) => { + const lf = focusedShowIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + > + + {index() === lf ? "❯" : " "} + + + {showTitle(feed)} + + + ({feed.episodes.length}) + + + ); + }} + + + + + {/* depth ≥1: episodes */} + = 1}> + 0} + fallback={ + + No episodes. :refresh + + } + > + + {(ep, index) => { + const lf = focusedEpIdx(); + return ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + > + + + {index() === lf ? "❯" : " "} + + + {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} + {ep.title} + + + + + {formatDate(ep.pubDate)} + + + {formatDuration(ep.duration)} + + + + + + + {downloadLabel(ep.id)} + + + + + ); + }} + + + + + + - {/* ── pane 2: preview ───────────────────────────────────────────────────── */} - - + {/* ── right: preview ────────────────────────────────────────────────── */} + + Preview - - No episode focused - - } - > - {(ep) => ( - - - - {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} - {ep().title} - - - - {formatDate(ep().pubDate)} - {formatDuration(ep().duration)} - - - {downloadLabel(ep().id)} + {/* depth 0 preview: hovered show */} + + + No show focused + + } + > + {(show) => ( + + + {showTitle(show())} + + + by {show().podcast.author} + + + {show().episodes.length} episodes + + + {show().podcast.description?.slice(0, 400) ?? + "No description."} + + + enter/l: open · h: back + + )} + + + + {/* depth ≥1 preview: hovered episode */} + = 1}> + + No episode focused + + } + > + {(ep) => ( + + + + {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} + {ep().title} + + + + {formatDate(ep().pubDate)} + {formatDuration(ep().duration)} + + + {downloadLabel(ep().id)} + + + + + + by {selectedShow()!.podcast.author} + + + {ep().description?.slice(0, 400) ?? + "No description available."} + {(ep().description?.length ?? 0) > 400 ? "…" : ""} + + + + enter: play · space: select · h: back + - - by {selectedShow()!.podcast.author} - - - - {ep().description?.slice(0, 400) ?? - "No description available."} - {(ep().description?.length ?? 0) > 400 ? "…" : ""} - - - enter: play space: select h/l: panes - - )} + )} + diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index d6f86ab..4607e0f 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -1,159 +1,94 @@ -import { createSignal } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; -import { useAppStore } from "@/stores/app"; -import { useTheme } from "@/context/ThemeContext"; -import type { ThemeName } from "@/types/settings"; +/** + * PreferencesPanel — exposes theme/font/speed/explicit/auto-download as + * SettingItems for the yazi depth-stack. No own useKeyboard; all movement is + * driven by the Shell router via nav.action. + */ -type FocusField = "theme" | "font" | "speed" | "explicit" | "auto"; +import { useAppStore } from "@/stores/app"; +import type { ThemeName } from "@/types/settings"; +import type { SettingItem } from "./types"; const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ - { value: "system", label: "System" }, - { value: "catppuccin", label: "Catppuccin" }, - { value: "gruvbox", label: "Gruvbox" }, - { value: "tokyo", label: "Tokyo" }, - { value: "nord", label: "Nord" }, - { value: "custom", label: "Custom" }, + { value: "system", label: "System" }, + { value: "catppuccin", label: "Catppuccin" }, + { value: "gruvbox", label: "Gruvbox" }, + { value: "tokyo", label: "Tokyo" }, + { value: "nord", label: "Nord" }, + { value: "custom", label: "Custom" }, ]; -export function PreferencesPanel() { - const appStore = useAppStore(); - const { theme } = useTheme(); - const [focusField, setFocusField] = createSignal("theme"); +export function usePreferencesItems(): SettingItem[] { + const app = useAppStore(); - const settings = () => appStore.state().settings; - const preferences = () => appStore.state().preferences; + const settings = () => app.state().settings; + const prefs = () => app.state().preferences; - const handleKey = (key: { name: string; shift?: boolean }) => { - if (key.name === "tab") { - const fields: FocusField[] = [ - "theme", - "font", - "speed", - "explicit", - "auto", - ]; - const idx = fields.indexOf(focusField()); - const next = key.shift - ? (idx - 1 + fields.length) % fields.length - : (idx + 1) % fields.length; - setFocusField(fields[next]); - return; - } - - if (key.name === "left" || key.name === "h") { - stepValue(-1); - } - if (key.name === "right" || key.name === "l") { - stepValue(1); - } - if (key.name === "space" || key.name === "return") { - toggleValue(); - } - }; - - const stepValue = (delta: number) => { - const field = focusField(); - if (field === "theme") { - const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme); - const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length; - appStore.setTheme(THEME_LABELS[next].value); - return; - } - if (field === "font") { - const next = Math.min(20, Math.max(10, settings().fontSize + delta)); - appStore.updateSettings({ fontSize: next }); - return; - } - if (field === "speed") { - const next = Math.min( - 2, - Math.max(0.5, settings().playbackSpeed + delta * 0.1), - ); - appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) }); - } - }; - - const toggleValue = () => { - const field = focusField(); - if (field === "explicit") { - appStore.updatePreferences({ showExplicit: !preferences().showExplicit }); - } - if (field === "auto") { - appStore.updatePreferences({ autoDownload: !preferences().autoDownload }); - } - }; - - useKeyboard(handleKey); - - return ( - - Preferences - - - - - Theme: - - - - {THEME_LABELS.find((t) => t.value === settings().theme)?.label} - - - [Left/Right] - - - - - Font Size: - - - {settings().fontSize}px - - [Left/Right] - - - - - Playback: - - - {settings().playbackSpeed}x - - [Left/Right] - - - - - Show Explicit: - - - - {preferences().showExplicit ? "On" : "Off"} - - - [Space] - - - - - Auto Download: - - - - {preferences().autoDownload ? "On" : "Off"} - - - [Space] - - - - Tab to move focus, Left/Right to adjust - - ); + return [ + { + id: "theme", + label: "Theme", + kind: "select", + display: () => + THEME_LABELS.find((t) => t.value === settings().theme)?.label ?? + settings().theme, + help: () => + `Color theme.\nType: select\nDefault: system\nCurrent: ${settings().theme}\nCycle with j/k; Enter to apply.`, + cycle: (dir) => { + const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme); + const next = (idx + dir + THEME_LABELS.length) % THEME_LABELS.length; + app.setTheme(THEME_LABELS[next].value); + }, + }, + { + id: "fontSize", + label: "Font Size", + kind: "number", + display: () => `${settings().fontSize}px`, + help: () => + `Terminal font size in pixels.\nType: number (10–20)\nDefault: 14\nCurrent: ${settings().fontSize}\nj/k to −/+1px.`, + cycle: (dir) => { + const next = Math.min(20, Math.max(10, settings().fontSize + dir)); + app.updateSettings({ fontSize: next }); + }, + }, + { + id: "playbackSpeed", + label: "Playback Speed", + kind: "number", + display: () => `${settings().playbackSpeed}x`, + help: () => + `Default audio playback speed.\nType: number (0.5–2.0)\nDefault: 1.0\nCurrent: ${settings().playbackSpeed}\nj/k to −/+0.1.`, + cycle: (dir) => { + const next = Math.min( + 2, + Math.max(0.5, settings().playbackSpeed + dir * 0.1), + ); + app.updateSettings({ playbackSpeed: Number(next.toFixed(1)) }); + }, + }, + { + id: "showExplicit", + label: "Show Explicit", + kind: "toggle", + display: () => (prefs().showExplicit ? "On" : "Off"), + help: () => + `Whether to list explicit episodes.\nType: toggle\nDefault: true\nCurrent: ${prefs().showExplicit}\nSpace/Enter to toggle.`, + toggle: () => + app.updatePreferences({ + showExplicit: !prefs().showExplicit, + }), + }, + { + id: "autoDownload", + label: "Auto Download", + kind: "toggle", + display: () => (prefs().autoDownload ? "On" : "Off"), + help: () => + `Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`, + toggle: () => + app.updatePreferences({ + autoDownload: !prefs().autoDownload, + }), + }, + ]; } diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 74c4d6c..25389ac 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -1,86 +1,199 @@ /** - * SettingsPage — yazi-style 2-pane view. + * SettingsPage — yazi depth-stack settings. * - * pane 0 (parent) — section list (Sync, Sources, Preferences, ...) - * pane 1 (current) — active panel for the focused section + * depth 0 — sections list (Sync / Sources / Preferences / Visualizer / ...) + * depth 1 — the focused section's items as a navigable list + * depth 2 — per-item editor (for editor-kind items) or value adjuster * - * Movement (j/k, gg/G, page-jumps) on pane 0 navigates the section list. - * The panel (pane 1) reactively shows the focused section's content. - * Audio transport and tab/pane swipes are handled by the Shell router. + * Columns render as yazi's prev | current | preview: + * left = previous depth's list (empty at depth 0) + * right = preview/help text for the hovered item in center + * + * 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 — + * that was the root cause of the old right-pane key conflicts. */ -import { For, Show, onMount, onCleanup } from "solid-js"; -import { SourceManager } from "./SourceManager"; -import { PreferencesPanel } from "./PreferencesPanel"; -import { SyncPanel } from "./SyncPanel"; -import { VisualizerSettings } from "./VisualizerSettings"; +import { For, Show, onMount, onCleanup, createMemo } from "solid-js"; import { useTheme } from "@/context/ThemeContext"; import { useNavigation, NavMode, - PaneSlot, + DEPTH_CENTER_PANE, type PaneId, } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; import { PANE_RATIO } from "@/utils/navigation"; +import type { SettingItem, SettingsSectionDef } from "./types"; +import { usePreferencesItems } from "./PreferencesPanel"; +import { useVisualizerItems } from "./VisualizerSettings"; +import { useSyncItems, closeSyncEditor } from "./SyncPanel"; +import { useSourceItems } from "./SourceManager"; -export const SettingsPaneCount = 2; +export const SettingsPaneCount = 1; -const SECTIONS = [ - { id: 0, label: "Sync" }, - { id: 1, label: "Sources" }, - { id: 2, label: "Preferences" }, - { id: 3, label: "Visualizer" }, - { id: 4, label: "Account" }, -] as const; +const SECTIONS: SettingsSectionDef[] = [ + { + id: 0, + label: "Sync", + description: "Import/export subscriptions and sync status.", + }, + { + id: 1, + label: "Sources", + description: "Podcast search/RSS sources — add, enable, remove.", + }, + { + id: 2, + label: "Preferences", + description: "Theme, font, playback speed, explicit/auto-download.", + }, + { + id: 3, + label: "Visualizer", + description: "Audio visualizer: bars, sensitivity, cutoffs.", + }, + { + id: 4, + label: "Account", + description: "Account login & OAuth (not yet implemented).", + }, +]; + +/** Resolve the items for a section id at render time. Section 4 (Account) has + * no items yet. */ +function sectionItems(sectionId: number): SettingItem[] { + switch (sectionId) { + case 0: + return useSyncItems(); + case 1: + return useSourceItems(); + case 2: + return usePreferencesItems(); + case 3: + return useVisualizerItems(); + default: + return []; + } +} export function SettingsPage() { const { theme } = useTheme(); - const muted = () => theme.muted || theme.text; const nav = useNavigation(); - const SECTIONS_PANE = PaneSlot.PARENT; // 0 - const PANEL = PaneSlot.CURRENT; // 1 + const stack = nav.depthStack; + const depth = nav.currentDepth; - // The focused section tracks pane 0's focused index. - const focusedSection = () => { - const idx = nav.focusedIndex(SECTIONS_PANE); - return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0]; + // ── depth 0: sections ──────────────────────────────────────────────────── + const focusedSectionIdx = () => + Math.min(nav.depthFocus(0), SECTIONS.length - 1); + const focusedSection = () => SECTIONS[focusedSectionIdx()] ?? SECTIONS[0]; + + // ── depth ≥1: section items (resolved from the section id stored in the + // depth-0 frame's ctx). The depth-1 frame kind is "settings:". ──── + const sectionForDepth1 = (): SettingsSectionDef | undefined => { + const f = stack()[1]; + if (!f) return undefined; + const id = Number(f.ctx ?? "0"); + return SECTIONS[id]; }; - - // Register a resolver so visual-mode range selection grows by section id. - onMount(() => { - nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) => - SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(), - ); + const items = createMemo(() => { + const sec = sectionForDepth1(); + if (!sec) return []; + return sectionItems(sec.id); }); + const focusedItemIdx = () => + items().length === 0 ? 0 : Math.min(nav.depthFocus(1), items().length - 1); + const focusedItem = (): SettingItem | undefined => items()[focusedItemIdx()]; - // ── nav.action handler ────────────────────────────────────────────────────── - const PAGE_ACTIONS: Partial< - Record void> - > = { - "move-down": (p) => step(p, 1), - "move-up": (p) => step(p, -1), - "jump-down": (p) => step(p, 5), - "jump-up": (p) => step(p, -5), - "page-down": (p) => step(p, 10), - "page-up": (p) => step(p, -10), - "goto-top": (p) => nav.gotoIndex(0, len(p)), - "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), - open: (p) => { - if (p === SECTIONS_PANE) { - nav.swipe(1, SettingsPaneCount); - } - }, + // ── depth 2: the editor item (resolved from depth-1 frame ctx + item id) ─ + const editorItem = (): SettingItem | undefined => { + const f1 = stack()[1]; + const f2 = stack()[2]; + if (!f1 || !f2) return undefined; + const secId = Number(f1.ctx ?? "0"); + const list = sectionItems(secId); + return list.find((it) => it.id === f2.ctx); }; - function len(pane: PaneId): number { - if (pane === SECTIONS_PANE) return SECTIONS.length; - return 0; + // ── drill / open dispatch ─────────────────────────────────────────────── + function open() { + const d = depth(); + if (d === 0) { + // drill into the focused section's items + const id = focusedSection().id; + nav.pushDepth({ + kind: `settings:${id}`, + ctx: String(id), + focus: 0, + }); + nav.setActivePane(DEPTH_CENTER_PANE); + return; + } + if (d === 1) { + const it = focusedItem(); + if (!it) return; + switch (it.kind) { + case "toggle": + it.toggle?.(); + return; + case "action": + it.run?.(); + return; + case "info": + return; + case "editor": + case "number": + case "select": + nav.pushDepth({ + kind: `settings:item:${it.id}`, + ctx: it.id, + focus: 0, + }); + nav.setActivePane(DEPTH_CENTER_PANE); + return; + } + } + if (d === 2) { + // in an editor: Enter adjusts/cycles a number/select forward, toggles + const it = editorItem(); + if (!it) return; + if (it.kind === "number" || it.kind === "select") it.cycle?.(1); + else if (it.kind === "toggle") it.toggle?.(); + return; + } } - function step(pane: PaneId, delta: number) { - nav.move(delta, len(pane)); + + // ── movement (j/k etc.) routed by the Shell over nav.action ─────────────── + const PAGE_ACTIONS: Partial void>> = { + "move-down": () => step(1), + "move-up": () => step(-1), + "jump-down": () => step(5), + "jump-up": () => step(-5), + "page-down": () => step(10), + "page-up": () => step(-10), + "goto-top": () => nav.gotoIndex(0, len()), + "goto-bottom": () => nav.gotoIndex(len() - 1, len()), + open: () => open(), + }; + + function len(): number { + const d = depth(); + if (d === 0) return SECTIONS.length; + if (d === 1) return items().length; + return 0; // depth 2 editor: no list length; j/k cycles instead + } + function step(delta: number) { + const d = depth(); + if (d === 2) { + // editor: j/k nudges the value + const it = editorItem(); + if (it?.kind === "number" || it?.kind === "select") + it.cycle?.(delta as -1 | 1); + return; + } + nav.move(delta, len()); } const onAction = (data: { @@ -88,98 +201,319 @@ export function SettingsPage() { pane: PaneId; mode: NavMode; }) => { + // ignore actions meant for non-center panes + if (data.pane !== DEPTH_CENTER_PANE) return; + if (nav.activePane() !== DEPTH_CENTER_PANE) return; const handler = PAGE_ACTIONS[data.action]; - if (handler) handler(data.pane); + if (handler) handler(); }; onMount(() => { on("nav.action", onAction); - onCleanup(() => off("nav.action", onAction)); + // keep a resolver so visual-mode range selection grows by section/item id + nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { + const d = depth(); + if (d === 0) return SECTIONS[i]?.id.toString(); + if (d === 1) return items()[i]?.id; + return undefined; + }); + }); + onCleanup(() => off("nav.action", onAction)); + + // when leaving a sync editor (h to pop), close any open dialog overlay + onCleanup(() => closeSyncEditor()); + + // ── render helpers ─────────────────────────────────────────────────────── + 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 + const previewText = createMemo(() => { + const d = depth(); + if (d === 0) { + return `${focusedSection().label}\n\n${focusedSection().description}\n\nDrill in (Enter/l) to open this section's settings.`; + } + if (d === 1) { + const it = focusedItem(); + return it?.help() ?? "No item."; + } + // editor: same help, plus note + const it = editorItem(); + return it + ? `${it.help()}\n\n— Editor —\nj/k adjust · h back` + : "No editor."; }); - // ── render ────────────────────────────────────────────────────────────────── - const isActive = (p: PaneId) => nav.activePane() === p; - const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); - - const focusBg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) - ? theme.primary - : i === nav.focusedIndex(pane) - ? theme.border - : undefined; - const focusFg = (i: number, pane: PaneId) => - i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; - - return ( - - {/* ── pane 0: sections ─────────────────────────────────────────────────── */} - - - Settings - + // ── column content builders ────────────────────────────────────────────── + // left = previous depth (read-only list), or empty at depth 0 + const LeftCol = () => ( + + + + = 1} fallback=" "> + {depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")} + + + + {(section, index) => ( - { - nav.setActivePane(SECTIONS_PANE); - nav.setFocusedIndex(SECTIONS_PANE, index()); - }} - > - - {index() === nav.focusedIndex(SECTIONS_PANE) ? "❯" : " "} - - - {section.label} - - + )} - - - {/* ── pane 1: panel ─────────────────────────────────────────────────────── */} - - - {focusedSection().label} - + + - - + + {(it, index) => ( + + )} + + + + + ); + + // center = current depth + const CenterCol = () => ( + + + + + {sectionForDepth1()?.label ?? "Items"} + + } + > + Settings - - - - - - - - - - - - Account settings (not yet implemented) + + + + + + {(section, index) => ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + /> + )} + + + + + {(it, index) => ( + { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + /> + )} + + + + (No items.) - + + + } + > + {editorItem()!.renderEditor!()} + + + + + ); + + // right = preview / help + const RightCol = () => ( + + + Preview + + + + + + + ); + + return ( + + {LeftCol()} + {CenterCol()} + {RightCol()} ); } + +/** Per-kind hint glyph shown at the right of an item row. */ +function hintFor(it: SettingItem): string { + switch (it.kind) { + case "toggle": + return "⏻"; + case "number": + case "select": + return "±"; + case "action": + return "↵"; + case "editor": + return "→"; + case "info": + return "·"; + } +} + +function Row(props: { + label: string; + value?: string; + focused: boolean; + active: boolean; + hint?: string; + onMouseDown?: () => void; +}) { + const { theme } = useTheme(); + const bg = () => + props.focused && props.active + ? theme.primary + : props.focused + ? theme.border + : undefined; + const fg = () => (props.focused && props.active ? theme.surface : theme.text); + return ( + + {props.focused ? "❯" : " "} + {props.label} + + + {props.value} + + + {props.hint} + + + ); +} + +/** Center editor for number/select/toggle items without a bespoke renderer. */ +function GenericEditor(props: { item: SettingItem }) { + const { theme } = useTheme(); + const it = props.item; + return ( + + + {it.label} + + + Value: + + {it.display()} + + + + + j/k to adjust · Enter to nudge forward · h to go back + + + + + Enter/Space to toggle · h to go back + + + + ); +} + +/** Renders a string with `\n` newlines as stacked lines. */ +function MultiLine(props: { text: string }) { + const lines = () => props.text.split("\n"); + const { theme } = useTheme(); + return ( + + {(line, i) => ( + + {line || " "} + + )} + + ); +} diff --git a/src/pages/Settings/SourceManager.tsx b/src/pages/Settings/SourceManager.tsx index 5a28785..948ff69 100644 --- a/src/pages/Settings/SourceManager.tsx +++ b/src/pages/Settings/SourceManager.tsx @@ -1,317 +1,141 @@ /** - * Source management component for PodTUI - * Add, remove, and configure podcast sources + * SourceManager — exposes podcast sources as SettingItems for the depth-stack. + * + * • "Add Source" — an editor item; drilling in shows a name/URL add form. + * • Each source — a toggle item (Space toggles enabled) whose display shows + * the source type and on/off state. + * + * Advanced per-API-source options (country/language/explicit) are flattened to + * simple toggles/cycles reachable by drilling into the source's editor. + * Movement flows through nav.action — no own useKeyboard (avoids the old + * right-pane key conflicts). */ -import { createSignal, For } from "solid-js"; +import { createSignal, For, Show } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useTheme } from "@/context/ThemeContext"; import { SourceType } from "@/types/source"; import type { PodcastSource } from "@/types/source"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; +import type { SettingItem } from "./types"; -interface SourceManagerProps { - focused?: boolean; - onClose?: () => void; +export function useSourceItems(): SettingItem[] { + const feedStore = useFeedStore(); + + const typeBadge = (s: PodcastSource) => + s.type === SourceType.API + ? "[API]" + : s.type === SourceType.RSS + ? "[RSS]" + : "[?]"; + + const items: SettingItem[] = [ + { + id: "add", + label: "Add Source", + kind: "editor", + display: () => "+", + help: () => + `Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`, + renderEditor: () => , + }, + ]; + + for (const s of feedStore.sources()) { + items.push({ + id: `src:${s.id}`, + label: s.name, + kind: "toggle", + display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`, + help: () => + `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`, + toggle: () => feedStore.toggleSource(s.id), + }); + } + + return items; } -type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"; +function AddSourceForm() { + const feedStore = useFeedStore(); + const { theme } = useTheme(); + const [name, setName] = createSignal(""); + const [url, setUrl] = createSignal(""); + const [error, setError] = createSignal(null); -export function SourceManager(props: SourceManagerProps) { - const feedStore = useFeedStore(); - const { theme } = useTheme(); - const [selectedIndex, setSelectedIndex] = createSignal(0); - const [focusArea, setFocusArea] = createSignal("list"); - const [newSourceUrl, setNewSourceUrl] = createSignal(""); - const [newSourceName, setNewSourceName] = createSignal(""); - const [error, setError] = createSignal(null); + const submit = () => { + const u = url().trim(); + if (!u) { + setError("URL is required"); + return; + } + try { + new URL(u); + } catch { + setError("Invalid URL format"); + return; + } + feedStore.addSource({ + name: name().trim() || "Custom Source", + type: SourceType.RSS, + baseUrl: u, + enabled: true, + description: `Custom RSS feed: ${u}`, + }); + setName(""); + setUrl(""); + setError(null); + }; - const sources = () => feedStore.sources(); - - const handleKeyPress = (key: { name: string; shift?: boolean }) => { - if (key.name === "escape") { - if (focusArea() !== "list") { - setFocusArea("list"); - setError(null); - } else if (props.onClose) { - props.onClose(); - } - return; - } - - if (key.name === "tab") { - const areas: FocusArea[] = [ - "list", - "country", - "language", - "explicit", - "add", - "url", - ]; - const idx = areas.indexOf(focusArea()); - const nextIdx = key.shift - ? (idx - 1 + areas.length) % areas.length - : (idx + 1) % areas.length; - setFocusArea(areas[nextIdx]); - return; - } - - if (focusArea() === "list") { - if (key.name === "up" || key.name === "k") { - setSelectedIndex((i) => Math.max(0, i - 1)); - } else if (key.name === "down" || key.name === "j") { - setSelectedIndex((i) => Math.min(sources().length - 1, i + 1)); - } else if ( - key.name === "return" || - key.name === "space" - ) { - const source = sources()[selectedIndex()]; - if (source) { - feedStore.toggleSource(source.id); - } - } else if (key.name === "d" || key.name === "delete") { - const source = sources()[selectedIndex()]; - if (source) { - const removed = feedStore.removeSource(source.id); - if (!removed) { - setError("Cannot remove default sources"); - } - } - } else if (key.name === "a") { - setFocusArea("add"); - } - } - - if (focusArea() === "country") { - if ( - key.name === "enter" || - key.name === "return" || - key.name === "space" - ) { - const source = sources()[selectedIndex()]; - if (source && source.type === SourceType.API) { - const next = source.country === "US" ? "GB" : "US"; - feedStore.updateSource(source.id, { country: next }); - } - } - } - - if (focusArea() === "explicit") { - if ( - key.name === "return" || - key.name === "space" - ) { - const source = sources()[selectedIndex()]; - if (source && source.type === SourceType.API) { - feedStore.updateSource(source.id, { - allowExplicit: !source.allowExplicit, - }); - } - } - } - - if (focusArea() === "language") { - if ( - key.name === "return" || - key.name === "space" - ) { - const source = sources()[selectedIndex()]; - if (source && source.type === SourceType.API) { - const next = source.language === "ja_jp" ? "en_us" : "ja_jp"; - feedStore.updateSource(source.id, { language: next }); - } - } - } - }; - - const handleAddSource = () => { - const url = newSourceUrl().trim(); - const name = newSourceName().trim() || `Custom Source`; - - if (!url) { - setError("URL is required"); - return; - } - - try { - new URL(url); - } catch { - setError("Invalid URL format"); - return; - } - - feedStore.addSource({ - name, - type: "rss" as SourceType, - baseUrl: url, - enabled: true, - description: `Custom RSS feed: ${url}`, - }); - - setNewSourceUrl(""); - setNewSourceName(""); - setFocusArea("list"); - setError(null); - }; - - const getSourceIcon = (source: PodcastSource) => { - if (source.type === SourceType.API) return "[API]"; - if (source.type === SourceType.RSS) return "[RSS]"; - return "[?]"; - }; - - const selectedSource = () => sources()[selectedIndex()]; - const isApiSource = () => selectedSource()?.type === SourceType.API; - const sourceCountry = () => selectedSource()?.country || "US"; - const sourceExplicit = () => selectedSource()?.allowExplicit !== false; - const sourceLanguage = () => selectedSource()?.language || "en_us"; - - return ( - - - - Podcast Sources - - - [Esc] Close - - - - Manage where to search for podcasts - - {/* Source list */} - - - Sources: - - - - {(source, index) => ( - focusArea() === "list" && index() === selectedIndex()} - flexDirection="row" - gap={1} - padding={0} - onMouseDown={() => { - setSelectedIndex(index()); - setFocusArea("list"); - feedStore.toggleSource(source.id); - }} - > - focusArea() === "list" && index() === selectedIndex()} - primary - > - {focusArea() === "list" && index() === selectedIndex() - ? ">" - : " "} - - focusArea() === "list" && index() === selectedIndex()} - primary - > - {source.name} - - - )} - - - - Space/Enter to toggle, d to delete, a to add - - - {/* API settings */} - - false} primary={isApiSource()}> - {isApiSource() - ? "API Settings" - : "API Settings (select an API source)"} - - - - false} primary={focusArea() === "country"}> - Country: {sourceCountry()} - - - - false} primary={focusArea() === "language"}> - Language:{" "} - {sourceLanguage() === "ja_jp" ? "Japanese" : "English"} - - - - false} primary={focusArea() === "explicit"}> - Explicit: {sourceExplicit() ? "Yes" : "No"} - - - - false} tertiary> - Enter/Space to toggle focused setting - - - - - {/* Add new source form */} - - false} primary={focusArea() === "add" || focusArea() === "url"}> - Add New Source: - - - - false} tertiary>Name: - - - - - false} tertiary>URL: - { - setNewSourceUrl(v); - setError(null); - }} - placeholder="https://example.com/feed.rss" - focused={props.focused && focusArea() === "url"} - width={35} - /> - - - - false} primary>[+] Add Source - - - - {/* Error message */} - {error() && false} tertiary>{error()}} - - false} tertiary>Tab to switch sections, Esc to close - - ); + return ( + + + Add Source + + + Name: + + + + URL: + { + setUrl(v); + setError(null); + }} + placeholder="https://example.com/feed.rss" + width={35} + /> + + + [+] Add + + {(e) => {e()}} + 0}> + + + Current sources ({feedStore.sources().length}): + + + {(s) => ( + + {s.enabled ? "●" : "○"} {s.name} + + )} + + + + + ); } diff --git a/src/pages/Settings/SyncPanel.tsx b/src/pages/Settings/SyncPanel.tsx index be17aec..9c0355a 100644 --- a/src/pages/Settings/SyncPanel.tsx +++ b/src/pages/Settings/SyncPanel.tsx @@ -1,32 +1,57 @@ -const createSignal = (value: T): [() => T, (next: T) => void] => { - let current = value - return [() => current, (next) => { - current = next - }] +/** + * SyncPanel — exposes Import / Export / status as SettingItems. The Import and + * Export dialogs render as depth-2 editors. No own useKeyboard. + */ + +import { createSignal } from "solid-js"; +import { ImportDialog } from "./ImportDialog"; +import { ExportDialog } from "./ExportDialog"; +import { SyncStatus } from "./SyncStatus"; +import type { SettingItem } from "./types"; + +// Module-level state so the action items can open their dialogs as depth-2 +// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show. +const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>( + null, +); +export { syncEditor }; +export function closeSyncEditor() { + setSyncEditor(null); } -import { ImportDialog } from "./ImportDialog" -import { ExportDialog } from "./ExportDialog" -import { SyncStatus } from "./SyncStatus" -import { useTheme } from "@/context/ThemeContext" - -export function SyncPanel() { - const { theme } = useTheme(); - const mode = createSignal<"import" | "export" | null>(null) - - return ( - - - mode[1]("import")}> - Import - - mode[1]("export")}> - Export - - - - {mode[0]() === "import" ? : null} - {mode[0]() === "export" ? : null} - - ) +export function useSyncItems(): SettingItem[] { + return [ + { + id: "import", + label: "Import", + kind: "editor", + display: () => "→", + help: () => + `Import subscriptions from a sync file (JSON or OPML).\nDrill in (Enter/l) to open the import dialog.\nType: editor`, + renderEditor: () => , + }, + { + id: "export", + label: "Export", + kind: "editor", + display: () => "→", + help: () => + `Export subscriptions to a sync file.\nDrill in (Enter/l) to open the export dialog.\nType: editor`, + renderEditor: () => , + }, + { + id: "status", + label: "Status", + kind: "info", + display: () => "Idle", + help: () => + `Last sync status. (Sync is run from the import/export dialogs.)\nType: info`, + }, + ]; +} + +/** Renders the live sync status block (used by the Settings page header for the + * Sync section, when relevant). */ +export function SyncStatusBlock() { + return ; } diff --git a/src/pages/Settings/VisualizerSettings.tsx b/src/pages/Settings/VisualizerSettings.tsx index 267b4c5..05edcc9 100644 --- a/src/pages/Settings/VisualizerSettings.tsx +++ b/src/pages/Settings/VisualizerSettings.tsx @@ -1,164 +1,81 @@ /** - * VisualizerSettings — settings panel for the real-time audio visualizer. - * - * Allows adjusting bar count, noise reduction, sensitivity, and - * frequency cutoffs. All changes persist via the app store. + * VisualizerSettings — exposes bars/sensitivity/noise/lowCut/highCut as + * SettingItems for the yazi depth-stack. No own useKeyboard. */ -import { createSignal } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; import { useAppStore } from "@/stores/app"; -import { useTheme } from "@/context/ThemeContext"; +import type { SettingItem } from "./types"; -type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut"; +export function useVisualizerItems(): SettingItem[] { + const app = useAppStore(); + const viz = () => app.state().settings.visualizer; -const FIELDS: FocusField[] = [ - "bars", - "sensitivity", - "noise", - "lowCut", - "highCut", -]; - -export function VisualizerSettings() { - const appStore = useAppStore(); - const { theme } = useTheme(); - const [focusField, setFocusField] = createSignal("bars"); - - const viz = () => appStore.state().settings.visualizer; - - const handleKey = (key: { name: string; shift?: boolean }) => { - if (key.name === "tab") { - const idx = FIELDS.indexOf(focusField()); - const next = key.shift - ? (idx - 1 + FIELDS.length) % FIELDS.length - : (idx + 1) % FIELDS.length; - setFocusField(FIELDS[next]); - return; - } - - if (key.name === "left" || key.name === "h") { - stepValue(-1); - } - if (key.name === "right" || key.name === "l") { - stepValue(1); - } - }; - - const stepValue = (delta: number) => { - const field = focusField(); - const v = viz(); - - switch (field) { - case "bars": { - // Step by 8: 8, 16, 24, 32, ..., 128 - const next = Math.min(128, Math.max(8, v.bars + delta * 8)); - appStore.updateVisualizer({ bars: next }); - break; - } - case "sensitivity": { - // Toggle: 0 (manual) or 1 (auto) - appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 }); - break; - } - case "noise": { - // Step by 0.05: 0.0 – 1.0 - const next = Math.min( - 1, - Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))), - ); - appStore.updateVisualizer({ noiseReduction: next }); - break; - } - case "lowCut": { - // Step by 10: 20 – 500 Hz - const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10)); - appStore.updateVisualizer({ lowCutOff: next }); - break; - } - case "highCut": { - // Step by 500: 1000 – 20000 Hz - const next = Math.min( - 20000, - Math.max(1000, v.highCutOff + delta * 500), - ); - appStore.updateVisualizer({ highCutOff: next }); - break; - } - } - }; - - useKeyboard(handleKey); - - return ( - - Visualizer - - - - - Bars: - - - {viz().bars} - - [Left/Right +/-8] - - - - - Auto Sensitivity: - - - - {viz().sensitivity === 1 ? "On" : "Off"} - - - [Left/Right] - - - - - Noise Reduction: - - - {viz().noiseReduction.toFixed(2)} - - [Left/Right +/-0.05] - - - - - Low Cutoff: - - - {viz().lowCutOff} Hz - - [Left/Right +/-10] - - - - - High Cutoff: - - - {viz().highCutOff} Hz - - [Left/Right +/-500] - - - - Tab to move focus, Left/Right to adjust - - ); + return [ + { + id: "bars", + label: "Bars", + kind: "number", + display: () => String(viz().bars), + help: () => + `Number of visualizer bars.\nType: number (8–128, step 8)\nDefault: 64\nCurrent: ${viz().bars}\nj/k to −/+8.`, + cycle: (dir) => + app.updateVisualizer({ + bars: Math.min(128, Math.max(8, viz().bars + dir * 8)), + }), + }, + { + id: "sensitivity", + label: "Auto Sensitivity", + kind: "toggle", + display: () => (viz().sensitivity === 1 ? "On" : "Off"), + help: () => + `Automatic gain sensitivity.\nType: toggle\nDefault: on\nCurrent: ${viz().sensitivity === 1 ? "on" : "off"}\nSpace/Enter to toggle.`, + toggle: () => + app.updateVisualizer({ + sensitivity: viz().sensitivity === 1 ? 0 : 1, + }), + }, + { + id: "noiseReduction", + label: "Noise Reduction", + kind: "number", + display: () => viz().noiseReduction.toFixed(2), + help: () => + `FFT noise reduction factor.\nType: number (0.00–1.00, step 0.05)\nDefault: 0.20\nCurrent: ${viz().noiseReduction.toFixed(2)}\nj/k to −/+0.05.`, + cycle: (dir) => + app.updateVisualizer({ + noiseReduction: Math.min( + 1, + Math.max(0, Number((viz().noiseReduction + dir * 0.05).toFixed(2))), + ), + }), + }, + { + id: "lowCutOff", + label: "Low Cutoff", + kind: "number", + display: () => `${viz().lowCutOff} Hz`, + help: () => + `Lower frequency cutoff.\nType: number (20–500 Hz, step 10)\nDefault: 20\nCurrent: ${viz().lowCutOff}\nj/k to −/+10.`, + cycle: (dir) => + app.updateVisualizer({ + lowCutOff: Math.min(500, Math.max(20, viz().lowCutOff + dir * 10)), + }), + }, + { + id: "highCutOff", + label: "High Cutoff", + kind: "number", + display: () => `${viz().highCutOff} Hz`, + help: () => + `Upper frequency cutoff.\nType: number (1000–20000 Hz, step 500)\nDefault: 20000\nCurrent: ${viz().highCutOff}\nj/k to −/+500.`, + cycle: (dir) => + app.updateVisualizer({ + highCutOff: Math.min( + 20000, + Math.max(1000, viz().highCutOff + dir * 500), + ), + }), + }, + ]; } diff --git a/src/pages/Settings/types.ts b/src/pages/Settings/types.ts new file mode 100644 index 0000000..3e22c2d --- /dev/null +++ b/src/pages/Settings/types.ts @@ -0,0 +1,45 @@ +/** + * Settings item model — each settings section exposes a list of items that the + * SettingsPage renders through the yazi depth-stack (sections → items → editor). + * + * All movement flows through the Shell's nav.action router (j/k move, Enter/l + * drill, h back), so panels no longer register their own useKeyboard — that was + * the root cause of the "right pane ignores keys / double-handled input" bugs. + */ +import type { JSX } from "solid-js"; + +export type SettingItemKind = + | "toggle" + | "number" + | "select" + | "action" + | "editor" + | "info"; + +export interface SettingItem { + /** Stable id within its section. */ + id: string; + /** One-line label shown in the items list. */ + label: string; + /** Category — decides how the item is interacted with. */ + kind: SettingItemKind; + /** Current value as a short string (shown to the right of the label). */ + display: () => string; + /** Help text for the preview pane: description, type, default, current. */ + help: () => string; + /** For number/select: nudge the value by -1 or +1 (j/k at depth 2). */ + cycle?: (dir: -1 | 1) => void; + /** For toggle: flip the value (Space/Enter at depth 1). */ + toggle?: () => void; + /** For action: run immediately (Enter at depth 1). */ + run?: () => void; + /** For editor: a bespoke depth-2 editor component. */ + renderEditor?: () => JSX.Element; +} + +export interface SettingsSectionDef { + id: number; + label: string; + description: string; + items?: () => SettingItem[]; +} diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index 0071d31..f1ec5d8 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -20,6 +20,35 @@ export enum TABS { } export const TabsCount = 6; +/** Tabs that use the yazi depth-stack model (prev | current | preview + * columns, infinite drill via push/pop). Search and Player keep the legacy + * fixed-pane model. */ +export const DEPTH_TABS: ReadonlySet = new Set([ + TABS.FEED, + TABS.MYSHOWS, + TABS.DISCOVER, + TABS.SETTINGS, +]); + +/** Root (depth-0) frame for a depth-tab — identifies the top-level list each + * page renders at root. Pages interpret the `kind` to derive their list. */ +export function rootFrameFor( + tab: TABS, +): import("@/context/NavigationContext").DepthFrame { + switch (tab) { + case TABS.FEED: + return { kind: "feeds", focus: 0 }; + case TABS.MYSHOWS: + return { kind: "shows", focus: 0 }; + case TABS.DISCOVER: + return { kind: "discover:categories", focus: 0 }; + case TABS.SETTINGS: + return { kind: "settings:sections", focus: 0 }; + default: + return { kind: "root", focus: 0 }; + } +} + export const LayerGraph = { [TABS.FEED]: FeedPage, [TABS.MYSHOWS]: MyShowsPage, @@ -47,14 +76,18 @@ export const PANE_RATIO = { preview: 3, } as const; -// Number of interactive panes per tab (for the yazi h/l swipe). Slots beyond -// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing -// the old NavigationContext top-level-init circular deadlock. +// Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/ +// Settings) now have a single focusable content pane (the center/current +// column at depth 0..N); prev and preview are derived, not focusable. Search +// keeps its 3 fixed panes; Player is single-pane. The Shell's h/l dispatch +// routes depth-tabs to push/pop instead of pane swipe. Defined here (after +// TABS) to avoid re-introducing the old NavigationContext top-level-init +// circular deadlock. export const TabPaneCount: Record = { - [TABS.FEED]: 3, // feeds | episodes | preview - [TABS.MYSHOWS]: 3, // shows | episodes | preview - [TABS.DISCOVER]: 3, // categories | results | detail - [TABS.SEARCH]: 3, // query | results | detail + [TABS.FEED]: 1, // depth: feeds → episodes → preview + [TABS.MYSHOWS]: 1, // depth: shows → episodes → preview + [TABS.DISCOVER]: 1, // depth: categories → results → preview + [TABS.SEARCH]: 3, // fixed: query | results | detail [TABS.PLAYER]: 1, // single pane - [TABS.SETTINGS]: 2, // sections | panel + [TABS.SETTINGS]: 1, // depth: sections → items → editor };