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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user