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