Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/ui/PanelShell.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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')});
}
150 changes: 150 additions & 0 deletions src/ui/surface.ts
Original file line number Diff line number Diff line change
@@ -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<string>(padY).fill(''), ...rows, ...Array<string>(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];
}
89 changes: 89 additions & 0 deletions tests/surface.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(patch: Record<string, string | undefined>, 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']);
});
Loading