3.4 Layouts & Columns
A real app has multiple panels side by side — a sidebar, a main content area, a status bar. This lesson builds a simple but powerful layout system.
The Layout Model
Section titled “The Layout Model”┌──────────────────────────────────────────────────────────────────────┐│ Header bar (fixed: 1 row) │├────────────────────────┬─────────────────────────────────────────────┤│ Sidebar │ Main content ││ (fixed: 20 cols) │ (flex: fills remaining width) ││ │ ││ │ │├────────────────────────┴─────────────────────────────────────────────┤│ Status bar (fixed: 1 row) │└──────────────────────────────────────────────────────────────────────┘Two layout strategies:
- Fixed: a panel occupies a fixed number of rows or columns
- Flex / Fraction: a panel takes a fraction of the remaining space
Layout Primitives
Section titled “Layout Primitives”export type Panel = { col: number; // 1-indexed row: number; // 1-indexed width: number; height: number;};
type SizeSpec = { fixed: number } | { fraction: number };
export function hSplit(parent: Panel, specs: SizeSpec[]): Panel[] { const totalFixed = specs.reduce((s, sp) => s + ('fixed' in sp ? sp.fixed : 0), 0); const remaining = parent.width - totalFixed; const totalFraction = specs.reduce((s, sp) => s + ('fraction' in sp ? sp.fraction : 0), 0);
let col = parent.col; return specs.map(sp => { const w = 'fixed' in sp ? sp.fixed : Math.floor((sp.fraction / totalFraction) * remaining); const panel: Panel = { col, row: parent.row, width: w, height: parent.height }; col += w; return panel; });}
export function vSplit(parent: Panel, specs: SizeSpec[]): Panel[] { const totalFixed = specs.reduce((s, sp) => s + ('fixed' in sp ? sp.fixed : 0), 0); const remaining = parent.height - totalFixed; const totalFraction = specs.reduce((s, sp) => s + ('fraction' in sp ? sp.fraction : 0), 0);
let row = parent.row; return specs.map(sp => { const h = 'fixed' in sp ? sp.fixed : Math.floor((sp.fraction / totalFraction) * remaining); const panel: Panel = { col: parent.col, row, width: parent.width, height: h }; row += h; return panel; });}
export function screenPanel(): Panel { return { col: 1, row: 1, width: process.stdout.columns, height: process.stdout.rows };}Using the Layout System
Section titled “Using the Layout System”import chalk from 'chalk';import ansiEscapes from 'ansi-escapes';import { screenPanel, hSplit, vSplit, type Panel } from './layout';import { drawBox } from './draw-box';
function fillPanel(panel: Panel, color: (s: string) => string, label: string) { drawBox({ col: panel.col, row: panel.row, width: panel.width, height: panel.height, title: label, borderStyle: 'single', borderColor: color, });}
function render() { process.stdout.write(ansiEscapes.cursorTo(0, 0)); process.stdout.write(ansiEscapes.clearScreen);
const screen = screenPanel();
// Split screen vertically: header(1) + body(flex) + footer(1) const [header, body, footer] = vSplit(screen, [ { fixed: 3 }, // header { fraction: 1 }, // body { fixed: 3 }, // footer ]);
// Split body horizontally: sidebar(20) + main(flex) const [sidebar, main] = hSplit(body, [ { fixed: 22 }, // sidebar { fraction: 1 }, // main content ]);
// Split main vertically into two equal panes const [topPane, bottomPane] = vSplit(main, [ { fraction: 1 }, { fraction: 1 }, ]);
fillPanel(header, chalk.magenta, 'Header'); fillPanel(sidebar, chalk.cyan, 'Sidebar'); fillPanel(topPane, chalk.green, 'Main Top'); fillPanel(bottomPane, chalk.yellow, 'Main Bottom'); fillPanel(footer, chalk.blue, 'Footer');
// Show dimensions const showDims = (p: Panel, label: string, row: number) => { process.stdout.write(ansiEscapes.cursorTo(p.col, p.row + 1)); process.stdout.write(chalk.dim(`${p.width}×${p.height}`)); };
showDims(header, 'header', 0); showDims(sidebar, 'sidebar', 0); showDims(topPane, 'top', 0); showDims(bottomPane, 'bottom', 0); showDims(footer, 'footer', 0);}
function cleanup() { process.stdin.setRawMode(false); process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0);}
process.stdout.write(ansiEscapes.enterAlternativeScreen);process.stdout.write(ansiEscapes.cursorHide);process.stdout.write(ansiEscapes.clearScreen);process.on('SIGINT', cleanup);process.on('SIGTERM', cleanup);process.stdout.on('resize', render);
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.setEncoding('utf8');process.stdin.on('data', (k: string) => { if (k === 'q' || k === '\x03') cleanup(); });
render();Resize the terminal — the layout reflows automatically.
Inset Utility
Section titled “Inset Utility”Most panels want inner content that doesn’t overlap borders. An inset helper shrinks a panel by its border:
export function inset(panel: Panel, amount = 1): Panel { return { col: panel.col + amount, row: panel.row + amount, width: panel.width - amount * 2, height: panel.height - amount * 2, };}
// Usage: render content inside a boxed panelconst contentArea = inset(sidebar);// contentArea.col/row are inside the border charactersA Note on Rounding
Section titled “A Note on Rounding”Math.floor in hSplit/vSplit means that when the screen width isn’t evenly divisible by fractions, the last panel may be 1 column narrower. For most TUIs this is invisible and acceptable. If pixel-perfect layouts matter, track the accumulated width and assign the remainder to the last panel.