feat(ui): improve narrow-screen tab and footer rendering
This commit is contained in:
+168
-30
@@ -1,9 +1,10 @@
|
|||||||
import { truncateToWidth } from "@mariozechner/pi-tui";
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
getCurrentQuestion,
|
getCurrentQuestion,
|
||||||
isQuestionAnswered,
|
isQuestionAnswered,
|
||||||
isSubmitTab,
|
isSubmitTab,
|
||||||
} from "../state/selectors.ts";
|
} from "../state/selectors.ts";
|
||||||
|
import { wrapText } from "../text.ts";
|
||||||
import type { AskState } from "../types.ts";
|
import type { AskState } from "../types.ts";
|
||||||
import { renderFooterText } from "./render-helpers.ts";
|
import { renderFooterText } from "./render-helpers.ts";
|
||||||
import type { Theme } from "./render-types.ts";
|
import type { Theme } from "./render-types.ts";
|
||||||
@@ -34,52 +35,189 @@ export function renderFrameFooter(args: {
|
|||||||
}) {
|
}) {
|
||||||
const { lines, state, theme, width } = args;
|
const { lines, state, theme, width } = args;
|
||||||
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
||||||
const footer = renderFooter(state, theme);
|
const footer = renderFooter(state, width);
|
||||||
if (footer) {
|
if (footer.length > 0) {
|
||||||
add();
|
add();
|
||||||
add(footer);
|
for (const line of footer) {
|
||||||
|
add(theme.fg("dim", line));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
|
add(theme.fg("accent", "─".repeat(Math.max(1, width))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabs(state: AskState, theme: Theme, width: number): string {
|
function renderTabs(state: AskState, theme: Theme, width: number): string {
|
||||||
const segments: string[] = [theme.fg("dim", "← ")];
|
const tabs = state.questions.map((question, index) => {
|
||||||
for (let index = 0; index < state.questions.length; index++) {
|
|
||||||
const question = state.questions[index];
|
|
||||||
const active = state.activeTabIndex === index;
|
const active = state.activeTabIndex === index;
|
||||||
const answered = isQuestionAnswered(state, question.id);
|
const answered = isQuestionAnswered(state, question.id);
|
||||||
const marker = answered ? "☒" : "☐";
|
const marker = answered ? "☒" : "☐";
|
||||||
const text = ` ${marker} ${question.label} `;
|
const text = ` ${marker} ${question.label} `;
|
||||||
segments.push(
|
return {
|
||||||
active
|
text,
|
||||||
|
width: visibleWidth(text),
|
||||||
|
render: active
|
||||||
? theme.bg("selectedBg", theme.fg("text", text))
|
? theme.bg("selectedBg", theme.fg("text", text))
|
||||||
: theme.fg(answered ? "success" : "muted", text)
|
: theme.fg(answered ? "success" : "muted", text),
|
||||||
);
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
const submitText = " ✔ Submit ";
|
const submitText = " ✔ Submit ";
|
||||||
segments.push(
|
tabs.push({
|
||||||
isSubmitTab(state)
|
text: submitText,
|
||||||
|
width: visibleWidth(submitText),
|
||||||
|
render: isSubmitTab(state)
|
||||||
? theme.bg("selectedBg", theme.fg("text", submitText))
|
? 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", " →"));
|
const leftArrow = theme.fg("dim", "← ");
|
||||||
return truncateToWidth(` ${segments.join(" ")}`, width);
|
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 {
|
function getVisibleTabRange(
|
||||||
if (state.view.kind === "input") {
|
widths: number[],
|
||||||
return theme.fg("dim", renderFooterText("input"));
|
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);
|
||||||
const question = getCurrentQuestion(state);
|
return expandTabs(widths, activeIndex, availableWidth, mode);
|
||||||
return theme.fg(
|
}
|
||||||
"dim",
|
|
||||||
renderFooterText(question?.type === "multi" ? "multi" : "default")
|
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;
|
||||||
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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