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