/** * SettingsPage — yazi depth-stack settings. * * 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 * * Renders entirely through `` (parent | current | preview): * parent = previous depth's list (sections at depth 1, items at depth 2); * blank placeholder at depth 0 (1/5 slot kept). * current = the current-depth list (or editor at depth 2); the only * focusable column. * preview = help/preview text for the hovered item in current. * * All movement comes from the Shell router over `nav.action` (j/k move, * Enter/l drill, h back). Panels no longer register their own useKeyboard — * that was the root cause of the old right-pane key conflicts. */ import { For, Show, onMount, onCleanup, createMemo } from "solid-js"; import { rgbToHex, type RGBA } from "@opentui/core"; import { useTheme, type ThemeResolved } from "@/context/ThemeContext"; import { useNavigation, NavMode, DEPTH_CENTER_PANE, type PaneId, } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { SettingItem, SettingsSectionDef } from "./types"; import { usePreferencesItems } from "./PreferencesPanel"; import { useVisualizerItems } from "./VisualizerSettings"; import { useSyncItems, closeSyncEditor } from "./SyncPanel"; import { useSourceItems } from "./SourceManager"; import { useDownloadItems } from "./DownloadManager"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; export const SettingsPaneCount = 1; const SECTIONS: SettingsSectionDef[] = [ { id: 0, label: "Sync", description: "Import/export subscriptions and sync status.", icon: NF_ICONS.sync, }, { id: 1, label: "Sources", description: "Podcast search/RSS sources — add, enable, remove.", icon: NF_ICONS.sources, }, { id: 2, label: "Preferences", description: "Theme, font, playback speed, explicit/auto-download.", icon: NF_ICONS.preferences, }, { id: 3, label: "Visualizer", description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.", icon: NF_ICONS.visualizer, }, { id: 4, label: "Downloads", description: "Manage downloaded episodes — delete by show or individually.", icon: NF_ICONS.downloads, }, ]; // Static: detection never changes mid-session. Module-level because the Row // component below (a sibling module function) needs it too. const nerd = supportsNerdFonts(); /** Resolve the items for a section id at render time. */ function sectionItems(sectionId: number): SettingItem[] { switch (sectionId) { case 0: return useSyncItems(); case 1: return useSourceItems(); case 2: return usePreferencesItems(); case 3: return useVisualizerItems(); case 4: return useDownloadItems(); default: return []; } } export function SettingsPage() { const { theme } = useTheme(); const nav = useNavigation(); const stack = nav.depthStack; const depth = nav.currentDepth; // ── 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]; }; 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()]; // ── 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); }; // ── 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; } } // ── 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: { action: KeybindActionName; 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(); }; onMount(() => { on("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; // Whether the currently-focused settings row is the Theme select — the // only item whose Detail pane carries a color breakdown below the help text. const isThemeItem = () => { const d = depth(); if (d === 1) return focusedItem()?.id === "theme"; if (d === 2) return editorItem()?.id === "theme"; return false; }; // 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."; }); // ── column label ─────────────────────────────────────────────────────────── const currentLabel = () => { const d = depth(); if (d === 0) return "Settings"; if (d === 1) return sectionForDepth1()?.label ?? "Items"; return editorItem()?.label ?? "Editor"; }; // ── parent pane: previous-depth list (blank at depth 0) ──────────────── // Sibling blocks per depth (mirrors the preview pane) so Solid // mounts every branch once and toggles children on depth change — the // known-good opentui disposal pattern. A ternary returning different // roots leaves subtree orphaned on swap; the trick is a STABLE fragment // root whose inner children swap instead. const parentContent = () => ( <> {/* app root: the tab list as the parent (muted) at the lowest depth */} {/* previous depth = sections list (read-only) */} {(section, index) => ( )} {/* previous depth = items list (read-only) */} {(it, index) => ( )} ); // ── current pane: current-depth list (or editor at depth 2) ─────────────── const currentContent = () => ( <> {(section, index) => ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); }} /> )} {(it, index) => ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} /> )} (No items.) {/* depth 2: editor */} } > {editorItem()!.renderEditor!()} ); // ── preview pane ────────────────────────────────────────────────────────── const previewContent = () => ( {/* Keep everything on a stable root so Solid re-resolves the swap between plain help text and the theme breakdown on focus move. */} }> ); return ( ); } /** 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; icon?: 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 : props.focused ? theme.selectedListItemText ?? theme.text : theme.text; const ref = useScrollIntoView(() => props.focused); const marker = useSelectionMarker(); return ( {props.focused ? marker() : " "} {props.icon && nerd && {props.icon}} {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 ); } /** Curated theme color roles shown in the Theme breakdown. */ const THEME_ROLES: Array<{ key: keyof ThemeResolved; label: string }> = [ { key: "primary", label: "Primary" }, { key: "secondary", label: "Secondary" }, { key: "accent", label: "Accent" }, { key: "text", label: "Text" }, { key: "textMuted", label: "Muted" }, { key: "background", label: "Background" }, { key: "surface", label: "Surface" }, { key: "border", label: "Border" }, { key: "error", label: "Error" }, { key: "warning", label: "Warning" }, { key: "success", label: "Success" }, { key: "info", label: "Info" }, ]; /** Color swatch breakdown (‹block›