feat(ui): improve narrow-screen tab and footer rendering

This commit is contained in:
Erik Kotsiuba
2026-04-25 20:01:43 +02:00
parent b59c967d98
commit 1ce721094c
2 changed files with 339 additions and 30 deletions
+168 -30
View File
@@ -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<string[]>((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;
}, []);
}
+171
View File
@@ -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}</${color}>`;
},
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("<dim>← </dim>")));
assert(lines.some((line) => line.includes("<success> ✔ Submit </success>")));
});
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}<text> 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")));
});