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

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