From 74fe2cad9fb1c81390cd1f36c75427708dfce973 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:55:00 +0200 Subject: [PATCH] feat: add the TUI responsive layout math Below 72 columns the preview pane is dropped and rows take two lines, the constraint the `sc` chooser was built around and the reason it is still usable on a phone; above it a clamped sidebar carries the list and the preview takes the rest. Every region is clamped to a non-negative size, so a 5x5 terminal degrades to a header instead of handing the renderer negative widths. Co-Authored-By: Claude Fable 5 --- src/tui/tui-layout.ts | 137 ++++++++++++++++++++++++++++++++++++ test/tui/tui-layout.test.ts | 133 ++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/tui/tui-layout.ts create mode 100644 test/tui/tui-layout.test.ts diff --git a/src/tui/tui-layout.ts b/src/tui/tui-layout.ts new file mode 100644 index 00000000..664d6da4 --- /dev/null +++ b/src/tui/tui-layout.ts @@ -0,0 +1,137 @@ +/** + * @fileoverview Pure responsive layout math for the TUI frame. + * + * One rule decides the shape: below 72 columns (Termius, iPhone portrait) the + * preview pane is gone and rows take two lines, which is the constraint the + * `sc` chooser was built around and the reason it is still usable on a phone. + * Above it, a clamped sidebar carries the session list and the preview takes + * the rest. + * + * Rectangles are 1-based (row 1, column 1 is the top-left cell) because that is + * what `ESC [ ; H` takes, and every region is clamped to a + * non-negative size so a 5x5 terminal degrades instead of producing negative + * widths that would crash the renderer. + * + * @module tui/tui-layout + */ + +import type { TuiConnectionStatus } from './tui-types.js'; + +/** Width at which the preview pane is dropped and rows become two lines. */ +export const NARROW_BREAKPOINT = 72; +/** Sidebar clamp: narrower than this and a session name stops being readable. */ +export const SIDEBAR_MIN_WIDTH = 34; +/** Sidebar clamp: wider than this is wasted on a list of short names. */ +export const SIDEBAR_MAX_WIDTH = 44; +/** A preview thinner than this shows nothing useful, so the layout goes narrow instead. */ +export const PREVIEW_MIN_WIDTH = 24; +/** Share of the width the sidebar aims for between the clamps. */ +const SIDEBAR_RATIO = 0.36; + +export interface TuiRect { + /** 1-based terminal row of the first line. */ + row: number; + /** 1-based terminal column of the first cell. */ + col: number; + width: number; + height: number; +} + +export interface TuiLayoutOptions { + /** + * Reserve one line under the header for the connection banner. The caller + * decides with `needsBanner(model.connection)`, so layout stays pure math. + */ + banner?: boolean; +} + +export interface TuiLayout { + cols: number; + rows: number; + /** No preview pane, two-line rows. */ + narrow: boolean; + /** Terminal lines one session row occupies. */ + rowHeight: 1 | 2; + header: TuiRect; + /** Connection banner, when the caller asked for one and there was room. */ + banner: TuiRect | null; + /** Everything between header and footer, banner included. */ + body: TuiRect; + /** The session list. */ + list: TuiRect; + /** The one-column rule between list and preview; null in narrow mode. */ + divider: TuiRect | null; + /** The preview pane; null in narrow mode. */ + preview: TuiRect | null; + footer: TuiRect; +} + +/** Which connection states get a banner line under the header. */ +export function needsBanner(connection: TuiConnectionStatus): boolean { + return connection !== 'connected'; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * Rectangles for one frame at `cols` x `rows`. + * + * The header always exists; the footer appears from 2 rows up; the body is + * whatever is left, which may legitimately be zero lines high. + */ +export function computeLayout(cols: number, rows: number, options: TuiLayoutOptions = {}): TuiLayout { + const width = Math.max(1, Math.floor(cols) || 1); + const height = Math.max(1, Math.floor(rows) || 1); + + const headerHeight = 1; + const footerHeight = height >= 2 ? 1 : 0; + const bodyHeight = Math.max(0, height - headerHeight - footerHeight); + const bodyRow = headerHeight + 1; + + const header: TuiRect = { row: 1, col: 1, width, height: headerHeight }; + const footer: TuiRect = { row: height, col: 1, width, height: footerHeight }; + const body: TuiRect = { row: bodyRow, col: 1, width, height: bodyHeight }; + + const bannerHeight = options.banner === true && bodyHeight > 0 ? 1 : 0; + const banner: TuiRect | null = bannerHeight > 0 ? { row: bodyRow, col: 1, width, height: 1 } : null; + + const contentRow = bodyRow + bannerHeight; + const contentHeight = Math.max(0, bodyHeight - bannerHeight); + + const sidebarTarget = Math.floor(width * SIDEBAR_RATIO); + const sidebarWidth = clamp(sidebarTarget, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH); + const previewWidth = width - sidebarWidth - 1; + const narrow = width < NARROW_BREAKPOINT || previewWidth < PREVIEW_MIN_WIDTH; + + if (narrow) { + return { + cols: width, + rows: height, + narrow: true, + rowHeight: 2, + header, + banner, + body, + list: { row: contentRow, col: 1, width, height: contentHeight }, + divider: null, + preview: null, + footer, + }; + } + + return { + cols: width, + rows: height, + narrow: false, + rowHeight: 1, + header, + banner, + body, + list: { row: contentRow, col: 1, width: sidebarWidth, height: contentHeight }, + divider: { row: contentRow, col: sidebarWidth + 1, width: 1, height: contentHeight }, + preview: { row: contentRow, col: sidebarWidth + 2, width: previewWidth, height: contentHeight }, + footer, + }; +} diff --git a/test/tui/tui-layout.test.ts b/test/tui/tui-layout.test.ts new file mode 100644 index 00000000..031a4a60 --- /dev/null +++ b/test/tui/tui-layout.test.ts @@ -0,0 +1,133 @@ +/** + * @fileoverview Unit tests for the responsive layout math. + * + * Two things are pinned here because the renderer trusts them blindly: the + * regions tile the screen exactly (no gaps, no overlap, full coverage), and no + * region is ever negative, however small or absurd the terminal gets. + */ +import { describe, it, expect } from 'vitest'; +import { + computeLayout, + needsBanner, + NARROW_BREAKPOINT, + SIDEBAR_MAX_WIDTH, + SIDEBAR_MIN_WIDTH, + type TuiLayout, +} from '../../src/tui/tui-layout.js'; + +function rects(layout: TuiLayout) { + return [layout.header, layout.banner, layout.body, layout.list, layout.divider, layout.preview, layout.footer]; +} + +function expectSane(layout: TuiLayout): void { + for (const rect of rects(layout)) { + if (!rect) continue; + expect(rect.width).toBeGreaterThanOrEqual(0); + expect(rect.height).toBeGreaterThanOrEqual(0); + expect(rect.row).toBeGreaterThanOrEqual(1); + expect(rect.col).toBeGreaterThanOrEqual(1); + expect(rect.col + rect.width - 1).toBeLessThanOrEqual(Math.max(1, layout.cols)); + if (rect.height > 0) expect(rect.row + rect.height - 1).toBeLessThanOrEqual(layout.rows); + } + expect(layout.header.height + layout.body.height + layout.footer.height).toBe(layout.rows); +} + +describe('computeLayout', () => { + it('stacks header, body and footer with no gap', () => { + const layout = computeLayout(100, 30); + expect(layout.header).toEqual({ row: 1, col: 1, width: 100, height: 1 }); + expect(layout.body.row).toBe(2); + expect(layout.body.height).toBe(28); + expect(layout.footer).toEqual({ row: 30, col: 1, width: 100, height: 1 }); + expectSane(layout); + }); + + it('splits a wide body into sidebar, divider and preview covering every column', () => { + const layout = computeLayout(100, 30); + expect(layout.narrow).toBe(false); + expect(layout.rowHeight).toBe(1); + expect(layout.list.width).toBe(36); + expect(layout.divider).toEqual({ row: 2, col: 37, width: 1, height: 28 }); + expect(layout.preview).toEqual({ row: 2, col: 38, width: 63, height: 28 }); + expect(layout.list.width + 1 + (layout.preview?.width ?? 0)).toBe(layout.cols); + }); + + it('clamps the sidebar at both ends', () => { + expect(computeLayout(NARROW_BREAKPOINT, 30).list.width).toBe(SIDEBAR_MIN_WIDTH); + expect(computeLayout(200, 30).list.width).toBe(SIDEBAR_MAX_WIDTH); + expect(computeLayout(400, 30).list.width).toBe(SIDEBAR_MAX_WIDTH); + }); + + it('drops the preview and doubles the row height below the breakpoint', () => { + const narrow = computeLayout(NARROW_BREAKPOINT - 1, 24); + expect(narrow.narrow).toBe(true); + expect(narrow.rowHeight).toBe(2); + expect(narrow.preview).toBeNull(); + expect(narrow.divider).toBeNull(); + expect(narrow.list.width).toBe(NARROW_BREAKPOINT - 1); + expect(computeLayout(NARROW_BREAKPOINT, 24).narrow).toBe(false); + expectSane(narrow); + }); + + it('carves the banner out of the top of the body when asked', () => { + const plain = computeLayout(100, 30); + const banner = computeLayout(100, 30, { banner: true }); + expect(plain.banner).toBeNull(); + expect(banner.banner).toEqual({ row: 2, col: 1, width: 100, height: 1 }); + expect(banner.body.height).toBe(plain.body.height); + expect(banner.list.row).toBe(plain.list.row + 1); + expect(banner.list.height).toBe(plain.list.height - 1); + expectSane(banner); + }); + + it('degrades on a tiny terminal without producing negative sizes', () => { + for (const [cols, rows] of [ + [5, 5], + [1, 1], + [1, 2], + [3, 3], + [80, 2], + [80, 1], + ] as const) { + const layout = computeLayout(cols, rows, { banner: true }); + expectSane(layout); + // The shape is width-driven, never height-driven: an 80x1 terminal is + // still a wide one, it just has nowhere to put the body. + expect(layout.narrow).toBe(cols < NARROW_BREAKPOINT); + } + const one = computeLayout(1, 1); + expect(one.body.height).toBe(0); + expect(one.footer.height).toBe(0); + const two = computeLayout(40, 2); + expect(two.body.height).toBe(0); + expect(two.banner).toBeNull(); + expect(two.footer.height).toBe(1); + }); + + it('clamps nonsense dimensions to one cell', () => { + for (const [cols, rows] of [ + [0, 0], + [-10, -10], + [Number.NaN, Number.NaN], + ] as const) { + const layout = computeLayout(cols, rows); + expect(layout.cols).toBe(1); + expect(layout.rows).toBe(1); + expectSane(layout); + } + }); + + it('floors fractional dimensions', () => { + expect(computeLayout(100.9, 30.9).cols).toBe(100); + expect(computeLayout(100.9, 30.9).rows).toBe(30); + }); +}); + +describe('needsBanner', () => { + it('is the caller-side rule for reserving the banner row', () => { + expect(needsBanner('connected')).toBe(false); + expect(needsBanner('reconnecting')).toBe(true); + expect(needsBanner('degraded')).toBe(true); + expect(needsBanner('down')).toBe(true); + }); +});