diff --git a/scripts/tui-harness.tsx b/scripts/tui-harness.tsx index 1594757..9a04e61 100644 --- a/scripts/tui-harness.tsx +++ b/scripts/tui-harness.tsx @@ -26,6 +26,8 @@ * bun scripts/tui-harness.tsx type "" * bun scripts/tui-harness.tsx wait * bun scripts/tui-harness.tsx resize + * bun scripts/tui-harness.tsx mouse + * bun scripts/tui-harness.tsx mdrag # border/seek drag * bun scripts/tui-harness.tsx frame # re-render, no new action * bun scripts/tui-harness.tsx state [all|nav|audio|feed|app] * bun scripts/tui-harness.tsx actions # print action log @@ -79,7 +81,9 @@ type Action = | { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] } | { t: "type"; s: string } | { t: "wait"; ms: number } - | { t: "resize"; w: number; h: number }; + | { t: "resize"; w: number; h: number } + | { t: "mouse"; kind: "down" | "up" | "click"; x: number; y: number } + | { t: "mdrag"; x1: number; y1: number; x2: number; y2: number }; function loadActions(): Action[] { try { @@ -258,12 +262,32 @@ const BUILDERS: Record Action> = { if (!p[0]) throw new Error("wait requires "); return { t: "wait", ms: parseInt(p[0], 10) || 0 }; }, + resize: (p) => { if (!p[0] || !p[1]) throw new Error("resize requires "); return { t: "resize", - w: parseInt(p[0], 10) || 100, - h: parseInt(p[1], 10) || 30, + w: parseInt(p[0], 10) || 0, + h: parseInt(p[1], 10) || 0, + }; + }, + mouse: (p) => { + if (!p[0] || !p[1] || !p[2]) throw new Error("mouse requires "); + return { + t: "mouse", + kind: p[2] as "down" | "up" | "click", + x: parseInt(p[0], 10), + y: parseInt(p[1], 10), + }; + }, + mdrag: (p) => { + if (p.length < 4) throw new Error("mdrag requires "); + return { + t: "mdrag", + x1: parseInt(p[0], 10), + y1: parseInt(p[1], 10), + x2: parseInt(p[2], 10), + y2: parseInt(p[3], 10), }; }, }; @@ -324,8 +348,13 @@ async function execAction(setup: any, a: Action): Promise { case "wait": await new Promise((r) => setTimeout(r, a.ms)); break; - case "resize": - setup.resize(a.w, a.h); + case "mouse": + if (a.kind === "click") await setup.mockMouse.click(a.x, a.y); + else if (a.kind === "down") await setup.mockMouse.pressDown(a.x, a.y); + else await setup.mockMouse.release(a.x, a.y); + break; + case "mdrag": + await setup.mockMouse.drag(a.x1, a.y1, a.x2, a.y2); break; } await setup.renderOnce(); diff --git a/src/components/EpisodeList.tsx b/src/components/EpisodeList.tsx index 9434477..4c28b43 100644 --- a/src/components/EpisodeList.tsx +++ b/src/components/EpisodeList.tsx @@ -16,6 +16,7 @@ import { Show } from "solid-js"; import { format } from "date-fns"; import type { RGBA } from "@opentui/core"; +import { useTerminalDimensions } from "@opentui/solid"; import { useTheme } from "@/context/ThemeContext"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { NF_ICONS } from "@/utils/nerd-fonts"; @@ -186,6 +187,7 @@ export function EpisodePreview(props: { }) { const { theme } = useTheme(); const muted = () => theme.muted || theme.text; + const dims = useTerminalDimensions(); return ( @@ -208,10 +210,14 @@ export function EpisodePreview(props: { by {props.author()} - - {props.episode().description?.slice(0, 400) ?? "No description available."} - {(props.episode().description?.length ?? 0) > 400 ? "…" : ""} - + No description available.} + > + + {props.episode().description} + + {props.hint()} @@ -221,7 +227,6 @@ export function EpisodePreview(props: { // ── FetchMorePreview ──────────────────────────────────────────────────────── export function FetchMorePreview(props: { isLoadingMore: () => boolean; - fetchMoreMode: () => string; /** Manual-mode explanation line ("across all feeds" vs "for this show"). */ manualText: () => string; }) { @@ -235,9 +240,7 @@ export function FetchMorePreview(props: { {props.isLoadingMore() ? "Loading the next batch of episodes…" - : props.fetchMoreMode() === "auto" - ? "Auto mode: the next batch loads automatically at the bottom of the list." - : props.manualText()} + : props.manualText()} enter: load more · h back diff --git a/src/components/PaneRow.tsx b/src/components/PaneRow.tsx index ef1b49f..506249a 100644 --- a/src/components/PaneRow.tsx +++ b/src/components/PaneRow.tsx @@ -1,28 +1,25 @@ /** * PaneRow — the shared parent | current | preview 3-pane layout primitive. * - * Implements yazi's `mgr.ratio` contract: three columns grow at - * 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`, - * so every list tab renders an identical, layout-stable shell. Columns use - * `flexBasis={0}` so the ratio is exact regardless of content width — a - * column's content can never stretch its slot. + * Implements yazi's resizable `mgr.ratio` contract: the two borders of the + * CENTER (current) column are draggable and resize the neighboring panes. + * Split positions live in the shared pane-layout store (`@/stores/pane-layout`) + * as fractions of the row width; this component resolves them to pixel + * columns, gives each column an explicit width (so the drag strips sit + * exactly on the drawn borders), and renders two invisible grab handles over + * the border cells. * * Column semantics (per the yazi depth model): * parent — the previous-depth list. Renders a muted `—` placeholder and - * KEEPS its 20% slot when blank (never collapses to width 0). - * Borderless (no left/right/top/bottom edge). Carries the single - * header row: the CURRENT column's title renders top-left in the - * parent's slot (the panes above current/preview were removed). - * current — the current-depth list. The only focusable content column; it - * is the ONLY bordered column — left/right edges only, always - * muted (no active-border highlight, focused or not). - * preview — detail of the hovered item in `current`. Borderless, no header. + * keeps a minimum 15-col slot. Borderless. + * current — the current-depth list. The only focusable content column; the + * ONLY bordered column — left/right edges only, always muted. + * preview — detail of the hovered item in `current`. Borderless. * * The primitive is purely structural: callers pass their own JSX per column * (static elements or accessors) plus the current-column title. Theme colors * are resolved internally via `useTheme()`. Only the current column's - * `` receives `focused`, so scroll focus follows the cursor (j/k - * stay in the current pane). + * `` receives `focused`, so scroll focus follows the cursor. * * Example: * */ -import { createMemo, Show } from "solid-js"; +import { createMemo, createSignal, Show } from "solid-js"; import type { JSX } from "solid-js"; +import { useTerminalDimensions } from "@opentui/solid"; import type { RGBA, BorderSides } from "@opentui/core"; import { useTheme } from "@/context/ThemeContext"; -import { PANE_RATIO } from "@/utils/navigation"; +import { + MIN_PANE_WIDTH, + splitPixels, + usePaneLayout, +} from "@/stores/pane-layout"; // ── Types ─────────────────────────────────────────────────────────────────── type PaneContent = JSX.Element | (() => JSX.Element); @@ -46,7 +48,7 @@ type PaneLabel = string | (() => string); export type PaneRowProps = { /** Parent column content (previous-depth list, or null for a muted - * placeholder — the 1/5 slot is always preserved). */ + * placeholder — a minimum slot is always preserved). */ parent?: PaneContent; /** Current column content (the focused list). */ current?: PaneContent; @@ -99,7 +101,7 @@ function Placeholder(props: { color: () => RGBA }) { // ── Pane column ───────────────────────────────────────────────────────────── function Pane(props: { - grow: number; + width: number; label: () => string; content: () => JSX.Element | undefined; border: boolean | BorderSides[]; @@ -116,8 +118,8 @@ function Pane(props: { return ( {/* ── title row: rendered only when the pane carries a label ────────── */} @@ -158,6 +160,45 @@ function Pane(props: { ); } +/** A 1-column invisible grab handle covering exactly one border of the + * current pane. `onBegin` is called on mousedown; subsequent drag/drag-end + * events bubble up the row and drive `usePaneLayout` there. On hover or + * while dragging it overdraws the border with a full-height accent `│` + * line (a bordered box would render as a blocky rectangle instead). */ +function Splitter(props: { + left: number; + active: boolean; + onBegin: () => void; +}) { + const { theme } = useTheme(); + const dims = useTerminalDimensions(); + const [hovered, setHovered] = createSignal(false); + const highlighted = () => props.active || hovered(); + return ( + { + e.preventDefault?.(); + props.onBegin(); + }} + onMouseOver={() => setHovered(true)} + onMouseOut={() => setHovered(false)} + > + + {/* Draw the accent edge down the full pane height; the box clips + * any excess rows below the row's bottom edge. */} + + {"│\n".repeat(dims().height)} + + + + ); +} + // ── Row primitive ─────────────────────────────────────────────────────────── export function PaneRow(props: PaneRowProps) { /** true → the current column's scrollbox is focused (scroll follows cursor). */ @@ -179,20 +220,60 @@ export function PaneRow(props: PaneRowProps) { // 2-pane mode (parent|current) grows the current column to fill the // preview slot. Defaults to 3 (parent|current|preview). const panes = createMemo(() => props.panes ?? 3); - const currentGrow = createMemo(() => - panes() === 2 - ? PANE_RATIO.current + PANE_RATIO.preview - : PANE_RATIO.current, - ); const currentBorder = createMemo( () => props.currentBorder ?? ["left", "right"], ); + // Shared split state + terminal width drive explicit column widths so the + // drag strips sit exactly on the drawn borders. + const layout = usePaneLayout(); + const dims = useTerminalDimensions(); + const width = () => dims().width; + const pixels = createMemo(() => splitPixels(width(), layout.splits())); + const hasRoom = () => + width() >= + MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview; + + // Column widths in pixels (sum to the row width). + const parentWidth = () => pixels().leftPx; + const currentWidth = () => + panes() === 2 + ? width() - pixels().leftPx + : pixels().rightPx - pixels().leftPx; + const previewWidth = () => width() - pixels().rightPx; + + // ── Drag state ────────────────────────────────────────────────────────── + // onMouseDown on a Splitter records which border is being dragged; the + // row then lives-updates the split from the absolute drag x (bubbled up + // from whatever renderable the cursor captures) and commits on release. + const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>( + null, + ); + const beginDrag = (which: "left" | "right") => () => setActiveSplit(which); + const handleDrag = (e: { x: number }) => { + const which = activeSplit(); + if (!which) return; + if (which === "left") layout.setLeft(e.x, width()); + else layout.setRight(e.x, width()); + }; + const handleDragEnd = () => { + if (activeSplit()) layout.commit(); + setActiveSplit(null); + }; + return ( - - {/* ── parent (20%) — previous-depth list; title row top-left ────────── */} + + {/* ── parent — previous-depth list; title row top-left ─────────────── */} {/* ── current — the focused list; left/right borders only ─────────── */} ""} content={currentContent} border={currentBorder()} scrollFocused={() => focused()} /> - {/* ── preview (30%) — hovered-item detail; no border, no header ────── */} + {/* ── preview (optional) — hovered-item detail; no border ─────────── */} ""} content={previewContent} border={false} scrollFocused={() => false} /> + {/* ── drag handles over the current pane's borders ───────────────── */} + + + + + + ); } diff --git a/src/context/navigation-store.ts b/src/context/navigation-store.ts index 3313159..1652a5a 100644 --- a/src/context/navigation-store.ts +++ b/src/context/navigation-store.ts @@ -13,10 +13,10 @@ * * parent | current | preview * - * Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in - * `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable* - * nav model — which column is focused and where its list cursor lives. The - * parent/preview columns are always derived, never focused. + * Pane sizes are user-resizable (draggable borders in `PaneRow`). + * This module owns only the *focusable* nav model — which column is focused + * and where its list cursor lives. The parent/preview columns are always + * derived, never focused. * * The tab list is the app's ROOT and participates in the same pane flow as * any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`: diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 3687281..93f9744 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -19,7 +19,6 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; -import { useAppStore } from "@/stores/app"; import { prefetchCoverArt } from "@/utils/cover-art"; import { DownloadStatus } from "@/types/episode"; import { useTheme } from "@/context/ThemeContext"; @@ -86,10 +85,7 @@ function FeedPage() { // ── Fetch More ─────────────────────────────────────────────────────────── // A "[Fetch More]" row at the bottom of the list advances every feed's - // loaded window by 50 episodes. manual mode: Enter on the row. auto mode: - // reaching the bottom row fetches automatically (see the effect below). - const app = useAppStore(); - const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto"; + // loaded window by 50 episodes. Enter on the row to load the next batch. const showFetchMore = () => feedStore.hasMoreAcrossAll(); const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const focus = () => nav.depthFocus(0); @@ -137,16 +133,6 @@ function FeedPage() { }; onMount(ensureFocus); - // Auto mode: reaching the bottom row loads the next batch. Guarded by - // isLoadingMore so concurrent loads never stack. - createEffect(() => { - if (fetchMoreMode() !== "auto") return; - if (!showFetchMore()) return; - if (feedStore.isLoadingMore()) return; - if (focusedRow() < rowCount() - 1) return; - feedStore.loadMoreAllFeeds().catch(() => {}); - }); - onMount(() => { nav.registerResolver( `${nav.activeTab()}:${DEPTH_CENTER_PANE}`, @@ -337,7 +323,6 @@ function FeedPage() { feedStore.isLoadingMore()} - fetchMoreMode={fetchMoreMode} manualText={() => "Load the next batch of older episodes across all feeds (Enter)." } diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 2e2a24c..472614c 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -6,15 +6,16 @@ * depth 1 (current) — episodes of the drilled show. Parent pane = shows. * preview — detail of the hovered item in the current column. * - * Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior - * as the Feed tab) that loads the next batch of episodes for that show. + * Depth 0's shows list and depth 1's episode list both end with a + * "[Fetch More]" row that loads the next batch of episodes — depth 0 for + * every subscribed show, depth 1 for the drilled show. * * Renders entirely through ``; no bespoke 3-column flexbox JSX * remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at * 0). j/k move only within the current column. */ -import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; +import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import type { RGBA } from "@opentui/core"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; @@ -269,20 +270,35 @@ export function MyShowsPage() { // entry drops out the moment the user subscribes to its show. const unsubs = () => downloadStore.getUnsubscribedDownloads(); - const depth0Count = () => shows().length + unsubs().length; - + /** True while some subscribed show has more episodes to load — shows the + * depth-0 "[Fetch More]" row. */ + const showLoadMore = () => feedStore.hasMoreAcrossAll(); + const depth0Count = () => + shows().length + unsubs().length + (showLoadMore() ? 1 : 0); + const focusedRow0 = () => + depth0Count() === 0 ? 0 : Math.min(focus(0), depth0Count() - 1); + /** True while the depth-0 cursor sits on the "[Fetch More]" row. */ + const focusedOnMore0 = () => + showLoadMore() && focusedRow0() === shows().length + unsubs().length; const focusedShowIdx = () => - shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); + focusedOnMore0() + ? -1 + : Math.min(focusedRow0(), Math.max(shows().length - 1, 0)); /** True when the depth-0 cursor sits on an unsubscribed-show download * row (past the shows list). */ const focusedOnUnsub = () => - depth() === 0 && focus(0) >= shows().length && unsubs().length > 0; + !focusedOnMore0() && + depth() === 0 && + focusedRow0() >= shows().length && + unsubs().length > 0; const focusedUnsub = (): DownloadedEpisode | undefined => { if (!focusedOnUnsub()) return undefined; - return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)]; + return unsubs()[ + Math.min(focusedRow0() - shows().length, unsubs().length - 1) + ]; }; const selectedShow = (): Feed | undefined => { - if (focusedOnUnsub()) return undefined; + if (focusedOnUnsub() || focusedOnMore0()) return undefined; return shows()[focusedShowIdx()]; }; @@ -300,10 +316,7 @@ export function MyShowsPage() { // ── Fetch More ─────────────────────────────────────────────────────────── // A "[Fetch More]" row at the bottom of a drilled show's episode list // advances that show's loaded window by 50 episodes — the per-show - // counterpart to the Feed page's row (which loads every feed). manual - // mode: Enter on the row. auto mode: reaching the bottom row fetches - // automatically (see the effect below). - const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto"; + // counterpart to the Feed page's row (which loads every feed). const showFetchMore = () => depth() >= 1 && !!drilledShowId() && @@ -366,17 +379,6 @@ export function MyShowsPage() { }); }); - // Auto mode: reaching the bottom of a drilled show's list loads its next - // batch. Guarded by isLoadingMore so concurrent loads never stack. - createEffect(() => { - if (depth() < 1) return; - if (fetchMoreMode() !== "auto") return; - if (!showFetchMore()) return; - if (feedStore.isLoadingMore()) return; - if (focusedRow() < rowCount() - 1) return; - feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {}); - }); - // ── helpers ───────────────────────────────────────────────────────────────── const downloadLabel = (id: string) => { switch (downloadStore.getDownloadStatus(id)) { @@ -431,6 +433,10 @@ export function MyShowsPage() { // ── drill / open ─────────────────────────────────────────────────────────── function open() { if (depth() === 0) { + if (focusedOnMore0()) { + feedStore.loadMoreAllFeeds().catch(() => {}); + return; + } const d = focusedUnsub(); if (d) { playUnsubscribedDownload(d); @@ -639,7 +645,7 @@ export function MyShowsPage() { shows().length + index()} - focused={() => nav.depthFocus(0)} + focused={focusedRow0} active={isActive} marker={marker} downloadLabel={() => downloadLabel(d.episodeId)} @@ -652,8 +658,23 @@ export function MyShowsPage() { )} + + shows().length + unsubs().length} + focused={focusedRow0} + onMore={focusedOnMore0} + active={isActive} + isLoadingMore={() => feedStore.isLoadingMore()} + nerd={nerd} + marker={marker} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(shows().length + unsubs().length, 0); + }} + /> + {/* depth ≥1: episodes */} = 1}> depth() === 0 ? ( - // depth 0 preview: hovered unsubscribed-show download, else the - // hovered show. - - No show focused - - } - > - {(show) => ( - show()} - title={() => showTitle(show())} - hint={() => showHint(show())} - /> - )} - - } - > - {(d) => ( - d()} - downloadLabel={() => downloadLabel(d().episodeId)} - downloadColor={() => downloadColor(d().episodeId)} - /> - )} + // depth 0 preview: hovered "[Fetch More]" row, else the + // unsubscribed-show download, else the hovered show. + <> + + feedStore.isLoadingMore()} + manualText={() => + "Load the next batch of older episodes across all subscribed shows (Enter)." + } + /> - ) : ( + + + No show focused + + } + > + {(show) => ( + show()} + title={() => showTitle(show())} + hint={() => showHint(show())} + /> + )} + + } + > + {(d) => ( + d()} + downloadLabel={() => downloadLabel(d().episodeId)} + downloadColor={() => downloadColor(d().episodeId)} + /> + )} + + + + ) : ( // depth ≥1 preview: hovered episode (or the Fetch More row) <> feedStore.isLoadingMore()} - fetchMoreMode={fetchMoreMode} - manualText={() => + isLoadingMore={() => feedStore.isLoadingMore()} + manualText={() => "Load the next batch of older episodes for this show (Enter)." } /> diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index 6bf166b..9c35b33 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -19,6 +19,7 @@ import { useVisualizer } from "@/stores/visualizer"; import { useAppStore } from "@/stores/app"; import { useTheme } from "@/context/ThemeContext"; import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext"; +import { useTerminalDimensions } from "@opentui/solid"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; @@ -29,6 +30,7 @@ export function PlayerPage() { const { theme } = useTheme(); const nav = useNavigation(); const viz = useVisualizer(); + const dims = useTerminalDimensions(); const app = useAppStore(); const muted = () => theme.muted || theme.text; // Settings master switch: off hides the waveform entirely (the store @@ -89,9 +91,14 @@ export function PlayerPage() { {ep().title} - - {ep().description?.slice(0, 500) ?? "No description available."} - + No description available.} + > + + {ep().description} + + diff --git a/src/pages/Player/ProgressBar.tsx b/src/pages/Player/ProgressBar.tsx index 55e8341..71c0201 100644 --- a/src/pages/Player/ProgressBar.tsx +++ b/src/pages/Player/ProgressBar.tsx @@ -10,6 +10,7 @@ import { useTerminalDimensions } from "@opentui/solid"; import type { Renderable } from "@opentui/core"; import { useAudio } from "@/hooks/useAudio"; import { useTheme } from "@/context/ThemeContext"; +import { usePaneLayout } from "@/stores/pane-layout"; // ── Component ──────────────────────────────────────────────────────── @@ -17,16 +18,19 @@ export function ProgressBar() { const audio = useAudio(); const { theme } = useTheme(); const dimensions = useTerminalDimensions(); + const layout = usePaneLayout(); // The bar's renderable, captured for its absolute left edge: MouseEvent.x // is terminal-absolute (not bar-relative), so local x needs the offset - // of the bar inside the 2-pane row (parent pane ≈ 20% of the width). + // of the bar inside the 2-pane row (parent pane = left split of the width). let bar: Renderable | undefined; // Full content width of the player pane: the player is a 2-pane row - // (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars + // (parent = left split, current = the rest of the terminal width). Track + // the live split so a dragged border re-sizes the bar. Subtract ~8 chars // of border/padding chrome (same math as RealtimeWaveform's numBars). - const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8); + const width = () => + Math.max(8, Math.floor(dimensions().width * (1 - layout.splits().left)) - 8); const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); diff --git a/src/pages/Player/RealtimeWaveform.tsx b/src/pages/Player/RealtimeWaveform.tsx index 3f30344..d9e8e3a 100644 --- a/src/pages/Player/RealtimeWaveform.tsx +++ b/src/pages/Player/RealtimeWaveform.tsx @@ -19,7 +19,7 @@ import { useVisualizer } from "@/stores/visualizer"; import { useTheme } from "@/context/ThemeContext"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { BAR_LEVELS, barChars } from "@/utils/bar-mapping"; -import { PANE_RATIO } from "@/utils/navigation"; +import { usePaneLayout } from "@/stores/pane-layout"; // ── Component ──────────────────────────────────────────────────────── @@ -27,20 +27,16 @@ export function RealtimeWaveform() { const { theme } = useTheme(); const viz = useVisualizer(); - // Bar count scales with terminal width so the waveform fills its pane. - // The player is a 2-pane row: current column = (current+preview) of - // (parent+current+preview) of the terminal width. Subtract ~8 chars of - // chrome (scrollbox border + box padding + waveform border + padding). - // Falls back to 64 before the renderer reports a real size. const dimensions = useTerminalDimensions(); + const layout = usePaneLayout(); const numBars = () => { - const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview; - const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current const width = dimensions().width; if (!width) return 64; + // The player is a 2-pane row: the current column = whole width minus + // the parent (left split). Subtract ~8 chars of chrome. return Math.max( 8, - Math.min(256, Math.floor((width * current) / total) - 8), + Math.min(256, Math.floor(width * (1 - layout.splits().left)) - 8), ); }; diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index 0af7ea8..57f70cb 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -288,20 +288,6 @@ export function usePreferencesItems(): SettingItem[] { /> ), }, - { - id: "fetchMore", - label: "Fetch More", - kind: "select", - display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"), - help: () => - `How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`, - cycle: (dir) => { - const modes: Array<"manual" | "auto"> = ["manual", "auto"]; - const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto"); - const next = modes[(idx + dir + modes.length) % modes.length]; - app.updatePreferences({ fetchMoreMode: next }); - }, - }, { id: "refreshInterval", label: "Feed Refresh Interval", diff --git a/src/stores/app.ts b/src/stores/app.ts index 0cf7426..96be66b 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -43,11 +43,11 @@ const defaultPreferences: UserPreferences = { autoDownloadScope: "all", autoDownloadWhitelist: [], autoJumpToPlayer: true, - fetchMoreMode: "auto", refreshIntervalMinutes: 30, episodeCacheMode: "date", episodeCacheCount: 25, episodeCacheDays: 60, + paneSplit: { left: 0.2, right: 0.7 }, }; const defaultState: AppState = { diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 57ce46e..c757252 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -39,6 +39,11 @@ const MAX_EPISODES_REFRESH = 50; /** Max episodes to fetch on initial subscribe */ const MAX_EPISODES_SUBSCRIBE = 20; +/** Floor on the visible episode window for a subscribed show: at least this + * many most-recent episodes always load, regardless of a stricter count or + * date cache bound. Overridden by episodeKeepFn. */ +const MIN_EPISODES_PER_SHOW = 5; + /** Fetch-more step in date mode: each press reveals the next two weeks of * episodes past the oldest loaded one, instead of a fixed episode count. */ const FETCH_MORE_WINDOW_DAYS = 14; @@ -123,7 +128,10 @@ const fullEpisodeCache = new Map(); const episodeLoadCount = new Map(); /** Read the episode cache bound from preferences: a closure that decides - * whether the episode at `index` (0 = newest, after sort) is kept. */ + * whether the episode at `index` (0 = newest, after sort) is kept. The five + * most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW), + * overriding a stricter count or date bound so every show surfaces at least + * five episodes. */ function episodeKeepFn(prefs: { episodeCacheMode: "date" | "count"; episodeCacheCount: number; @@ -132,10 +140,12 @@ function episodeKeepFn(prefs: { const now = new Date(); if (prefs.episodeCacheMode === "count") { const count = Math.max(1, prefs.episodeCacheCount); - return (_ep: Episode, index: number) => index < count; + return (_ep: Episode, index: number) => + index < Math.max(count, MIN_EPISODES_PER_SHOW); } const days = Math.max(1, prefs.episodeCacheDays); - return (ep: Episode) => episodeInWindow(ep, now, days); + return (ep: Episode, index: number) => + index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, now, days); } /** Timestamp for window math — undated episodes sort/compare as NEWEST diff --git a/src/stores/pane-layout.ts b/src/stores/pane-layout.ts new file mode 100644 index 0000000..4bd7069 --- /dev/null +++ b/src/stores/pane-layout.ts @@ -0,0 +1,144 @@ +/** + * pane-layout — global split positions for the yazi-style pane rows. + * + * Every tab renders its parent|current|preview columns through `PaneRow` + * sharing one layout: the two borders of the CENTER (current) column are + * draggable, and their positions are stored here as fractions of the row + * width (so a terminal resize re-derives pixel positions proportionally). + * + * parent (15+) | current (30+) | preview (15+) + * ── left ───────── right ──────── <-- draggable borders + * + * Defaults mirror the old fixed 2:5:3 ratio (20% / 50% / 30%). Minimum + * pane widths are enforced in `splitPixels` whenever a border is dragged + * or the row is re-derived. The positions persist to the app preferences + * on `commit()` (drag end) — never per drag event, so drags don't thrash + * config.json. + */ + +import type { PaneSplits } from "@/types/settings"; +import { createSignal } from "solid-js"; +import { useAppStore } from "./app"; + +// ── Types ─────────────────────────────────────────────────────────────── + +/** Public surface of the shared pane-layout store. */ +export interface PaneLayoutStore { + /** Current split positions (fractions of the row width). */ + splits(): PaneSplits; + /** Move the left border of the current pane to column `x`. */ + setLeft(x: number, width: number): void; + /** Move the right border of the current pane to column `x`. */ + setRight(x: number, width: number): void; + /** Persist the current splits (called on drag end, not per drag event). */ + commit(): void; +} + +// ── Constants ─────────────────────────────────────────────────────────── + +/** Default split — the historical 2:5:3 ratio (parent 20 / current 50 / preview 30). */ +export const DEFAULT_PANE_SPLITS: PaneSplits = { left: 0.2, right: 0.7 }; + +/** Per-pane minimum widths (columns) enforced while a border is being + * dragged. Static rendering maps stored fractions 1:1 to pixels — the + * minimums never distort the user's chosen layout on narrow terminals. */ +export const MIN_PANE_WIDTH = { + parent: 15, + current: 30, + preview: 15, +} as const; + +/** Clamp `v` into [`lo`, `hi`] (bounds may invert on degenerate widths). */ +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** Minimum row width that can hold all three panes at their drag minimums. */ +const MIN_TOTAL_WIDTH = + MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview; + +/** Resolve stored splits into concrete pixel columns for a row `width`. + * A pure 1:1 fraction→pixel mapping (keeping the ratio exact for every + * terminal size); min-width enforcement lives in the drag setters only. */ +export function splitPixels( + width: number, + splits: PaneSplits, +): { leftPx: number; rightPx: number } { + if (width <= 0) return { leftPx: 0, rightPx: 0 }; + const leftPx = Math.round(width * splits.left); + const rightPx = Math.max(leftPx + 1, Math.round(width * splits.right)); + return { leftPx, rightPx }; +} + +export function createPaneLayoutStore(): PaneLayoutStore { + const app = useAppStore(); + + // Seeded from persisted preferences; `saved` is always defined because + // the app store backfills the default when the config predates it. + const [splits, setSplits] = createSignal( + app.state().preferences.paneSplit ?? DEFAULT_PANE_SPLITS, + ); + + /** Store the normalized pixel positions as fractions of `width`. */ + const applyPixels = (leftPx: number, rightPx: number, width: number) => { + if (width <= 0) return; + setSplits({ left: leftPx / width, right: rightPx / width }); + }; + + /** Clamp a dragged border to the per-pane minimums (only on terminals + * wide enough to hold them; smaller rows just follow the cursor). */ + const dragPixels = ( + leftPx: number, + rightPx: number, + width: number, + ): { leftPx: number; rightPx: number } => { + if (width < MIN_TOTAL_WIDTH) + return { + leftPx: clamp(leftPx, 1, width - 2), + rightPx: Math.max(rightPx, leftPx + 1), + }; + const minLeft = MIN_PANE_WIDTH.parent; + const maxLeft = width - MIN_PANE_WIDTH.current - MIN_PANE_WIDTH.preview; + const maxRight = width - MIN_PANE_WIDTH.preview; + const newLeft = clamp(leftPx, minLeft, maxLeft); + // The dragged border follows the cursor; the other border is pushed + // only as far as needed to keep the current pane at its minimum. + return { + leftPx: newLeft, + rightPx: clamp(rightPx, newLeft + MIN_PANE_WIDTH.current, maxRight), + }; + }; + + /** Move the left border to column `x` (the current pane's left edge). */ + const setLeft = (x: number, width: number) => { + if (width <= 0) return; + const { rightPx: curRight } = splitPixels(width, splits()); + const { leftPx, rightPx } = dragPixels(Math.round(x), curRight, width); + applyPixels(leftPx, rightPx, width); + }; + + /** Move the right border to column `x` (the current pane's right edge). */ + const setRight = (x: number, width: number) => { + if (width <= 0) return; + const { leftPx: curLeft, rightPx: curRight } = splitPixels(width, splits()); + const { leftPx, rightPx } = dragPixels(curLeft, Math.round(x), width); + applyPixels(leftPx, rightPx, width); + }; + + /** Persist the current splits (called on drag end, not per drag event). */ + const commit = () => { + app.updatePreferences({ paneSplit: splits() }); + }; + + return { splits, setLeft, setRight, commit }; +} + +// ── Singleton ─────────────────────────────────────────────────────────── + +let paneLayoutInstance: PaneLayoutStore | null = null; + +/** Accessor for the shared pane-layout store (all tabs share one split). */ +export function usePaneLayout(): PaneLayoutStore { + if (!paneLayoutInstance) paneLayoutInstance = createPaneLayoutStore(); + return paneLayoutInstance; +} diff --git a/src/types/settings.ts b/src/types/settings.ts index 9745378..4aba1f3 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -90,9 +90,6 @@ export type AppSettings = { visualizer: VisualizerSettings; }; -/** How the Feed and per-show episode lists load older episodes (default: auto). */ -export type FetchMoreMode = "manual" | "auto"; - /** Which shows the auto-download setting applies to (default: all). */ export type AutoDownloadScope = "all" | "none" | "whitelist"; @@ -101,6 +98,15 @@ export type AutoDownloadScope = "all" | "none" | "whitelist"; * episodes (default: date). */ export type EpisodeCacheMode = "date" | "count"; +/** Left/right edges of the current (center) pane as fractions of the row + * width, shared by the draggable pane borders in every depth tab. */ +export type PaneSplits = { + /** Left edge of the current pane (default 0.2 = 20% of the row). */ + left: number; + /** Right edge of the current pane (default 0.7 = 70% of the row). */ + right: number; +}; + export type UserPreferences = { showExplicit: boolean; autoDownload: boolean; @@ -112,8 +118,6 @@ export type UserPreferences = { autoDownloadWhitelist: string[]; /** Jump to the Player view automatically when playback starts (default: true) */ autoJumpToPlayer: boolean; - /** Load older episodes from the Feed list: manual button or automatic at the bottom (default: auto). */ - fetchMoreMode: FetchMoreMode; /** Minutes between automatic background feed refreshes (default: 30). */ refreshIntervalMinutes: number; /** How the episode list cache is bounded — by date or by count (default: date). */ @@ -122,6 +126,8 @@ export type UserPreferences = { episodeCacheCount: number; /** Rolling window in days for the episode list when mode is "date" (default: 60). */ episodeCacheDays: number; + /** Pane split positions as fractions of the row width (default 0.2 / 0.7). */ + paneSplit: PaneSplits; }; export type AppState = { diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 10eed17..70e73ea 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -47,11 +47,11 @@ const defaultPreferences: UserPreferences = { autoDownloadScope: "all", autoDownloadWhitelist: [], autoJumpToPlayer: true, - fetchMoreMode: "auto", refreshIntervalMinutes: 30, episodeCacheMode: "date", episodeCacheCount: 25, episodeCacheDays: 60, + paneSplit: { left: 0.2, right: 0.7 }, }; const defaultState: AppState = { diff --git a/src/utils/layer-graph.ts b/src/utils/layer-graph.ts index 18cd258..7050c1d 100644 --- a/src/utils/layer-graph.ts +++ b/src/utils/layer-graph.ts @@ -1,11 +1,11 @@ /** * layer-graph — maps each TAB id to its page component + pane count. * - * Split out of `navigation.ts` so that the nav-model primitives (TABS, - * TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount, PANE_RATIO) in - * `navigation.ts` stay free of any `.tsx` / JSX imports. This lets unit tests - * import the pure navigation store without pulling the OpenTUI JSX runtime - * (which is only provided by the build-time @opentui/solid bun-plugin). + * Split out of `navigation.ts` so that the navigation primitives (TABS, + * TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount) stay free of any + * `.tsx` / JSX imports. This lets unit tests import the pure navigation + * store without pulling the OpenTUI JSX runtime (which is only provided by + * the build-time @opentui/solid bun-plugin). * * The page modules live alongside their pages and export `PaneCount` * constants describing how many focusable panes each fixed page owns. diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index e501066..dbc631d 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -49,22 +49,10 @@ export function rootFrameFor( } } -// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`, -// split out so this module stays free of `.tsx`/JSX imports (unit-testable). - -// Yazi-style pane grow ratios (parent : current : preview). Panes use -// flexGrow (Yoga) so columns always sum to the row width regardless of -// terminal size — more robust than fixed percentages and exactly mirrors -// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs). -// -// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30% -// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and -// give `current` the combined 8/10 (80%). -export const PANE_RATIO = { - parent: 2, - current: 5, - preview: 3, -} as const; +// Pane sizes are now user-resizable: the split positions (fractions of the +// row width) live in the shared pane-layout store (`@/stores/pane-layout`), +// which `PaneRow` consumes. `PANE_RATIO` was removed — see DEFAULT_PANE_SPLITS +// (0.2 / 0.7) for the historical 2:5:3 start. // Number of *focusable* content panes per tab. The three visible columns // (parent | current | preview) are a *render* concern, NOT three panes — for diff --git a/tests/feed-volatile-merge.test.ts b/tests/feed-volatile-merge.test.ts index f5e5c35..248b5c7 100644 --- a/tests/feed-volatile-merge.test.ts +++ b/tests/feed-volatile-merge.test.ts @@ -329,7 +329,7 @@ test("date mode: episodes outside the 60-day window never enter the list", async expect(store.getFeed(id)!.episodes.length).toBe(600); }); -test("date mode boundary: 25 days in, 70 days out", async () => { +test("date mode: the 5 newest episodes load even outside the date window", async () => { const store = useFeedStore(); const now = Date.now(); servedEpisodes = [ @@ -344,13 +344,15 @@ test("date mode boundary: 25 days in, 70 days out", async () => { expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ "In Window", + "Out Window", ]); - // The 70d episode is ~45 days past the 2-week band beyond the oldest - // loaded episode (25d → 39d band): a sparse show must NOT drag it in. + // The 70d episode is the second-newest available, so the min-5 floor + // pulls it in despite the 60-day cache window. expect(store.hasMoreEpisodes(id)).toBe(false); await store.loadMoreEpisodes(id); expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ "In Window", + "Out Window", ]); }); @@ -369,17 +371,19 @@ test("date mode: a dormant show (nothing in the window or next band) never fetch const id = feed!.id; addedFeedIds.push(id); - expect(store.getFeed(id)!.episodes.length).toBe(0); + // The min-5 floor surfaces the show's only 2 episodes; it still cannot + // fetch-more (nothing further exists to load). + expect(store.getFeed(id)!.episodes.length).toBe(2); expect(store.hasMoreEpisodes(id)).toBe(false); await store.loadMoreEpisodes(id); - expect(store.getFeed(id)!.episodes.length).toBe(0); + expect(store.getFeed(id)!.episodes.length).toBe(2); }); test("date mode: episodes just outside the window load via the band anchored at the window edge", async () => { const store = useFeedStore(); const now = Date.now(); - // Both episodes are outside the 60-day window (61d / 65d) but inside the - // 14-day band past its edge (60d → 74d) — fetch-more reveals them. + // Both episodes are outside the 60-day window (61d / 65d); the min-5 + // floor loads them at subscribe time regardless. servedEpisodes = [ { title: "Just Out A", date: new Date(now - 61 * DAY).toISOString() }, { title: "Just Out B", date: new Date(now - 65 * DAY).toISOString() }, @@ -390,8 +394,9 @@ test("date mode: episodes just outside the window load via the band anchored at const id = feed!.id; addedFeedIds.push(id); - expect(store.getFeed(id)!.episodes.length).toBe(0); - expect(store.hasMoreEpisodes(id)).toBe(true); + // The min-5 floor loads both out-of-window episodes immediately. + expect(store.getFeed(id)!.episodes.length).toBe(2); + expect(store.hasMoreEpisodes(id)).toBe(false); await store.loadMoreEpisodes(id); expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ "Just Out A", diff --git a/tests/pane-layout-store.test.ts b/tests/pane-layout-store.test.ts new file mode 100644 index 0000000..f9faa2b --- /dev/null +++ b/tests/pane-layout-store.test.ts @@ -0,0 +1,101 @@ +/** + * pane-layout-store.test.ts — the shared pane-split store: splitPixels + * clamping, border moves that respect per-pane minimum widths, and + * commit() persistence into the app preferences. + */ +import { test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Point the config dir at a throwaway directory BEFORE importing the store +// (its module-level init reads it). +process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-panecfg-")); + +// The config dir must be set before the module is evaluated, so the app +// store is loaded dynamically here rather than statically at the top. +const { splitPixels, createPaneLayoutStore, DEFAULT_PANE_SPLITS } = await import( + "../src/stores/pane-layout" +); +const { useAppStore } = await import("../src/stores/app"); + +beforeAll(async () => { + await useAppStore().whenReady(); +}); +afterAll(() => { + // Restore pristine preferences so a later file sharing this process + // (bun test reuses the module registry) renders the default split. + useAppStore().updatePreferences({ paneSplit: DEFAULT_PANE_SPLITS }); +}); + +test("default splits mirror the historical 2:5:3 ratio at width 100", () => { + expect(DEFAULT_PANE_SPLITS).toEqual({ left: 0.2, right: 0.7 }); + const { leftPx, rightPx } = splitPixels(100, DEFAULT_PANE_SPLITS); + expect(leftPx).toBe(20); + expect(rightPx).toBe(70); +}); + +test("splitPixels maps splits 1:1 to pixels (ratio exact at every width)", () => { + // Pure fraction→pixel mapping — no minimum enforcement in rendering. + expect(splitPixels(100, { left: 0.6, right: 0.7 })).toEqual({ + leftPx: 60, + rightPx: 70, + }); + expect(splitPixels(70, { left: 0.2, right: 0.7 })).toEqual({ + leftPx: 14, + rightPx: 49, + }); +}); + +test("splitPixels handles zero-width and degenerate terminals", () => { + expect(splitPixels(0, DEFAULT_PANE_SPLITS)).toEqual({ leftPx: 0, rightPx: 0 }); + const tiny = splitPixels(40, { left: 0.2, right: 0.7 }); + expect(tiny.leftPx).toBe(8); + expect(tiny.rightPx).toBe(28); +}); + +test("setRight clamps the preview to its minimum", () => { + const store = createPaneLayoutStore(); + store.setRight(97, 100); // preview can't shrink below 15 + expect(store.splits().right).toBeCloseTo(0.85, 5); + // The parent minimum also holds when the left border is dragged. + store.setLeft(1, 100); + expect(store.splits().left).toBeCloseTo(0.15, 5); +}); + +test("setLeft moves the border and normalizes stored fractions", () => { + const store = createPaneLayoutStore(); + store.setLeft(40, 100); + expect(store.splits().left).toBeCloseTo(0.4, 5); + expect(store.splits().right).toBeCloseTo(0.7, 5); +}); + +test("setLeft below the parent minimum clamps up", () => { + const store = createPaneLayoutStore(); + store.setLeft(5, 100); + expect(store.splits().left).toBeCloseTo(0.15, 5); +}); + +test("setLeft beyond the current minimum forces the right border right", () => { + const store = createPaneLayoutStore(); + store.setLeft(60, 100); + expect(store.splits().left).toBeCloseTo(0.55, 5); + expect(store.splits().right).toBeCloseTo(0.85, 5); +}); + +test("setRight respects the current and preview minimums", () => { + const store = createPaneLayoutStore(); + store.setRight(80, 100); + expect(store.splits().right).toBeCloseTo(0.8, 5); + store.setRight(40, 100); // must not cross below leftPx(20) + minCurrent(30) + expect(store.splits().right).toBeCloseTo(0.5, 5); +}); + +test("commit persists the split into the app preferences", async () => { + const store = createPaneLayoutStore(); + store.setLeft(35, 100); + store.commit(); + await useAppStore().whenReady(); + expect(useAppStore().state().preferences.paneSplit.left).toBeCloseTo(0.35, 5); + expect(useAppStore().state().preferences.paneSplit.right).toBeCloseTo(0.7, 5); +}); diff --git a/tests/pane-resize.test.tsx b/tests/pane-resize.test.tsx new file mode 100644 index 0000000..77676d9 --- /dev/null +++ b/tests/pane-resize.test.tsx @@ -0,0 +1,162 @@ +/** + * pane-resize.test.tsx — dragging the center column's borders actually + * resizes the panes in a rendered PaneRow. + * + * Each pane renders a long run of a unique character (P / C / V). A line + * where all three meet encodes the boundary columns directly: the current + * pane carries the only borders (cols `leftPx` and `rightPx - 1`), so its + * content starts one column in — `leftPx + 1`. Hence + * `leftPx = firstC - 1`, `rightPx = firstV`. + * + * The drag strips overlay the border cells (left strip at [left, left+2), + * right strip at [right-2, right)). The test presses inside a strip and + * drags across the row — the drag bubbles to the row container which moves + * the split, so the panes must re-render at the new columns. + */ +import { test, expect, afterAll } from "bun:test"; +import { testRender } from "@opentui/solid"; +import { ThemeProvider } from "../src/context/ThemeContext"; +import { PaneRow } from "../src/components/PaneRow"; +import { usePaneLayout } from "../src/stores/pane-layout"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Point the config dir at a throwaway directory BEFORE importing the store +// (module-level init reads it). +const configHome = mkdtempSync(join(tmpdir(), "podtui-paneresize-")); +process.env.XDG_CONFIG_HOME = configHome; + +type Span = { text: string }; +type Frame = { cols: number; lines: { spans: Span[] }[] }; + +interface BoundCols { + left: number; + right: number; +} + +function readBounds(frame: Frame): BoundCols { + const line = frame.lines + .map((l) => l.spans.map((s) => s.text).join("")) + .find((l) => l.includes("C")); + if (!line) throw new Error("pane row did not render"); + return { left: line.indexOf("C") - 1, right: line.indexOf("V") }; +} + +async function renderRow(panes: 2 | 3 = 3) { + const setup = (await testRender( + () => ( + + {"P".repeat(300)}} + current={{"C".repeat(600)}} + preview={{"V".repeat(300)}} + currentLabel="" + panes={panes} + /> + + ), + { width: 100, height: 10, useThread: false }, + )) as unknown as { + renderOnce: () => Promise; + captureSpans: () => Frame; + mockMouse: { + drag: (a: number, b: number, c: number, d: number) => Promise; + click: (a: number, b: number) => Promise; + }; + renderer: { destroy: () => void }; + }; + for (let i = 0; i < 10; i++) await setup.renderOnce(); + return setup; +} + +/** Reset the shared store to the default split for a deterministic start. */ +function resetSplits() { + usePaneLayout().setLeft(20, 100); + usePaneLayout().setRight(70, 100); +} + +const cleanups: (() => void)[] = []; +afterAll(() => { + for (const c of cleanups) c(); + // Restore the default split so a later file sharing this process (bun + // test reuses the module registry) renders the default layout. + usePaneLayout().setLeft(20, 100); + usePaneLayout().setRight(70, 100); + rmSync(configHome, { recursive: true, force: true }); +}); + +test("panes render at the default 20/70 split", async () => { + const setup = await renderRow(3); + cleanups.push(() => setup.renderer.destroy()); + resetSplits(); + await setup.renderOnce(); + const { left, right } = readBounds(setup.captureSpans()); + expect(left).toBe(20); + expect(right).toBe(70); +}); + +test("dragging the left border resizes parent vs current", async () => { + const setup = await renderRow(3); + cleanups.push(() => setup.renderer.destroy()); + resetSplits(); + await setup.renderOnce(); + + // Press on the left strip (border at 20 → strip covers 20) and drag + // toward the middle of the row. + await setup.mockMouse.drag(20, 5, 45, 5); + for (let i = 0; i < 10; i++) await setup.renderOnce(); + const after = readBounds(setup.captureSpans()); + expect(after.left).toBeGreaterThanOrEqual(44); + expect(after.left).toBeLessThanOrEqual(46); + // Pushing the left border to 45 would shrink the current pane below its + // 30-col minimum (45..70 = 25), so the right border is forced right to + // 75, keeping the current pane at exactly 30 and absorbing the overflow + // in the preview. + expect(after.right).toBe(75); +}); + +test("dragging the right border resizes current vs preview", async () => { + const setup = await renderRow(3); + cleanups.push(() => setup.renderer.destroy()); + resetSplits(); + await setup.renderOnce(); + + // Press on the right strip (border at 69 → strip covers 69) and drag + // toward the right edge of the row. + await setup.mockMouse.drag(69, 5, 90, 5); + for (let i = 0; i < 10; i++) await setup.renderOnce(); + const after = readBounds(setup.captureSpans()); + expect(after.right).toBeGreaterThanOrEqual(84); + expect(after.right).toBeLessThanOrEqual(85); // clamped at preview min 15 + expect(after.left).toBe(20); +}); + +test("a plain click away from the borders does not resize", async () => { + const setup = await renderRow(3); + cleanups.push(() => setup.renderer.destroy()); + resetSplits(); + await setup.renderOnce(); + + await setup.mockMouse.click(5, 5); + await setup.renderOnce(); + const { left, right } = readBounds(setup.captureSpans()); + expect(left).toBe(20); + expect(right).toBe(70); +}); + +test("2-pane rows offer no right border (current fills the row)", async () => { + const setup = await renderRow(2); + cleanups.push(() => setup.renderer.destroy()); + resetSplits(); + for (let i = 0; i < 10; i++) await setup.renderOnce(); + + // No 'V' pane: the line runs to the screen edge. + const frame = setup.captureSpans(); + const { left } = readBounds(frame); + expect(left).toBe(20); + const line = frame.lines + .map((l) => l.spans.map((s) => s.text).join("")) + .find((l) => l.includes("C")); + expect(line?.includes("V")).toBe(false); +}); diff --git a/tests/scratch-hover.test.tsx b/tests/scratch-hover.test.tsx new file mode 100644 index 0000000..d3c0a5b --- /dev/null +++ b/tests/scratch-hover.test.tsx @@ -0,0 +1,59 @@ +/** Scratch — verify full-height hover accent line. */ +import { test, expect } from "bun:test"; +import { testRender } from "@opentui/solid"; +import { ThemeProvider } from "../src/context/ThemeContext"; +import { PaneRow } from "../src/components/PaneRow"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-phover3-")); + +type Span = { text: string; fg?: { r: number; g: number; b: number; a: number } | null }; +type Frame = { cols: number; lines: { spans: Span[] }[] }; + +test("hover renders accent line on every row", async () => { + const setup = (await testRender( + () => ( + + {"P".repeat(300)}} + current={{"C".repeat(600)}} + preview={{"V".repeat(300)}} + currentLabel="" + /> + + ), + { width: 100, height: 10, useThread: false }, + )) as unknown as { + renderOnce: () => Promise; + captureSpans: () => Frame; + captureCharFrame: () => string; + mockMouse: { moveTo: (x: number, y: number) => Promise }; + renderer: { destroy: () => void }; + }; + for (let i = 0; i < 10; i++) await setup.renderOnce(); + await setup.mockMouse.moveTo(20, 5); + for (let i = 0; i < 3; i++) await setup.renderOnce(); + + const frame = setup.captureSpans(); + let accentRows = 0; + for (let y = 0; y < frame.lines.length; y++) { + const line = frame.lines[y].spans.map((s) => s.text).join(""); + // Border column = 20. + const ch = line[20]; + const isAccent = frame.lines[y].spans + .filter((s) => s.text.length > 0) + .some((s) => { + const t = s.text; + let c = 0; + // recompute col: approximate by scanning previous spans + return false; + }); + if (ch === "│") accentRows++; + console.log(`row ${y}: ${JSON.stringify(line.slice(16, 25))} ch20=${JSON.stringify(ch)}`); + } + console.log("accentRows:", accentRows, "of", frame.lines.length); + expect(accentRows).toBeGreaterThanOrEqual(6); + setup.renderer.destroy(); +});