feat(FN-2283): align dashboard TUI help overlay borders

- Add buildHelpLines() to construct box and compact help overlay rows from shared width-safe logic
- Replace manual padRight-based row padding with visibleLength-driven padding for consistent right border alignment
- Compute overlay centering from raw uncolored line widths and apply coloring during render
- Add targeted tests validating box row geometry, border width consistency, and padding calculations
This commit is contained in:
Fusion
2026-04-23 03:29:37 -07:00
committed by gsxdsm
parent f5507e6046
commit 84330812b0
2 changed files with 190 additions and 40 deletions

View File

@@ -238,3 +238,140 @@ describe("Type exports", () => {
expect(settings.enginePaused).toBe(false);
});
});
// ── Help Overlay Alignment Tests ─────────────────────────────────────────────
/**
* Calculate visible length of a string (strips ANSI escape sequences).
* Mirrors the helper function in dashboard-tui.ts for testing purposes.
*/
function visibleLength(s: string): number {
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
}
describe("Help overlay border alignment in box-drawing mode", () => {
/**
* Helper that mirrors the boxRow logic from buildHelpLines.
* Ensures total visible width = boxWidth + 2 for rows with content <= boxWidth.
*/
function boxRow(content: string, boxWidth: number): string {
const padding = Math.max(0, boxWidth - visibleLength(content));
return "│" + content + " ".repeat(padding) + "│";
}
function centerText(text: string, width: number, padChar = " "): string {
const padding = Math.max(0, width - visibleLength(text));
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return padChar.repeat(leftPad) + text + padChar.repeat(rightPad);
}
/**
* Build help lines exactly as buildHelpLines() does.
* Used to verify box geometry without requiring TTY mode.
*/
function buildHelpLinesForTest(boxWidth: number): string[] {
return [
"┌" + "─".repeat(boxWidth) + "┐",
boxRow(centerText("KEYBOARD SHORTCUTS", boxWidth, " "), boxWidth),
"├" + "─".repeat(boxWidth) + "┤",
boxRow(" [1-5] Switch to tab by number", boxWidth),
boxRow(" [n] / → Next tab", boxWidth),
boxRow(" [p] / ← Previous tab", boxWidth),
boxRow(" [r] Refresh stats (Utilities)", boxWidth),
boxRow(" [c] Clear logs (Utilities)", boxWidth),
boxRow(" [t] Toggle engine pause (Utilities)", boxWidth),
boxRow(" [?] / [h] Toggle help", boxWidth),
boxRow(" [q] Quit", boxWidth),
boxRow(" [Ctrl+C] Force quit", boxWidth),
"└" + "─".repeat(boxWidth) + "┘",
];
}
it("all box-drawing rows have consistent total visible width for standard terminal", () => {
const boxWidth = 62;
const helpLines = buildHelpLinesForTest(boxWidth);
const expectedWidth = boxWidth + 2; // boxWidth interior + 2 box characters
// All rows should have exactly the same visible width
for (const line of helpLines) {
expect(visibleLength(line)).toBe(expectedWidth);
}
});
it("box rows match the top/bottom border width", () => {
const boxWidth = 62;
const helpLines = buildHelpLinesForTest(boxWidth);
// Get top border width
const topBorder = helpLines[0];
const borderWidth = visibleLength(topBorder);
// Every row must match the border width
for (let i = 1; i < helpLines.length; i++) {
expect(visibleLength(helpLines[i])).toBe(borderWidth);
}
});
it("right border characters align across all rows for standard terminal", () => {
const boxWidth = 62;
const helpLines = buildHelpLinesForTest(boxWidth);
const expectedWidth = boxWidth + 2;
// Top border determines the box width
const topBorder = helpLines[0];
const topBorderWidth = visibleLength(topBorder);
// Every interior row should have the same total width as the top border
for (let i = 1; i < helpLines.length; i++) {
const line = helpLines[i];
// The line should have the same total width as top border
expect(visibleLength(line)).toBe(topBorderWidth);
expect(visibleLength(line)).toBe(expectedWidth);
}
});
it("handles narrow terminal width (boxWidth = 62) for full-width content", () => {
// Use 62 to accommodate the longest content line (~45 chars)
const boxWidth = 62;
const helpLines = buildHelpLinesForTest(boxWidth);
const expectedWidth = boxWidth + 2;
// All rows should have consistent width with boxWidth=62
for (const line of helpLines) {
expect(visibleLength(line)).toBe(expectedWidth);
}
});
it("all interior rows end with right border character", () => {
const boxWidth = 62;
const helpLines = buildHelpLinesForTest(boxWidth);
// Check that all interior rows (indices 1 through length-2) end with │
// Skip header/title row (index 1) which has centered text
for (let i = 3; i < helpLines.length - 1; i++) {
const line = helpLines[i];
expect(line.endsWith("│")).toBe(true);
}
});
it("padding is zero when content fills the boxWidth", () => {
const boxWidth = 62;
// " [t] Toggle engine pause (Utilities)" is 45 chars
const content = " [t] Toggle engine pause (Utilities)";
const padding = Math.max(0, boxWidth - visibleLength(content));
expect(padding).toBe(17); // 62 - 45 = 17
});
it("padding calculation ensures consistent row width", () => {
// Test that for content shorter than boxWidth, padding fills the gap
const boxWidth = 62;
const shortContent = " [q] Quit"; // 18 chars
const expectedPadding = boxWidth - visibleLength(shortContent); // 62 - 18 = 44
const row = boxRow(shortContent, boxWidth);
expect(visibleLength(row)).toBe(boxWidth + 2);
expect(row.startsWith("│")).toBe(true);
expect(row.endsWith("│")).toBe(true);
});
});

