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();
+});