Files
fusion/packages/tui/src/utils/terminal.ts
gsxdsm 2ef27cf749 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
2026-04-09 21:32:19 -07:00

257 lines
7.9 KiB
TypeScript

/**
* 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,
};
}