View File

@@ -884,6 +884,52 @@ export class DashboardTUI {
process.stdout.write("\n");
}
/**
* Build help overlay lines as an array of strings.
* Uses dynamic visibleLength calculation to ensure consistent box width.
* All box-drawing rows (including borders) have the same total visible width.
*
* @param boxWidth - The interior content width (excluding the two box characters)
* @param useBoxDrawing - Whether to use box-drawing characters (true) or compact text (false)
* @returns Array of help lines
*/
private buildHelpLines(boxWidth: number, useBoxDrawing: boolean): string[] {
if (useBoxDrawing) {
// Helper to create a width-safe interior row with consistent total width
// Formula: │ + content + padding + │ where padding = max(0, boxWidth - visibleLength(content))
// This ensures total visible width = boxWidth + 2 for ALL rows
const boxRow = (content: string): string => {
const padding = Math.max(0, boxWidth - visibleLength(content));
return "│" + content + " ".repeat(padding) + "│";
};
// Full box drawing style for terminals wide enough
return [
"┌" + "─".repeat(boxWidth) + "┐",
boxRow(centerText("KEYBOARD SHORTCUTS", boxWidth, " ")),
"├" + "─".repeat(boxWidth) + "┤",
boxRow(" [1-5] Switch to tab by number"),
boxRow(" [n] / → Next tab"),
boxRow(" [p] / ← Previous tab"),
boxRow(" [r] Refresh stats (Utilities)"),
boxRow(" [c] Clear logs (Utilities)"),
boxRow(" [t] Toggle engine pause (Utilities)"),
boxRow(" [?] / [h] Toggle help"),
boxRow(" [q] Quit"),
boxRow(" [Ctrl+C] Force quit"),
"└" + "─".repeat(boxWidth) + "┘",
];
} else {
// Compact plain text for narrow terminals - use visibleTruncate to prevent overflow
return [
"KEYBOARD SHORTCUTS",
" [1-5] Switch tab | [n/p] Next/Prev | [q] Quit",
" [r] Refresh | [c] Clear logs | [t] Toggle engine",
" [?/h] Help | [Ctrl+C] Force quit",
];
}
}
private renderHelpOverlay(): void {
const cols = process.stdout.columns || 80;
const rows = process.stdout.rows || 24;
@@ -892,38 +938,11 @@ export class DashboardTUI {
const boxWidth = Math.min(62, Math.max(cols - 4, 20));
const useBoxDrawing = cols >= boxWidth + 4;
let helpLines: string[];
if (useBoxDrawing) {
// Full box drawing style for terminals wide enough
helpLines = [
colorize("┌" + "─".repeat(boxWidth) + "┐", "brightBlue"),
colorize("│" + centerText("KEYBOARD SHORTCUTS", boxWidth, " ") + "│", "brightBlue"),
colorize("├" + "─".repeat(boxWidth) + "┤", "brightBlue"),
colorize("│ [1-5] Switch to tab by number" + padRight("", boxWidth - 39) + "│", "white"),
colorize("│ [n] / → Next tab" + padRight("", boxWidth - 25) + "│", "white"),
colorize("│ [p] / ← Previous tab" + padRight("", boxWidth - 27) + "│", "white"),
colorize("│ [r] Refresh stats (Utilities)" + padRight("", boxWidth - 36) + "│", "white"),
colorize("│ [c] Clear logs (Utilities)" + padRight("", boxWidth - 33) + "│", "white"),
colorize("│ [t] Toggle engine pause (Utilities)" + padRight("", boxWidth - 42) + "│", "white"),
colorize("│ [?] / [h] Toggle help" + padRight("", boxWidth - 24) + "│", "white"),
colorize("│ [q] Quit" + padRight("", boxWidth - 15) + "│", "white"),
colorize("│ [Ctrl+C] Force quit" + padRight("", boxWidth - 22) + "│", "white"),
colorize("└" + "─".repeat(boxWidth) + "┘", "brightBlue"),
];
} else {
// Compact plain text for narrow terminals - use visibleTruncate to prevent overflow
const maxLineWidth = cols - 2; // Padding for left margin
helpLines = [
visibleTruncate(colorize("KEYBOARD SHORTCUTS", "brightBlue"), maxLineWidth),
visibleTruncate(colorize(" [1-5] Switch tab | [n/p] Next/Prev | [q] Quit", "white"), maxLineWidth),
visibleTruncate(colorize(" [r] Refresh | [c] Clear logs | [t] Toggle engine", "white"), maxLineWidth),
visibleTruncate(colorize(" [?/h] Help | [Ctrl+C] Force quit", "white"), maxLineWidth),
];
}
const rawHelpLines = this.buildHelpLines(boxWidth, useBoxDrawing);
// Compute compact box width from actual line widths for proper centering
const compactBoxWidth = useBoxDrawing ? boxWidth : Math.max(...helpLines.map(visibleLength));
const boxHeight = helpLines.length;
const compactBoxWidth = useBoxDrawing ? boxWidth : Math.max(...rawHelpLines.map(visibleLength));
const boxHeight = rawHelpLines.length;
// Clamp origin to terminal-safe coordinates (minimum 1)
const safeStartX = Math.max(1, Math.floor((cols - compactBoxWidth) / 2));
const safeStartY = Math.max(1, Math.floor((rows - boxHeight) / 2));
@@ -932,16 +951,17 @@ export class DashboardTUI {
const clearTop = Math.max(1, safeStartY - 1);
const clearBottom = Math.min(rows, safeStartY + boxHeight);
// Clear screen area for overlay
// Colorize lines and clear screen area for overlay
for (let y = clearTop; y <= clearBottom; y++) {
moveCursorTo(1, y);
clearLine();
}
// Draw the help box
for (let i = 0; i < helpLines.length; i++) {
for (let i = 0; i < rawHelpLines.length; i++) {
const color = i === 0 || i === 2 || i === rawHelpLines.length - 1 ? "brightBlue" : "white";
moveCursorTo(safeStartX, safeStartY + i);
process.stdout.write(helpLines[i]);
process.stdout.write(colorize(rawHelpLines[i], color));
}
}
}
@@ -1002,13 +1022,6 @@ function centerText(text: string, width: number, padChar: string = " "): string
return padChar.repeat(leftPad) + text + padChar.repeat(rightPad);
}
/** Right-pad a string to a given width */
function padRight(text: string, width: number): string {
if (width <= 0) return "";
const visibleLen = visibleLength(text);
return text + " ".repeat(Math.max(0, width - visibleLen));
}
// ── Check if TTY is available ─────────────────────────────────────────────────
export function isTTYAvailable(): boolean {