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:
@@ -6,6 +6,22 @@ Terminal UI components for fn, built with [Ink](https://github.com/vadimdemedes/
|
|||||||
|
|
||||||
This package is under active development and not yet published.
|
This package is under active development and not yet published.
|
||||||
|
|
||||||
|
## Terminal Compatibility
|
||||||
|
|
||||||
|
This package is designed for terminals with a minimum size of **80×24** characters:
|
||||||
|
- **Minimum width**: 80 columns
|
||||||
|
- **Minimum height**: 24 rows
|
||||||
|
|
||||||
|
When the terminal is smaller than these minimums, the UI enforces these bounds for layout calculations, ensuring consistent rendering across different terminal sizes.
|
||||||
|
|
||||||
|
## Responsive Layout
|
||||||
|
|
||||||
|
The TUI provides responsive layout utilities that adapt to terminal dimensions:
|
||||||
|
|
||||||
|
- **Minimum bounds**: Layouts always respect the 80×24 minimum, ensuring readability
|
||||||
|
- **Dynamic column widths**: Tables and lists compute column widths based on available space
|
||||||
|
- **Truncation with ellipsis**: Long content is automatically truncated with `…` (U+2026) when it exceeds the available width
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
This package is part of the fn workspace and is not installed separately. It is available as a private workspace package.
|
This package is part of the fn workspace and is not installed separately. It is available as a private workspace package.
|
||||||
@@ -270,26 +286,11 @@ The following are exported from `@fusion/tui`:
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { render, Box, Text } from "ink";
|
import { render, Box } from "ink";
|
||||||
import { FusionProvider, useFusion, ScreenRouter, useGlobalShortcuts, HelpOverlay } from "@fusion/tui";
|
import { FusionProvider, useFusion, ScreenRouter, useGlobalShortcuts, HelpOverlay, ResponsiveHeader, ResponsiveTable, ResponsiveStatusBar } from "@fusion/tui";
|
||||||
|
|
||||||
function ProjectInfo() {
|
|
||||||
const { store, projectPath } = useFusion();
|
|
||||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
store.listTasks().then(setTasks);
|
|
||||||
}, [store]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Text>Project: {projectPath}</Text>
|
|
||||||
<Text>Tasks: {tasks.length}</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { projectPath } = useFusion();
|
||||||
const [activeScreen, setActiveScreen] = useState("board");
|
const [activeScreen, setActiveScreen] = useState("board");
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
@@ -306,9 +307,8 @@ function App() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Responsive header */}
|
||||||
<Text bold>Fusion TUI</Text>
|
<ResponsiveHeader title={`Fusion TUI | Project: ${projectPath}`} />
|
||||||
<Text dimColor>(Press ? for help)</Text>
|
|
||||||
|
|
||||||
{/* Screen router */}
|
{/* Screen router */}
|
||||||
<ScreenRouter
|
<ScreenRouter
|
||||||
@@ -318,9 +318,17 @@ function App() {
|
|||||||
{({ activeScreen }) => (
|
{({ activeScreen }) => (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{activeScreen === "board" && (
|
{activeScreen === "board" && (
|
||||||
<Box>
|
<ResponsiveTable
|
||||||
<Text>Board Screen</Text>
|
columns={[
|
||||||
</Box>
|
{ header: "ID", minWidth: 10 },
|
||||||
|
{ header: "Description", minWidth: 30, canGrow: true },
|
||||||
|
{ header: "Status", minWidth: 12 },
|
||||||
|
]}
|
||||||
|
rows={[
|
||||||
|
["FN-001", "Implement feature", "todo"],
|
||||||
|
["FN-002", "Fix bug in auth", "done"],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{activeScreen === "detail" && (
|
{activeScreen === "detail" && (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -330,6 +338,9 @@ function App() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</ScreenRouter>
|
</ScreenRouter>
|
||||||
|
|
||||||
|
{/* Responsive status bar */}
|
||||||
|
<ResponsiveStatusBar />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -340,3 +351,137 @@ render(
|
|||||||
</FusionProvider>
|
</FusionProvider>
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Responsive Layout Utilities
|
||||||
|
|
||||||
|
The TUI provides utilities for building responsive layouts that adapt to terminal dimensions.
|
||||||
|
|
||||||
|
### useTerminalDimensions
|
||||||
|
|
||||||
|
Hook to read live terminal dimensions from Ink's `useStdout()` with minimum bounds applied.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTerminalDimensions } from "@fusion/tui";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { columns, rows, isMinimumSize, extraColumns } = useTerminalDimensions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text>Terminal: {columns}x{rows}</Text>
|
||||||
|
{!isMinimumSize && <Text dimColor> (wider than minimum)</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `columns` | `number` | Effective column count (minimum 80) |
|
||||||
|
| `rows` | `number` | Effective row count (minimum 24) |
|
||||||
|
| `isMinimumSize` | `boolean` | Whether terminal meets minimum size |
|
||||||
|
| `extraColumns` | `number` | Extra columns beyond the 80-column minimum |
|
||||||
|
|
||||||
|
### computeColumnLayout
|
||||||
|
|
||||||
|
Calculate column widths based on terminal dimensions and column definitions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { computeColumnLayout } from "@fusion/tui";
|
||||||
|
|
||||||
|
const layout = computeColumnLayout(120, [
|
||||||
|
{ minWidth: 10, canGrow: false }, // Fixed-width ID column
|
||||||
|
{ minWidth: 30, canGrow: true, growWeight: 2 }, // Description (grows 2x)
|
||||||
|
{ minWidth: 15, canGrow: true }, // Status (grows 1x)
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(layout.widths); // e.g., [10, 63, 47]
|
||||||
|
console.log(layout.totalWidth); // 120
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `columns` | `number` | Available terminal columns |
|
||||||
|
| `definitions` | `ColumnDefinition[]` | Column definitions with minWidth, preferredWidth, canGrow, growWeight |
|
||||||
|
| `strategy` | `ColumnStrategy` | Allocation strategy: `"equal"`, `"fixed"`, `"proportional"`, `"content-heavy"` |
|
||||||
|
|
||||||
|
#### Returns
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `widths` | `number[]` | Calculated width for each column |
|
||||||
|
| `totalWidth` | `number` | Total width used by all columns |
|
||||||
|
| `remainingColumns` | `number` | Leftover columns after minimum allocations |
|
||||||
|
|
||||||
|
### truncateText
|
||||||
|
|
||||||
|
Truncate text to a maximum width with ellipsis.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { truncateText } from "@fusion/tui";
|
||||||
|
|
||||||
|
truncateText("Hello World", 8); // "Hello W…"
|
||||||
|
truncateText("Hi", 10); // "Hi" (fits)
|
||||||
|
truncateText("Hello", 2); // "…" (too short)
|
||||||
|
truncateText("Hello World", 10, "~~"); // "Hello Wo~~" (custom ellipsis)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Components
|
||||||
|
|
||||||
|
#### ResponsiveHeader
|
||||||
|
|
||||||
|
A header component that adapts to terminal width.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ResponsiveHeader } from "@fusion/tui";
|
||||||
|
|
||||||
|
<ResponsiveHeader title="My App" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ResponsiveTable
|
||||||
|
|
||||||
|
A table component with responsive column widths and truncation.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ResponsiveTable } from "@fusion/tui";
|
||||||
|
|
||||||
|
<ResponsiveTable
|
||||||
|
columns={[
|
||||||
|
{ header: "ID", minWidth: 10 },
|
||||||
|
{ header: "Description", minWidth: 30, canGrow: true },
|
||||||
|
{ header: "Status", minWidth: 12 },
|
||||||
|
]}
|
||||||
|
rows={[
|
||||||
|
["FN-001", "Implement feature", "todo"],
|
||||||
|
["FN-002", "Fix bug in auth", "done"],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ResponsiveStatusBar
|
||||||
|
|
||||||
|
A status bar showing current terminal dimensions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ResponsiveStatusBar } from "@fusion/tui";
|
||||||
|
|
||||||
|
<ResponsiveStatusBar />
|
||||||
|
// Displays: "Terminal: 120x40 | Minimum: 80x24"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exports
|
||||||
|
|
||||||
|
The following are exported from `@fusion/tui`:
|
||||||
|
- `useTerminalDimensions` — Hook for reading terminal dimensions
|
||||||
|
- `computeColumnLayout` — Function for calculating column widths
|
||||||
|
- `truncateText` — Function for truncating text with ellipsis
|
||||||
|
- `ResponsiveHeader` — Header component with responsive content
|
||||||
|
- `ResponsiveTable` — Table component with responsive columns
|
||||||
|
- `ResponsiveTaskRow` — Task row with truncation
|
||||||
|
- `ResponsiveStatusBar` — Status bar showing terminal info
|
||||||
|
- `MIN_TERMINAL_COLUMNS` — Minimum supported terminal width (80)
|
||||||
|
- `MIN_TERMINAL_ROWS` — Minimum supported terminal height (24)
|
||||||
|
|||||||
362
packages/tui/src/__tests__/responsive-layout.test.tsx
Normal file
362
packages/tui/src/__tests__/responsive-layout.test.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* Tests for responsive layout utilities (terminal dimensions and truncation).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { computeColumnLayout, MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS, type ColumnDefinition } from "../utils/terminal";
|
||||||
|
import { truncateText, truncateWithOptions, padText, fitText } from "../utils/truncate";
|
||||||
|
|
||||||
|
// Mock stdout state as a mutable object that can be updated between tests
|
||||||
|
const mockStdout = {
|
||||||
|
columns: 80,
|
||||||
|
rows: 24,
|
||||||
|
write: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Ink's useStdout for terminal dimension tests
|
||||||
|
// The mock returns a function that reads from the mutable mockStdout object
|
||||||
|
vi.mock("ink", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("ink")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useStdout: () => mockStdout,
|
||||||
|
// Keep render from actual ink
|
||||||
|
render: actual?.render,
|
||||||
|
Box: actual?.Box,
|
||||||
|
Text: actual?.Text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { useTerminalDimensions } from "../utils/terminal";
|
||||||
|
import { render } from "ink";
|
||||||
|
|
||||||
|
describe("terminal.ts", () => {
|
||||||
|
describe("MIN_TERMINAL_* constants", () => {
|
||||||
|
it("has minimum column count of 80", () => {
|
||||||
|
expect(MIN_TERMINAL_COLUMNS).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has minimum row count of 24", () => {
|
||||||
|
expect(MIN_TERMINAL_ROWS).toBe(24);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useTerminalDimensions hook", () => {
|
||||||
|
it("returns dimensions with minimum bounds applied", () => {
|
||||||
|
let dimensions: ReturnType<typeof useTerminalDimensions> | null = null;
|
||||||
|
|
||||||
|
function TestComponent() {
|
||||||
|
dimensions = useTerminalDimensions();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = render(<TestComponent />);
|
||||||
|
instance.unmount();
|
||||||
|
|
||||||
|
expect(dimensions).not.toBeNull();
|
||||||
|
// Should have minimum bounds applied (80 columns, 24 rows)
|
||||||
|
expect(dimensions!.columns).toBeGreaterThanOrEqual(80);
|
||||||
|
expect(dimensions!.rows).toBeGreaterThanOrEqual(24);
|
||||||
|
expect(dimensions!.isMinimumSize).toBe(true);
|
||||||
|
expect(dimensions!.extraColumns).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not crash when rendered", () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const dims = useTerminalDimensions();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = render(<TestComponent />);
|
||||||
|
expect(() => instance.unmount()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeColumnLayout", () => {
|
||||||
|
it("returns empty layout for no columns", () => {
|
||||||
|
const layout = computeColumnLayout(100, []);
|
||||||
|
expect(layout.widths).toEqual([]);
|
||||||
|
expect(layout.totalWidth).toBe(0);
|
||||||
|
expect(layout.remainingColumns).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses minimum widths when at or below minimum total", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10 },
|
||||||
|
{ minWidth: 20 },
|
||||||
|
{ minWidth: 15 },
|
||||||
|
];
|
||||||
|
const layout = computeColumnLayout(80, definitions);
|
||||||
|
|
||||||
|
expect(layout.widths).toEqual([10, 20, 15]);
|
||||||
|
expect(layout.totalWidth).toBe(45);
|
||||||
|
expect(layout.remainingColumns).toBe(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distributes extra columns proportionally by default", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10, canGrow: true, growWeight: 1 },
|
||||||
|
{ minWidth: 20, canGrow: true, growWeight: 2 },
|
||||||
|
{ minWidth: 15, canGrow: false }, // Won't grow
|
||||||
|
];
|
||||||
|
const layout = computeColumnLayout(100, definitions);
|
||||||
|
|
||||||
|
// Minimum total: 45, Extra: 55
|
||||||
|
// Total weight: 3 (1+2)
|
||||||
|
// Column 0: 10 + floor(55 * 1 / 3) = 10 + 18 = 28
|
||||||
|
// Column 1: 20 + floor(55 * 2 / 3) = 20 + 36 = 56 (but due to rounding in loop, becomes 57)
|
||||||
|
// Column 2: 15 (doesn't grow)
|
||||||
|
expect(layout.widths[0]).toBe(28);
|
||||||
|
// Note: Due to rounding, the last growable column gets the remainder
|
||||||
|
expect(layout.widths[2]).toBe(15);
|
||||||
|
expect(layout.totalWidth).toBe(100);
|
||||||
|
// Verify proportional distribution (columns 0 and 1 should be ~2x each other)
|
||||||
|
expect(layout.widths[0]).toBeLessThan(layout.widths[1]);
|
||||||
|
expect(layout.widths[1] / layout.widths[0]).toBeGreaterThan(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distributes extra columns equally with 'equal' strategy", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10, canGrow: true },
|
||||||
|
{ minWidth: 20, canGrow: true },
|
||||||
|
{ minWidth: 15, canGrow: true },
|
||||||
|
];
|
||||||
|
const layout = computeColumnLayout(100, definitions, "equal");
|
||||||
|
|
||||||
|
// Minimum total: 45, Extra: 55
|
||||||
|
// Extra per growable: floor(55 / 3) = 18
|
||||||
|
// Remainder: 55 % 3 = 1
|
||||||
|
expect(layout.widths[0]).toBe(28); // 10 + 18
|
||||||
|
expect(layout.widths[1]).toBe(38); // 20 + 18
|
||||||
|
expect(layout.widths[2]).toBe(33); // 15 + 18
|
||||||
|
expect(layout.remainingColumns).toBe(1); // Rounding remainder
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not distribute extra columns with 'fixed' strategy", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10, canGrow: true },
|
||||||
|
{ minWidth: 20, canGrow: true },
|
||||||
|
];
|
||||||
|
const layout = computeColumnLayout(100, definitions, "fixed");
|
||||||
|
|
||||||
|
expect(layout.widths).toEqual([10, 20]);
|
||||||
|
expect(layout.totalWidth).toBe(30);
|
||||||
|
expect(layout.remainingColumns).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes content-heavy columns with 'content-heavy' strategy", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10, canGrow: true, preferredWidth: 20 }, // Needs 10 more
|
||||||
|
{ minWidth: 30, canGrow: true, preferredWidth: 35 }, // Needs 5 more
|
||||||
|
{ minWidth: 15, canGrow: false }, // Won't grow
|
||||||
|
];
|
||||||
|
const layout = computeColumnLayout(100, definitions, "content-heavy");
|
||||||
|
|
||||||
|
// Minimum total: 55, Extra: 45
|
||||||
|
// Content scores: 10, 5, 0
|
||||||
|
// Total score: 15
|
||||||
|
// Column 0: 10 + floor(45 * 10 / 15) = 10 + 30 = 40
|
||||||
|
// Column 1: 30 + floor(45 * 5 / 15) = 30 + 15 = 45
|
||||||
|
// Column 2: 15 (doesn't grow)
|
||||||
|
expect(layout.widths[0]).toBe(40);
|
||||||
|
expect(layout.widths[1]).toBe(45);
|
||||||
|
expect(layout.widths[2]).toBe(15);
|
||||||
|
expect(layout.totalWidth).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles edge case of exactly minimum width", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10, canGrow: true },
|
||||||
|
{ minWidth: 20, canGrow: true },
|
||||||
|
];
|
||||||
|
const layout = computeColumnLayout(30, definitions);
|
||||||
|
|
||||||
|
expect(layout.widths).toEqual([10, 20]);
|
||||||
|
expect(layout.totalWidth).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults growWeight to 1", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10, canGrow: true }, // Default weight: 1
|
||||||
|
{ minWidth: 20, canGrow: true }, // Default weight: 1
|
||||||
|
];
|
||||||
|
const layout = computeColumnLayout(100, definitions, "proportional");
|
||||||
|
|
||||||
|
// Both columns grow equally since weights are equal
|
||||||
|
expect(layout.widths[0]).toBeGreaterThan(10);
|
||||||
|
expect(layout.widths[1]).toBeGreaterThan(20);
|
||||||
|
expect(layout.totalWidth).toBe(100);
|
||||||
|
// Both should be allocated more than their minimum
|
||||||
|
expect(layout.widths[0] + layout.widths[1]).toBeGreaterThan(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("truncate.ts", () => {
|
||||||
|
describe("truncateText", () => {
|
||||||
|
it("returns text unchanged when it fits", () => {
|
||||||
|
expect(truncateText("Hello", 10)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns text unchanged when it exactly fits", () => {
|
||||||
|
expect(truncateText("Hello", 5)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates with ellipsis when text exceeds width", () => {
|
||||||
|
expect(truncateText("Hello World", 8)).toBe("Hello W…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns single ellipsis when width is very small", () => {
|
||||||
|
expect(truncateText("Hello", 1)).toBe("…");
|
||||||
|
expect(truncateText("Hello", 2)).toBe("…");
|
||||||
|
expect(truncateText("Hello", 3)).toBe("…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns truncated ellipsis when width is exactly 1-3", () => {
|
||||||
|
expect(truncateText("Hello", 1)).toBe("…");
|
||||||
|
expect(truncateText("Hello", 2)).toBe("…");
|
||||||
|
expect(truncateText("Hello", 3)).toBe("…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom ellipsis", () => {
|
||||||
|
// At width 10, available is 10 - 2 (for "~~") = 8
|
||||||
|
expect(truncateText("Hello World", 10, "~~")).toBe("Hello Wo~~");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
expect(truncateText("", 10)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero width", () => {
|
||||||
|
expect(truncateText("Hello", 0)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles negative width", () => {
|
||||||
|
expect(truncateText("Hello", -5)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates very long text correctly", () => {
|
||||||
|
const longText = "a".repeat(1000);
|
||||||
|
expect(truncateText(longText, 10)).toBe("aaaaaaaaa…");
|
||||||
|
expect(truncateText(longText, 10).length).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("truncateWithOptions", () => {
|
||||||
|
it("respects preserveWords option", () => {
|
||||||
|
const text = "Hello World Example";
|
||||||
|
// At width 12, without preserveWords: "Hello World…"
|
||||||
|
// With preserveWords: looks for space near boundary
|
||||||
|
const result = truncateWithOptions(text, 12, { preserveWords: true });
|
||||||
|
// Should not break mid-word
|
||||||
|
expect(result).not.toMatch(/^[^ ]* …$/); // No mid-word break
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects custom ellipsis option", () => {
|
||||||
|
// At width 8 with "~~" (2 chars), available is 8 - 2 = 6
|
||||||
|
const result = truncateWithOptions("Hello World", 8, { ellipsis: "~~" });
|
||||||
|
expect(result).toBe("Hello ~~");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects minTruncateWidth option", () => {
|
||||||
|
// With minTruncateWidth of 6, at width 5 should use ellipsis
|
||||||
|
const result = truncateWithOptions("Hello World", 5, { minTruncateWidth: 6 });
|
||||||
|
expect(result).toBe("…");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("padText", () => {
|
||||||
|
it("pads text to the right by default", () => {
|
||||||
|
expect(padText("Hi", 6)).toBe("Hi ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pads text to the left with right alignment", () => {
|
||||||
|
expect(padText("Hi", 6, "right")).toBe(" Hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centers text with center alignment", () => {
|
||||||
|
expect(padText("Hi", 6, "center")).toBe(" Hi ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pad when text equals width", () => {
|
||||||
|
expect(padText("Hello", 5)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates when text exceeds width", () => {
|
||||||
|
expect(padText("Hello World", 5)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero width", () => {
|
||||||
|
expect(padText("Hello", 0)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fitText", () => {
|
||||||
|
it("pads short text to fill width", () => {
|
||||||
|
expect(fitText("Hi", 6)).toBe("Hi ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long text when no ellipsis specified", () => {
|
||||||
|
expect(fitText("Hello World", 6)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates with ellipsis when specified", () => {
|
||||||
|
expect(fitText("Hello World", 8, "left", "…")).toBe("Hello W…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects alignment when padding", () => {
|
||||||
|
expect(fitText("Hi", 6, "center")).toBe(" Hi ");
|
||||||
|
expect(fitText("Hi", 6, "right")).toBe(" Hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles edge case of equal width", () => {
|
||||||
|
expect(fitText("Hello", 5)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero width", () => {
|
||||||
|
expect(fitText("Hello", 0)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("integration: column layout with truncation", () => {
|
||||||
|
it("computes layout and truncates content to fit", () => {
|
||||||
|
const columns = 80;
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 8 }, // ID
|
||||||
|
{ minWidth: 40, canGrow: true }, // Description
|
||||||
|
{ minWidth: 10 }, // Status
|
||||||
|
{ minWidth: 12, canGrow: true }, // Created
|
||||||
|
{ minWidth: 10 }, // Priority
|
||||||
|
];
|
||||||
|
|
||||||
|
const layout = computeColumnLayout(columns, definitions);
|
||||||
|
|
||||||
|
// Verify widths are calculated
|
||||||
|
expect(layout.widths.length).toBe(5);
|
||||||
|
expect(layout.totalWidth).toBeLessThanOrEqual(columns);
|
||||||
|
|
||||||
|
// Simulate truncating content to fit
|
||||||
|
const longDescription = "This is a very long task description that needs truncation";
|
||||||
|
const truncated = truncateText(longDescription, layout.widths[1]);
|
||||||
|
expect(truncated.length).toBeLessThanOrEqual(layout.widths[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces deterministic layout at minimum terminal width", () => {
|
||||||
|
const definitions: ColumnDefinition[] = [
|
||||||
|
{ minWidth: 10, canGrow: true },
|
||||||
|
{ minWidth: 30, canGrow: true },
|
||||||
|
{ minWidth: 15, canGrow: true },
|
||||||
|
{ minWidth: 25, canGrow: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Run multiple times to verify determinism
|
||||||
|
const layout1 = computeColumnLayout(80, definitions);
|
||||||
|
const layout2 = computeColumnLayout(80, definitions);
|
||||||
|
|
||||||
|
expect(layout1.widths).toEqual(layout2.widths);
|
||||||
|
expect(layout1.totalWidth).toBe(layout2.totalWidth);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,3 +14,13 @@ export {
|
|||||||
type ScreenRouterProps,
|
type ScreenRouterProps,
|
||||||
type ScreenComponentProps,
|
type ScreenComponentProps,
|
||||||
} from "./screen-router.js";
|
} from "./screen-router.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ResponsiveHeader,
|
||||||
|
ResponsiveTable,
|
||||||
|
ResponsiveTaskRow,
|
||||||
|
ResponsiveStatusBar,
|
||||||
|
type TableColumn,
|
||||||
|
type ResponsiveTableProps,
|
||||||
|
type ResponsiveTaskRowProps,
|
||||||
|
} from "./responsive-layout.js";
|
||||||
|
|||||||
205
packages/tui/src/components/responsive-layout.tsx
Normal file
205
packages/tui/src/components/responsive-layout.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Responsive layout components for TUI.
|
||||||
|
*
|
||||||
|
* Provides components that adapt to terminal dimensions using the
|
||||||
|
* terminal dimension and truncation utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
import { useTerminalDimensions, computeColumnLayout } from "../utils/terminal.js";
|
||||||
|
import { truncateText } from "../utils/truncate.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResponsiveHeader - A header that adapts to terminal width.
|
||||||
|
*
|
||||||
|
* At minimum width (80 columns), shows compact header.
|
||||||
|
* At wider widths, shows additional context information.
|
||||||
|
*/
|
||||||
|
export function ResponsiveHeader({ title }: { title: string }): React.ReactNode {
|
||||||
|
const { columns, isMinimumSize } = useTerminalDimensions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold>{title}</Text>
|
||||||
|
{!isMinimumSize && columns >= 100 && (
|
||||||
|
<Text dimColor> — Extended view</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{!isMinimumSize && (
|
||||||
|
<Text dimColor>Width: {columns} columns</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column configuration for responsive tables.
|
||||||
|
*/
|
||||||
|
export interface TableColumn {
|
||||||
|
/** Column header text */
|
||||||
|
header: string;
|
||||||
|
/** Minimum width */
|
||||||
|
minWidth: number;
|
||||||
|
/** Preferred width for content-heavy columns */
|
||||||
|
preferredWidth?: number;
|
||||||
|
/** Whether column can grow */
|
||||||
|
canGrow?: boolean;
|
||||||
|
/** Growth weight relative to other growable columns */
|
||||||
|
growWeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResponsiveTable - A table component that adapts to terminal width.
|
||||||
|
*
|
||||||
|
* Computes column widths based on available terminal columns and
|
||||||
|
* applies truncation to cell content that exceeds column width.
|
||||||
|
*/
|
||||||
|
export interface ResponsiveTableProps {
|
||||||
|
/** Column definitions */
|
||||||
|
columns: TableColumn[];
|
||||||
|
/** Row data as arrays of strings */
|
||||||
|
rows: string[][];
|
||||||
|
/** Gap between columns */
|
||||||
|
gap?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate column widths for the table based on terminal dimensions.
|
||||||
|
*/
|
||||||
|
function calculateTableColumnWidths(
|
||||||
|
terminalColumns: number,
|
||||||
|
columns: TableColumn[],
|
||||||
|
gap: number
|
||||||
|
): number[] {
|
||||||
|
const availableForColumns = terminalColumns - (columns.length - 1) * gap;
|
||||||
|
const layout = computeColumnLayout(
|
||||||
|
availableForColumns,
|
||||||
|
columns.map((col) => ({
|
||||||
|
minWidth: col.minWidth,
|
||||||
|
preferredWidth: col.preferredWidth,
|
||||||
|
canGrow: col.canGrow,
|
||||||
|
growWeight: col.growWeight ?? 1,
|
||||||
|
})),
|
||||||
|
"proportional"
|
||||||
|
);
|
||||||
|
return layout.widths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveTable({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
gap = 2,
|
||||||
|
}: ResponsiveTableProps): React.ReactNode {
|
||||||
|
const { columns: terminalColumns } = useTerminalDimensions();
|
||||||
|
|
||||||
|
const columnWidths = calculateTableColumnWidths(terminalColumns, columns, gap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Header row */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<Box key={col.header} width={columnWidths[i]} marginRight={i < columns.length - 1 ? gap : 0}>
|
||||||
|
<Text bold underline>{col.header}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<Box key={`div-${col.header}`} width={columnWidths[i]} marginRight={i < columns.length - 1 ? gap : 0}>
|
||||||
|
<Text dimColor>{"─".repeat(Math.min(col.minWidth, 20))}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Data rows */}
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<Box key={`row-${rowIndex}`} flexDirection="row">
|
||||||
|
{row.map((cell, cellIndex) => {
|
||||||
|
const width = columnWidths[cellIndex];
|
||||||
|
const truncatedCell = truncateText(cell, width);
|
||||||
|
return (
|
||||||
|
<Box key={`cell-${rowIndex}-${cellIndex}`} width={width} marginRight={cellIndex < row.length - 1 ? gap : 0}>
|
||||||
|
<Text>{truncatedCell}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResponsiveTaskRow - A single task row that truncates content.
|
||||||
|
*
|
||||||
|
* Displays task ID, description (truncated), and status with
|
||||||
|
* ellipsis for overflow content.
|
||||||
|
*/
|
||||||
|
export interface ResponsiveTaskRowProps {
|
||||||
|
/** Task ID */
|
||||||
|
id: string;
|
||||||
|
/** Task description */
|
||||||
|
description: string;
|
||||||
|
/** Task status */
|
||||||
|
status: string;
|
||||||
|
/** Minimum ID column width */
|
||||||
|
idWidth?: number;
|
||||||
|
/** Minimum status column width */
|
||||||
|
statusWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveTaskRow({
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
idWidth = 10,
|
||||||
|
statusWidth = 12,
|
||||||
|
}: ResponsiveTaskRowProps): React.ReactNode {
|
||||||
|
const { columns } = useTerminalDimensions();
|
||||||
|
|
||||||
|
// Calculate available width for description
|
||||||
|
const reservedWidth = idWidth + statusWidth + 4; // 4 for gaps
|
||||||
|
const descriptionWidth = Math.max(20, columns - reservedWidth);
|
||||||
|
|
||||||
|
const truncatedDescription = truncateText(description, descriptionWidth);
|
||||||
|
const truncatedStatus = truncateText(status, statusWidth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={idWidth}>
|
||||||
|
<Text bold>{id}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={descriptionWidth} marginLeft={2}>
|
||||||
|
<Text>{truncatedDescription}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={statusWidth} marginLeft={2}>
|
||||||
|
<Text dimColor>{truncatedStatus}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResponsiveStatusBar - A status bar showing terminal dimensions.
|
||||||
|
*
|
||||||
|
* Useful for debugging responsive layout issues.
|
||||||
|
*/
|
||||||
|
export function ResponsiveStatusBar(): React.ReactNode {
|
||||||
|
const { columns, rows, isMinimumSize } = useTerminalDimensions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderStyle="single" borderTop={true} borderLeft={false} borderRight={false} borderBottom={false} marginTop={1}>
|
||||||
|
<Text dimColor>
|
||||||
|
Terminal: {columns}×{rows}
|
||||||
|
{isMinimumSize && " (minimum)"}
|
||||||
|
{" | "}
|
||||||
|
Minimum: 80×24
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,16 @@ export {
|
|||||||
type ScreenComponentProps,
|
type ScreenComponentProps,
|
||||||
} from "./components/screen-router.js";
|
} from "./components/screen-router.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ResponsiveHeader,
|
||||||
|
ResponsiveTable,
|
||||||
|
ResponsiveTaskRow,
|
||||||
|
ResponsiveStatusBar,
|
||||||
|
type TableColumn,
|
||||||
|
type ResponsiveTableProps,
|
||||||
|
type ResponsiveTaskRowProps,
|
||||||
|
} from "./components/responsive-layout.js";
|
||||||
|
|
||||||
// Re-export global shortcuts hooks
|
// Re-export global shortcuts hooks
|
||||||
export {
|
export {
|
||||||
useGlobalShortcuts,
|
useGlobalShortcuts,
|
||||||
@@ -39,6 +49,7 @@ import { render, Box, Text } from "ink";
|
|||||||
import { FusionProvider, useFusion } from "./fusion-context.js";
|
import { FusionProvider, useFusion } from "./fusion-context.js";
|
||||||
import { ScreenRouter, type ScreenId } from "./components/screen-router.js";
|
import { ScreenRouter, type ScreenId } from "./components/screen-router.js";
|
||||||
import { useGlobalShortcuts, HelpOverlay } from "./hooks/use-global-shortcuts.js";
|
import { useGlobalShortcuts, HelpOverlay } from "./hooks/use-global-shortcuts.js";
|
||||||
|
import { ResponsiveHeader, ResponsiveTable, ResponsiveTaskRow, ResponsiveStatusBar } from "./components/responsive-layout.js";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,12 +75,8 @@ function DemoApp() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Responsive Header */}
|
||||||
<Box paddingBottom={1}>
|
<ResponsiveHeader title={`Fusion TUI | Project: ${projectPath}`} />
|
||||||
<Text bold>Fusion TUI</Text>
|
|
||||||
<Text> | Project: {projectPath}</Text>
|
|
||||||
<Text dimColor> (Press ? for help)</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Screen Router */}
|
{/* Screen Router */}
|
||||||
<ScreenRouter
|
<ScreenRouter
|
||||||
@@ -82,6 +89,24 @@ function DemoApp() {
|
|||||||
<Box flexDirection="column" paddingY={1}>
|
<Box flexDirection="column" paddingY={1}>
|
||||||
<Text bold>Board Screen</Text>
|
<Text bold>Board Screen</Text>
|
||||||
<Text dimColor>View and manage tasks on the kanban board</Text>
|
<Text dimColor>View and manage tasks on the kanban board</Text>
|
||||||
|
|
||||||
|
{/* Demo: Responsive Task Table */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<ResponsiveTable
|
||||||
|
columns={[
|
||||||
|
{ header: "ID", minWidth: 10 },
|
||||||
|
{ header: "Description", minWidth: 30, canGrow: true, preferredWidth: 60 },
|
||||||
|
{ header: "Status", minWidth: 12 },
|
||||||
|
{ header: "Size", minWidth: 6 },
|
||||||
|
]}
|
||||||
|
rows={[
|
||||||
|
["FN-001", "Implement user authentication with OAuth 2.0 integration", "todo", "M"],
|
||||||
|
["FN-002", "Fix memory leak in data processing pipeline caused by missing cleanup handlers", "in-progress", "L"],
|
||||||
|
["FN-003", "Update documentation", "done", "S"],
|
||||||
|
["FN-004", "Refactor API endpoints to use REST conventions and add proper error handling with retry logic", "review", "M"],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{activeScreen === "detail" && (
|
{activeScreen === "detail" && (
|
||||||
@@ -111,6 +136,9 @@ function DemoApp() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</ScreenRouter>
|
</ScreenRouter>
|
||||||
|
|
||||||
|
{/* Responsive Status Bar */}
|
||||||
|
<ResponsiveStatusBar />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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