diff --git a/src/pages/Player/ProgressBar.tsx b/src/pages/Player/ProgressBar.tsx index 6122670..407808a 100644 --- a/src/pages/Player/ProgressBar.tsx +++ b/src/pages/Player/ProgressBar.tsx @@ -7,6 +7,7 @@ */ import { useTerminalDimensions } from "@opentui/solid"; +import type { Renderable } from "@opentui/core"; import { useAudio } from "@/hooks/useAudio"; import { useTheme } from "@/context/ThemeContext"; @@ -17,6 +18,11 @@ export function ProgressBar() { const { theme } = useTheme(); const dimensions = useTerminalDimensions(); + // 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). + 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 // of border/padding chrome (same math as RealtimeWaveform's numBars). @@ -39,15 +45,21 @@ export function ProgressBar() { padding={0} flexDirection="row" gap={0} + ref={(el) => { + bar = el; + }} onMouseDown={(e: { x: number }) => { const duration = audio.duration(); - if (duration <= 0) return; - // e.x = 0 is the box border; content starts at x = 1. - const ratio = Math.max(0, Math.min(1, (e.x - 1) / width())); + if (duration <= 0 || !bar) return; + // localX = 0 is the box border; content starts at localX = 1. + const localX = e.x - bar.x; + const ratio = Math.max(0, Math.min(1, (localX - 1) / width())); void audio.seek(ratio * duration); }} > - {"\u2588".repeat(playedChars())} + {playedChars() > 0 && ( + {"\u2588".repeat(playedChars())} + )} {"\u2591".repeat(width() - playedChars())} diff --git a/tests/progress-bar-seek.test.tsx b/tests/progress-bar-seek.test.tsx new file mode 100644 index 0000000..9b83321 --- /dev/null +++ b/tests/progress-bar-seek.test.tsx @@ -0,0 +1,181 @@ +/** + * ProgressBar click-to-seek test — pins the mouse→seek coordinate mapping. + * + * @opentui MouseEvent.x/y are terminal-absolute (the SGR parser returns + * `col - 1` and the event propagates up the renderable tree unchanged), + * NOT element-relative. The bar must subtract its own absolute left edge + * (read from the renderable ref) or every click lands shifted by the panes + * to its left — the parent/Up pane ≈ 20% of the terminal width. The bar is + * rendered here exactly as PlayerPage renders it (2-pane PaneRow, padded + * column), so the terminal geometry matches production. + */ + +import { describe, test, expect, afterAll, mock } from "bun:test"; +import { testRender } from "@opentui/solid"; +import { ThemeProvider } from "../src/context/ThemeContext"; +import { PaneRow } from "../src/components/PaneRow"; +import { ProgressBar } from "../src/pages/Player/ProgressBar"; + +// ── Audio stub ───────────────────────────────────────────────────────── + +const seeks: number[] = []; +const fakeAudio = { + duration: () => 100, + position: () => 0, + seek: async (seconds: number) => { + seeks.push(seconds); + }, +}; + +mock.module("../src/hooks/useAudio", () => ({ + useAudio: () => fakeAudio, +})); + +// ── Harness ─────────────────────────────────────────────────────────── + +type Span = { text: string }; +type Frame = { cols: number; lines: { spans: Span[] }[] }; +type TestSetup = { + renderOnce: () => Promise; + captureSpans: () => unknown; + mockMouse: { click: (x: number, y: number) => Promise }; + renderer: { destroy: () => Promise }; +}; + +interface BarGeometry { + setup: TestSetup; + /** Terminal column of the first rendered ░ (the bar's played/remaining run). */ + runStartX: number; + /** Number of ░ glyphs drawn (the bar's content width). */ + contentWidth: number; + /** Terminal row of the bar's content line. */ + barY: number; + destroy: () => Promise; +} + +async function renderBar(): Promise { + const setup = (await testRender( + () => ( + + ( + + + + )} + currentLabel="Player" + panes={2} + /> + + ), + { width: 100, height: 10, useThread: false }, + )) as unknown as TestSetup; + + let runStartX = -1; + let contentWidth = -1; + let barY = -1; + for (let i = 0; i < 40; i++) { + await setup.renderOnce(); + const frame = setup.captureSpans() as unknown as Frame; + const lines = frame.lines.map((l) => l.spans.map((s) => s.text).join("")); + const line = lines.find((l) => l.includes("░")); + if (line) { + barY = lines.indexOf(line); + runStartX = line.indexOf("░"); + contentWidth = line.split("░").length - 1; + break; + } + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, 100); + await promise; + } + if (barY < 0) throw new Error("ProgressBar did not render before timeout"); + + return { + setup, + runStartX, + contentWidth, + barY, + 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 + } + } +}); + +// ── Click-to-seek ────────────────────────────────────────────────────── + +describe("ProgressBar click-to-seek", () => { + test("the bar sits past the parent pane (test premise)", async () => { + const bar = await renderBar(); + cleanups.push(bar.destroy); + // Parent pane keeps its 20% slot: the bar's run must start well + // right of column 0, otherwise the test would not reproduce the + // coordinate-offset bug. + expect(bar.runStartX).toBeGreaterThan(15); + }); + + test("clicking the first content column seeks near the start, not the parent pane offset", async () => { + const bar = await renderBar(); + cleanups.push(bar.destroy); + + seeks.length = 0; + // Pre-fix, the handler read the absolute mouse x as bar-local: + // clicking the bar's left edge sought to ~20%+ of the duration + // (the Up pane's width). It must now seek to the very start. + await bar.setup.mockMouse.click(bar.runStartX, bar.barY); + expect(seeks).toHaveLength(1); + expect(seeks[0]).toBe(0); + }); + + test("clicking the bar's right edge seeks to the end", async () => { + const bar = await renderBar(); + cleanups.push(bar.destroy); + + seeks.length = 0; + // The column right of the last drawn char is the box's right border: + // local x there equals the content width, so the seek must be 100%. + await bar.setup.mockMouse.click( + bar.runStartX + bar.contentWidth, + bar.barY, + ); + expect(seeks).toHaveLength(1); + expect(seeks[0]).toBeCloseTo(100, 0); + }); + + test("clicking the bar's middle seeks to half the duration", async () => { + const bar = await renderBar(); + cleanups.push(bar.destroy); + + seeks.length = 0; + const mid = bar.runStartX + Math.floor(bar.contentWidth / 2); + await bar.setup.mockMouse.click(mid, bar.barY); + expect(seeks).toHaveLength(1); + expect(seeks[0]).toBeCloseTo(50, 0); + }); + + test("clicks map linearly across the whole bar", async () => { + const bar = await renderBar(); + cleanups.push(bar.destroy); + + seeks.length = 0; + // One click at each drawn column; each must seek to that column's + // exact share of the duration (within one second of rounding). + for (let offset = 0; offset < bar.contentWidth; offset++) { + await bar.setup.mockMouse.click(bar.runStartX + offset, bar.barY); + const expected = (offset / bar.contentWidth) * 100; + expect(seeks[seeks.length - 1]).toBeCloseTo(expected, 0); + } + }); +});