| 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. |
| 12 | import { Box, Text } from 'ink'; |
| 13 | import type { ReactElement } from 'react'; |
| 14 | import { html } from './html.ts'; |
| 15 | import * as buffer from '../buffer.ts'; |
| 16 | import type { Buffer } from '../buffer.ts'; |
| 17 | import type { Span } from '../highlights.ts'; |
| 18 | |
| 19 | /** A run of characters sharing one style. */ |
| 20 | interface Run { |
| 21 | text: string; |
| 22 | background?: string; |
| 23 | foreground?: string; |
| 24 | cursor: boolean; |
| 25 | selected: boolean; |
| 26 | } |
| 27 | |
| 28 | interface CellStyle { |
| 29 | background?: string; |
| 30 | foreground?: string; |
| 31 | selected: boolean; |
| 32 | } |
| 33 | |
| 34 | const 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 | */ |
| 43 | function 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 | |
| 73 | function 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. */ |
| 92 | export type CursorStyle = 'block' | 'bar'; |
| 93 | |
| 94 | interface 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 | |
| 106 | function 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 | |
| 124 | export 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 | */ |
| 147 | function 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 | |
| 172 | function 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 | |
| 192 | const EMPTY_STYLES: ReadonlyMap<number, CellStyle> = new Map(); |
| 193 | const EMPTY_SELECTION: ReadonlySet<number> = new Set(); |
| 194 | |
| 195 | export 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 | } |