diff --git a/src/ui/actionControls.ts b/src/ui/actionControls.ts new file mode 100644 index 00000000..01164231 --- /dev/null +++ b/src/ui/actionControls.ts @@ -0,0 +1,171 @@ +import {displayWidth, truncateText} from '../util/text.js'; +import {getCurrentGlyphMode} from './glyphs.js'; +import {background, foreground, UI_COLORS} from './palette.js'; + +/** + * Action controls: how a handful of semantic actions (Apply / Cancel, Yes / No, Diff / Checks) are drawn at the foot of + * a panel. Identity and handler stay semantic: a control is `{id, label, state}` and the renderer never decides what an + * action may do or whether it is available, it only draws what its caller says is available (and says why not, for a + * disabled one). Appearance is separate from behavior: + * + * state default · focused · selected · disabled · destructive · destructiveFocused + * style outline (default) · filled · soft · plain + * geometry square [ ] · rounded ( ) + * + * Every state is told apart by its marks, not by color, so NO_COLOR, low-color and Safe/ASCII terminals lose nothing: + * + * default [ Apply ] + * focused [▸Apply◂] (Safe: [>Apply<]) + * selected [✓ Apply ] (Safe: [x Apply ]) + * disabled [– Apply ] (Safe: [- Apply ]) its reason is listed under the row + * destructive [! Delete ] + * destructiveFocused [▸!Delete◂] + * + * Labels are untrusted text (a branch name, a title): control and format characters are removed, and every control is + * measured in display cells. When a row does not fit, essential controls keep their words and the others fold into + * "+N" first; below that, styles drop to plain, then to the key letters. + */ +export type ActionState = 'default' | 'focused' | 'selected' | 'disabled' | 'destructive' | 'destructiveFocused'; +export type ActionStyle = 'outline' | 'filled' | 'soft' | 'plain'; +export type ActionGeometry = 'square' | 'rounded'; + +export interface ActionControl { + /** Semantic identity (`Action.Apply`): returned by hit-testing, never derived from the label. */ + id: string; + label: string; + state: ActionState; + /** The key that invokes it, when it has one; shown as a hint and used in the tightest layout. */ + key?: string; + /** Why a disabled control is disabled (required for the reason line; a disabled control without one is not drawn). */ + reason?: string; + /** Kept with its words when space runs out; non-essential controls fold into "+N" first. */ + essential?: boolean; +} + +export interface ActionRowOptions { + columns: number; + style?: ActionStyle; + geometry?: ActionGeometry; + /** Force Safe/ASCII marks; by default follows the current glyph mode. */ + safe?: boolean; + /** Show each control's key after it (`[>Apply<] Enter`), so the keyboard way is always visible. */ + keys?: boolean; + /** Draw escapes; false gives plain text (used by tests and for transcripts, which never receive chrome). */ + color?: boolean; +} + +export interface ActionRegion { id: string; row: number; column: number; end: number } +export interface ActionRow { rows: string[]; regions: ActionRegion[]; plain: string[] } + +const clean = (text: string) => text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ').replace(/\s+/gu, ' ').trim(); + +interface Marks { left: string; right: string; focusLeft: string; focusRight: string; selected: string; disabled: string; destructive: string } + +function marks(geometry: ActionGeometry, safe: boolean): Marks { + const [left, right] = geometry === 'rounded' ? ['(', ')'] : ['[', ']']; + return safe ? {left, right, focusLeft: '>', focusRight: '<', selected: 'x ', disabled: '- ', destructive: '! '} + : {left, right, focusLeft: '▸', focusRight: '◂', selected: '✓ ', disabled: '– ', destructive: '! '}; +} + +/** One control as plain text in the given style (marks only, no color). Every state has its own marks, so none depends on color. */ +export function actionText(control: ActionControl, style: ActionStyle, geometry: ActionGeometry, safe: boolean, label = clean(control.label)): string { + const m = marks(geometry, safe); + const focused = control.state === 'focused' || control.state === 'destructiveFocused'; + const lead = control.state === 'selected' ? m.selected : control.state === 'disabled' ? m.disabled + : control.state === 'destructive' || control.state === 'destructiveFocused' ? m.destructive : ''; + const inner = focused ? `${m.focusLeft}${lead.trim()}${label}${m.focusRight}` : `${lead || ' '}${label} `; + return style === 'plain' ? inner.trim() : `${m.left}${inner}${m.right}`; +} + +function paint(control: ActionControl, text: string, style: ActionStyle, color: boolean): string { + if (!color) return text; + const reset = '\u001b[0m'; + const destructive = control.state === 'destructive' || control.state === 'destructiveFocused'; + const tone = control.state === 'disabled' ? UI_COLORS.subtle : destructive ? UI_COLORS.failure : control.state === 'selected' ? UI_COLORS.success + : control.state === 'focused' ? UI_COLORS.accent : style === 'soft' ? UI_COLORS.subtle : UI_COLORS.secondary; + const weight = control.state === 'focused' || control.state === 'destructiveFocused' ? '\u001b[1m' : control.state === 'disabled' ? '\u001b[2m' : ''; + // Filled draws the control as a block; the foreground then follows the chrome's primary text so it stays readable. + return style === 'filled' ? `${background(tone)}${foreground(UI_COLORS.primary)}${weight}${text}${reset}` : `${weight}${foreground(tone)}${text}${reset}`; +} + +/** + * The controls as one or more rows that fit `columns`, with the region each control occupies (for the pointer, which + * invokes the same semantic action as its key) and the plain text of each row. Disabled controls add a reason row. + */ +export function renderActionRow(controls: readonly ActionControl[], options: ActionRowOptions): ActionRow { + const safe = options.safe ?? getCurrentGlyphMode() === 'safe'; + const geometry = options.geometry ?? 'square'; + const color = options.color ?? true; + const columns = Math.max(4, options.columns); + const shown = controls.filter(control => control.state !== 'disabled' || control.reason); + const gap = 1; + const attempt = (style: ActionStyle, only: readonly ActionControl[], label: (control: ActionControl) => string) => { + const parts = only.map(control => ({control, text: actionText(control, style, geometry, safe, label(control)) + (options.keys && control.key && label === words ? ` ${clean(control.key)}` : '')})); + return {parts, width: parts.reduce((sum, part) => sum + displayWidth(part.text), 0) + gap * Math.max(0, parts.length - 1)}; + }; + const words = (control: ActionControl) => truncateText(clean(control.label), 24) || (control.key ?? control.id); + // The tightest forms keep a recognizable letter rather than an ellipsis: `C` for Cancel, never `…`. + const initial = (control: ActionControl) => Array.from(clean(control.label))[0] ?? control.id.slice(0, 1); + const keysOnly = (control: ActionControl) => control.key ? clean(control.key) : initial(control); + const essential = shown.filter(control => control.essential || control.state === 'focused' || control.state === 'destructiveFocused'); + const kept = essential.length ? essential : shown.slice(0, 1); + const first = kept.slice(0, 1); + // Widest first; the first form that fits is used. The last form always fits (its text is cut to the width). + const ladder: Array<{style: ActionStyle; only: readonly ActionControl[]; label: (control: ActionControl) => string}> = [ + {style: options.style ?? 'outline', only: shown, label: words}, {style: 'plain', only: shown, label: words}, + {style: 'plain', only: kept, label: words}, {style: 'plain', only: kept, label: keysOnly}, {style: 'plain', only: first, label: keysOnly}, + {style: 'plain', only: first, label: control => truncateText(keysOnly(control), Math.max(1, columns - 2))}, + ]; + let style = ladder[0]!.style; + let layout = attempt(style, shown, words); + let folded = 0; + for (const step of ladder) { + const candidate = attempt(step.style, step.only, step.label); + const hidden = shown.length - step.only.length; + const extra = hidden ? displayWidth(` +${hidden}`) : 0; + style = step.style; layout = candidate; folded = hidden; + if (candidate.width + extra <= columns) break; + } + const rows: string[] = []; + const plain: string[] = []; + const regions: ActionRegion[] = []; + let line = ''; + let plainLine = ''; + let column = 1; + const flush = () => { if (plainLine) { rows.push(line); plain.push(plainLine); } line = ''; plainLine = ''; column = 1; }; + for (const part of layout.parts) { + const width = displayWidth(part.text); + if (plainLine && column + width > columns) flush(); + if (plainLine) { line += ' '; plainLine += ' '; column += gap; } + regions.push({id: part.control.id, row: rows.length, column, end: column + width - 1}); + line += paint(part.control, part.text, style, color); + plainLine += part.text; + column += width; + } + // The count of folded controls is shown when there is room for it; the control itself always comes first. + if (folded && displayWidth(plainLine) + displayWidth(` +${folded}`) <= columns) { const more = ` +${folded}`; line += more; plainLine += more; } + flush(); + // A reason belongs to a control that is drawn: a folded one is explained by describeActions (help), not by an orphan line. + // The line fits the width: when it is too long the label yields first (down to half the width), then the whole line is cut. + for (const {control} of layout.parts) { + if (control.state !== 'disabled' || !control.reason) continue; + const room = Math.max(Math.floor(columns / 2), columns - 2 - displayWidth(clean(control.reason))); + const label = truncateText(clean(control.label), Math.max(1, room)); + const reason = truncateText(`${label}: ${clean(control.reason)}`, columns); + rows.push(color ? `${foreground(UI_COLORS.subtle)}${reason}\u001b[0m` : reason); plain.push(reason); + } + return {rows, regions, plain}; +} + +/** Contextual help text for the same controls: key, label and, for a disabled one, why it is unavailable. Nothing is listed that is not drawn. */ +export function describeActions(controls: readonly ActionControl[]): string[] { + return controls.filter(control => control.state !== 'disabled' || control.reason).map(control => { + const key = control.key ? `${control.key} ` : ''; + return control.state === 'disabled' ? `${key}${clean(control.label)} (unavailable: ${clean(control.reason!)})` : `${key}${clean(control.label)}`; + }); +} + +/** The control a click at (row, column) lands on, or undefined: the pointer invokes the same semantic action as its key. */ +export function hitAction(regions: readonly ActionRegion[], row: number, column: number): string | undefined { + return regions.find(region => region.row === row && column >= region.column && column <= region.end)?.id; +} diff --git a/src/worktrees/view.ts b/src/worktrees/view.ts index 372d6c87..81b60801 100644 --- a/src/worktrees/view.ts +++ b/src/worktrees/view.ts @@ -1,3 +1,4 @@ +import {renderActionRow} from '../ui/actionControls.js'; import {colorEscape} from '../chroma/escape.js'; import type {ColorLevel} from '../presentation/capabilities.js'; import type {GlyphMode} from '../ui/glyphs.js'; @@ -111,8 +112,12 @@ export function renderWorktreeView(view: WorktreeView, options: RenderOptions): if (state.review) { const title = state.review.kind === 'remove' ? 'Remove worktree?' : 'Create worktree?'; const body = describePlan(state.review.plan, path => displayPath(path, options.home)).map(line => fit(line, width, safe)); - const controls = fit(`Enter confirm${dot}Esc cancel`, width, safe); - return [paint(bold + color(UI_COLORS.primary), fit(title, width, safe)), '', ...body, '', controls].slice(0, Math.max(1, options.rows)); + // Removal is the destructive one: its mark ("!") says so without color. The keys stay Enter and Esc. + const controls = renderActionRow([ + {id: 'Action.Confirm', label: state.review.kind === 'remove' ? 'Remove' : 'Create', state: state.review.kind === 'remove' ? 'destructiveFocused' : 'focused', key: 'Enter', essential: true}, + {id: 'Action.Cancel', label: 'Cancel', state: 'default', key: 'Esc', essential: true}, + ], {columns: width, safe, keys: true, color: options.level !== 'none'}).rows; + return [paint(bold + color(UI_COLORS.primary), fit(title, width, safe)), '', ...body, '', ...controls].slice(0, Math.max(1, options.rows)); } const budget = Math.max(0, options.rows - header.length - footer.length - 1); diff --git a/tests/actionControls.test.ts b/tests/actionControls.test.ts new file mode 100644 index 00000000..fb3274d7 --- /dev/null +++ b/tests/actionControls.test.ts @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {actionText, describeActions, hitAction, renderActionRow, type ActionControl, type ActionState} from '../src/ui/actionControls.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const STATES: ActionState[] = ['default', 'focused', 'selected', 'disabled', 'destructive', 'destructiveFocused']; +const control = (state: ActionState, label = 'Apply'): ActionControl => ({id: `Action.${label}`, label, state, ...(state === 'disabled' ? {reason: 'nothing selected'} : {})}); + +test('all six states look different without any color, in Unicode and in Safe/ASCII', () => { + for (const safe of [false, true]) { + for (const style of ['outline', 'plain', 'soft', 'filled'] as const) { + const texts = STATES.map(state => actionText(control(state), style, 'square', safe)); + assert.equal(new Set(texts).size, STATES.length, `${safe ? 'safe' : 'unicode'} ${style}: ${texts.join(' | ')}`); + if (safe) for (const text of texts) assert.match(text, /^[\x20-\x7e]+$/u, 'Safe marks are ASCII'); + } + } + assert.deepEqual(STATES.map(state => actionText(control(state), 'outline', 'square', true)), + ['[ Apply ]', '[>Apply<]', '[x Apply ]', '[- Apply ]', '[! Apply ]', '[>!Apply<]']); +}); + +test('geometry and style are independent of state; rounded swaps only the brackets', () => { + assert.equal(actionText(control('default'), 'outline', 'rounded', true), '( Apply )'); + assert.equal(actionText(control('focused'), 'outline', 'rounded', true), '(>Apply<)'); + assert.equal(actionText(control('selected'), 'plain', 'square', false), '✓ Apply'); +}); + +test('colour is only decoration: stripping it leaves exactly the plain text, and colour:false emits no escapes', () => { + const controls = STATES.map(state => control(state, state)); + const coloured = renderActionRow(controls, {columns: 200, color: true, safe: true}); + const plain = renderActionRow(controls, {columns: 200, color: false, safe: true}); + assert.equal(coloured.rows.map(stripAnsi).join('\n'), plain.rows.join('\n')); + assert.ok(!plain.rows.join('').includes('\u001b')); + assert.deepEqual(plain.plain, plain.rows); +}); + +test('a disabled control says why, and one without a reason is not drawn at all', () => { + const row = renderActionRow([control('default', 'Cancel'), control('disabled', 'Merge'), {id: 'Action.Dead', label: 'Dead', state: 'disabled'}], {columns: 80, color: false, safe: true}); + assert.equal(row.plain.length, 2); + assert.match(row.plain[0]!, /\[- Merge \]/u); + assert.doesNotMatch(row.plain.join('\n'), /Dead/u); + assert.match(row.plain[1]!, /^Merge: nothing selected$/u); + assert.deepEqual(describeActions([{...control('default', 'Cancel'), key: 'Esc'}, control('disabled', 'Merge'), {id: 'x', label: 'Dead', state: 'disabled'}]), + ['Esc Cancel', 'Merge (unavailable: nothing selected)']); +}); + +test('narrow windows: essential controls keep their words, the rest fold into a count, nothing overflows', () => { + const controls: ActionControl[] = [{...control('focused', 'Confirm'), essential: true, key: 'Enter'}, {...control('default', 'Cancel'), key: 'Esc', essential: true}, + {...control('default', 'Open in browser'), key: 'o'}, {...control('default', 'Copy link'), key: 'c'}]; + for (const columns of [120, 60, 40, 30, 20, 12, 6]) { + const row = renderActionRow(controls, {columns, color: false, safe: true}); + for (const text of row.plain) assert.ok(displayWidth(text) <= columns, `${columns}: ${text}`); + } + const narrow = renderActionRow(controls, {columns: 40, color: false, safe: true}); + assert.match(narrow.plain.join(' '), /Confirm/u); + assert.match(narrow.plain.join(' '), /Cancel/u); + assert.match(narrow.plain.join(' '), /\+2/u, 'the others are counted, not silently dropped'); + assert.doesNotMatch(narrow.plain.join(' '), /browser/u); + const tight = renderActionRow(controls, {columns: 14, color: false, safe: true}); + assert.match(tight.plain.join(' '), /Enter|Esc|C/u, 'the tightest layout falls back to keys'); +}); + +test('wide windows keep one row; regions say where each control is, and the pointer reaches the same semantic action', () => { + const controls = [control('default', 'Yes'), control('default', 'No')]; + const row = renderActionRow(controls, {columns: 80, color: false, safe: true}); + assert.equal(row.rows.length, 1); + assert.deepEqual(row.regions, [{id: 'Action.Yes', row: 0, column: 1, end: 7}, {id: 'Action.No', row: 0, column: 9, end: 14}]); + assert.equal(hitAction(row.regions, 0, 3), 'Action.Yes'); + assert.equal(hitAction(row.regions, 0, 8), undefined, 'the gap is not a control'); + assert.equal(hitAction(row.regions, 0, 12), 'Action.No'); + assert.equal(hitAction(row.regions, 1, 3), undefined); +}); + +test('hostile labels are drawn as text only: no escapes, bidi, newlines or oversize', () => { + const hostile = control('default', '\u001b[2JApply‮\nnow\u0007' + 'x'.repeat(200)); + const row = renderActionRow([hostile], {columns: 60, color: false, safe: false}); + const text = row.plain.join(''); + assert.doesNotMatch(text, /[\u0000-\u001f\u007f-\u009f‮]/u); + assert.ok(displayWidth(text) <= 60); + assert.match(text, /Apply/u); +}); + +test('keys can be shown after each control and are kept in the measured width', () => { + const row = renderActionRow([{...control('focused', 'Apply'), key: 'Enter'}, {...control('default', 'Cancel'), key: 'Esc'}], {columns: 80, color: false, safe: true, keys: true}); + assert.equal(row.plain[0], '[>Apply<] Enter [ Cancel ] Esc'); + for (const columns of [30, 16, 8]) for (const text of renderActionRow([{...control('focused', 'Apply'), key: 'Enter'}, {...control('default', 'Cancel'), key: 'Esc'}], {columns, color: false, safe: true, keys: true}).plain) assert.ok(displayWidth(text) <= columns, `${columns}: ${text}`); +}); + +test('a disabled control\'s reason fits the width and is only listed for a control that is drawn', () => { + const remove: ActionControl = {id: 'Action.Remove', label: 'Remove worktree feature/very-long-branch-name', state: 'disabled', reason: 'the worktree has uncommitted changes that would be lost'}; + const cancel: ActionControl = {id: 'Action.Cancel', label: 'Cancel', state: 'focused', essential: true}; + for (const columns of [80, 40, 30, 16, 8, 4]) { + const row = renderActionRow([remove, cancel], {columns, color: true, safe: true}); + for (const text of row.plain) assert.ok(displayWidth(text) <= columns, `${columns}: ${text}`); + for (const text of row.rows) assert.ok(displayWidth(stripAnsi(text)) <= columns, `${columns} (colored): ${stripAnsi(text)}`); + const drawn = row.regions.some(region => region.id === 'Action.Remove'); + assert.equal(row.plain.some(text => text.includes(': the')), drawn, `${columns}: a reason line only with its control`); + } + assert.match(renderActionRow([remove, cancel], {columns: 120, color: false, safe: true}).plain[1]!, /^Remove worktree feature\/very-long-branch-name: the worktree has/u); + // Help still explains the folded control. + assert.deepEqual(describeActions([remove]), ['Remove worktree feature/very-long-branch-name (unavailable: the worktree has uncommitted changes that would be lost)']); +}); + +test('the tightest layout keeps a letter of the label, never only an ellipsis', () => { + const row = renderActionRow([{id: 'Action.Cancel', label: 'Cancel', state: 'focused', essential: true}, {id: 'Action.Other', label: 'Other', state: 'default'}], {columns: 8, color: false, safe: true}); + assert.match(row.plain[0]!, /^>C!Remove<\] Enter \[ Cancel \] Esc/u, 'removal is the destructive control, and the keys stay visible'); await controller.handleKey({kind: 'escape'}); assert.equal(controller.state.review, undefined); await controller.handleKey({kind: 'text', value: 'x'});