mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 [ <row>;<col> 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user