Skip to content

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.

┌──────────────────────────────────────────────────────────────────────┐
│ 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
examples/module-3/layout.ts
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 };
}
examples/module-3/04-layouts.ts
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.

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 panel
const contentArea = inset(sidebar);
// contentArea.col/row are inside the border characters

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.