anvilsign in

collin/strudel-claude

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
11export 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
22export function create(text = ''): Buffer {
23 const lines = text.split('\n');
24 return { lines, row: 0, col: 0, desiredCol: 0 };
25}
26
27export function toText(buf: Buffer): string {
28 return buf.lines.join('\n');
29}
30
31const lineAt = (lines: readonly string[], row: number): string => lines[row] ?? '';
32
33const clampCol = (lines: readonly string[], row: number, col: number): number =>
34 Math.max(0, Math.min(col, lineAt(lines, row).length));
35
36function withCursor(buf: Buffer, row: number, col: number, desiredCol: number = col): Buffer {
37 return { ...buf, row, col, desiredCol };
38}
39
40export 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
48export 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
60export 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
76export 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
90export 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
97export 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
104export 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
110export 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
117export const moveLineStart = (buf: Buffer): Buffer => withCursor(buf, buf.row, 0);
118export 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. */
122export 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 */
136export 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}. */
148export 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. */
156export 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 */
167export 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. */
184export function setCursorOffset(buf: Buffer, offset: number): Buffer {
185 const { row, col } = offsetToPosition(buf.lines, offset);
186 return { ...buf, row, col, desiredCol: col };
187}
188
189export 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}