fix: moving focus as episodes load, loading indicator
This commit is contained in:
@@ -20,6 +20,7 @@ import { useTerminalDimensions } from "@opentui/solid";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { NF_ICONS } from "@/utils/nerd-fonts";
|
||||
import { useHeldFlag } from "@/hooks/useHeldFlag";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import type { Episode } from "@/types/episode";
|
||||
|
||||
@@ -138,6 +139,10 @@ export function FetchMoreRow(props: {
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// Hold the spinner past the raw load signal: a warm-cache load can
|
||||
// begin and end between two renderer frames, and without the hold the
|
||||
// [Fetch More] → spinner swap paints zero frames.
|
||||
const loading = useHeldFlag(props.isLoadingMore);
|
||||
const ref = useScrollIntoView(props.onMore);
|
||||
const bg = () =>
|
||||
props.index() === props.focused() && props.active()
|
||||
@@ -165,7 +170,7 @@ export function FetchMoreRow(props: {
|
||||
<text fg={fg()}>{NF_ICONS.more}</text>
|
||||
)}
|
||||
<Show
|
||||
when={!props.isLoadingMore()}
|
||||
when={!loading()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
>
|
||||
<text fg={fg()}>[Fetch More]</text>
|
||||
@@ -232,13 +237,16 @@ export function FetchMorePreview(props: {
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
// Same minimum-spinning window as FetchMoreRow: keep the "Loading…"
|
||||
// line up across the (sub-frame) cached load burst.
|
||||
const loading = useHeldFlag(props.isLoadingMore);
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>[Fetch More]</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{props.isLoadingMore()
|
||||
{loading()
|
||||
? "Loading the next batch of episodes…"
|
||||
: props.manualText()}
|
||||
</text>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSearchStore } from "@/stores/search";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useActivityStore } from "@/stores/activity";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { useHeldFlag } from "@/hooks/useHeldFlag";
|
||||
|
||||
/**
|
||||
* GlobalActivityIndicator — one global top-right signal that ANY feed
|
||||
@@ -16,11 +17,14 @@ export function GlobalActivityIndicator() {
|
||||
const searchStore = useSearchStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const activity = useActivityStore();
|
||||
// Fetch-more loads can begin and end between two renderer frames (warm
|
||||
// cache); hold the feed-more contribution so the indicator paints.
|
||||
const isLoadingMoreHeld = useHeldFlag(() => feedStore.isLoadingMore());
|
||||
|
||||
/** True while any tracked activity is in flight */
|
||||
const isActive = () =>
|
||||
feedStore.isLoadingFeeds() ||
|
||||
feedStore.isLoadingMore() ||
|
||||
isLoadingMoreHeld() ||
|
||||
searchStore.isSearching() ||
|
||||
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
||||
activity.isActive();
|
||||
|
||||
46
src/hooks/useHeldFlag.ts
Normal file
46
src/hooks/useHeldFlag.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* useHeldFlag — keep a boolean true for a minimum time after it falls.
|
||||
*
|
||||
* A warm-cache fetch-more load begins and ends between two renderer
|
||||
* frames: the raw `isLoadingMore` signal flips true→false without a
|
||||
* single paint, so the "[Fetch More]" → spinner swap never appears and
|
||||
* the press looks like a no-op. Components rendering a spinner for such
|
||||
* bursts read through this hook instead of the raw signal — the spinner
|
||||
* stays up (and animating) for at least `minMs` after the load ends,
|
||||
* guaranteeing several painted frames.
|
||||
*
|
||||
* Deliberately a display-layer concern: the store's own `isLoadingMore`
|
||||
* keeps its exact load-window semantics (guards, tests) and only the
|
||||
* rendered indicators are held.
|
||||
*/
|
||||
import { createSignal, createEffect, onCleanup } from "solid-js";
|
||||
|
||||
export function useHeldFlag(
|
||||
source: () => boolean,
|
||||
minMs = 250,
|
||||
): () => boolean {
|
||||
const [held, setHeld] = createSignal(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearTimer = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (source()) {
|
||||
// (Re)rising edge: show immediately; a pending fall from an
|
||||
// earlier burst is cancelled.
|
||||
clearTimer();
|
||||
if (!held()) setHeld(true);
|
||||
} else if (held()) {
|
||||
// Falling edge: hold the flag up for the remaining window.
|
||||
clearTimer();
|
||||
timer = setTimeout(() => setHeld(false), minMs);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(clearTimer);
|
||||
return held;
|
||||
}
|
||||
75
src/hooks/useStableListFocus.ts
Normal file
75
src/hooks/useStableListFocus.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* useStableListFocus — keep the cursor on the SAME row (by id), not the same
|
||||
* index, when a lazy load inserts rows around it.
|
||||
*
|
||||
* The nav store keeps one integer focus per depth frame. That is correct
|
||||
* for j/k (each press moves exactly one row) but wrong when the LIST
|
||||
* changes underneath the cursor: a fetch-more press appends revealed
|
||||
* episodes ABOVE the "[Fetch More]" row (every feed's deeper history is
|
||||
* older than the union's tail), so the button's index shifts down and an
|
||||
* index-stable cursor silently lands on another row. In the Feed tab the
|
||||
* union can even gain rows in the MIDDLE (a revealed episode of one show
|
||||
* sorts newer than another show's already-visible deep rows), moving the
|
||||
* focused episode itself.
|
||||
*
|
||||
* Usage: pages call this hook with a stable row-id accessor (episode id,
|
||||
* show id, or the FETCH_MORE_ROW_ID sentinel for the button row) plus
|
||||
* read/write access to the nav frame focus. Whenever the row count
|
||||
* changes, the cursor is re-anchored onto the previously focused row:
|
||||
* • still present → cursor follows that row (index may change)
|
||||
* • gone (removed) → keep the current, clamped index
|
||||
*
|
||||
* The id snapshot is taken on EVERY change of focus or rows (not just
|
||||
* count changes), so selection by mouse and j/k both re-anchor correctly.
|
||||
*/
|
||||
import { createEffect, on, untrack } from "solid-js";
|
||||
|
||||
/** Sentinel id for the "[Fetch More]" row — never collides with real ids. */
|
||||
export const FETCH_MORE_ROW_ID = "__fetch-more__";
|
||||
|
||||
export function useStableListFocus(deps: {
|
||||
/** Total row count of the list this pane shows. */
|
||||
count: () => number;
|
||||
/** Stable id of the row at `index` (undefined for out-of-range). */
|
||||
getItemId: (index: number) => string | undefined;
|
||||
/** Current focused row index of this pane. */
|
||||
getFocus: () => number;
|
||||
/** Write the focused row index of this pane. */
|
||||
setFocus: (index: number) => void;
|
||||
}): void {
|
||||
/** Focused row id at the time of the last snapshot. */
|
||||
let focusedId: string | undefined;
|
||||
|
||||
// Snapshot the focused row's id whenever focus or rows change. Reading
|
||||
// the list here would also re-run on unrelated row-content changes, so
|
||||
// only the id resolution is untracked.
|
||||
createEffect(() => {
|
||||
const idx = deps.getFocus();
|
||||
untrack(() => {
|
||||
focusedId = deps.getItemId(idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-anchor after the row count changes (deferred: never on first run —
|
||||
// initial focus placement is the page's job).
|
||||
createEffect(
|
||||
on(
|
||||
deps.count,
|
||||
(count) => {
|
||||
if (focusedId === undefined) return;
|
||||
let next: number | null = null;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (deps.getItemId(i) === focusedId) {
|
||||
next = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Row gone (removed): leave the clamped index alone — the
|
||||
// page's own ensureFocus handles bounds.
|
||||
if (next === null) return;
|
||||
if (next !== deps.getFocus()) deps.setFocus(next);
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,10 @@ import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
import {
|
||||
useStableListFocus,
|
||||
FETCH_MORE_ROW_ID,
|
||||
} from "@/hooks/useStableListFocus";
|
||||
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
@@ -103,6 +107,23 @@ function FeedPage() {
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
const curLen = () => rowCount();
|
||||
|
||||
// ── Focus stability across lazy loads ─────────────────────────────────────
|
||||
// The nav cursor is a plain row index; fetch-more inserts revealed
|
||||
// episodes above the [Fetch More] row (and, in the chronological union,
|
||||
// can splice rows into the middle), which silently moves an index-stable
|
||||
// cursor onto a different episode or off the button. Re-anchor the cursor
|
||||
// onto the focused row's ID after any row-count change — the user stays
|
||||
// on the exact episode (or the button) they were on before the load.
|
||||
useStableListFocus({
|
||||
count: rowCount,
|
||||
getItemId: (i) =>
|
||||
i === episodes().length
|
||||
? FETCH_MORE_ROW_ID
|
||||
: episodes()[i]?.episode.id,
|
||||
getFocus: () => nav.depthFocus(0),
|
||||
setFocus: (i) => nav.setDepthFocus(i, 0),
|
||||
});
|
||||
|
||||
// ── Render window ────────────────────────────────────────────────────────
|
||||
// The union grows to thousands of episodes after repeated fetch-more
|
||||
// presses; rendering every row per frame froze the UI. Render only a
|
||||
|
||||
@@ -47,6 +47,10 @@ import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
import {
|
||||
useStableListFocus,
|
||||
FETCH_MORE_ROW_ID,
|
||||
} from "@/hooks/useStableListFocus";
|
||||
|
||||
// ── render components ────────────────────────────────────────────────────────
|
||||
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
|
||||
@@ -361,6 +365,32 @@ export function MyShowsPage() {
|
||||
|
||||
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||
|
||||
// ── Focus stability across lazy loads ─────────────────────────────────────
|
||||
// The nav cursor is a plain row index; fetch-more inserts revealed
|
||||
// episodes above the [Fetch More] row, silently moving an index-stable
|
||||
// cursor onto a different episode or off the button. Re-anchor the cursor
|
||||
// onto the focused row's ID after any row-count change, at both depths
|
||||
// (the shows list shifts when subscriptions or unsubscribed downloads
|
||||
// change; the episode list shifts on fetch-more).
|
||||
useStableListFocus({
|
||||
count: curLen,
|
||||
getItemId: (i) => {
|
||||
if (depth() === 0) {
|
||||
const showsLen = shows().length;
|
||||
const unsubsLen = unsubs().length;
|
||||
if (i < showsLen) return shows()[i]?.id;
|
||||
if (i < showsLen + unsubsLen)
|
||||
return unsubs()[i - showsLen]?.episodeId;
|
||||
return FETCH_MORE_ROW_ID;
|
||||
}
|
||||
return i === episodes().length
|
||||
? FETCH_MORE_ROW_ID
|
||||
: episodes()[i]?.id;
|
||||
},
|
||||
getFocus: () => focus(depth()),
|
||||
setFocus: (i) => nav.setDepthFocus(i, depth()),
|
||||
});
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||
|
||||
Reference in New Issue
Block a user