anvilsign in

collin/strudel-claude

pre-demo / node-tui / ui / Editor.ts
1// The code pane: the buffer, the cursor, and the follow-along highlighting.
2//
3// The cursor is drawn by inverting one character rather than by moving the
4// terminal's real cursor: Ink repaints by rewriting the frame, so a hardware
5// cursor position set during render doesn't survive. Inverting a cell is the
6// only way to make it stay put.
7//
8// Rendering is a single left-to-right sweep per line that groups adjacent
9// characters sharing a style into one <Text> run. That matters — a naive
10// character-per-element render of a 60-column line is 60 React elements per
11// line per frame, at 25fps, and Ink's diff is not free.
12import { Box, Text } from 'ink';
13import type { ReactElement } from 'react';
14import { html } from './html.ts';
15import * as buffer from '../buffer.ts';
16import type { Buffer } from '../buffer.ts';
17import type { Span } from '../highlights.ts';
18
19/** A run of characters sharing one style. */
20interface Run {
21 text: string;
22 background?: string;
23 foreground?: string;
24 cursor: boolean;
25 selected: boolean;
26}
27
28interface CellStyle {
29 background?: string;
30 foreground?: string;
31 selected: boolean;
32}
33
34const sameStyle = (a: CellStyle, b: CellStyle): boolean =>
35 a.background === b.background && a.foreground === b.foreground && a.selected === b.selected;
36
37/**
38 * Group a line's characters into styled runs.
39 *
40 * `cursorCol` may sit one past the last character (that's where the cursor is
41 * when appending), so the line is padded by one to give it a cell.
42 */
43function buildRuns(
44 text: string,
45 styles: ReadonlyMap<number, CellStyle>,
46 cursorCol: number | null,
47 selection: ReadonlySet<number>,
48): Run[] {
49 const width = cursorCol === null ? text.length : Math.max(text.length, cursorCol + 1);
50 const padded = text.padEnd(width, ' ');
51 const runs: Run[] = [];
52
53 for (let col = 0; col < width; col++) {
54 const isCursor = col === cursorCol;
55 const style: CellStyle = styles.get(col) ?? { selected: selection.has(col) };
56 const cell: CellStyle = { ...style, selected: style.selected || selection.has(col) };
57 const last = runs.at(-1);
58
59 // The cursor always breaks the run — it's exactly one cell wide.
60 if (last && !isCursor && !last.cursor
61 && sameStyle({ background: last.background, foreground: last.foreground, selected: last.selected }, cell)) {
62 last.text += padded[col];
63 continue;
64 }
65 const run: Run = { text: padded[col] ?? ' ', cursor: isCursor, selected: cell.selected };
66 if (cell.background !== undefined) run.background = cell.background;
67 if (cell.foreground !== undefined) run.foreground = cell.foreground;
68 runs.push(run);
69 }
70 return runs;
71}
72
73function renderRun(run: Run, i: number, cursorStyle: CursorStyle): ReactElement {
74 // Cursor and selection both read as "inverted"; a highlighted cell keeps its
75 // colour underneath, so a sounding character under the cursor still shows
76 // which voice it belongs to.
77 //
78 // Insert mode underlines instead of inverting, which is the terminal's
79 // closest equivalent to vim's bar cursor — you can still read the character
80 // you're about to type over.
81 const bar = run.cursor && cursorStyle === 'bar';
82 return html`<${Text}
83 key=${i}
84 backgroundColor=${run.background}
85 color=${run.foreground}
86 inverse=${(run.cursor && !bar) || run.selected}
87 underline=${bar}
88 >${run.text}<//>`;
89}
90
91/** Block in normal/visual mode, bar (underline) in insert — as vim does. */
92export type CursorStyle = 'block' | 'bar';
93
94interface LineProps {
95 text: string;
96 cursorCol: number | null;
97 number: number;
98 gutterWidth: number;
99 styles: ReadonlyMap<number, CellStyle>;
100 selection: ReadonlySet<number>;
101 /** The line the cursor is on — the only thing the gutter marks. */
102 current: boolean;
103 cursorStyle: CursorStyle;
104}
105
106function Line({
107 text, cursorCol, number, gutterWidth, styles, selection, current, cursorStyle,
108}: LineProps): ReactElement {
109 // The gutter tracks the cursor and nothing else. It used to light up for
110 // whichever lines were sounding, which duplicated what the characters
111 // themselves already say — and since a pattern lights most lines most of the
112 // time, the one number worth finding at a glance was the one that never
113 // stood out.
114 const label = String(number).padStart(gutterWidth);
115 const gutter = html`<${Text} color=${current ? 'cyan' : undefined} dimColor=${!current}>${label} ${''}<//>`;
116
117 if (cursorCol === null && styles.size === 0 && selection.size === 0) {
118 return html`<${Box}>${gutter}<${Text}>${text}<//><//>`;
119 }
120 const runs = buildRuns(text, styles, cursorCol, selection);
121 return html`<${Box}>${gutter}${runs.map((run, i) => renderRun(run, i, cursorStyle))}<//>`;
122}
123
124export interface EditorProps {
125 buffer: Buffer;
126 height: number;
127 /** Character ranges currently sounding, as offsets into the buffer text. */
128 spans: readonly Span[];
129 /** The visual-mode selection, as an offset range. */
130 selection: { start: number; end: number } | null;
131 cursorStyle: CursorStyle;
132 /**
133 * First visible line. Computed by the caller rather than here, because the
134 * mouse hit test needs the same number — deriving it in two places is how a
135 * click ends up on the wrong line.
136 */
137 scrollTop: number;
138}
139
140/**
141 * Turn offset ranges into per-row, per-column styles.
142 *
143 * Ranges can span lines; each row gets the slice that falls inside it, so a
144 * multi-line range lights up on every line it touches rather than only the
145 * first.
146 */
147function styleByRow(
148 lines: readonly string[],
149 spans: readonly Span[],
150): Map<number, Map<number, CellStyle>> {
151 const byRow = new Map<number, Map<number, CellStyle>>();
152
153 for (const span of spans) {
154 const from = buffer.offsetToPosition(lines, span.start);
155 const to = buffer.offsetToPosition(lines, span.end);
156 for (let row = from.row; row <= to.row; row++) {
157 const lineLength = (lines[row] ?? '').length;
158 const startCol = row === from.row ? from.col : 0;
159 const endCol = row === to.row ? to.col : lineLength;
160 let cells = byRow.get(row);
161 if (!cells) byRow.set(row, (cells = new Map()));
162 for (let col = startCol; col < Math.max(endCol, startCol + 1) && col < lineLength; col++) {
163 // Later spans win. Overlapping ranges mean two voices share a character,
164 // and showing the most recent one beats blending into mud.
165 cells.set(col, { background: span.background, foreground: span.foreground, selected: false });
166 }
167 }
168 }
169 return byRow;
170}
171
172function selectionByRow(
173 lines: readonly string[],
174 selection: { start: number; end: number } | null,
175): Map<number, Set<number>> {
176 const byRow = new Map<number, Set<number>>();
177 if (!selection) return byRow;
178
179 const from = buffer.offsetToPosition(lines, selection.start);
180 const to = buffer.offsetToPosition(lines, selection.end);
181 for (let row = from.row; row <= to.row; row++) {
182 const lineLength = (lines[row] ?? '').length;
183 const startCol = row === from.row ? from.col : 0;
184 const endCol = row === to.row ? to.col : lineLength;
185 const cols = new Set<number>();
186 for (let col = startCol; col < endCol; col++) cols.add(col);
187 byRow.set(row, cols);
188 }
189 return byRow;
190}
191
192const EMPTY_STYLES: ReadonlyMap<number, CellStyle> = new Map();
193const EMPTY_SELECTION: ReadonlySet<number> = new Set();
194
195export function Editor({
196 buffer: bufferState, height, spans, selection, cursorStyle, scrollTop,
197}: EditorProps): ReactElement {
198 const { lines, row, col } = bufferState;
199 const gutterWidth = String(lines.length).length;
200
201 const visible = Math.max(1, height);
202 const top = scrollTop;
203 const shown = lines.slice(top, top + visible);
204
205 const styles = styleByRow(lines, spans);
206 const selected = selectionByRow(lines, selection);
207
208 return html`
209 <${Box} flexDirection="column">
210 ${shown.map((text, i) => {
211 const lineRow = top + i;
212 return html`<${Line}
213 key=${lineRow}
214 text=${text}
215 number=${lineRow + 1}
216 gutterWidth=${gutterWidth}
217 cursorCol=${lineRow === row ? col : null}
218 styles=${styles.get(lineRow) ?? EMPTY_STYLES}
219 selection=${selected.get(lineRow) ?? EMPTY_SELECTION}
220 current=${lineRow === row}
221 cursorStyle=${cursorStyle}
222 />`;
223 })}
224 <//>
225 `;
226}