From 3692df2315b3f54984876240f762e62fa90b23eb Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:23:47 +0530 Subject: [PATCH] Add surface primitives for frames, fills and layout --- src/ui/PanelShell.ts | 7 +- src/ui/surface.ts | 150 ++++++++++++++++++++++++++++++++++++++++++ tests/surface.test.ts | 89 +++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 src/ui/surface.ts create mode 100644 tests/surface.test.ts diff --git a/src/ui/PanelShell.ts b/src/ui/PanelShell.ts index 5ee174b8..40fea4ca 100644 --- a/src/ui/PanelShell.ts +++ b/src/ui/PanelShell.ts @@ -1,6 +1,7 @@ import {background, foreground, UI_COLORS} from './palette.js'; -import {repeatToWidth, truncateAnsi, displayWidth} from '../util/text.js'; -import {GLYPHS} from './glyphs.js'; +import {truncateAnsi, displayWidth} from '../util/text.js'; +import {theme} from '../chroma/chroma.js'; +import {renderSurface} from './surface.js'; const RESET = '\u001B[0m'; const BOLD = '\u001B[1m'; @@ -51,5 +52,5 @@ export function renderTabStrip(tabs: readonly string[], selected: number, column /** Framing belongs to the live overlay, never to OutputBuffer or an archive. */ export function framePanel(rows: string[], columns: number): string[] { - return [`${foreground(UI_COLORS.separator)}${repeatToWidth(GLYPHS.separator, Math.max(1, columns))}${RESET}`, ...rows]; + return renderSurface(rows, columns, {frame: 'topLine', frameColor: theme('separator')}); } diff --git a/src/ui/surface.ts b/src/ui/surface.ts new file mode 100644 index 00000000..42de65ee --- /dev/null +++ b/src/ui/surface.ts @@ -0,0 +1,150 @@ +import { + backgroundOf, foregroundOf, gradientCells, resolveColor, sampleGradient, type ColorRef, type Gradient, +} from '../chroma/chroma.js'; +import {colorEscape} from '../chroma/escape.js'; +import {graphemes} from '../input/inputLayout.js'; +import {displayWidth, repeatToWidth, truncateAnsi} from '../util/text.js'; +import {getCurrentGlyphMode} from './glyphs.js'; + +/** + * Surface geometry: where a panel's cells are (frame, padding, inset, width, + * alignment). Which colors those cells get is Chroma's job; a surface only + * holds color references. Frame and fill are independent. + */ +export type FrameStyle = 'none' | 'topLine' | 'square' | 'rounded' | 'double' | 'heavy'; +/** A single color reference, or a left-to-right gradient. */ +export type Paint = ColorRef | Gradient; + +export interface SurfaceSpec { + frame?: FrameStyle; + frameColor?: Paint; + fill?: Paint; + /** Blank columns/rows between the frame and the content. */ + padding?: {x?: number; y?: number}; + /** Columns left of the surface. */ + inset?: number; + /** `fill` spans the available width; `content` hugs the widest row. */ + width?: 'fill' | 'content'; + /** Where a `content`-width surface sits in the available width. */ + align?: 'left' | 'center' | 'right'; +} + +const RESET = '\u001B[0m'; +const SGR = /^\u001B\[[0-9;]*m/u; + +interface Box {tl: string; tr: string; bl: string; br: string; h: string; v: string} +const BOXES: Record<'square' | 'rounded' | 'double' | 'heavy', Box> = { + square: {tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│'}, + rounded: {tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│'}, + double: {tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║'}, + heavy: {tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃'}, +}; +const SAFE_BOX: Box = {tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|'}; + +function isGradient(paint: Paint): paint is Gradient { + return 'stops' in paint; +} + +function boxFor(frame: 'square' | 'rounded' | 'double' | 'heavy'): Box { + return getCurrentGlyphMode() === 'nerd' ? BOXES[frame] : SAFE_BOX; +} + +/** Foreground escape for cell `index` of `count` across a frame edge. */ +function edgeColor(paint: Paint | undefined, index: number, count: number): string { + if (!paint) return ''; + if (!isGradient(paint)) return foregroundOf(paint); + return colorEscape(38, sampleGradient(paint, count <= 1 ? 0 : index / (count - 1))); +} + +/** Background escape for a column of a filled row. */ +function cellFill(fill: Paint, column: number, width: number): string { + return isGradient(fill) + ? colorEscape(48, gradientCells(fill, Math.max(1, width))[Math.min(column, Math.max(0, width - 1))]!) + : backgroundOf(fill); +} + +/** Paints `row` (already ANSI-styled) onto a fill, keeping the fill behind every glyph and restoring it after resets. */ +function fillRow(row: string, fill: Paint, width: number, startColumn: number): string { + let out = ''; + let column = startColumn; + let index = 0; + let plainRun = ''; + const flush = () => { + for (const glyph of graphemes(plainRun)) { + out += `${cellFill(fill, column, width)}${glyph}`; + column += displayWidth(glyph); + } + plainRun = ''; + }; + while (index < row.length) { + const match = row[index] === '\u001B' ? SGR.exec(row.slice(index)) : null; + if (match) { flush(); out += match[0]; index += match[0].length; continue; } + const codePoint = row.codePointAt(index)!; + const character = String.fromCodePoint(codePoint); + plainRun += character; + index += character.length; + } + flush(); + return `${out}${RESET}`; +} + +/** A run of `glyph` across `count` cells starting at edge position `from` of `width`; solid paints emit one escape. */ +function run(glyph: string, count: number, from: number, width: number, frameColor: Paint | undefined): string { + if (count <= 0) return ''; + if (frameColor && isGradient(frameColor)) { + return Array.from({length: count}, (_, offset) => `${edgeColor(frameColor, from + offset, width)}${glyph}`).join(''); + } + return `${edgeColor(frameColor, 0, 1)}${repeatToWidth(glyph, count)}`; +} + +function ruleRow(left: string, glyph: string, right: string, width: number, frameColor: Paint | undefined): string { + if (!left) return `${run(glyph, width, 0, width, frameColor)}${RESET}`; + return `${run(left, 1, 0, width, frameColor)}${run(glyph, width - 2, 1, width, frameColor)}${run(right, 1, width - 1, width, frameColor)}${RESET}`; +} + +/** + * Lays `rows` out as a surface within `columns`. Narrow widths degrade in a + * fixed order: padding first, then boxed frames fall back to a top line. + * With no fill, padding or side frame, rows pass through unchanged. + */ +export function renderSurface(rows: readonly string[], columns: number, spec: SurfaceSpec = {}): string[] { + const total = Math.max(1, columns); + const inset = Math.max(0, Math.min(spec.inset ?? 0, total - 1)); + const available = total - inset; + let frame: FrameStyle = spec.frame ?? 'none'; + let padX = Math.max(0, spec.padding?.x ?? 0); + const padY = Math.max(0, spec.padding?.y ?? 0); + const widest = Math.max(0, ...rows.map(row => displayWidth(row))); + const sides = () => (frame === 'square' || frame === 'rounded' || frame === 'double' || frame === 'heavy' ? 2 : 0); + + let width = spec.width === 'content' ? Math.min(available, widest + 2 * padX + sides()) : available; + while (width - sides() - 2 * padX < 1) { + if (padX > 0) padX -= 1; + else if (sides() > 0) frame = 'topLine'; + else break; + } + if (spec.width === 'content') width = Math.min(available, widest + 2 * padX + sides()); + const inner = Math.max(1, width - sides() - 2 * padX); + const align = spec.align ?? 'left'; + const slack = available - width; + const lead = ' '.repeat(inset + (align === 'center' ? Math.floor(slack / 2) : align === 'right' ? slack : 0)); + + const needsWidth = spec.fill !== undefined || sides() > 0; + const boxed = sides() > 0; + const box = boxed ? boxFor(frame as 'square') : undefined; + const body = [...Array(padY).fill(''), ...rows, ...Array(padY).fill('')].map((row, index) => { + const isBlank = index < padY || index >= padY + rows.length; + const content = truncateAnsi(isBlank ? '' : row, inner); + if (!needsWidth && padX === 0) return `${lead}${content}`; + const middle = `${' '.repeat(padX)}${content}${' '.repeat(padX + Math.max(0, inner - displayWidth(content)))}`; + const painted = spec.fill ? fillRow(middle, spec.fill, width, boxed ? 1 : 0) : middle; + if (!box) return `${lead}${painted}`; + return `${lead}${edgeColor(spec.frameColor, 0, width)}${box.v}${RESET}${painted}${edgeColor(spec.frameColor, width - 1, width)}${box.v}${RESET}`; + }); + + const topGlyph = getCurrentGlyphMode() === 'nerd' ? '─' : '-'; + const top = box ? [`${lead}${ruleRow(box.tl, box.h, box.tr, width, spec.frameColor)}`] + : frame === 'topLine' ? [`${lead}${ruleRow('', topGlyph, '', width, spec.frameColor)}`] : []; + const bottom = box ? [`${lead}${ruleRow(box.bl, box.h, box.br, width, spec.frameColor)}`] : []; + return [...top, ...body, ...bottom]; +} diff --git a/tests/surface.test.ts b/tests/surface.test.ts new file mode 100644 index 00000000..0e72fcb4 --- /dev/null +++ b/tests/surface.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {linearGradient, solid, theme} from '../src/chroma/chroma.js'; +import {framePanel} from '../src/ui/PanelShell.js'; +import {renderSurface, type SurfaceSpec} from '../src/ui/surface.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const plain = (rows: string[]) => rows.map(stripAnsi); +const RED = solid({red: 200, green: 0, blue: 0}); +const BLUE = solid({red: 0, green: 0, blue: 200}); + +function withEnv(patch: Record, run: () => T): T { + const saved = Object.fromEntries(Object.keys(patch).map(name => [name, process.env[name]])); + for (const [name, value] of Object.entries(patch)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + try { return run(); } finally { + for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + } +} + +test('every frame style renders with the same geometry', () => { + withEnv({NMSH_ICONS: 'nerd'}, () => { + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'square'})), ['┌────┐', '│hi │', '└────┘']); + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'rounded'})), ['╭────╮', '│hi │', '╰────╯']); + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'double'})), ['╔════╗', '║hi ║', '╚════╝']); + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'heavy'})), ['┏━━━━┓', '┃hi ┃', '┗━━━━┛']); + assert.deepEqual(plain(renderSurface(['hi'], 4, {frame: 'topLine'})), ['────', 'hi']); + assert.deepEqual(plain(renderSurface(['hi'], 4, {frame: 'none'})), ['hi']); + }); + withEnv({NMSH_ICONS: 'safe'}, () => { + assert.deepEqual(plain(renderSurface(['hi'], 6, {frame: 'double'})), ['+----+', '|hi |', '+----+']); + }); +}); + +test('frame and fill are independent: all four combinations are valid', () => { + const specs: SurfaceSpec[] = [ + {fill: theme('selection')}, + {frame: 'rounded'}, + {frame: 'rounded', fill: theme('selection')}, + {frame: 'square', fill: linearGradient(RED, BLUE), frameColor: linearGradient(BLUE, RED)}, + ]; + for (const spec of specs) { + const rows = renderSurface(['abc', 'de'], 10, spec); + for (const row of rows) assert.ok(displayWidth(row) <= 10, JSON.stringify(plain([row]))); + assert.ok(stripAnsi(rows.join('\n')).includes('abc')); + } + assert.ok(renderSurface(['x'], 5, {fill: theme('selection')})[0]!.includes('\u001B[48;')); + assert.ok(!renderSurface(['x'], 5, {frame: 'rounded'}).some(row => row.includes('\u001B[48;'))); +}); + +test('gradient fill gives different columns different backgrounds and survives inner resets', () => { + const row = renderSurface(['\u001B[1mab\u001B[0mcd'], 4, {fill: linearGradient(RED, BLUE)})[0]!; + const backgrounds = row.match(/\u001B\[48;2;\d+;\d+;\d+m/gu)!; + assert.equal(backgrounds.length, 4); + assert.equal(new Set(backgrounds).size, 4); + assert.equal(stripAnsi(row), 'abcd'); +}); + +test('padding, inset, width and alignment', () => { + assert.deepEqual(plain(renderSurface(['hi'], 12, {frame: 'square', padding: {x: 1, y: 1}, width: 'content'})), + ['┌────┐', '│ │', '│ hi │', '│ │', '└────┘']); + assert.deepEqual(plain(renderSurface(['hi'], 10, {inset: 2})), [' hi']); + assert.deepEqual(plain(renderSurface(['hi'], 10, {frame: 'square', width: 'content', align: 'right'})), [' ┌──┐', ' │hi│', ' └──┘']); + assert.deepEqual(plain(renderSurface(['hi'], 10, {frame: 'square', width: 'content', align: 'center'}))[1], ' │hi│'); +}); + +test('narrow terminals degrade instead of overflowing', () => { + for (const columns of [1, 2, 3, 4, 5, 8]) { + for (const frame of ['square', 'rounded', 'double', 'heavy', 'topLine', 'none'] as const) { + const rows = renderSurface(['hello world'], columns, {frame, padding: {x: 2, y: 1}, inset: 3, fill: theme('selection')}); + for (const row of rows) assert.ok(displayWidth(row) <= columns, `${frame}@${columns}: ${JSON.stringify(plain([row]))}`); + } + } + assert.deepEqual(plain(renderSurface(['hello'], 2, {frame: 'square'})), ['──', 'h…']); +}); + +test('no-color drops fills and frame colors but keeps geometry', () => { + withEnv({NO_COLOR: '1', NMSH_COLOR: undefined, NMSH_ICONS: 'nerd'}, () => { + const rows = renderSurface(['hi'], 6, {frame: 'rounded', fill: theme('selection'), frameColor: linearGradient(RED, BLUE)}); + assert.ok(rows.every(row => !/\u001B\[(38|48);/u.test(row))); + assert.equal(rows[0]!.replace(/\u001B\[0m/gu, ''), '╭────╮'); + }); +}); + +test('framePanel keeps its top line over the untouched rows', () => { + const rows = framePanel(['one', 'two'], 8); + assert.equal(rows.length, 3); + assert.equal(stripAnsi(rows[0]!), '────────'); + assert.deepEqual(rows.slice(1), ['one', 'two']); +});