feat(ui): improve narrow-screen tab and footer rendering
This commit is contained in:
+166
-28
@@ -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 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);
|
||||
return theme.fg(
|
||||
"dim",
|
||||
renderFooterText(question?.type === "multi" ? "multi" : "default")
|
||||
);
|
||||
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;
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
Reference in New Issue
Block a user