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:
23
packages/tui/src/utils/index.ts
Normal file
23
packages/tui/src/utils/index.ts
Normal 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";
|
||||
256
packages/tui/src/utils/terminal.ts
Normal file
256
packages/tui/src/utils/terminal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
211
packages/tui/src/utils/truncate.ts
Normal file
211
packages/tui/src/utils/truncate.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user