feat(FN-1472): add responsive terminal layout for TUI

- Add ResponsiveLayout component with dynamic row/column splitting based on terminal size
- Create terminal utility for detecting terminal dimensions and layout preferences
- Add truncate utility for smart text truncation with min/max bounds
- Implement comprehensive responsive layout tests with all breakpoints
- Update TUI exports to expose new layout and utility components
- Enhance README documentation with responsive layout guide and examples
This commit is contained in:
gsxdsm
2026-04-09 21:32:19 -07:00
parent 7db308b118
commit 8891ff7bed
8 changed files with 1270 additions and 30 deletions

View File

@@ -0,0 +1,23 @@
/**
* TUI Utility modules.
*/
export {
useTerminalDimensions,
computeColumnLayout,
type TerminalDimensions,
type ColumnLayout,
type ColumnDefinition,
type ColumnStrategy,
MIN_TERMINAL_COLUMNS,
MIN_TERMINAL_ROWS,
} from "./terminal.js";
export {
truncateText,
truncateWithOptions,
padText,
fitText,
DEFAULT_ELLIPSIS,
type TruncateOptions,
} from "./truncate.js";

View File

@@ -0,0 +1,256 @@
/**
* Terminal dimension utilities for responsive TUI layouts.
*
* Provides hooks and helpers for reading live terminal dimensions from Ink's
* useStdout() and computing deterministic column widths.
*/
import { useStdout } from "ink";
import { useMemo } from "react";
/**
* Minimum supported terminal dimensions.
* These values are used as lower bounds for layout calculations.
*/
export const MIN_TERMINAL_COLUMNS = 80;
export const MIN_TERMINAL_ROWS = 24;
/**
* Effective terminal dimensions with minimum bounds applied.
*/
export interface TerminalDimensions {
/** Effective column count (minimum 80) */
columns: number;
/** Effective row count (minimum 24) */
rows: number;
/** Whether the terminal meets minimum size requirements */
isMinimumSize: boolean;
/** Extra columns available beyond the minimum */
extraColumns: number;
}
/**
* useTerminalDimensions - Hook to read live terminal dimensions with minimum bounds.
*
* Uses Ink's useStdout() to get the actual terminal size, then applies minimum
* bounds of 80 columns and 24 rows for layout calculations. This ensures
* deterministic layout even in smaller terminals.
*
* The hook updates whenever the terminal is resized.
*
* @returns {TerminalDimensions} Effective terminal dimensions
*
* @example
* ```tsx
* function MyComponent() {
* const { columns, rows, isMinimumSize, extraColumns } = useTerminalDimensions();
*
* return (
* <Box>
* <Text>Terminal: {columns}x{rows}</Text>
* {!isMinimumSize && <Text dimColor> (narrow)</Text>}
* </Box>
* );
* }
* ```
*/
export function useTerminalDimensions(): TerminalDimensions {
const { stdout } = useStdout();
// Defensive: use default terminal dimensions if stdout is unavailable
const columns = stdout?.columns ?? MIN_TERMINAL_COLUMNS;
const rows = stdout?.rows ?? MIN_TERMINAL_ROWS;
return useMemo(() => {
const effectiveColumns = Math.max(columns, MIN_TERMINAL_COLUMNS);
const effectiveRows = Math.max(rows, MIN_TERMINAL_ROWS);
const extraColumns = Math.max(0, effectiveColumns - MIN_TERMINAL_COLUMNS);
const isMinimumSize = effectiveColumns <= MIN_TERMINAL_COLUMNS && effectiveRows <= MIN_TERMINAL_ROWS;
return {
columns: effectiveColumns,
rows: effectiveRows,
isMinimumSize,
extraColumns,
};
}, [columns, rows]);
}
/**
* Column layout configuration for responsive tables/lists.
*/
export interface ColumnLayout {
/** Width of each column */
widths: number[];
/** Total width used by all columns */
totalWidth: number;
/** Remaining columns after minimum allocations */
remainingColumns: number;
}
/**
* Column allocation strategy.
*/
export type ColumnStrategy = "equal" | "fixed" | "proportional" | "content-heavy";
/**
* Column definition for layout calculation.
*/
export interface ColumnDefinition {
/** Minimum width for this column */
minWidth: number;
/** Preferred/ideal width (optional) */
preferredWidth?: number;
/** Whether this column can grow to fill extra space */
canGrow?: boolean;
/** Growth weight relative to other growable columns */
growWeight?: number;
}
/**
* computeColumnLayout - Calculate column widths based on terminal dimensions.
*
* Produces deterministic column widths that:
* - Respect minimum column widths
* - Keep required columns readable at 80 columns
* - Share extra width with content-heavy columns
*
* @param columns - Available terminal columns
* @param definitions - Column definitions with minimum/preferred widths
* @param strategy - Allocation strategy for extra space
* @returns {ColumnLayout} Calculated column widths
*
* @example
* ```tsx
* const layout = computeColumnLayout(100, [
* { minWidth: 10, canGrow: false }, // ID column
* { minWidth: 40, canGrow: true, growWeight: 2 }, // Description (grows 2x)
* { minWidth: 10, canGrow: true, growWeight: 1 }, // Status (grows 1x)
* ], "proportional");
* // Returns widths array based on available space
* ```
*/
export function computeColumnLayout(
columns: number,
definitions: ColumnDefinition[],
strategy: ColumnStrategy = "proportional"
): ColumnLayout {
const definitionCount = definitions.length;
if (definitionCount === 0) {
return { widths: [], totalWidth: 0, remainingColumns: columns };
}
// Step 1: Calculate minimum total width
const minimumTotal = definitions.reduce((sum, def) => sum + def.minWidth, 0);
// Step 2: If at or below minimum, use minimum widths
if (columns <= minimumTotal) {
return {
widths: definitions.map((def) => def.minWidth),
totalWidth: minimumTotal,
remainingColumns: 0,
};
}
// Step 3: Distribute extra columns based on strategy
const extraColumns = columns - minimumTotal;
const growableColumns = definitions
.map((def, index) => ({ def, index, weight: def.growWeight ?? 1 }))
.filter(({ def }) => def.canGrow);
if (growableColumns.length === 0 || strategy === "fixed") {
// Fixed strategy: don't distribute extra space
return {
widths: definitions.map((def) => def.minWidth),
totalWidth: minimumTotal,
remainingColumns: extraColumns,
};
}
if (strategy === "equal") {
// Equal strategy: divide extra space evenly among growable columns
const extraPerGrowable = Math.floor(extraColumns / growableColumns.length);
const widths = definitions.map((def) => def.minWidth);
for (const { index } of growableColumns) {
widths[index] += extraPerGrowable;
}
return {
widths,
totalWidth: columns,
remainingColumns: extraColumns % growableColumns.length,
};
}
if (strategy === "proportional") {
// Proportional strategy: distribute based on grow weights
const totalWeight = growableColumns.reduce((sum, c) => sum + c.weight, 0);
const widths = definitions.map((def) => def.minWidth);
let distributed = 0;
// Distribute proportionally (all but last to avoid rounding errors)
for (let i = 0; i < growableColumns.length - 1; i++) {
const { index, weight } = growableColumns[i];
const share = Math.floor((extraColumns * weight) / totalWeight);
widths[index] += share;
distributed += share;
}
// Last growable column gets the remainder
const last = growableColumns[growableColumns.length - 1];
widths[last.index] += extraColumns - distributed;
return {
widths,
totalWidth: columns,
remainingColumns: 0,
};
}
// Content-heavy: prioritize columns with preferredWidth
// Distribute based on how much each column is below its preferred width
const widths = definitions.map((def) => def.minWidth);
const contentScores = definitions.map((def) => {
if (!def.canGrow) return 0;
const preferred = def.preferredWidth ?? def.minWidth * 2;
return Math.max(0, preferred - def.minWidth);
});
const totalScore = contentScores.reduce((a, b) => a + b, 0);
if (totalScore === 0) {
// Fall back to equal distribution
const extraPerGrowable = Math.floor(extraColumns / growableColumns.length);
for (const { index } of growableColumns) {
widths[index] += extraPerGrowable;
}
return {
widths,
totalWidth: columns,
remainingColumns: extraColumns % growableColumns.length,
};
}
// Distribute proportionally to content score
let distributed = 0;
const sortedGrowable = [...growableColumns].sort((a, b) => {
const scoreA = contentScores[a.index];
const scoreB = contentScores[b.index];
return scoreB - scoreA; // Higher scores first
});
for (let i = 0; i < sortedGrowable.length - 1; i++) {
const { index } = sortedGrowable[i];
const share = Math.floor((extraColumns * contentScores[index]) / totalScore);
widths[index] += share;
distributed += share;
}
// Last column gets the remainder
const last = sortedGrowable[sortedGrowable.length - 1];
widths[last.index] += extraColumns - distributed;
return {
widths,
totalWidth: columns,
remainingColumns: 0,
};
}

