From f976bdc2b726ac0d7618045ea85b4c59751bce10 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 10 Aug 2026 20:57:06 -0400 Subject: [PATCH] =?UTF-8?q?fix(rows):=20keep=20episode=20rows=20exactly=20?= =?UTF-8?q?3=20lines=20=E2=80=94=20truncate,=20don't=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed and My Shows rows could grow to 4+ lines when a long title was shrunk by the current pane: flexible text wrapped instead of truncating, shifting every row below while scrolling. Add wrapMode=none + truncate to flexible text and flexShrink=0 to fixed-width cells so rows stay one line tall. Feed rows also move the podcast name onto its own line. Adds a rendered-layout regression test at 70 columns (35-col current pane). --- src/pages/Feed/FeedPage.tsx | 48 +++++-- src/pages/MyShows/MyShowsPage.tsx | 27 +++- tests/feed-row-wrap.test.tsx | 207 ++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 18 deletions(-) create mode 100644 tests/feed-row-wrap.test.tsx diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index 404ec36..0db2235 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -267,31 +267,55 @@ function FeedPage() { }} > - + {index() === fi() ? marker() : " "} - + {item.episode.episodeNumber ? `#${item.episode.episodeNumber} ` : ""} {item.episode.title} - - - {formatDate(item.episode.pubDate)} - - - {formatDuration(item.episode.duration)} - - + {/* podcast name on its own row — readable at a glance; the + 50% current pane fits it in full for typical names, and + truncate keeps the row one line tall either way */} + + {item.feed.customName || item.feed.podcast.title} + + + + {formatDate(item.episode.pubDate)} + + + {formatDuration(item.episode.duration)} + - + + ● + - + {downloadLabel(item.episode.id)} diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index a1dac5a..213da62 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -362,26 +362,41 @@ export function MyShowsPage() { }} > - + {index() === lf() ? marker() : " "} - + {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} {ep.title} - + {formatDate(ep.pubDate)} - + {formatDuration(ep.duration)} - + + ● + - + {downloadLabel(ep.id)} diff --git a/tests/feed-row-wrap.test.tsx b/tests/feed-row-wrap.test.tsx new file mode 100644 index 0000000..d852c40 --- /dev/null +++ b/tests/feed-row-wrap.test.tsx @@ -0,0 +1,207 @@ +/** + * Episode-row height regression — Feed rows must stay exactly 3 lines tall: + * title, podcast name, meta (date + duration + markers). Every line carries + * `wrapMode="none"` + `truncate` on flexible text and `flexShrink={0}` on + * fixed-width cells so Yoga can never shrink a text below its content width + * and wrap it — a wrapped row grows to 4+ lines and every entry below + * shifts its starting position while scrolling (the original bug). + * + * Rendered at 70 columns so the 35-col current pane is narrow enough to + * force truncation on the long title; at the default 100-col/50-col pane + * the same rows show everything in full. + */ + +import { describe, test, expect, afterAll } from "bun:test"; +import type { JSX } from "solid-js"; +import { testRender } from "@opentui/solid"; +import { ThemeProvider } from "../src/context/ThemeContext"; +import { PaneRow } from "../src/components/PaneRow"; + +type Frame = { cols: number; lines: { spans: { text: string }[] }[] }; + +function frameText(spans: Frame): string[] { + return spans.lines.map((l) => l.spans.map((s) => s.text).join("")); +} + +// The exact row shape FeedPage renders: title, podcast name, meta. Static +// text, no store hooks — pure layout probe. +const FixedRow = () => ( + + + + + #674 - Scott Payne + + + + + This Past Weekend w/ Theo Von + + + + Aug 10, 2026 + 3h 50m + + +); + +// A long title proves the truncate guard: without it the title wraps to +// multiple lines and the row grows past 3 lines. +const LongTitleFixed = () => ( + + + + + Out of Whiskey and Reaching for the Rotgut (Members Only #338) + + + + + This Past Weekend w/ Theo Von + + + + Aug 10, 2026 + 3h 50m + + +); + +// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title +// wraps at the shrunken width and the row grows. +const NaiveRow = () => ( + + + + Out of Whiskey and Reaching for the Rotgut (Members Only #338) + + + This Past Weekend w/ Theo Von + + + Aug 10, 2026 + 3h 50m + + +); + +async function renderCurrent( + row: () => JSX.Element, + width = 70, +): Promise<{ lines: string[]; destroy: () => Promise }> { + const setup = await testRender( + () => ( + + + + ), + { width, height: 10, useThread: false }, + ); + // ThemeProvider mounts children only once the theme resolves; poll for + // the header row so the captured frame is a mounted PaneRow. + let lines: string[] | null = null; + for (let i = 0; i < 40 && !lines; i++) { + await setup.renderOnce(); + const frame = setup.captureSpans() as unknown as Frame; + const ls = frameText(frame); + if (ls.some((l) => l.includes("List"))) lines = ls; + else await new Promise((r) => setTimeout(r, 100)); + } + if (!lines) throw new Error("PaneRow did not render before timeout"); + return { + lines, + destroy: async () => { + setup.renderer.destroy(); + }, + }; +} + +const cleanups: (() => void | Promise)[] = []; +afterAll(async () => { + for (const c of cleanups) { + try { + await c(); + } catch { + // renderer already torn down — ignore + } + } +}); + +describe("episode row height in the current pane (70-wide → 35-col pane)", () => { + test("fixed row: title, podcast name and meta each render on exactly one line", async () => { + const { lines, destroy } = await renderCurrent(() => ); + cleanups.push(destroy); + + // Podcast name is fully visible on its own line — the usability the + // dedicated row exists for. + expect(lines.filter((l) => l.includes("This Past Weekend w/ Theo Von"))).toHaveLength(1); + // Title and meta each on a single frame line (a wrapped date would + // split "Aug 10, 2026" across lines). + expect(lines.filter((l) => l.includes("Scott Payne"))).toHaveLength(1); + expect(lines.filter((l) => l.includes("Aug 10, 2026"))).toHaveLength(1); + expect(lines.filter((l) => l.includes("3h 50m"))).toHaveLength(1); + // The row occupies exactly the 3 content lines below the header. + const content = lines.filter( + (l) => + l.includes("Scott Payne") || + l.includes("This Past Weekend") || + l.includes("Aug 10,"), + ); + expect(content).toHaveLength(3); + }); + + test("long title stays on one line (truncated), keeping the row at 3 lines", async () => { + const { lines, destroy } = await renderCurrent(() => ); + cleanups.push(destroy); + + // Truncated: the title head appears on exactly one line and the full + // title never appears on any line (middle-ellipsis clips it). + expect(lines.filter((l) => l.includes("Out of Whiske"))).toHaveLength(1); + expect( + lines.some((l) => + l.includes( + "Out of Whiskey and Reaching for the Rotgut (Members Only #338)", + ), + ), + ).toBe(false); + // Podcast name and meta still each on one line — row total 3. + expect(lines.filter((l) => l.includes("This Past Weekend"))).toHaveLength(1); + expect(lines.filter((l) => l.includes("Aug 10,"))).toHaveLength(1); + const content = lines.filter( + (l) => + l.includes("Out of Whiske") || + l.includes("This Past Weekend") || + l.includes("Aug 10,"), + ); + expect(content).toHaveLength(3); + }); + + test("naive row (pre-fix props) wraps the long title — the regression the test guards", async () => { + const { lines, destroy } = await renderCurrent(() => ); + cleanups.push(destroy); + + // The title's wrapped fragments span 3 frame lines instead of 1. + const titleFragments = lines.filter( + (l) => + l.includes("Out of Whiske") || + l.includes("for the Rotgut") || + l.includes("#338)"), + ); + expect(titleFragments).toHaveLength(3); + // Row total: 3 title lines + podcast + meta = 5 content lines. + const content = lines.filter( + (l) => + l.includes("Out of Whiske") || + l.includes("for the Rotgut") || + l.includes("#338)") || + l.includes("This Past Weekend") || + l.includes("Aug 10,"), + ); + expect(content).toHaveLength(5); + }); +});