From 1ce721094c21faf6180098a7244b6f2abd13623e Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Sat, 25 Apr 2026 18:43:52 +0200 Subject: [PATCH] feat(ui): improve narrow-screen tab and footer rendering --- src/ui/render-frame.ts | 198 +++++++++++++++++++++++++++++++------ tests/render-frame.test.ts | 171 ++++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+), 30 deletions(-) create mode 100644 tests/render-frame.test.ts diff --git a/src/ui/render-frame.ts b/src/ui/render-frame.ts index 555fad7..10c78dc 100644 --- a/src/ui/render-frame.ts +++ b/src/ui/render-frame.ts @@ -1,9 +1,10 @@ -import { truncateToWidth } from "@mariozechner/pi-tui"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { getCurrentQuestion, isQuestionAnswered, isSubmitTab, } from "../state/selectors.ts"; +import { wrapText } from "../text.ts"; import type { AskState } from "../types.ts"; import { renderFooterText } from "./render-helpers.ts"; import type { Theme } from "./render-types.ts"; @@ -34,52 +35,189 @@ export function renderFrameFooter(args: { }) { const { lines, state, theme, width } = args; const add = (text = "") => lines.push(truncateToWidth(text, width)); - const footer = renderFooter(state, theme); - if (footer) { + const footer = renderFooter(state, width); + if (footer.length > 0) { add(); - add(footer); + for (const line of footer) { + add(theme.fg("dim", line)); + } } add(theme.fg("accent", "─".repeat(Math.max(1, width)))); } function renderTabs(state: AskState, theme: Theme, width: number): string { - const segments: string[] = [theme.fg("dim", "← ")]; - for (let index = 0; index < state.questions.length; index++) { - const question = state.questions[index]; + const tabs = state.questions.map((question, index) => { const active = state.activeTabIndex === index; const answered = isQuestionAnswered(state, question.id); const marker = answered ? "☒" : "☐"; const text = ` ${marker} ${question.label} `; - segments.push( - active + return { + text, + width: visibleWidth(text), + render: active ? theme.bg("selectedBg", theme.fg("text", text)) - : theme.fg(answered ? "success" : "muted", text) - ); - } + : theme.fg(answered ? "success" : "muted", text), + }; + }); const submitText = " ✔ Submit "; - segments.push( - isSubmitTab(state) + tabs.push({ + text: submitText, + width: visibleWidth(submitText), + render: isSubmitTab(state) ? theme.bg("selectedBg", theme.fg("text", submitText)) - : theme.fg("success", submitText) + : theme.fg("success", submitText), + }); + + const activeIndex = Math.min(state.activeTabIndex, tabs.length - 1); + const { start, end } = getVisibleTabRange( + tabs.map((tab) => tab.width), + activeIndex, + Math.max(1, width - 2) ); - segments.push(theme.fg("dim", " →")); - return truncateToWidth(` ${segments.join(" ")}`, width); + const leftArrow = theme.fg("dim", "← "); + const rightArrow = theme.fg("dim", " →"); + const visibleTabs = tabs + .slice(start, end + 1) + .map((tab) => tab.render) + .join(" "); + return truncateToWidth(` ${leftArrow}${visibleTabs}${rightArrow}`, width); } -function renderFooter(state: AskState, theme: Theme): string { - if (state.view.kind === "input") { - return theme.fg("dim", renderFooterText("input")); +function getVisibleTabRange( + widths: number[], + activeIndex: number, + availableWidth: number +): { start: number; end: number } { + if (widths.length === 0) { + return { start: 0, end: -1 }; } - if (state.view.kind === "note") { - return theme.fg("dim", renderFooterText("note")); + + const totalWidth = + widths.reduce((sum, width) => sum + width, 0) + + Math.max(0, widths.length - 1); + if (totalWidth <= availableWidth) { + return { start: 0, end: widths.length - 1 }; } - if (isSubmitTab(state)) { - return theme.fg("dim", renderFooterText("submit")); - } - const question = getCurrentQuestion(state); - return theme.fg( - "dim", - renderFooterText(question?.type === "multi" ? "multi" : "default") - ); + + const zone = activeIndex / Math.max(1, widths.length - 1); + const mode = getViewportMode(zone); + return expandTabs(widths, activeIndex, availableWidth, mode); +} + +function getViewportMode(zone: number): "left" | "right" | "balanced" { + if (zone <= 0.33) { + return "right"; + } + if (zone >= 0.67) { + return "left"; + } + return "balanced"; +} + +function expandTabs( + widths: number[], + activeIndex: number, + availableWidth: number, + mode: "left" | "right" | "balanced" +): { start: number; end: number } { + const range = { start: activeIndex, end: activeIndex }; + let usedWidth = widths[activeIndex] ?? 0; + let preferRight = mode !== "left"; + + while (true) { + const directions = getGrowthDirections(mode, preferRight); + const grew = directions.some((direction) => { + const nextWidth = getNextTabWidth(widths, range, direction); + if ( + nextWidth === undefined || + usedWidth + 1 + nextWidth > availableWidth + ) { + return false; + } + usedWidth += 1 + nextWidth; + growRange(range, direction); + return true; + }); + if (!grew) { + return range; + } + if (mode === "balanced") { + preferRight = !preferRight; + } + } +} + +function getGrowthDirections( + mode: "left" | "right" | "balanced", + preferRight: boolean +): Array<"left" | "right"> { + if (mode === "left") { + return ["left", "right"]; + } + if (mode === "right") { + return ["right", "left"]; + } + return preferRight ? ["right", "left"] : ["left", "right"]; +} + +function getNextTabWidth( + widths: number[], + range: { start: number; end: number }, + direction: "left" | "right" +): number | undefined { + if (direction === "left") { + return widths[range.start - 1]; + } + return widths[range.end + 1]; +} + +function growRange( + range: { start: number; end: number }, + direction: "left" | "right" +) { + if (direction === "left") { + range.start -= 1; + return; + } + range.end += 1; +} + +function renderFooter(state: AskState, width: number): string[] { + let footer: string; + if (state.view.kind === "input") { + footer = renderFooterText("input"); + } else if (state.view.kind === "note") { + footer = renderFooterText("note"); + } else if (isSubmitTab(state)) { + footer = renderFooterText("submit"); + } else { + const question = getCurrentQuestion(state); + footer = renderFooterText(question?.type === "multi" ? "multi" : "default"); + } + return wrapFooterHints(footer, width); +} + +function wrapFooterHints(footer: string, width: number): string[] { + return footer.split(" · ").reduce((lines, chunk) => { + const current = lines.at(-1) ?? ""; + const next = current ? `${current} · ${chunk}` : chunk; + const fitsCurrentLine = visibleWidth(next) <= width; + if (fitsCurrentLine) { + if (current) { + lines[lines.length - 1] = next; + } else { + lines.push(next); + } + return lines; + } + + const wrappedChunk = wrapText(chunk, width); + if (!current) { + lines.push(...wrappedChunk); + return lines; + } + lines.push(...wrappedChunk); + return lines; + }, []); } diff --git a/tests/render-frame.test.ts b/tests/render-frame.test.ts new file mode 100644 index 0000000..6b3debf --- /dev/null +++ b/tests/render-frame.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createInitialState } from "../src/state/create.ts"; +import { renderAskScreen } from "../src/ui/render.ts"; + +function mockTheme() { + return { + fg(color: string, text: string) { + return `<${color}>${text}`; + }, + bg(color: string, text: string) { + return `{${color}}${text}{/${color}}`; + }, + bold(text: string) { + return text; + }, + } as never; +} + +function mockEditor() { + return { + getText() { + return ""; + }, + render() { + return []; + }, + } as never; +} + +test("tab strip always shows dimmed framing arrows on wide screens", () => { + const state = createInitialState({ + title: "Demo", + questions: [ + { + id: "q1", + label: "One", + prompt: "One", + options: [{ value: "a", label: "A" }], + }, + { + id: "q2", + label: "Two", + prompt: "Two", + options: [{ value: "a", label: "A" }], + }, + ], + }); + + const lines = renderAskScreen({ + state, + theme: mockTheme(), + width: 120, + editor: mockEditor(), + }); + + assert(lines.some((line) => line.includes("← "))); + assert(lines.some((line) => line.includes(" ✔ Submit "))); +}); + +test("narrow tab strip keeps active middle tab visible", () => { + const state = createInitialState({ + title: "Demo", + questions: [ + { + id: "q1", + label: "One", + prompt: "One", + options: [{ value: "a", label: "A" }], + }, + { + id: "q2", + label: "Two", + prompt: "Two", + options: [{ value: "a", label: "A" }], + }, + { + id: "q3", + label: "Three", + prompt: "Three", + options: [{ value: "a", label: "A" }], + }, + { + id: "q4", + label: "Four", + prompt: "Four", + options: [{ value: "a", label: "A" }], + }, + { + id: "q5", + label: "Five", + prompt: "Five", + options: [{ value: "a", label: "A" }], + }, + ], + }); + state.activeTabIndex = 2; + + const lines = renderAskScreen({ + state, + theme: mockTheme(), + width: 28, + editor: mockEditor(), + }); + + assert(lines.some((line) => line.includes("Three"))); + assert(!lines.some((line) => line.includes("{selectedBg} One "))); +}); + +test("narrow tab strip keeps submit tab visible when active", () => { + const state = createInitialState({ + title: "Demo", + questions: [ + { + id: "q1", + label: "One", + prompt: "One", + options: [{ value: "a", label: "A" }], + }, + { + id: "q2", + label: "Two", + prompt: "Two", + options: [{ value: "a", label: "A" }], + }, + { + id: "q3", + label: "Three", + prompt: "Three", + options: [{ value: "a", label: "A" }], + }, + ], + }); + state.activeTabIndex = state.questions.length; + state.view = { kind: "submit" }; + + const lines = renderAskScreen({ + state, + theme: mockTheme(), + width: 24, + editor: mockEditor(), + }); + + assert(lines.some((line) => line.includes("Submit"))); + assert(lines.some((line) => line.includes("←") || line.includes("→"))); +}); + +test("footer hints wrap on narrow screens instead of truncating", () => { + const state = createInitialState({ + questions: [ + { + id: "q1", + label: "Features", + prompt: "Pick features", + type: "multi", + options: [{ value: "a", label: "A" }], + }, + ], + }); + + const lines = renderAskScreen({ + state, + theme: mockTheme(), + width: 26, + editor: mockEditor(), + }); + + assert(lines.some((line) => line.includes("Space toggle"))); + assert(lines.some((line) => line.includes("Enter continue"))); + assert(lines.some((line) => line.includes("N/Shift+N note"))); +});