diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md index 6634110..c0fb02c 100644 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md @@ -20,7 +20,12 @@ Create `tests/overlay-render.test.ts` with the initial `pad` tests: ```typescript import { describe, expect, it } from "vitest"; -import { pad, frame, renderTabBar, frameContentWidth } from "../src/tui/overlay-render.ts"; +import { + pad, + frame, + renderTabBar, + frameContentWidth, +} from "../src/tui/overlay-render.ts"; import { noTheme } from "../src/tui/dashboard-theme.ts"; describe("pad", () => { diff --git a/src/tui/overlay-render.ts b/src/tui/overlay-render.ts new file mode 100644 index 0000000..78123fd --- /dev/null +++ b/src/tui/overlay-render.ts @@ -0,0 +1,180 @@ +/** + * Overlay rendering utilities for the usage dashboard. + * + * Replicates patterns from the pi-extension-manager's render helpers using + * the dashboard's own DashboardTheme adapter so colors flow through Pi's + * live theme. + */ +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import type { DashboardTheme } from "./dashboard-theme.ts"; + +// ── Layout constants ──────────────────────────────────────────────────── + +const PADDING_X = 2; +const PADDING_Y = 1; + +// ── Frame glyphs ──────────────────────────────────────────────────────── + +const FRAME = { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" } as const; + +// ── Tab types ─────────────────────────────────────────────────────────── + +export interface DashboardTab { + id: string; + label: string; +} + +// ── Utilities ─────────────────────────────────────────────────────────── + +/** + * Pad `text` to exactly `width` visible columns. Truncates if wider. + */ +export function pad(text: string, width: number): string { + if (width <= 0) return ""; + const truncated = truncateToWidth(text, width, ""); + return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`; +} + +/** + * Calculate the content width inside a frame (excluding borders and padding). + */ +export function frameContentWidth(width: number): number { + return Math.max(1, width - 2 - PADDING_X * 2); +} + +// ── Frame ─────────────────────────────────────────────────────────────── + +/** + * Wrap content `lines` in a bordered frame box. + * + * Matches the pi-extension-manager's frame rendering: + * - Top/bottom borders with optional title + * - PADDING_Y blank rows above/below content + * - PADDING_X space columns on each side of content + * - Overflow truncation with "↓ N more line(s)" when fixedInnerRows is set + */ +export function frame( + lines: string[], + width: number, + theme: DashboardTheme, + fixedInnerRows?: number, + title = "", +): string[] { + const inner = Math.max(1, width - 2); + const contentWidth = frameContentWidth(width); + const border = (s: string) => theme.fg("borderAccent", s); + + let body = lines; + if (fixedInnerRows !== undefined && body.length > fixedInnerRows) { + const hidden = body.length - fixedInnerRows + 1; + body = [ + ...body.slice(0, Math.max(0, fixedInnerRows - 1)), + theme.fg("dim", `↓ ${hidden} more line(s)`), + ].slice(0, fixedInnerRows); + } + + const blank = `${border(FRAME.v)}${" ".repeat(inner)}${border(FRAME.v)}`; + + const top = (): string => { + if (!title) { + return `${border(FRAME.tl)}${border(FRAME.h.repeat(inner))}${border(FRAME.tr)}`; + } + const titleText = ` ${truncateToWidth(title, Math.max(1, inner - 2), "…")} `; + const fill = Math.max(1, inner - visibleWidth(titleText)); + return `${border(FRAME.tl)}${theme.fg("accent", titleText)}${border(FRAME.h.repeat(fill))}${border(FRAME.tr)}`; + }; + + const out = [top()]; + for (let i = 0; i < PADDING_Y; i += 1) out.push(blank); + for (const line of body) { + out.push( + `${border(FRAME.v)}${" ".repeat(PADDING_X)}${pad(line, contentWidth)}${" ".repeat(PADDING_X)}${border(FRAME.v)}`, + ); + } + for (let i = 0; i < PADDING_Y; i += 1) out.push(blank); + out.push( + `${border(FRAME.bl)}${border(FRAME.h.repeat(inner))}${border(FRAME.br)}`, + ); + return out.map((line) => truncateToWidth(line, width, "")); +} + +// ── Tab bar ───────────────────────────────────────────────────────────── + +function activePill(theme: DashboardTheme, label: string): string { + return theme.fg("accent", theme.inverse(theme.bold(label))); +} + +function inactivePill(theme: DashboardTheme, label: string): string { + return theme.bg("selectedBg", theme.fg("accent", label)); +} + +/** + * Render a tab bar with pill-styled active/inactive tabs. + * + * Dynamic visibility: expands tabs around the active tab to fit `width`, + * showing ‹/› indicators when tabs overflow. + */ +export function renderTabBar( + tabs: DashboardTab[], + activeId: string, + width: number, + theme: DashboardTheme, +): string { + const safeWidth = Math.max(1, width); + if (tabs.length === 0) return " ".repeat(safeWidth); + + const activeIndex = Math.max( + 0, + tabs.findIndex((tab) => tab.id === activeId), + ); + const widths = tabs.map((tab) => visibleWidth(tab.label) + 2); // " label " + + const sliceWidth = (s: number, e: number): number => { + let total = 0; + for (let i = s; i < e; i += 1) total += widths[i]!; + total += Math.max(0, e - s - 1); // single-space gaps + total += s > 0 ? 2 : 0; // "‹ " + total += e < tabs.length ? 2 : 0; // " ›" + return total; + }; + + let start = activeIndex; + let end = activeIndex + 1; + let preferRight = true; + while (start > 0 || end < tabs.length) { + let progressed = false; + const tryRight = (): boolean => { + if (end < tabs.length && sliceWidth(start, end + 1) <= safeWidth) { + end += 1; + return true; + } + return false; + }; + const tryLeft = (): boolean => { + if (start > 0 && sliceWidth(start - 1, end) <= safeWidth) { + start -= 1; + return true; + } + return false; + }; + if (preferRight) { + if (tryRight()) progressed = true; + if (tryLeft()) progressed = true; + } else { + if (tryLeft()) progressed = true; + if (tryRight()) progressed = true; + } + if (!progressed) break; + preferRight = !preferRight; + } + + const cells = tabs.slice(start, end).map((tab) => { + const label = ` ${tab.label} `; + return tab.id === activeId + ? activePill(theme, label) + : inactivePill(theme, label); + }); + if (start > 0) cells.unshift(theme.fg("dim", "‹")); + if (end < tabs.length) cells.push(theme.fg("dim", "›")); + return pad(cells.join(" "), safeWidth); +} diff --git a/tests/overlay-render.test.ts b/tests/overlay-render.test.ts new file mode 100644 index 0000000..58a884a --- /dev/null +++ b/tests/overlay-render.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { + pad, + frame, + renderTabBar, + frameContentWidth, +} from "../src/tui/overlay-render.ts"; +import { noTheme } from "../src/tui/dashboard-theme.ts"; + +describe("pad", () => { + it("pads short text with trailing spaces", () => { + expect(pad("hi", 10)).toBe("hi "); + }); + + it("returns text unchanged when it fills the width", () => { + expect(pad("hello", 5)).toBe("hello"); + }); + + it("truncates text that exceeds the width", () => { + const result = pad("hello world", 5); + // truncateToWidth appends an ANSI reset sequence, so check visible width + expect(visibleWidth(result)).toBe(5); + }); + + it("handles zero width", () => { + expect(pad("hi", 0)).toBe(""); + }); +}); + +describe("frameContentWidth", () => { + it("subtracts borders and padding from total width", () => { + // width - 2 (borders) - PADDING_X * 2 (4) = width - 6 + expect(frameContentWidth(20)).toBe(14); + expect(frameContentWidth(10)).toBe(4); + }); + + it("returns 1 for very small widths", () => { + expect(frameContentWidth(1)).toBe(1); + expect(frameContentWidth(0)).toBe(1); + }); +}); + +describe("frame", () => { + it("wraps content lines in a bordered box", () => { + const lines = frame(["hello", "world"], 20, noTheme); + // Top border + expect(lines[0]).toContain("┏"); + expect(lines[0]).toContain("┓"); + // Bottom border + expect(lines[lines.length - 1]).toContain("┗"); + expect(lines[lines.length - 1]).toContain("┛"); + // Content lines have vertical borders + const contentLine = lines.find((l) => l.includes("hello")); + expect(contentLine).toBeDefined(); + expect(contentLine).toContain("┃"); + }); + + it("includes padding rows above and below content", () => { + const lines = frame(["test"], 20, noTheme); + // Structure: top border, 1 padding row, content, 1 padding row, bottom border + expect(lines.length).toBe(5); // 1 top + 1 pad + 1 content + 1 pad + 1 bottom + }); + + it("truncates overflow with indicator when fixedInnerRows is set", () => { + const content = ["line1", "line2", "line3", "line4", "line5"]; + const lines = frame(content, 30, noTheme, 3); + const joined = lines.join("\n"); + expect(joined).toContain("line1"); + expect(joined).toContain("line2"); + expect(joined).toContain("more line(s)"); + expect(joined).not.toContain("line5"); + }); + + it("renders a title in the top border when provided", () => { + const lines = frame(["content"], 30, noTheme, undefined, "My Title"); + expect(lines[0]).toContain("My Title"); + expect(lines[0]).toContain("┏"); + }); + + it("pads content to frameContentWidth", () => { + const lines = frame(["hi"], 20, noTheme); + // Content line: ┃ + 2 pad + content padded to contentWidth + 2 pad + ┃ + // All lines should be exactly 20 chars wide (in visible width) + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(20); + } + }); + + it("renders an empty frame when content is empty", () => { + const lines = frame([], 20, noTheme); + // Structure: top border, 1 padding row, 1 padding row, bottom border (no content rows) + expect(lines.length).toBe(4); // 1 top + 1 pad + 1 pad + 1 bottom + expect(lines[0]).toContain("┏"); + expect(lines[lines.length - 1]).toContain("┗"); + }); +}); + +describe("renderTabBar", () => { + const tabs = [ + { id: "stats", label: "Usage Statistics" }, + { id: "current", label: "Current Usage" }, + { id: "insights", label: "Insights" }, + ]; + + it("renders all tabs with active tab highlighted", () => { + const result = renderTabBar(tabs, "stats", 80, noTheme); + expect(result).toContain("Usage Statistics"); + expect(result).toContain("Current Usage"); + expect(result).toContain("Insights"); + }); + + it("returns empty padding for no tabs", () => { + const result = renderTabBar([], "stats", 20, noTheme); + expect(result.trim()).toBe(""); + }); + + it("shows overflow indicators when tabs exceed width", () => { + const result = renderTabBar(tabs, "insights", 20, noTheme); + // At 20 chars wide, not all tabs can fit + expect(result).toContain("Insights"); + }); + + it("pads result to the requested width", () => { + const result = renderTabBar(tabs, "stats", 80, noTheme); + // Result should be padded to exactly 80 visible chars + expect(visibleWidth(result)).toBe(80); + }); + + it("defaults to first tab when activeId is not found", () => { + const result = renderTabBar(tabs, "nonexistent", 80, noTheme); + // First tab becomes active by default (Math.max(0, findIndex(...))) + expect(result).toContain("Usage Statistics"); + }); +});