fix border resizing ui

This commit is contained in:
2026-08-28 19:45:14 -04:00
parent 4ff8aabb51
commit 74158d75d4
21 changed files with 806 additions and 193 deletions

View File

@@ -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 (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
@@ -208,10 +210,14 @@ export function EpisodePreview(props: {
<text fg={muted()}>by {props.author()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{props.episode().description?.slice(0, 400) ?? "No description available."}
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show
when={props.episode().description}
fallback={<text fg={theme.textSecondary}>No description available.</text>}
>
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={theme.textSecondary}>{props.episode().description}</text>
</scrollbox>
</Show>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
@@ -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: {
<text fg={muted()}>
{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()}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>

View File

@@ -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
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
* stay in the current pane).
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor.
*
* Example:
* <PaneRow
@@ -34,11 +31,16 @@
* />
*/
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 (
<box
flexDirection="column"
flexGrow={props.grow}
flexBasis={0}
width={props.width}
flexShrink={0}
height="100%"
>
{/* ── 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 (
<box
position="absolute"
left={props.left}
top={0}
width={1}
height="100%"
onMouseDown={(e) => {
e.preventDefault?.();
props.onBegin();
}}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<Show when={highlighted()}>
{/* Draw the accent edge down the full pane height; the box clips
* any excess rows below the row's bottom edge. */}
<text fg={theme.primary} selectable={false}>
{"│\n".repeat(dims().height)}
</text>
</Show>
</box>
);
}
// ── 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<boolean | BorderSides[]>(
() => 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 (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
<box
flexDirection="row"
width="100%"
height="100%"
flexGrow={1}
onMouseDrag={handleDrag}
onMouseDragEnd={handleDragEnd}
onMouseUp={handleDragEnd}
>
{/* ── parent — previous-depth list; title row top-left ─────────────── */}
<Pane
grow={PANE_RATIO.parent}
width={parentWidth()}
label={currentLabel}
content={parentContent}
border={false}
@@ -200,22 +281,37 @@ export function PaneRow(props: PaneRowProps) {
/>
{/* ── current — the focused list; left/right borders only ─────────── */}
<Pane
grow={currentGrow()}
width={currentWidth()}
label={() => ""}
content={currentContent}
border={currentBorder()}
scrollFocused={() => focused()}
/>
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
{/* ── preview (optional) — hovered-item detail; no border ─────────── */}
<Show when={panes() === 3}>
<Pane
grow={PANE_RATIO.preview}
width={previewWidth()}
label={() => ""}
content={previewContent}
border={false}
scrollFocused={() => false}
/>
</Show>
{/* ── drag handles over the current pane's borders ───────────────── */}
<Show when={hasRoom()}>
<Splitter
left={pixels().leftPx}
active={activeSplit() === "left"}
onBegin={beginDrag("left")}
/>
<Show when={panes() === 3}>
<Splitter
left={pixels().rightPx - 1}
active={activeSplit() === "right"}
onBegin={beginDrag("right")}
/>
</Show>
</Show>
</box>
);
}

View File

@@ -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`:

View File

@@ -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() {
<Show when={focusedOnMore()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes across all feeds (Enter)."
}

View File

@@ -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 `<PaneRow>`; 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() {
<UnsubscribedRow
d={d}
index={() => shows().length + index()}
focused={() => nav.depthFocus(0)}
focused={focusedRow0}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
@@ -652,8 +658,23 @@ export function MyShowsPage() {
)}
</For>
</Show>
<Show when={showLoadMore()}>
<FetchMoreRow
index={() => 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);
}}
/>
</Show>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
@@ -738,45 +759,56 @@ export function MyShowsPage() {
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered unsubscribed-show download, else the
// hovered show.
<Show
when={focusedUnsub()}
fallback={
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<UnsubscribedPreview
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.
<>
<Show when={focusedOnMore0()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
manualText={() =>
"Load the next batch of older episodes across all subscribed shows (Enter)."
}
/>
</Show>
) : (
<Show when={!focusedOnMore0()}>
<Show
when={focusedUnsub()}
fallback={
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show>
</Show>
</>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>
<Show when={focusedOnMore()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
isLoadingMore={() => feedStore.isLoadingMore()}
manualText={() =>
"Load the next batch of older episodes for this show (Enter)."
}
/>

View File

@@ -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() {
<text fg={theme.text}>
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<Show
when={ep().description}
fallback={<text fg={muted()}>No description available.</text>}
>
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={muted()}>{ep().description}</text>
</scrollbox>
</Show>
<ProgressBar />

View File

@@ -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));

View File

@@ -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),
);
};

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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<string, Episode[]>();
const episodeLoadCount = new Map<string, number>();
/** 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

144
src/stores/pane-layout.ts Normal file
View File

@@ -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<PaneSplits>(
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;
}

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 `<count>PaneCount`
* constants describing how many focusable panes each fixed page owns.

View File

@@ -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