diff --git a/.gitignore b/.gitignore index 976b75d..1395a38 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Finder (MacOS) folder config .DS_Store .harness/ +.ralpi diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..c4d699b --- /dev/null +++ b/notes.md @@ -0,0 +1 @@ +- [ ] Audio play can survive quit out diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index a05db6c..71efeb9 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -15,10 +15,7 @@ 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 } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useFeedStore } from "@/stores/feed"; @@ -28,6 +25,8 @@ import { emit } from "@/utils/event-bus"; import { LayerGraph } from "@/utils/layer-graph"; import { TABS, TabPaneCount } from "@/utils/navigation"; import { createDispatcher } from "@/utils/dispatch"; +import { TabListPane } from "@/components/TabPanel"; +import { YaziPaneRow } from "@/components/YaziPaneRow"; const TAB_LABEL: Record = { [TABS.FEED]: "Feed", @@ -94,7 +93,7 @@ export function Shell() { case "q": case "quit": case "exit": - process.exit(0); + return process.exit(0); case "refresh": case "r": emit("nav.action", { @@ -190,7 +189,10 @@ export function Shell() { // ── Unified router (normal + visual) ─────────────────────────────────────── const { dispatch } = createDispatcher({ nav, - audio: { togglePlayback: audio.togglePlayback, seekRelative: audio.seekRelative }, + audio: { + togglePlayback: audio.togglePlayback, + seekRelative: audio.seekRelative, + }, k, setShowHelp, advanceEpisode, @@ -232,11 +234,36 @@ export function Shell() { height="100%" backgroundColor={t.surface} > - {/* ── Middle row: full-width active page ──────────────────────────────── */} - - {LayerGraph[nav.activeTab()]()} + {/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */} + + + {LayerGraph[nav.activeTab()]()} + + } + > + {/* app root: the tab list is the CURRENT pane, nothing in UP */} + + + + } + current={} + preview={ + + j/k move · l/Enter open a tab + + } + parentLabel="Up" + currentLabel="Tabs" + previewLabel="" + focused + /> + - {/* ── Bottom status / command bar ─────────────────────────────────────── */} - {TAB_LABEL[nav.activeTab()]} ·{" "} - {nav.isDepthTab() - ? `depth ${nav.currentDepth()}` - : `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`} + {nav.atRootTab() + ? "Tabs · root" + : `${TAB_LABEL[nav.activeTab()]} · ${ + nav.isDepthTab() + ? `depth ${nav.currentDepth()}` + : `pane ${nav.activePane()}/${TabPaneCount[nav.activeTab()]}` + }`} 0}> @@ -271,27 +301,6 @@ export function Shell() { {pendingLabel()} - {/* ── Tab strip ─────────────────────────────────────────────────── */} - typeof v === "number", - )} - > - {(tab) => { - const active = () => nav.activeTab() === tab; - return ( - - {active() ? "≡" : " "}[{tab}]{" "} - {TAB_LABEL[tab]}{" "} - - ); - }} - ~ @@ -310,7 +319,6 @@ export function Shell() { - {/* ── Help overlay ─────────────────────────────────────────────────────── */} ) { { group: "Panes", items: [ - ["h/l", "swipe pane"], + ["j/k", "switch tab (tab panel)"], + ["l/enter", "enter tab content"], + ["h", "back to tab panel"], ["1-6 / [ ]", "switch tabs"], [":", "command"], ["~", "help"], diff --git a/src/components/TabPanel.tsx b/src/components/TabPanel.tsx new file mode 100644 index 0000000..5de4246 --- /dev/null +++ b/src/components/TabPanel.tsx @@ -0,0 +1,78 @@ +/** + * TabListPane — the tab list as a pane you can drop into the UP | CURRENT | + * PREVIEW flow (replaces the old fixed chrome tab column). + * + * Renders one row per tab (digit + label): the ACTIVE tab gets a ● marker and + * accent fg; the CURSOR row (the one j/k hovers) gets the primary highlight. + * `focused` only matters to the surrounding frame (the CURRENT column draws + * its own accent ring in YaziPaneRow); when rendered as the muted UP/parent + * column (`muted`), the cursor highlight is suppressed and only the active ● + * shows, so it reads as the read-only parent listing. + */ + +import { For } from "solid-js"; +import { useTheme } from "@/context/ThemeContext"; +import { useNavigation } from "@/context/NavigationContext"; +import { TABS } from "@/utils/navigation"; + +const TAB_LABEL: Record = { + [TABS.FEED]: "Feed", + [TABS.MYSHOWS]: "My Shows", + [TABS.DISCOVER]: "Discover", + [TABS.SEARCH]: "Search", + [TABS.PLAYER]: "Player", + [TABS.SETTINGS]: "Settings", +}; + +/** Numeric TABS values, in declaration order (1..TabsCount). */ +const TAB_ORDER = Object.values(TABS).filter( + (v): v is TABS => typeof v === "number", +) as TABS[]; + +export function TabListPane(props: { muted?: boolean }) { + const { theme } = useTheme(); + const nav = useNavigation(); + + const cursor = () => nav.tabCursor(); + const active = () => nav.activeTab(); + const muted = () => props.muted ?? false; + + return ( + + {(tab) => { + const isCursor = () => cursor() === tab && !muted(); + const isActive = () => active() === tab; + const fg = () => + isCursor() + ? theme.textSelectedPrimary + : isActive() + ? theme.accent + : theme.text; + return ( + + + {isActive() ? "●" : " "} + + + {tab} + + + {TAB_LABEL[tab]} + + + ); + }} + + ); +} diff --git a/src/context/navigation-store.ts b/src/context/navigation-store.ts index 5aeba47..64d370b 100644 --- a/src/context/navigation-store.ts +++ b/src/context/navigation-store.ts @@ -18,23 +18,31 @@ * nav model — which column is focused and where its list cursor lives. The * parent/preview columns are always derived, never focused. * - * Two pane models coexist: + * Two pane models coexist under a single TAB list: + * + * • The tab list is the flow's leading pane (TAB_PANE = 0) — a normal, + * focusable pane at the left of every tab's content, just like in yazi. + * Starting focus lives here; tab switches made from here keep focus here. + * When it is focused, j/k moves the tab cursor (`tabCursor`) and + * `l`/Enter opens the hovered tab into its content. Swiping left past + * it goes out of the panes (inert — there is no pane beyond it). * * • Depth-stack tabs (Feed, MyShows, Discover, Settings) expose exactly ONE - * focusable pane — the current column (DEPTH_CENTER_PANE = 0). The parent - * column renders the previous depth's list (blank at depth 0); the preview - * column renders the hovered item. `l`/Enter drills in (push a frame); - * `h` pops a depth (a noop at depth 0). Depth is unbounded — each page - * decides per-item whether an item is drillable and what child list to - * push. Drill/pop is dispatched by the Shell, never via swipe. + * focusable content pane — the current column (DEPTH_CENTER_PANE = 1). The + * parent column renders the previous depth's list (blank at depth 0); the + * preview column renders the hovered item. `l`/Enter drills in (push a + * frame); `h` pops a depth. Depth is unbounded. At depth 0 `h` moves focus + * to the tab list (TAB_PANE). * * • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the * indexed pane model — `focusedIndex(pane)` + `swipe` — moving between the - * parent/current/preview columns with `h`/`l`, clamped to [0, paneCount-1]. + * parent/current/preview columns with `h`/`l`, clamped to + * [1, paneCount]; `h` on the first content pane (1) moves focus to the tab + * list; `h` on the tab list stays out-of-panear (inert). * - * Tabs switch only via digit keys `1`-`6`, `[`/`]`, or (later) a bottom tab - * strip. There is NO sidebar pane: `activePane` is plain tab pane state and is - * never a chrome/tab-list pane. + * Tabs switch via the tab list (j/k), digit keys `1`-`6`, and `[`/`]`. + * Focus on the tab list persists across a tab switch; from there `l`/Enter + * drops into the active tab's content (panes 1..N). */ import { createSignal, batch } from "solid-js"; import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation"; @@ -46,19 +54,28 @@ export enum NavMode { INPUT = "INPUT", } -/** The current pane. For depth-tabs this is the single focusable pane (the - * center column, index 0); for fixed-pane tabs it's the default landing pane - * on tab-enter. Every tab-enter resets `activePane` to this value. */ -export const DEPTH_CENTER_PANE = 0 as PaneId; +/** The current content pane of the active tab, i.e. the focusable column + * (index 1) for depth-tabs, and the default landing pane for fixed-pane + * tabs. Content panes occupy 1..n; the tab list is pane 0. A tab switch made + * while focused on content resets `activePane` to this pane (unless already + * on the tab list). */ +export const DEPTH_CENTER_PANE = 1 as PaneId; -/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */ +/** The tab list — the leading pane (pane 0) of the tab flow, rendered to the + * left of the active tab's content (1..n). It is the app's outermost pane: + * starting focus lives here, tab switches made from it keep focus on it, and + * swiping left past the first content pane returns to it. Swiping left again + * — beyond it — goes out of the panes (no-op). While it is focused, j/k + * moves the tab cursor and `l`/Enter opens the hovered tab's content. */ +/** Content pane slots for fixed-pane tabs (Search). Values are the global + * pane indices (content starts at 1). */ export enum PaneSlot { - PARENT = 0, // depth-tabs: center/current; Search: input - CURRENT = 1, // Search: results - PREVIEW = 2, // Search: detail + PARENT = 1, // Search: input + CURRENT = 2, // Search: results + PREVIEW = 3, // Search: detail } -export type PaneId = number; // 0-based index into the active tab's pane list +export type PaneId = number; // 0 = tab list; 1..n = the active tab's content panes // ── Depth stack ────────────────────────────────────────────────────────────── /** One frame in a tab's depth stack. `kind` identifies the list (page-defined, @@ -88,25 +105,35 @@ const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL; */ export function createNavigation() { const [activeTab, setActiveTab] = createSignal(TABS.FEED); - // App focus starts on the current pane (center, idx 0); every tab - // switch also resets here. There is no sidebar pane. - const [activePane, setActivePane] = - createSignal(DEPTH_CENTER_PANE); + // The root tab panel's cursor — which tab j/k is currently hovering. It is + // independent of `activeTab` until l/Enter activates it (activateTabCursor) + // or a direct tab switch (digits / [ ]) re-syncs it. So the panel behaves + // just like any other yazi list: j/k move the cursor, Enter/l open. + const [tabCursorSignal, setTabCursor] = createSignal(TABS.FEED); + // App focus starts on the tab list (the app root). `activePane` drives the + // fixed-pane pages (Search/Player) and each page's content focus ring; + // depth-tab focus is instead described by the per-tab depth stack plus the + // `atRootTab` flag (the tab sits as the CURRENT pane when at the root, and + // slides into the UP/parent pane once content is opened). + const [activePane, setActivePane] = createSignal(DEPTH_CENTER_PANE); + // Whether focus is on the tab-list root view — the tab is the CURRENT pane + // with nothing above it. Opening a tab moves it to UP; deeper goes back out. + const [atRootTabSignal, setAtTabRoot] = createSignal(true); const [mode, setMode] = createSignal(NavMode.NORMAL); const [count, setCount] = createSignal(null); const [inputFocused, setInputFocused] = createSignal(false); // per-tab depth stack. Depth-tabs get a root frame on first visit. - const [stacks, setStacks] = createSignal< - Partial> - >({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] }); + const [stacks, setStacks] = createSignal>>( + { [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 - >({}); + const [paneIndices, setPaneIndices] = createSignal>( + {}, + ); const [selections, setSelections] = createSignal({}); const [visualAnchor, setVisualAnchor] = createSignal<{ paneKey: string; @@ -132,13 +159,23 @@ export function createNavigation() { * no-op (server build). Routing every tab change through this helper * keeps the behavior identical under both runtimes. * - * - seed a root frame for fresh depth-tabs - * - reset focus to the current/center pane (no sidebar pane) + * - when switching to a special (fixed-pane) tab from the tab root, leave the + * root — those tabs render only their content, never the tab-list view. + * - keep focus on the tab root if it is focused (depth-tab switch), + * otherwise recenter on the active tab's current/center pane * - clear mode/command/visual/count state */ const applyTabSwitch = (tab: TABS) => { ensureStack(tab); batch(() => { - setActivePane(DEPTH_CENTER_PANE); + // A depth-tab switch from the root keeps the root; switching to a + // special (fixed-pane) tab always leaves it. Switches made from + // inside content drop into the new tab's content pane. + if (atRootTabSignal() && !DEPTH_TABS.has(tab)) { + setAtTabRoot(false); + } + if (!atRootTabSignal()) { + setActivePane(DEPTH_CENTER_PANE); + } setMode(NavMode.NORMAL); setCount(null); setCommandBuffer(""); @@ -154,8 +191,7 @@ export function createNavigation() { // (used by unit tests) createMemo is a no-op that freezes at creation, // so a plain function is the only option that stays correct in tests. const depthStack = (): DepthFrame[] => depthStackFor(activeTab()); - const currentDepth = (): number => - Math.max(0, depthStack().length - 1); + const currentDepth = (): number => Math.max(0, depthStack().length - 1); const topFrame = (): DepthFrame | undefined => depthStack()[depthStack().length - 1]; const isDepthTab = () => DEPTH_TABS.has(activeTab()); @@ -199,6 +235,9 @@ export function createNavigation() { * tab change — programmatic or key-driven — goes through one path. */ const switchTab = (tab: TABS) => { setActiveTab(tab); + // a direct tab switch re-syncs the root panel's cursor so the panel + // reflects what is actually active. + setTabCursor(tab); applyTabSwitch(tab); }; const gotoTab = (tab: TABS) => { @@ -217,16 +256,53 @@ export function createNavigation() { // ── pane focus ────────────────────────────────────────────────────────── const setPane = (pane: PaneId) => setActivePane(pane); - /** Move focus to the adjacent pane (fixed-pane tabs only). `dir` = + /** Move focus to the adjacent content pane (fixed-pane tabs only). `dir` = * -1 (left, toward parent) or +1 (right, toward preview). Clamped to - * [0, paneCount-1] — there is no sidebar pane to land on. */ + * [0, paneCount-1]. The root panel transition (from content pane 0 to + * (1..TabPaneCount) is handled by the dispatcher, not here. */ const swipe = (dir: -1 | 1, paneCount: number) => { setActivePane((p) => { - const n = Math.max(0, Math.min(paneCount - 1, p + dir)); + const n = Math.max(1, Math.min(paneCount, p + dir)); return n; }); }; + // ── tab root (the app's outermost pane) ────────────────────────────────── + /** True while focus is on the tab list as the CURRENT pane — the app root, + * with nothing above it. Only depth-tabs (Feed/MyShows/Discover/Settings) + * participate; Search & Player are special and always show their content. */ + const atRootTab = (): boolean => + atRootTabSignal() && DEPTH_TABS.has(activeTab()); + + /** Open the active tab's content: the tab slides from CURRENT into the + * UP/parent pane and focus lands on the content's current pane. */ + const enterTabContent = () => { + setAtTabRoot(false); + setActivePane(DEPTH_CENTER_PANE); + }; + + /** Move focus back to the tab list root (UP -> CURRENT), e.g. `h` popping + * out of content at depth 0. */ + const backToTabRoot = () => { + setAtTabRoot(true); + setActivePane(DEPTH_CENTER_PANE); + }; + + /** The tab the root's cursor is hovering (independent of activeTab). */ + const tabCursor = (): TABS => tabCursorSignal(); + + /** Move the root's cursor to the adjacent tab (clamped, no wrap). */ + const moveTabCursor = (dir: -1 | 1) => { + setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS); + }; + + /** Open the hovered tab (switch to it and enter its content) from the root. + * The yazi "open" of a tab row. */ + const activateTabCursor = () => { + switchTab(tabCursorSignal()); + enterTabContent(); + }; + // ── per-pane focus index ──────────────────────────────────────────────── const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; @@ -283,8 +359,7 @@ export function createNavigation() { }; // ── selection ─────────────────────────────────────────────────────────── - const selSet = (key: string): Set => - selections()[key] ?? new Set(); + const selSet = (key: string): Set => selections()[key] ?? new Set(); const toggleSelected = (id: string) => { const key = paneKey(); @@ -413,6 +488,13 @@ export function createNavigation() { setActiveTab: gotoTab, nextTab, prevTab, + // tab root (app's outermost pane) + atRootTab, + enterTabContent, + backToTabRoot, + tabCursor, + moveTabCursor, + activateTabCursor, // pane focus setActivePane: setPane, swipe, diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index b49788a..7fe9a96 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -29,6 +29,7 @@ import { import { on, off } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; import { YaziPaneRow } from "@/components/YaziPaneRow"; +import { TabListPane } from "@/components/TabPanel"; export const DiscoverPaneCount = 1; @@ -151,7 +152,7 @@ function DiscoverPage() { // Stable gate (not a ternary root swap) so the parent list // mounts/unmounts cleanly on depth change. const parentContent = () => ( - = 1}> + = 1} fallback={}> {(cat, index) => ( - {(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 ? "❯" : " "} - - {cat.name} - - - * - - - - ); - }} - - - {/* depth ≥1: results */} - = 1}> - 0} - fallback={ - - No podcasts found. :refresh - - } - > - - {(podcast, index) => { - const lf = focusedPodIdx(); + {(cat, index) => { + const lf = focusedCatIdx(); + const selected = () => cat.id === discoverStore.selectedCategory(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 1); + nav.setDepthFocus(index(), 0); + discoverStore.setSelectedCategory(cat.id); }} > - - - {index() === lf ? "❯" : " "} - - - {podcast.title} - - - - [+] - - - - - - by {podcast.author} + + {index() === lf ? "❯" : " "} + + {cat.name} + + + * @@ -260,6 +210,57 @@ function DiscoverPage() { }} + {/* 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} + + + + ); + }} + + ); @@ -316,8 +317,7 @@ function DiscoverPage() { - {pod().description?.slice(0, 400) ?? - "No description available."} + {pod().description?.slice(0, 400) ?? "No description available."} {(pod().description?.length ?? 0) > 400 ? "…" : ""} 0}> diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index a723fc8..cf872d4 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -36,6 +36,7 @@ import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { YaziPaneRow } from "@/components/YaziPaneRow"; +import { TabListPane } from "@/components/TabPanel"; export const FeedPaneCount = 1; @@ -237,7 +238,7 @@ function FeedPage() { // Wrap in a stable (the sibling-Show pattern) so the parent list // mounts/unmounts cleanly on depth change instead of swapping roots. const parentContent = () => ( - = 1}> + = 1} fallback={}> {(item, index) => { const lf = nav.depthFocus(0); @@ -268,110 +269,110 @@ function FeedPage() { 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)}) - - - ); - }} - - + 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)}) + + + ); + }} + + = 1}> {/* depth ≥1: episodes */} 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)} + 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)} + + + - - ); - }} - - - - - + ); + }} + + + + + + - ); @@ -439,12 +440,8 @@ function FeedPage() { - - {formatDate(it.episode.pubDate)} - - - {formatDuration(it.episode.duration)} - + {formatDate(it.episode.pubDate)} + {formatDuration(it.episode.duration)} {downloadLabel(it.episode.id)} diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 775e72c..b46d5c7 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -32,6 +32,7 @@ import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { YaziPaneRow } from "@/components/YaziPaneRow"; +import { TabListPane } from "@/components/TabPanel"; export const MyShowsPaneCount = 1; @@ -200,7 +201,7 @@ export function MyShowsPage() { // Stable gate (not a ternary root swap) so the parent list // mounts/unmounts cleanly on depth change. const parentContent = () => ( - = 1}> + = 1} fallback={}> {(feed, index) => { const lf = nav.depthFocus(0); @@ -231,105 +232,105 @@ export function MyShowsPage() { 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}) - - - ); - }} - - + 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)} + 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)} + + + - - ); - }} - - - - - + ); + }} + + + + + + - ); @@ -357,8 +358,7 @@ export function MyShowsPage() { {show().episodes.length} episodes - {show().podcast.description?.slice(0, 400) ?? - "No description."} + {show().podcast.description?.slice(0, 400) ?? "No description."} enter/l: open · h: back @@ -397,8 +397,7 @@ export function MyShowsPage() { - {ep().description?.slice(0, 400) ?? - "No description available."} + {ep().description?.slice(0, 400) ?? "No description available."} {(ep().description?.length ?? 0) > 400 ? "…" : ""} diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index c477590..044c3f2 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,12 +1,13 @@ /** * SearchPage — yazi-style 3-pane view. * - * pane 0 (parent) — query input with recent-search history (clickable) - * pane 1 (current) — search results list (navigate j/k) - * pane 2 (preview) — detail of the focused search result + * pane 1 (parent) — query input with recent-search history (clickable) + * pane 2 (current) — search results list (navigate j/k) + * pane 3 (preview) — detail of the focused search result * - * The Shell resets activePane to CURRENT(1) on tab enter so the user lands on - * the results pane. Swipe left (h) to pane 0 to type a query — the Shell + * (pane 0 is the app's tab list.) The Shell resets activePane to CURRENT(2) + * on tab enter so the user lands on the results pane. Swipe left (h) to pane + * 1 to type a query — the Shell * router skips keys while `nav.inputFocused()` is true so the `` * element captures typing natively. Press Enter (onSubmit) to search and * auto-swipe to the results pane. @@ -44,9 +45,9 @@ function SearchPage() { const muted = () => theme.muted || theme.text; const nav = useNavigation(); - const INPUT = PaneSlot.PARENT; // 0 - const RESULTS = PaneSlot.CURRENT; // 1 - const DETAIL = PaneSlot.PREVIEW; // 2 + const INPUT = PaneSlot.PARENT; // 1 (input row) + const RESULTS = PaneSlot.CURRENT; // 2 (results list) + const DETAIL = PaneSlot.PREVIEW; // 3 (detail preview) const results = () => searchStore.results(); diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 8e2ba32..21ec7d7 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -33,6 +33,7 @@ import { useVisualizerItems } from "./VisualizerSettings"; import { useSyncItems, closeSyncEditor } from "./SyncPanel"; import { useSourceItems } from "./SourceManager"; import { YaziPaneRow } from "@/components/YaziPaneRow"; +import { TabListPane } from "@/components/TabPanel"; export const SettingsPaneCount = 1; @@ -268,6 +269,10 @@ export function SettingsPage() { // 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) */} diff --git a/src/utils/dispatch.ts b/src/utils/dispatch.ts index 0f69b9a..d4e3907 100644 --- a/src/utils/dispatch.ts +++ b/src/utils/dispatch.ts @@ -9,23 +9,29 @@ * * The Shell builds a `DispatcherDeps` from its live hooks + the audio-side * `advanceEpisode` helper, then forwards every matched keystroke action to - * `dispatch`. Behavioural rules (task 06): + * `dispatch`. Behavioural rules (tab root + task 06): * - * • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`) are - * the SOLE tab switchers; focus always lands on `DEPTH_CENTER_PANE`. - * • `h`/`l` are `swipe-prev`/`swipe-next`: + * • The tab list is the app's ROOT. At the root (`nav.atRootTab()`) it is + * the CURRENT pane with nothing above it: + * `k`/`j` (`move-down`/`move-up`) move a cursor through the tabs + * (highlight follows `tabCursor`; the active tab is untouched), + * `l`/Enter (`swipe-next`/`open`) open the hovered tab (`activateTabCursor`) + * — the tab slides into UP and its content becomes CURRENT; `h` + * (`swipe-prev`) at the root stays (out of the panes); `1-6` / `[`/`]` + * switch tabs directly (re-syncing the cursor). + * • digit keys `1`-`6` / `tab-goto-*`, `tab-next` (`]`), `tab-prev` (`[`) + * switch tabs; focus keeps its context (root iff already at the root, + * otherwise the content `DEPTH_CENTER_PANE`). + * • `h`/`l` are `swipe-prev`/`swipe-next` in content: * - depth-tabs, current pane: `l` drills (`open` emit), `h` pops a depth - * (noop + inert at depth 0 — no error, no pane change, like yazi at root) - * - fixed-pane tabs: `swipe(∓1, count)` clamped to [0, paneCount-1] + * when depth > 0; at depth 0 `h` returns to the tab root (`backToTabRoot`), + * where the tab becomes CURRENT again. + * - fixed-pane tabs (Search/Player, special): `swipe(±1, count)` clamped to + * [1, paneCount]; `h` on the first content pane stays (no tab overflow). * • list/pane actions (`j`/`k`, `gg`/`G`, page-up/down, …) flow to - * `PAGE_ACTIONS` → `emit("nav.action")` for the current pane only. + * `PAGE_ACTIONS` → `emit("nav.action")` for the current active content pane. * • `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches - * are unchanged from the pre-rewrite Shell. - * - * There is NO sidebar pane and NO `SIDEBAR_ACTIONS` set here (removed in task - * 06): the sidebar's special-cased j/k branch is gone; every list movement - * goes straight to the active page via `nav.action`. - */ + * are unchanged from the pre-rewrite Shell. */ import type { KeybindActionName } from "@/context/KeybindContext"; import type { NavigationState, DepthFrame } from "@/context/navigation-store"; @@ -48,29 +54,30 @@ export type NavActionEvent = { /** Actions the active page is responsible for (pane/list-local). These flow * to the current pane only via `emit("nav.action", …)`. There is no * SIDEBAR_ACTIONS set — the sidebar pane was removed in the nav rework. */ -export const PAGE_ACTIONS: ReadonlySet = new Set([ - "move-down", - "move-up", - "page-down", - "page-up", - "full-down", - "full-up", - "jump-down", - "jump-up", - "goto-top", - "goto-bottom", - "toggle-select", - "visual-mode", - "toggle-all", - "invert-all", - "open", - "open-interactive", - "search", - "filter", - "sort", - "toggle-hidden", - "refresh", -]); +export const PAGE_ACTIONS: ReadonlySet = + new Set([ + "move-down", + "move-up", + "page-down", + "page-up", + "full-down", + "full-up", + "jump-down", + "jump-up", + "goto-top", + "goto-bottom", + "toggle-select", + "visual-mode", + "toggle-all", + "invert-all", + "open", + "open-interactive", + "search", + "filter", + "sort", + "toggle-hidden", + "refresh", + ]); /** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */ export function tabByDigit(action: KeybindActionName): TABS | null { @@ -100,7 +107,10 @@ export type DispatcherDeps = { export function createDispatcher(deps: DispatcherDeps) { const { nav, audio, k, setShowHelp, advanceEpisode } = deps; - function dispatch(action: KeybindActionName, evt: { preventDefault: () => void }) { + function dispatch( + action: KeybindActionName, + evt: { preventDefault: () => void }, + ) { const tab = nav.activeTab(); const pane = nav.activePane(); switch (action) { @@ -147,22 +157,44 @@ export function createDispatcher(deps: DispatcherDeps) { nav.setActiveTab(dt); break; } + // ── tab root focus ── + // At the app root the tab list is the CURRENT pane: j/k move the tab + // cursor (active tab untouched), l/Enter open the hovered tab (switch + // to it + enter its content), h stays inert (out of the panes). + if (nav.atRootTab()) { + evt.preventDefault(); + if (action === "move-down") { + nav.moveTabCursor(1); + break; + } + if (action === "move-up") { + nav.moveTabCursor(-1); + break; + } + if (action === "open") { + nav.activateTabCursor(); + break; + } + if (action === "swipe-next") { + nav.activateTabCursor(); + break; + } + if (action === "swipe-prev") break; + } // ── pane swipe / depth nav ── - // h/l swipe() on fixed-pane tabs (clamped to [0, paneCount-1] — - // there is no sidebar pane). Depth-tabs: l at the center drills - // in (emits `open`); h at the center pops a depth, noop at depth 0 - // (inert — like yazi at root: no pane change, no error). + // Depth-tabs: l at the center drills in (emits `open`); h at the + // center pops a depth; at depth 0 h returns to the tab root (the tab + // becomes CURRENT again). Fixed-pane tabs (Search/Player, special): + // h/l swipe across [1, paneCount]; h on the first pane stays. if (action === "swipe-prev") { evt.preventDefault(); - if ( - nav.isDepthTab() && - nav.activePane() === DEPTH_CENTER_PANE - ) { + if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) { if (nav.currentDepth() > 0) nav.popDepth(); - // else: noop at depth 0 (inert) - } else { - nav.swipe(-1, TabPaneCount[tab]); + else nav.backToTabRoot(); // depth 0 → tab root + } else if (nav.activePane() > DEPTH_CENTER_PANE) { + nav.swipe(-1, TabPaneCount[tab]); // content 1..n } + // fixed first content pane (1): no-op, stays (special tabs) break; } if (action === "swipe-next") { diff --git a/tasks/yazi-remake/README.md b/tasks/yazi-remake/README.md index e2cb042..de0899a 100644 --- a/tasks/yazi-remake/README.md +++ b/tasks/yazi-remake/README.md @@ -6,13 +6,13 @@ Status legend: [ ] todo, [~] in-progress, [x] done ## Tasks -- [ ] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md` -- [ ] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md` -- [ ] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md` -- [ ] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md` -- [ ] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md` -- [ ] 06 — rewire-keybinds → `06-rewire-keybinds.md` -- [ ] 07 — verify-remake → `07-verify-remake.md` +- [x] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md` +- [x] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md` +- [x] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md` +- [x] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md` +- [x] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md` +- [x] 06 — rewire-keybinds → `06-rewire-keybinds.md` +- [x] 07 — verify-remake → `07-verify-remake.md` ## Dependencies diff --git a/tests/dispatch-keybinds.test.ts b/tests/dispatch-keybinds.test.ts index 7e6f5b4..1d4c50b 100644 --- a/tests/dispatch-keybinds.test.ts +++ b/tests/dispatch-keybinds.test.ts @@ -1,27 +1,33 @@ /** - * dispatch-keybinds.test.ts — yazi remake task 06 unit + integration tests. + * dispatch-keybinds.test.ts — yazi remake task 06/07 unit + integration tests. * * Exercises the rewired unified keybind router (`createDispatcher`) directly, * without the OpenTUI render tree, mirroring how task 01 made the nav store a * plain factory for `bun test`. Covers the task 06 acceptance + integration - * cases: + * cases, plus the tab-root routing: * - * • Unit: `dispatch("move-down")` on a depth-tab current pane emits - * `nav.action { action: "move-down" }` pointing at the current pane only. - * • Integration: `swipe-next` (l) on a depth-tab at depth 0 emits `open` - * (drill); `swipe-prev` (h) at depth 1 pops to depth 0; `swipe-prev` at - * depth 0 is an inert noop (no emit, no pane/depth change, no error). - * • Acceptance: digit keys (`tab-goto-N`) switch tabs and focus lands on - * `DEPTH_CENTER_PANE`; `tab-next`/`tab-prev` cycle; `h` at depth 0 is - * inert; `j`/`k` (move-down/up) flow to `nav.action` for the current pane. + * • Tab root: j/k (`move-down`/`move-up`) move a tab cursor without touching + * the active tab; `open`/`swipe-next` (l/Enter) open the hovered tab and + * enter its content; `swipe-prev` (h) stays inert (out of the panes). + * • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill); + * `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root. + * • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount]; + * `h` on the first pane stays — never overflows to the tab root. + * • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch + * tabs and preserve focus context (root stays root for depth-tabs, content + * stays content). + * • `j`/`k` (move-down/up) in content flow to `nav.action` for the current + * pane. * - * The dispatcher is built with fake audio/k/help deps (the drills/moves under - * test never reach the audio or advanceEpisode paths) and a real nav store — - * the shared contract depth/l movement routes through. + * The dispatcher is built with fake audio/k/help deps (the paths under test + * never reach the audio or advanceEpisode branches) and a real nav store. */ import { test, expect, mock } from "bun:test"; import { createRoot } from "solid-js"; -import { createNavigation, DEPTH_CENTER_PANE } from "../src/context/navigation-store"; +import { + createNavigation, + DEPTH_CENTER_PANE, +} from "../src/context/navigation-store"; import { TABS } from "../src/utils/navigation"; import { createDispatcher, type DispatcherDeps } from "../src/utils/dispatch"; import { on } from "../src/utils/event-bus"; @@ -30,16 +36,17 @@ import type { KeybindActionName } from "../src/context/KeybindContext"; /** Build a real nav store + a dispatcher wired to fake audio/k/help deps, all * inside a reactive root (disposed after). Returns both so a test can read * nav state and call dispatch. */ -function withHarness(fn: (api: { - nav: ReturnType; - dispatch: (action: KeybindActionName) => void; - toggleHelp: () => boolean; - helpOpen: () => boolean; -}) => void) { +function withHarness( + fn: (api: { + nav: ReturnType; + dispatch: (action: KeybindActionName) => void; + toggleHelp: () => boolean; + helpOpen: () => boolean; + }) => void, +) { createRoot((dispose) => { const nav = createNavigation(); let help = false; - const toggleHelp = () => (help = !help); const deps: DispatcherDeps = { nav, audio: { @@ -57,7 +64,7 @@ function withHarness(fn: (api: { fn({ nav, dispatch: (action) => dispatch(action, evt() as any), - toggleHelp, + toggleHelp: () => (help = !help), helpOpen: () => help, }); dispose(); @@ -78,10 +85,66 @@ function captureNavActions(fn: () => void) { return captured; } -// ── Unit: move-down emits nav.action on the current pane only ───────────────── +// ── Tab root: j/k move the cursor; l/Enter open the hovered tab ────────────── +test("dispatch('move-down') on the tab root moves the cursor (no emit, active tab untouched)", () => { + withHarness(({ nav, dispatch }) => { + expect(nav.atRootTab()).toBe(true); + expect(nav.tabCursor()).toBe(TABS.FEED); + + const events = captureNavActions(() => dispatch("move-down")); + expect(events).toHaveLength(0); // j on the root moves the cursor only + expect(nav.tabCursor()).toBe(TABS.MYSHOWS); + expect(nav.activeTab()).toBe(TABS.FEED); // active tab untouched until opened + expect(nav.atRootTab()).toBe(true); + }); +}); + +test("dispatch('move-up') on the tab root moves the cursor up (clamped, no wrap)", () => { + withHarness(({ nav, dispatch }) => { + expect(nav.tabCursor()).toBe(TABS.FEED); + dispatch("move-up"); + expect(nav.tabCursor()).toBe(TABS.FEED); // clamped at the top + expect(nav.activeTab()).toBe(TABS.FEED); + expect(nav.atRootTab()).toBe(true); + }); +}); + +test("dispatch('open') on the tab root opens the hovered tab and enters its content", () => { + withHarness(({ nav, dispatch }) => { + dispatch("move-down"); // cursor -> MYSHOWS + dispatch("move-down"); // cursor -> DISCOVER + expect(nav.tabCursor()).toBe(TABS.DISCOVER); + dispatch("open"); + expect(nav.activeTab()).toBe(TABS.DISCOVER); // the hovered tab is opened + expect(nav.atRootTab()).toBe(false); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + }); +}); + +test("dispatch('swipe-next') on the tab root opens the hovered tab and enters its content (no emit)", () => { + withHarness(({ nav, dispatch }) => { + dispatch("move-down"); // cursor -> MYSHOWS + const events = captureNavActions(() => dispatch("swipe-next")); + expect(events).toHaveLength(0); + expect(nav.activeTab()).toBe(TABS.MYSHOWS); + expect(nav.atRootTab()).toBe(false); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + }); +}); + +test("dispatch('swipe-prev') on the tab root is inert (stays, no emit)", () => { + withHarness(({ nav, dispatch }) => { + const events = captureNavActions(() => dispatch("swipe-prev")); + expect(events).toHaveLength(0); + expect(nav.atRootTab()).toBe(true); + }); +}); + +// ── Unit: move-down emits nav.action on the current pane only ──────────────── test("dispatch('move-down') on a depth-tab current pane emits nav.action {action:'move-down'} on the current pane", () => { withHarness(({ nav, dispatch }) => { - nav.setActiveTab(TABS.FEED); // depth-tab → focus is the center pane + nav.setActiveTab(TABS.FEED); // depth-tab → enter content to test the list move + nav.enterTabContent(); expect(nav.isDepthTab()).toBe(true); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); @@ -96,21 +159,22 @@ test("dispatch('move-down') on a depth-tab current pane emits nav.action {action test("dispatch('move-up') emits nav.action on the current pane only (j/k never change depth)", () => { withHarness(({ nav, dispatch }) => { nav.setActiveTab(TABS.MYSHOWS); + nav.enterTabContent(); const beforeDepth = nav.currentDepth(); - const beforePane = nav.activePane(); const events = captureNavActions(() => dispatch("move-up")); expect(events).toHaveLength(1); expect(events[0].action).toBe("move-up"); - expect(events[0].pane).toBe(beforePane); + expect(events[0].pane).toBe(DEPTH_CENTER_PANE); // depth is untouched by j/k (only h/l and the page's open() touch it). expect(nav.currentDepth()).toBe(beforeDepth); }); }); -// ── Integration: l drills (open emit), h pops, h@0 noop ────────────────────── +// ── Integration: l drills (open emit), h pops, h@0 → tab root ──────────────── test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => { withHarness(({ nav, dispatch }) => { nav.setActiveTab(TABS.DISCOVER); + nav.enterTabContent(); expect(nav.isDepthTab()).toBe(true); expect(nav.currentDepth()).toBe(0); @@ -126,6 +190,7 @@ test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => { withHarness(({ nav, dispatch }) => { nav.setActiveTab(TABS.FEED); + nav.enterTabContent(); // simulate the page's open() having drilled one level. nav.pushDepth({ kind: "episodes:f1", ctx: "f1", focus: 0 }); expect(nav.currentDepth()).toBe(1); @@ -133,54 +198,105 @@ test("dispatch('swipe-prev') at depth 1 pops to depth 0", () => { const events = captureNavActions(() => dispatch("swipe-prev")); expect(events).toHaveLength(0); // a pop emits nothing — it just pops expect(nav.currentDepth()).toBe(0); + // focus stays in content (depth > 0 pop does not return to the root). + expect(nav.atRootTab()).toBe(false); }); }); -test("dispatch('swipe-prev') at depth 0 is an inert noop (no emit, no pane/depth change, no error)", () => { +test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => { withHarness(({ nav, dispatch }) => { nav.setActiveTab(TABS.SETTINGS); + nav.enterTabContent(); expect(nav.currentDepth()).toBe(0); - const paneBefore = nav.activePane(); + expect(nav.atRootTab()).toBe(false); const events = captureNavActions(() => dispatch("swipe-prev")); expect(events).toHaveLength(0); expect(nav.currentDepth()).toBe(0); - expect(nav.activePane()).toBe(paneBefore); + expect(nav.atRootTab()).toBe(true); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); }); }); -// ── Acceptance: digit keys switch tabs and land focus on the current pane ── -test("tab-goto-N switches tabs; focus always lands on DEPTH_CENTER_PANE", () => { +test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => { withHarness(({ nav, dispatch }) => { - // start on FEED (depth-tab, depth 0). - expect(nav.activeTab()).toBe(TABS.FEED); + nav.setActiveTab(TABS.SEARCH); // fixed-pane + nav.enterTabContent(); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + + dispatch("swipe-prev"); + // special tab: h on the first content pane does not return to the root. + expect(nav.atRootTab()).toBe(false); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + }); +}); + +test("dispatch('swipe-prev') on a fixed-pane tab at pane > 1 swipes leftwards", () => { + withHarness(({ nav, dispatch }) => { + nav.setActiveTab(TABS.SEARCH); // 3 panes + nav.enterTabContent(); + nav.swipe(1, 3); + nav.swipe(1, 3); + expect(nav.activePane()).toBe(3); + dispatch("swipe-prev"); + expect(nav.activePane()).toBe(2); + }); +}); + +// ── Acceptance: digit keys switch tabs and keep focus context ──────────────── +test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open", () => { + withHarness(({ nav, dispatch }) => { + // focus starts on the tab root. + expect(nav.atRootTab()).toBe(true); + + dispatch("tab-goto-3"); // → Discover + expect(nav.activeTab()).toBe(TABS.DISCOVER); + expect(nav.tabCursor()).toBe(TABS.DISCOVER); // cursor re-synced + expect(nav.atRootTab()).toBe(true); + + dispatch("tab-goto-2"); // → MyShows + expect(nav.activeTab()).toBe(TABS.MYSHOWS); + expect(nav.tabCursor()).toBe(TABS.MYSHOWS); + expect(nav.atRootTab()).toBe(true); + + // fixed-pane tab is special: switching from the root opens its content. + dispatch("tab-goto-4"); // → Search + expect(nav.activeTab()).toBe(TABS.SEARCH); + expect(nav.tabCursor()).toBe(TABS.SEARCH); + expect(nav.atRootTab()).toBe(false); + }); +}); + +test("tab-goto-N from content keeps focus in the active tab's content", () => { + withHarness(({ nav, dispatch }) => { + nav.enterTabContent(); + expect(nav.atRootTab()).toBe(false); dispatch("tab-goto-3"); // → Discover expect(nav.activeTab()).toBe(TABS.DISCOVER); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); - dispatch("tab-goto-2"); // → MyShows - expect(nav.activeTab()).toBe(TABS.MYSHOWS); - expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); - - // fixed-pane tab also lands on pane 0. - dispatch("tab-goto-4"); // → Search + dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane expect(nav.activeTab()).toBe(TABS.SEARCH); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.atRootTab()).toBe(false); }); }); -test("tab-next (]) / tab-prev ([) cycle tabs and reset focus to the current pane", () => { +test("tab-next (]) / tab-prev ([) cycle tabs and keep focus context", () => { withHarness(({ nav, dispatch }) => { - nav.setActiveTab(TABS.FEED); + expect(nav.activeTab()).toBe(TABS.FEED); + expect(nav.atRootTab()).toBe(true); + dispatch("tab-next"); expect(nav.activeTab()).toBe(TABS.MYSHOWS); - expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.tabCursor()).toBe(TABS.MYSHOWS); + expect(nav.atRootTab()).toBe(true); dispatch("tab-prev"); expect(nav.activeTab()).toBe(TABS.FEED); - expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.tabCursor()).toBe(TABS.FEED); + expect(nav.atRootTab()).toBe(true); }); }); diff --git a/tests/nav-model.test.ts b/tests/nav-model.test.ts index 1dad726..6a63940 100644 --- a/tests/nav-model.test.ts +++ b/tests/nav-model.test.ts @@ -1,12 +1,20 @@ /** - * nav-model.test.ts — yazi remake task 01 unit/integration tests. + * nav-model.test.ts — yazi remake task 01/07 unit/integration tests. * - * Covers the removal of the SIDEBAR_PANE concept: + * Covers the tab-list-as-root navigation model: * • createNavigation() exposes the nav factory directly (no Solid render * needed), wrapped in a createRoot so effects register/dispose. + * • focus starts on the tab list — the app root. `atRootTab()` is true while + * the tab list is the CURRENT pane (nothing above it). `enterTabContent()` + * slides the tab into UP and puts focus on the content; `backToTabRoot()` + * returns to the root. Only depth-tabs participate (`atRootTab()` is false + * for the fixed-pane Search/Player tabs). + * • the root tab list is a normal list: `tabCursor` is independent of + * `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens + * the hovered tab + enters content, and direct tab switches re-sync it. * • depth-tab focusedIndex depth-current read/writes the top frame's focus. - * • the tab-switch effect resets activePane to DEPTH_CENTER_PANE (0), not -1. - * • swipe() clamps to [0, paneCount-1] (no -1 sidebar slot). + * • swipe() is clamped to [1, paneCount] (content panes only; there is no + * pane-0 tab slot — the tab root is a flag, not a pane). */ import { test, expect } from "bun:test"; import { createRoot } from "solid-js"; @@ -27,17 +35,18 @@ function withNav(fn: (nav: ReturnType) => void) { }); } -test("createNavigation initial activePane is DEPTH_CENTER_PANE (0), not -1", () => { +test("createNavigation starts on the tab root (atRootTab true)", () => { withNav((nav) => { + expect(nav.atRootTab()).toBe(true); + expect(nav.activeTab()).toBe(TABS.FEED); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); - expect(nav.activePane()).toBe(0); }); }); // ── depth-tab focus: reads/writes the top frame's focus ─────────────────────── test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () => { withNav((nav) => { - // FEED is a depth-tab; its root frame is { kind: "feeds", focus: 0 }. + // FeeD is a depth-tab; its root frame is a the top frame on the stack. nav.setActiveTab(TABS.FEED); expect(nav.isDepthTab()).toBe(true); expect(nav.focusedIndex(DEPTH_CENTER_PANE)).toBe(0); @@ -52,6 +61,7 @@ test("depth-tab focusedIndex(DEPTH_CENTER_PANE) returns top frame's focus", () = test("setFocusedIndex on a 2-frame stack writes only the top frame", () => { withNav((nav) => { nav.setActiveTab(TABS.FEED); + nav.enterTabContent(); // root frame focus 3, then push a child frame whose focus is 5. nav.setFocusedIndex(DEPTH_CENTER_PANE, 3); nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 5 }); @@ -79,19 +89,120 @@ test("popDepth at depth 0 is a noop (returns false, no frame lost)", () => { }); }); -// ── tab-switch resets focus to DEPTH_CENTER_PANE, not a sidebar ────────────── -test("tab-switch effect resets activePane to DEPTH_CENTER_PANE", () => { +// ── tab switching keeps focus context ──────────────────────────────────────── +test("tab switch keeps focus context: at the root it stays at the root", () => { withNav((nav) => { - // start on a depth-tab, land on the current pane. + // focus starts on the tab root. + expect(nav.atRootTab()).toBe(true); + // switching depth-tabs from the root must not drop focus into content. + nav.setActiveTab(TABS.FEED); + expect(nav.atRootTab()).toBe(true); + expect(nav.activeTab()).toBe(TABS.FEED); + nav.setActiveTab(TABS.MYSHOWS); + expect(nav.atRootTab()).toBe(true); + }); +}); + +test("tab switch keeps focus context: in content it stays in content", () => { + withNav((nav) => { + nav.enterTabContent(); + expect(nav.atRootTab()).toBe(false); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); - // move pane focus away (swipe is a noop for depth-tabs count=1, so - // instead prove the effect resets on tab change). - nav.setActiveTab(TABS.SEARCH); - expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); - // switch to another tab; the effect must reset to 0, never -1. + // switching tabs from content keeps the content context. nav.setActiveTab(TABS.SETTINGS); expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); - expect(nav.activePane()).toBeGreaterThanOrEqual(0); + expect(nav.atRootTab()).toBe(false); + }); +}); + +test("switching to a Search/Player tab leaves the root (special content)", () => { + withNav((nav) => { + // at root, opening Search is special: atRootTab() reports false because + // Search has its own content and never renders the tab-list root view. + nav.setActiveTab(TABS.SEARCH); + expect(nav.atRootTab()).toBe(false); + nav.enterTabContent(); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.atRootTab()).toBe(false); + }); +}); + +// ── tab root <-> content transitions ───────────────────────────────────────── +test("enterTabContent/backToTabRoot round-trip between root and content", () => { + withNav((nav) => { + nav.setActiveTab(TABS.FEED); + expect(nav.atRootTab()).toBe(true); + nav.enterTabContent(); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.atRootTab()).toBe(false); + nav.backToTabRoot(); + expect(nav.atRootTab()).toBe(true); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + }); +}); + +test("enterTabContent preserves the active tab's depth", () => { + withNav((nav) => { + nav.setActiveTab(TABS.FEED); + nav.pushDepth({ kind: "episodes:feedId", ctx: "f1", focus: 0 }); + expect(nav.currentDepth()).toBe(1); + // moving between the root and content never touches the depth stack. + nav.backToTabRoot(); + nav.enterTabContent(); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + expect(nav.currentDepth()).toBe(1); + expect(nav.popDepth()).toBe(true); + expect(nav.currentDepth()).toBe(0); + }); +}); + +test("tabCursor starts on the active tab", () => { + withNav((nav) => { + expect(nav.tabCursor()).toBe(TABS.FEED); + expect(nav.activeTab()).toBe(TABS.FEED); + }); +}); + +test("moveTabCursor moves the cursor without changing the active tab, clamped at the ends", () => { + withNav((nav) => { + nav.setActiveTab(TABS.MYSHOWS); // cursor syncs to the active tab + expect(nav.tabCursor()).toBe(TABS.MYSHOWS); + nav.moveTabCursor(1); + expect(nav.tabCursor()).toBe(TABS.DISCOVER); + expect(nav.activeTab()).toBe(TABS.MYSHOWS); // active tab untouched + nav.moveTabCursor(-1); + expect(nav.tabCursor()).toBe(TABS.MYSHOWS); + // clamp: from FEED, up stays FEED; from SETTINGS, down stays SETTINGS. + nav.moveTabCursor(-1); // MYSHOWS -> FEED + nav.moveTabCursor(-1); // FEED -> FEED (clamped) + expect(nav.tabCursor()).toBe(TABS.FEED); + nav.setActiveTab(TABS.SETTINGS); + nav.moveTabCursor(1); // SETTINGS -> SETTINGS (clamped) + expect(nav.tabCursor()).toBe(TABS.SETTINGS); + expect(nav.activeTab()).toBe(TABS.SETTINGS); + }); +}); + +test("activateTabCursor switches to the hovered tab and enters its content", () => { + withNav((nav) => { + nav.moveTabCursor(1); // cursor -> MYSHOWS + nav.moveTabCursor(1); // cursor -> DISCOVER + expect(nav.tabCursor()).toBe(TABS.DISCOVER); + expect(nav.activeTab()).toBe(TABS.FEED); + nav.activateTabCursor(); + expect(nav.activeTab()).toBe(TABS.DISCOVER); // hovered tab opened + expect(nav.tabCursor()).toBe(TABS.DISCOVER); + expect(nav.atRootTab()).toBe(false); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + }); +}); + +test("direct tab switches re-sync the tab cursor", () => { + withNav((nav) => { + nav.moveTabCursor(1); // cursor -> MYSHOWS + expect(nav.activeTab()).toBe(TABS.FEED); + nav.setActiveTab(TABS.SETTINGS); + expect(nav.tabCursor()).toBe(TABS.SETTINGS); }); }); @@ -105,37 +216,44 @@ test("tab-switch resets mode/visual/command state", () => { }); }); -// ── swipe clamps to [0, paneCount-1] (no sidebar slot) ──────────────────────── -test("swipe(-1, 3) on a fixed-pane tab clamps to 0, not -1", () => { +// ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ────────────────────── +test("swipe on a fixed-pane tab stays within [1, paneCount]", () => { withNav((nav) => { nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3 expect(TabPaneCount[TABS.SEARCH]).toBe(3); - // tab-switch effect lands us on pane 0 (DEPTH_CENTER_PANE). - expect(nav.activePane()).toBe(0); - nav.swipe(-1, TabPaneCount[TABS.SEARCH]); - expect(nav.activePane()).toBe(0); // lower bound, never -1 - // swipe right twice then back: clamps to [0, 2]. - nav.swipe(1, 3); - nav.swipe(1, 3); - expect(nav.activePane()).toBe(2); // upper bound - nav.swipe(1, 3); - expect(nav.activePane()).toBe(2); // never exceeds count-1 - nav.swipe(-1, 3); + nav.enterTabContent(); + expect(nav.activePane()).toBe(1); + // swipe left stays at 1 (no pane 0). + nav.swipe(-1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(1); + nav.swipe(-1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(1); + // swipe right up through the columns, then hold the upper bound. + nav.swipe(1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(2); + nav.swipe(1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(3); + nav.swipe(1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(3); // never exceeds paneCount + nav.swipe(-1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(2); + nav.swipe(-1, TabPaneCount[TABS.SEARCH]); + expect(nav.activePane()).toBe(1); + nav.swipe(-1, TabPaneCount[TABS.SEARCH]); expect(nav.activePane()).toBe(1); - nav.swipe(-1, 3); - expect(nav.activePane()).toBe(0); }); }); -test("swipe on a single-pane fixed tab stays at 0", () => { +test("swipe on a single-pane fixed tab stays at its one content pane", () => { withNav((nav) => { nav.setActiveTab(TABS.PLAYER); // single-pane expect(TabPaneCount[TABS.PLAYER]).toBe(1); - expect(nav.activePane()).toBe(0); + nav.enterTabContent(); // lands on its one content pane (1) + expect(nav.activePane()).toBe(1); nav.swipe(1, TabPaneCount[TABS.PLAYER]); - expect(nav.activePane()).toBe(0); + expect(nav.activePane()).toBe(1); // upper bound nav.swipe(-1, TabPaneCount[TABS.PLAYER]); - expect(nav.activePane()).toBe(0); + expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 0 }); });