Give Every Column Its Fair Share: Per-Column Widths in the jterm Table Widget

September 18, 2026

Tables are the workhorse of every BBS screen. Message lists, who’s online, file areas, the sysop’s session dashboard — all of them are just rows and columns. And until this week, every column in a jterm Table got the same slice of the screen. Seven columns at 80 characters? Eleven characters each, whether you’re rendering a two-letter status flag or a 24-character username.

Equal division is the correct default — it’s fair, it’s predictable, and it requires the developer to decide nothing. But “requires no decisions” is a polite way of saying “offers no control.” A session table’s Screen column wants room to breathe; its Spy column (a two-character checkbox) is wasted space at eleven characters wide. The information density of the screen was being decided by arithmetic, not by design.

So Table now has per-column width constraints. This post walks through the API, the layout algorithm that resolves them, and the two design rules that kept it from becoming a configuration swamp.

The API: five setters

// exact share of usable width (terminal width minus separators)
table.setColumnWidthPct(2, 30.0);

// floors and ceilings, in characters or percent
table.setColumnMinWidth(0, 10);
table.setColumnMaxWidth(3, 20);
table.setColumnMinWidthPct(0, 12.0);
table.setColumnMaxWidthPct(1, 25.0);

A column pinned with setColumnWidthPct gets exactly its share — 50% and 50% fill the table edge to edge. Columns with only min/max bounds stay free to auto-size within them. And clearColumnWidthConstraints(col) returns a column to auto-sizing. Set nothing at all, and the layout is byte-identical to the old equal-share behavior; every existing screen renders exactly as it did before.

That last property mattered more than it sounds. A layout engine change that subtly reshuffles every existing screen is a bug factory. The legacy path is tested byte-for-byte.

The solver: three phases

Resolving constraints is a small resource-allocation problem: there’s a fixed budget (usable width) and a set of claims on it. The algorithm runs three phases.

Phase 1 — pin the claimed columns. Percentage columns take their exact share. Columns whose minimum exceeds their natural width get raised to it.

Phase 2 — fit the content. Every remaining column sizes to its natural width — the widest cell it needs to show — clamped by any min/max bounds.

Phase 3 — spread what’s left. Any unclaimed space is distributed one column at a time to columns that can grow, never pushing past a maximum.

The failure mode worth designing for is over-constraint. Suppose a sysop sets 80% + 30% + 40% — total 150% of the screen. The naive solver either overflows the terminal (garbage wrap) or collapses the last column to zero (invisible data). Instead, the solver treats the budget as hard: everything scales down proportionally, all columns keep their relative shares, and nothing falls below one character. The layout degrades gracefully instead of breaking. Same rule when a minimum exceeds a maximum: the maximum wins, every time, deterministically.

User         │IP          │Screen                │Conn  │Sess  │Status    │Spy
joe          │192.168.1.42│ThreadListScreen      │2m 13s│4m 02s│ACTIVE    │[x]
amelie       │[detached]  │MailScreen            │1h 22m│3d 05h│DETACHED  │[ ]

That’s Phosphor’s sysop dashboard — the first screen converted. Screen gets 30% (the widest content), the timestamp columns get small protected minimums, and on a narrow terminal the minimums keep the times legible while the hard clamp guarantees nothing overflows.

The two design rules

Rule 1: min and max speak both languages. Characters for when you know the content (“this renders m:ss timers, give it 6”), percentages for when you know the layout (“IP addresses get a sixth of the screen”). Forcing either unit everywhere would push every caller into string arithmetic. Both units clamp together, and a percentage minimum composes with a character maximum just fine.

Rule 2: constraints are clamps, not votes. Percentages set the target, min/max bound the result, and the budget always wins. There’s no priority ordering to learn, no “flex” weights competing with each other, no layout mode to pick. Every combination of settings resolves to a deterministic layout that fits the screen — the solver’s job is to make the bad cases merely suboptimal, never broken.

Try it

The changes are in the jterm repository (b7af31e on main), used in production on the Phosphor sysop dashboard, and covered by twelve new tests: exact shares, sum-to-available, both bound units, over-constrained overflow, min-above-max, invalid-argument handling, and restore-to-auto.

If you’re building screens on jterm, the short version is: set nothing until a layout bothers you, then constrain the one column that bothers you. The equal-share default is still doing 90% of the work — it just doesn’t get to overrule you anymore.

Phosphor is a digital third place you run yourself — a BBS with modern underpinnings, on the open web at phosphorbbs.net. The dev log publishes here as it ships; the RSS feed brings the next post to you.