| 1 | // A minimal multi-line text buffer: pure functions over { lines, row, col }. |
| 2 | // |
| 3 | // Kept separate from the Ink component so the editing rules are testable |
| 4 | // without a terminal, and so the component stays about rendering. |
| 5 | // |
| 6 | // `col` is allowed to sit one past the end of a line (that's where the cursor |
| 7 | // is when you're appending). Vertical movement preserves a "desired column" the |
| 8 | // caller threads through, so moving down through a short line and back up |
| 9 | // returns you to where you started — the same behaviour every real editor has. |
| 10 | |
| 11 | export interface Buffer { |
| 12 | readonly lines: readonly string[]; |
| 13 | readonly row: number; |
| 14 | readonly col: number; |
| 15 | /** |
| 16 | * The column vertical movement aims for. Preserved across up/down so passing |
| 17 | * through a short line doesn't permanently pull the cursor left. |
| 18 | */ |
| 19 | readonly desiredCol: number; |
| 20 | } |
| 21 | |
| 22 | export function create(text = ''): Buffer { |
| 23 | const lines = text.split('\n'); |
| 24 | return { lines, row: 0, col: 0, desiredCol: 0 }; |
| 25 | } |
| 26 | |
| 27 | export function toText(buf: Buffer): string { |
| 28 | return buf.lines.join('\n'); |
| 29 | } |
| 30 | |
| 31 | const lineAt = (lines: readonly string[], row: number): string => lines[row] ?? ''; |
| 32 | |
| 33 | const clampCol = (lines: readonly string[], row: number, col: number): number => |
| 34 | Math.max(0, Math.min(col, lineAt(lines, row).length)); |
| 35 | |
| 36 | function withCursor(buf: Buffer, row: number, col: number, desiredCol: number = col): Buffer { |
| 37 | return { ...buf, row, col, desiredCol }; |
| 38 | } |
| 39 | |
| 40 | export function insert(buf: Buffer, text: string): Buffer { |
| 41 | const { lines, row, col } = buf; |
| 42 | const line = lineAt(lines, row); |
| 43 | const next = [...lines]; |
| 44 | next[row] = line.slice(0, col) + text + line.slice(col); |
| 45 | return withCursor({ ...buf, lines: next }, row, col + text.length); |
| 46 | } |
| 47 | |
| 48 | export function newline(buf: Buffer): Buffer { |
| 49 | const { lines, row, col } = buf; |
| 50 | const line = lineAt(lines, row); |
| 51 | // Carry the current line's indentation onto the new one. Strudel patterns are |
| 52 | // routinely indented continuations of a chain, and re-typing the leading |
| 53 | // spaces every time is the kind of friction that makes live coding unpleasant. |
| 54 | const indent = /^\s*/.exec(line)?.[0] ?? ''; |
| 55 | const next = [...lines]; |
| 56 | next.splice(row, 1, line.slice(0, col), indent + line.slice(col)); |
| 57 | return withCursor({ ...buf, lines: next }, row + 1, indent.length); |
| 58 | } |
| 59 | |
| 60 | export function backspace(buf: Buffer): Buffer { |
| 61 | const { lines, row, col } = buf; |
| 62 | if (col > 0) { |
| 63 | const next = [...lines]; |
| 64 | const line = lineAt(lines, row); |
| 65 | next[row] = line.slice(0, col - 1) + line.slice(col); |
| 66 | return withCursor({ ...buf, lines: next }, row, col - 1); |
| 67 | } |
| 68 | if (row === 0) return buf; |
| 69 | // At column zero, join with the previous line and land where the seam is. |
| 70 | const prev = lineAt(lines, row - 1); |
| 71 | const next = [...lines]; |
| 72 | next.splice(row - 1, 2, prev + lineAt(lines, row)); |
| 73 | return withCursor({ ...buf, lines: next }, row - 1, prev.length); |
| 74 | } |
| 75 | |
| 76 | export function deleteForward(buf: Buffer): Buffer { |
| 77 | const { lines, row, col } = buf; |
| 78 | if (col < lineAt(lines, row).length) { |
| 79 | const next = [...lines]; |
| 80 | const line = lineAt(lines, row); |
| 81 | next[row] = line.slice(0, col) + line.slice(col + 1); |
| 82 | return { ...buf, lines: next }; |
| 83 | } |
| 84 | if (row === lines.length - 1) return buf; |
| 85 | const next = [...lines]; |
| 86 | next.splice(row, 2, lineAt(lines, row) + lineAt(lines, row + 1)); |
| 87 | return { ...buf, lines: next }; |
| 88 | } |
| 89 | |
| 90 | export function moveLeft(buf: Buffer): Buffer { |
| 91 | const { lines, row, col } = buf; |
| 92 | if (col > 0) return withCursor(buf, row, col - 1); |
| 93 | if (row === 0) return buf; |
| 94 | return withCursor(buf, row - 1, lineAt(lines, row - 1).length); |
| 95 | } |
| 96 | |
| 97 | export function moveRight(buf: Buffer): Buffer { |
| 98 | const { lines, row, col } = buf; |
| 99 | if (col < lineAt(lines, row).length) return withCursor(buf, row, col + 1); |
| 100 | if (row === lines.length - 1) return buf; |
| 101 | return withCursor(buf, row + 1, 0); |
| 102 | } |
| 103 | |
| 104 | export function moveUp(buf: Buffer): Buffer { |
| 105 | if (buf.row === 0) return withCursor(buf, 0, 0, buf.desiredCol); |
| 106 | const row = buf.row - 1; |
| 107 | return withCursor(buf, row, clampCol(buf.lines, row, buf.desiredCol), buf.desiredCol); |
| 108 | } |
| 109 | |
| 110 | export function moveDown(buf: Buffer): Buffer { |
| 111 | const last = buf.lines.length - 1; |
| 112 | if (buf.row === last) return withCursor(buf, last, lineAt(buf.lines, last).length, buf.desiredCol); |
| 113 | const row = buf.row + 1; |
| 114 | return withCursor(buf, row, clampCol(buf.lines, row, buf.desiredCol), buf.desiredCol); |
| 115 | } |
| 116 | |
| 117 | export const moveLineStart = (buf: Buffer): Buffer => withCursor(buf, buf.row, 0); |
| 118 | export const moveLineEnd = (buf: Buffer): Buffer => |
| 119 | withCursor(buf, buf.row, lineAt(buf.lines, buf.row).length); |
| 120 | |
| 121 | /** A position in the buffer, as a row and a column within that row. */ |
| 122 | export interface Position { |
| 123 | row: number; |
| 124 | col: number; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Translate a character offset into the full text to a row/column. |
| 129 | * |
| 130 | * Strudel reports the source location of every event as an offset into the code |
| 131 | * it evaluated, so this is what turns "characters 42–58 are sounding" into |
| 132 | * something the editor can paint. Offsets past the end clamp to the last |
| 133 | * position rather than throwing — while the buffer is dirty they refer to text |
| 134 | * that no longer exists, and a stale highlight is better than a crash. |
| 135 | */ |
| 136 | export function offsetToPosition(lines: readonly string[], offset: number): Position { |
| 137 | let remaining = Math.max(0, offset); |
| 138 | for (let row = 0; row < lines.length; row++) { |
| 139 | const len = lineAt(lines, row).length; |
| 140 | if (remaining <= len) return { row, col: remaining }; |
| 141 | remaining -= len + 1; // +1 for the newline |
| 142 | } |
| 143 | const last = Math.max(0, lines.length - 1); |
| 144 | return { row: last, col: lineAt(lines, last).length }; |
| 145 | } |
| 146 | |
| 147 | /** The inverse of {@link offsetToPosition}. */ |
| 148 | export function positionToOffset(lines: readonly string[], { row, col }: Position): number { |
| 149 | const r = Math.max(0, Math.min(row, lines.length - 1)); |
| 150 | let offset = 0; |
| 151 | for (let i = 0; i < r; i++) offset += lineAt(lines, i).length + 1; |
| 152 | return offset + Math.max(0, Math.min(col, lineAt(lines, r).length)); |
| 153 | } |
| 154 | |
| 155 | /** The cursor's position as an offset into the full text. */ |
| 156 | export const cursorOffset = (buf: Buffer): number => |
| 157 | positionToOffset(buf.lines, { row: buf.row, col: buf.col }); |
| 158 | |
| 159 | /** |
| 160 | * Replace the text between two offsets, and put the cursor somewhere. |
| 161 | * |
| 162 | * Every editing command beyond plain typing goes through here. Working on the |
| 163 | * flat text rather than on the line array means deletions that span lines, |
| 164 | * joins, and multi-line pastes are all the same operation — the line structure |
| 165 | * falls out of the result instead of having to be maintained through it. |
| 166 | */ |
| 167 | export function replaceRange( |
| 168 | buf: Buffer, |
| 169 | start: number, |
| 170 | end: number, |
| 171 | text: string, |
| 172 | cursorAt = start + text.length, |
| 173 | ): Buffer { |
| 174 | const full = toText(buf); |
| 175 | const from = Math.max(0, Math.min(start, end, full.length)); |
| 176 | const to = Math.min(Math.max(start, end), full.length); |
| 177 | const next = full.slice(0, from) + text + full.slice(to); |
| 178 | const lines = next.split('\n'); |
| 179 | const { row, col } = offsetToPosition(lines, Math.max(0, Math.min(cursorAt, next.length))); |
| 180 | return { lines, row, col, desiredCol: col }; |
| 181 | } |
| 182 | |
| 183 | /** Move the cursor to an absolute offset, clamped into the buffer. */ |
| 184 | export function setCursorOffset(buf: Buffer, offset: number): Buffer { |
| 185 | const { row, col } = offsetToPosition(buf.lines, offset); |
| 186 | return { ...buf, row, col, desiredCol: col }; |
| 187 | } |
| 188 | |
| 189 | export function setText(buf: Buffer, text: string): Buffer { |
| 190 | const lines = text.split('\n'); |
| 191 | const row = Math.min(buf.row, lines.length - 1); |
| 192 | return withCursor({ ...buf, lines }, row, clampCol(lines, row, buf.col)); |
| 193 | } |