View File

@@ -0,0 +1,211 @@
/**
* Text truncation utilities for clean terminal display.
*
* Provides consistent ellipsis output for overflow text while
* preserving short text unchanged.
*/
export const DEFAULT_ELLIPSIS = "…";
/**
* truncateText - Truncate text to a maximum width with ellipsis.
*
* When text exceeds maxWidth:
* - If maxWidth < 4, text is replaced with just ellipsis
* - Otherwise, text is truncated to (maxWidth - 1) characters + ellipsis
*
* When text fits within maxWidth, it is returned unchanged.
*
* @param text - Text to truncate (already stripped of ANSI codes)
* @param maxWidth - Maximum width in terminal columns
* @param ellipsis - Ellipsis character(s) to use (default: "…")
* @returns {string} Truncated text with ellipsis if needed
*
* @example
* ```typescript
* truncateText("Hello World", 10); // "Hello World" (fits)
* truncateText("Hello World", 8); // "Hello W…"
* truncateText("Hello World", 3); // "…" (too short for meaningful truncation)
* truncateText("Hello World", 2); // "…" (minimum display width)
* ```
*/
export function truncateText(text: string, maxWidth: number, ellipsis: string = DEFAULT_ELLIPSIS): string {
if (maxWidth <= 0) {
return "";
}
const textWidth = text.length;
// Text fits within maxWidth
if (textWidth <= maxWidth) {
return text;
}
// Too short for meaningful truncation
if (maxWidth < 4) {
return ellipsis.slice(0, Math.max(1, maxWidth));
}
// Truncate with ellipsis - reserve space for the actual ellipsis length
const availableWidth = maxWidth - ellipsis.length;
if (availableWidth <= 0) {
// Ellipsis alone exceeds width
return ellipsis.slice(0, Math.max(1, maxWidth));
}
return text.slice(0, availableWidth) + ellipsis;
}
/**
* TruncateOptions - Configuration options for truncate functions.
*/
export interface TruncateOptions {
/** Ellipsis character(s) to use */
ellipsis?: string;
/** Whether to preserve words (avoid breaking mid-word) */
preserveWords?: boolean;
/** Minimum width threshold for truncation */
minTruncateWidth?: number;
}
/**
* truncateWithOptions - Truncate with additional options.
*
* @param text - Text to truncate
* @param maxWidth - Maximum width
* @param options - Truncation options
* @returns {string} Truncated text
*/
export function truncateWithOptions(
text: string,
maxWidth: number,
options: TruncateOptions = {}
): string {
const {
ellipsis = DEFAULT_ELLIPSIS,
preserveWords = false,
minTruncateWidth = 4,
} = options;
if (maxWidth <= 0) {
return "";
}
const textWidth = text.length;
// Text fits within maxWidth
if (textWidth <= maxWidth) {
return text;
}
// Too short for meaningful truncation
if (maxWidth < minTruncateWidth) {
return ellipsis.slice(0, Math.max(1, maxWidth));
}
if (preserveWords) {
// Find the last space before the truncation point
const availableWidth = maxWidth - 1;
const truncatedAt = text.slice(0, availableWidth);
const lastSpace = truncatedAt.lastIndexOf(" ");
if (lastSpace > availableWidth * 0.5) {
// There's a word boundary in the first half - break there
const wordBoundary = text.slice(0, lastSpace).trimEnd();
if (wordBoundary.length + ellipsis.length <= maxWidth) {
return wordBoundary + ellipsis;
}
}
}
// Standard truncation
const availableWidth = maxWidth - ellipsis.length;
return text.slice(0, Math.max(0, availableWidth)) + ellipsis;
}
/**
* padText - Pad text to a specific width.
*
* @param text - Text to pad
* @param width - Target width
* @param align - Alignment direction ("left" | "right" | "center")
* @returns {string} Padded text
*
* @example
* ```typescript
* padText("Hi", 6); // "Hi " (left by default)
* padText("Hi", 6, "right"); // " Hi"
* padText("Hi", 6, "center"); // " Hi "
* ```
*/
export function padText(text: string, width: number, align: "left" | "right" | "center" = "left"): string {
if (width <= 0) {
return "";
}
const textWidth = text.length;
// Text equals or exceeds target width
if (textWidth >= width) {
return text.slice(0, width);
}
const padding = width - textWidth;
switch (align) {
case "right":
return " ".repeat(padding) + text;
case "center": {
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
}
default:
return text + " ".repeat(padding);
}
}
/**
* fitText - Fit text to a width by truncating or padding.
*
* @param text - Text to fit
* @param width - Target width
* @param align - Alignment when text is shorter than width
* @param ellipsis - Ellipsis for truncation (omit to use padding instead)
* @returns {string} Text fitted to width
*
* @example
* ```typescript
* fitText("Hi", 6); // "Hi " (padded)
* fitText("Hello World", 6); // "Hello " (truncated without ellipsis)
* ```
*/
export function fitText(
text: string,
width: number,
align: "left" | "right" | "center" = "left",
ellipsis?: string
): string {
if (width <= 0) {
return "";
}
const textWidth = text.length;
// Text exceeds target width
if (textWidth > width) {
if (ellipsis) {
return truncateText(text, width, ellipsis);
}
// Without ellipsis, truncate but don't include trailing space from mid-word break
const truncated = text.slice(0, width);
return truncated.trimEnd();
}
// Text fits perfectly - no padding needed
if (textWidth === width) {
return text;
}
// Text is shorter - pad to fit
return padText(text, width, align);
}