@xzzpig/pi-starline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +223 -0
- package/extensions/starline/config.ts +1317 -0
- package/extensions/starline/editor-cursor.ts +55 -0
- package/extensions/starline/editor-factory-marker.ts +49 -0
- package/extensions/starline/editor-metadata-format.ts +259 -0
- package/extensions/starline/extension-status.ts +88 -0
- package/extensions/starline/footer-format.ts +279 -0
- package/extensions/starline/footer.ts +744 -0
- package/extensions/starline/format.ts +473 -0
- package/extensions/starline/git.ts +467 -0
- package/extensions/starline/icons.ts +294 -0
- package/extensions/starline/index.ts +651 -0
- package/extensions/starline/live-context.ts +75 -0
- package/extensions/starline/mouse/capabilities.ts +207 -0
- package/extensions/starline/mouse/component-tree.ts +230 -0
- package/extensions/starline/mouse/editor-caret.ts +498 -0
- package/extensions/starline/mouse/editor-hit-test.ts +133 -0
- package/extensions/starline/mouse/editor-mouse.ts +142 -0
- package/extensions/starline/mouse/editor-scroll.ts +119 -0
- package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
- package/extensions/starline/mouse/editor-text-edit.ts +90 -0
- package/extensions/starline/mouse/hint.ts +14 -0
- package/extensions/starline/mouse/hit-test.ts +144 -0
- package/extensions/starline/mouse/index.ts +1119 -0
- package/extensions/starline/mouse/selection-state.ts +68 -0
- package/extensions/starline/mouse/tool-box.ts +290 -0
- package/extensions/starline/mouse/transcript-copy.ts +166 -0
- package/extensions/starline/mouse/word-select.ts +145 -0
- package/extensions/starline/package-version.ts +650 -0
- package/extensions/starline/paste-collapse.ts +312 -0
- package/extensions/starline/pill-config.ts +122 -0
- package/extensions/starline/pill.ts +295 -0
- package/extensions/starline/project-refresh.ts +104 -0
- package/extensions/starline/project-state.ts +66 -0
- package/extensions/starline/prototype-patch-registry.ts +136 -0
- package/extensions/starline/runtime.ts +841 -0
- package/extensions/starline/selector-border.ts +77 -0
- package/extensions/starline/session-lifecycle.ts +60 -0
- package/extensions/starline/settings-command.ts +925 -0
- package/extensions/starline/state.ts +65 -0
- package/extensions/starline/style.ts +528 -0
- package/extensions/starline/ui.ts +611 -0
- package/extensions/starline/user-message.ts +267 -0
- package/package.json +60 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the draft's text is on screen, and how to read it back out.
|
|
3
|
+
*
|
|
4
|
+
* Two features share this module because they share one arithmetic problem:
|
|
5
|
+
* turning a screen cell into a position in the editor's own buffer.
|
|
6
|
+
* `editorClickToCaret` needs it for a single press; `editorBufferCopy` needs it
|
|
7
|
+
* for the two ends of a selection.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the box's rectangle is not the text
|
|
10
|
+
*
|
|
11
|
+
* `editorBoxFor` (`editor-mouse.ts`) finds the box covering the editor's rows,
|
|
12
|
+
* but those rows are a *frame*: Starline's `renderPolishedFrame` draws a rule,
|
|
13
|
+
* an optional padding row, the text rows each prefixed with the rail, another
|
|
14
|
+
* padding row, the metadata row and a closing rule — and pi-tui's own
|
|
15
|
+
* `Editor.render` supplies the rules and, when focused, the reverse-video
|
|
16
|
+
* cursor cell inside them. So the first text row is `rect.y + 1 + paddingY`,
|
|
17
|
+
* and the first text *column* is past the rail, which is what the
|
|
18
|
+
* `editorTextColumn` helper (moved here from the fixed editor) measures.
|
|
19
|
+
*
|
|
20
|
+
* The bottom of the text cannot be read off the rectangle at all: the box's
|
|
21
|
+
* height also covers the autocomplete list, which appears and disappears under
|
|
22
|
+
* the frame. It comes instead from the editor itself — how many visual lines it
|
|
23
|
+
* has, less how far it is scrolled, capped by Pi's own window of
|
|
24
|
+
* `max(5, rows * 0.3)`.
|
|
25
|
+
*
|
|
26
|
+
* ## Why positions are logical, not visual
|
|
27
|
+
*
|
|
28
|
+
* A screen row is a *visual* line — the editor's word wrapping has already been
|
|
29
|
+
* applied to it. The buffer knows those rows are one logical line. Mapping
|
|
30
|
+
* through to logical `{ line, column }` before slicing is what keeps a copied
|
|
31
|
+
* wrapped paragraph one paragraph instead of a stack of hard-wrapped fragments,
|
|
32
|
+
* and it is the half of `editorBufferCopy` that shows up on every draft rather
|
|
33
|
+
* than only on tall ones.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { PolishedTuiConfig } from "../config";
|
|
37
|
+
import { editorTextColumn } from "./editor-hit-test";
|
|
38
|
+
import { activeEditor, editorBoxFor, pointerOverEditor } from "./editor-mouse";
|
|
39
|
+
import { editorVisibleLines } from "./editor-scroll";
|
|
40
|
+
import {
|
|
41
|
+
editorScrollOffset,
|
|
42
|
+
editorVisualRowCount,
|
|
43
|
+
resolveEditorTextPointAt,
|
|
44
|
+
} from "./editor-text-cursor";
|
|
45
|
+
import type { BoxLike } from "./hit-test";
|
|
46
|
+
|
|
47
|
+
/** A position in the editor's own buffer: which logical line, and where in it. */
|
|
48
|
+
export type CaretPosition = { line: number; column: number };
|
|
49
|
+
|
|
50
|
+
/** Where the editor's text rows are on screen, and what they are showing. */
|
|
51
|
+
export type EditorViewport = {
|
|
52
|
+
/** Screen row of the first text row. */
|
|
53
|
+
contentTop: number;
|
|
54
|
+
/** How many text rows the box is showing right now. */
|
|
55
|
+
contentRows: number;
|
|
56
|
+
/** Screen column of the box's left edge. */
|
|
57
|
+
left: number;
|
|
58
|
+
/** Columns the rail or prompt takes before the text starts. */
|
|
59
|
+
textColumn: number;
|
|
60
|
+
/** The absolute visual row drawn at `contentTop`. */
|
|
61
|
+
scrollOffset: number;
|
|
62
|
+
/** Screen rows the box itself occupies, borders and metadata included. */
|
|
63
|
+
boxTop: number;
|
|
64
|
+
boxBottom: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type EditorViewportOptions = {
|
|
68
|
+
/** `config.editorPaddingY` — 0 or 1 blank rows inside the frame. */
|
|
69
|
+
paddingY: number;
|
|
70
|
+
/** `editorTextColumn(...)`, passed in so the mapping stays a pure function. */
|
|
71
|
+
textColumn: number;
|
|
72
|
+
/** The terminal's row count, for Pi's own visible-line formula. */
|
|
73
|
+
terminalRows: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function editorViewport(
|
|
77
|
+
editor: unknown,
|
|
78
|
+
box: BoxLike | undefined,
|
|
79
|
+
options: EditorViewportOptions,
|
|
80
|
+
): EditorViewport | undefined {
|
|
81
|
+
if (!box) return undefined;
|
|
82
|
+
const visualRows = editorVisualRowCount(editor);
|
|
83
|
+
// Zero means either an empty map or an editor this module cannot read; both
|
|
84
|
+
// leave nothing to map a click onto.
|
|
85
|
+
if (visualRows === 0) return undefined;
|
|
86
|
+
|
|
87
|
+
const scrollOffset = Math.min(editorScrollOffset(editor), visualRows - 1);
|
|
88
|
+
const contentTop = box.rect.y + 1 + (options.paddingY > 0 ? 1 : 0);
|
|
89
|
+
const contentRows = Math.min(
|
|
90
|
+
visualRows - scrollOffset,
|
|
91
|
+
editorVisibleLines(options.terminalRows),
|
|
92
|
+
// A dock squeezed against the top of the viewport draws fewer rows than
|
|
93
|
+
// the editor rendered. Never claim a row the box does not cover.
|
|
94
|
+
box.rect.y + box.rect.height - contentTop,
|
|
95
|
+
);
|
|
96
|
+
if (contentRows <= 0) return undefined;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
contentTop,
|
|
100
|
+
contentRows,
|
|
101
|
+
left: box.rect.x,
|
|
102
|
+
textColumn: Math.max(0, options.textColumn),
|
|
103
|
+
scrollOffset,
|
|
104
|
+
boxTop: box.rect.y,
|
|
105
|
+
boxBottom: box.rect.y + box.rect.height - 1,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The buffer position under a screen cell, or undefined when the cell is not
|
|
111
|
+
* one of the editor's text rows.
|
|
112
|
+
*
|
|
113
|
+
* Only the row is rejected out of range; the column is clamped. A press on the
|
|
114
|
+
* rail lands on column 0 rather than on a negative one, and a press past the
|
|
115
|
+
* end of a line lands on its end — both are what every editor does with a click
|
|
116
|
+
* in the margin, and both are what a selection's own ends need when the drag
|
|
117
|
+
* ran off the side of the box.
|
|
118
|
+
*/
|
|
119
|
+
export function caretPositionAt(
|
|
120
|
+
editor: unknown,
|
|
121
|
+
viewport: EditorViewport,
|
|
122
|
+
x: number,
|
|
123
|
+
y: number,
|
|
124
|
+
): CaretPosition | undefined {
|
|
125
|
+
const row = y - viewport.contentTop;
|
|
126
|
+
if (row < 0 || row >= viewport.contentRows) return undefined;
|
|
127
|
+
const column = Math.max(0, x - viewport.left - viewport.textColumn);
|
|
128
|
+
const point = resolveEditorTextPointAt(editor, viewport.scrollOffset + row, column);
|
|
129
|
+
return point ? { line: point.line, column: point.index } : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The editor's buffer, however this editor exposes it. */
|
|
133
|
+
function editorLines(value: unknown): readonly string[] | undefined {
|
|
134
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
135
|
+
const editor = value as {
|
|
136
|
+
getLines?: () => string[];
|
|
137
|
+
state?: { lines?: string[] };
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
if (typeof editor.getLines === "function") {
|
|
141
|
+
const lines = editor.getLines();
|
|
142
|
+
if (Array.isArray(lines)) return lines;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// An editor whose accessor throws is one we fall back to reading directly.
|
|
146
|
+
}
|
|
147
|
+
const lines = editor.state?.lines;
|
|
148
|
+
return Array.isArray(lines) ? lines : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function comparePositions(a: CaretPosition, b: CaretPosition): number {
|
|
152
|
+
return a.line === b.line ? a.column - b.column : a.line - b.line;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function clamp(value: number, min: number, max: number): number {
|
|
156
|
+
return Math.max(min, Math.min(value, max));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The draft's own text between two buffer positions, whole lines in between.
|
|
161
|
+
*
|
|
162
|
+
* `to` may come before `from` — a drag upwards produces exactly that — so the
|
|
163
|
+
* two ends are ordered here rather than at every call site. Lines past the end
|
|
164
|
+
* of the buffer contribute nothing: this runs on positions resolved from an
|
|
165
|
+
* earlier frame, and the draft may have changed since.
|
|
166
|
+
*/
|
|
167
|
+
export function editorSelectionText(
|
|
168
|
+
editor: unknown,
|
|
169
|
+
from: CaretPosition,
|
|
170
|
+
to: CaretPosition,
|
|
171
|
+
): string {
|
|
172
|
+
const lines = editorLines(editor);
|
|
173
|
+
if (!lines || lines.length === 0) return "";
|
|
174
|
+
|
|
175
|
+
const [start, end] = comparePositions(from, to) <= 0 ? [from, to] : [to, from];
|
|
176
|
+
if (start.line >= lines.length) return "";
|
|
177
|
+
|
|
178
|
+
const last = Math.min(end.line, lines.length - 1);
|
|
179
|
+
const rows: string[] = [];
|
|
180
|
+
for (let line = Math.max(0, start.line); line <= last; line++) {
|
|
181
|
+
const text = lines[line] ?? "";
|
|
182
|
+
const begin = line === start.line ? clamp(start.column, 0, text.length) : 0;
|
|
183
|
+
const finish = line === end.line ? clamp(end.column, 0, text.length) : text.length;
|
|
184
|
+
rows.push(text.slice(begin, Math.max(begin, finish)));
|
|
185
|
+
}
|
|
186
|
+
return rows.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The shape of `TuiAltScreen.getSelectionBounds()`, as much as is read here. */
|
|
190
|
+
export type SelectionBoundsLike = {
|
|
191
|
+
start: { scrollView?: unknown; row: number; col: number };
|
|
192
|
+
end: { scrollView?: unknown; row: number; col: number; boundary?: boolean };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The buffer position for one end of a screen selection, with one concession
|
|
197
|
+
* `caretPositionAt` does not make: a drag that ran off the text rows but is
|
|
198
|
+
* still inside the box clamps to the text instead of declining. Releasing on
|
|
199
|
+
* the bottom border (or the metadata row) means "through the end of the
|
|
200
|
+
* visible text", and releasing on the top border means "from its start" —
|
|
201
|
+
* the column on a border row selects no text, so it is discarded. A row
|
|
202
|
+
* outside the box altogether — the transcript above, the footer below —
|
|
203
|
+
* still declines: that selection is not purely the editor's, and Pi copies
|
|
204
|
+
* it as screen text exactly as before.
|
|
205
|
+
*
|
|
206
|
+
* The end column follows Pi's own rule in `getSelectionColumns`: a character
|
|
207
|
+
* selection covers the glyph under `end.col`, so the exclusive end is one
|
|
208
|
+
* column further right, while a word or line selection arrives with
|
|
209
|
+
* `boundary: true` and already points past its last glyph. Converting a column
|
|
210
|
+
* rather than an index is what keeps that correct for wide glyphs —
|
|
211
|
+
* `displayColumnToStringIndex` walks by display width, so asking for the column
|
|
212
|
+
* after a two-cell glyph lands after the glyph, exactly as
|
|
213
|
+
* `getGraphemeCellRange` would.
|
|
214
|
+
*/
|
|
215
|
+
function selectionEndPosition(
|
|
216
|
+
editor: unknown,
|
|
217
|
+
viewport: EditorViewport,
|
|
218
|
+
point: { row: number; col: number; boundary?: boolean },
|
|
219
|
+
isEnd: boolean,
|
|
220
|
+
): CaretPosition | undefined {
|
|
221
|
+
const lastRow = viewport.contentTop + viewport.contentRows - 1;
|
|
222
|
+
let row = point.row;
|
|
223
|
+
let col = isEnd ? (point.boundary ? point.col : point.col + 1) : point.col;
|
|
224
|
+
if (row < viewport.contentTop) {
|
|
225
|
+
if (row < viewport.boxTop) return undefined;
|
|
226
|
+
row = viewport.contentTop;
|
|
227
|
+
col = 0;
|
|
228
|
+
} else if (row > lastRow) {
|
|
229
|
+
if (row > viewport.boxBottom) return undefined;
|
|
230
|
+
row = lastRow;
|
|
231
|
+
col = viewport.left + viewport.textColumn + Number.MAX_SAFE_INTEGER;
|
|
232
|
+
}
|
|
233
|
+
return caretPositionAt(editor, viewport, col, row);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The buffer range a screen selection covers, or undefined when the selection
|
|
238
|
+
* is not the editor's to interpret. "Not the editor's" is: a selection
|
|
239
|
+
* anchored in a scroll view (the transcript owns those), one reaching outside
|
|
240
|
+
* the box itself (see `selectionEndPosition` for the clamp applied inside
|
|
241
|
+
* it), or one over an editor this module cannot read. Each falls through to
|
|
242
|
+
* Pi untouched.
|
|
243
|
+
*/
|
|
244
|
+
export function editorSelectionRange(
|
|
245
|
+
editor: unknown,
|
|
246
|
+
viewport: EditorViewport,
|
|
247
|
+
bounds: SelectionBoundsLike,
|
|
248
|
+
): { from: CaretPosition; to: CaretPosition } | undefined {
|
|
249
|
+
if (bounds.start.scrollView || bounds.end.scrollView) return undefined;
|
|
250
|
+
const from = selectionEndPosition(editor, viewport, bounds.start, false);
|
|
251
|
+
if (!from) return undefined;
|
|
252
|
+
const to = selectionEndPosition(editor, viewport, bounds.end, true);
|
|
253
|
+
if (!to) return undefined;
|
|
254
|
+
return { from, to };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The slice of Pi's renderer this module reads. Both entries are plain instance
|
|
259
|
+
* fields, not methods, so neither is a probed capability — see
|
|
260
|
+
* `capabilities.ts`.
|
|
261
|
+
*/
|
|
262
|
+
export type CaretReceiver = {
|
|
263
|
+
currentLayout?: { root: BoxLike };
|
|
264
|
+
terminal?: { rows?: number };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The live editor, its box and the mapping into it, resolved fresh.
|
|
269
|
+
*
|
|
270
|
+
* Nothing here is cached. The box moves whenever the transcript grows, the
|
|
271
|
+
* scroll offset moves whenever the caret does, and the row count moves whenever
|
|
272
|
+
* the draft is edited — an answer kept even until the next event would be about
|
|
273
|
+
* rows that have moved.
|
|
274
|
+
*/
|
|
275
|
+
export function activeEditorViewport(
|
|
276
|
+
receiver: CaretReceiver,
|
|
277
|
+
config: PolishedTuiConfig,
|
|
278
|
+
): { editor: unknown; viewport: EditorViewport } | undefined {
|
|
279
|
+
const active = activeEditor();
|
|
280
|
+
if (!active) return undefined;
|
|
281
|
+
const rows = receiver.terminal?.rows;
|
|
282
|
+
if (typeof rows !== "number") return undefined;
|
|
283
|
+
const box = editorBoxFor(receiver.currentLayout?.root, active.component);
|
|
284
|
+
if (!box) return undefined;
|
|
285
|
+
const viewport = editorViewport(active.scrollable, box, {
|
|
286
|
+
paddingY: config.editorPaddingY,
|
|
287
|
+
textColumn: editorTextColumn({
|
|
288
|
+
copyFriendly: config.features.copyFriendly,
|
|
289
|
+
railIcon: config.icons.rail,
|
|
290
|
+
promptIcon: config.icons.editorPrompt,
|
|
291
|
+
}),
|
|
292
|
+
terminalRows: rows,
|
|
293
|
+
});
|
|
294
|
+
return viewport ? { editor: active.scrollable, viewport } : undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Put the caret where the pointer is. Returns false when the press was not the
|
|
299
|
+
* input box's, which is every press this feature must leave alone.
|
|
300
|
+
*
|
|
301
|
+
* The point has to be *painted* by the box, not merely inside its rectangle,
|
|
302
|
+
* which is why this asks `pointerOverEditor` — the same question the wheel asks
|
|
303
|
+
* — rather than testing the rect: a dock squeezed out of the viewport must not
|
|
304
|
+
* claim rows it is not drawing.
|
|
305
|
+
*/
|
|
306
|
+
export function moveEditorCaretTo(
|
|
307
|
+
receiver: CaretReceiver,
|
|
308
|
+
config: PolishedTuiConfig,
|
|
309
|
+
x: number,
|
|
310
|
+
y: number,
|
|
311
|
+
): boolean {
|
|
312
|
+
try {
|
|
313
|
+
const active = activeEditor();
|
|
314
|
+
if (!active) return false;
|
|
315
|
+
if (!pointerOverEditor(receiver.currentLayout?.root, active.component, x, y)) return false;
|
|
316
|
+
const resolved = activeEditorViewport(receiver, config);
|
|
317
|
+
if (!resolved) return false;
|
|
318
|
+
const position = caretPositionAt(resolved.editor, resolved.viewport, x, y);
|
|
319
|
+
if (!position) return false;
|
|
320
|
+
return setEditorCaret(resolved.editor, position);
|
|
321
|
+
} catch {
|
|
322
|
+
// Everything above reads Pi's layout and an editor this module did not
|
|
323
|
+
// necessarily build. Anything it trips over means the press was an
|
|
324
|
+
// ordinary press.
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
type CaretWritableEditor = {
|
|
330
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
331
|
+
preferredVisualCol?: number | null;
|
|
332
|
+
snappedFromCursorCol?: number | null;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Move the caret to a buffer position.
|
|
337
|
+
*
|
|
338
|
+
* The sticky column is cleared alongside it, the same way `scrollEditorBy`
|
|
339
|
+
* clears it: `preferredVisualCol` and `snappedFromCursorCol` describe where the
|
|
340
|
+
* caret used to be, and left in place they make the next up or down press jump
|
|
341
|
+
* back there instead of moving from where the click just put it.
|
|
342
|
+
*/
|
|
343
|
+
function setEditorCaret(value: unknown, position: CaretPosition): boolean {
|
|
344
|
+
if (typeof value !== "object" || value === null) return false;
|
|
345
|
+
const editor = value as CaretWritableEditor;
|
|
346
|
+
const lines = editor.state?.lines;
|
|
347
|
+
if (!editor.state || !Array.isArray(lines)) return false;
|
|
348
|
+
if (position.line < 0 || position.line >= lines.length) return false;
|
|
349
|
+
editor.state.cursorLine = position.line;
|
|
350
|
+
editor.state.cursorCol = clamp(position.column, 0, (lines[position.line] ?? "").length);
|
|
351
|
+
editor.preferredVisualCol = null;
|
|
352
|
+
editor.snappedFromCursorCol = null;
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The slice of Pi's `Editor` the range delete drives.
|
|
358
|
+
*
|
|
359
|
+
* Every entry is a real method on `Editor`
|
|
360
|
+
* (`node_modules/@earendil-works/pi-tui/dist/components/editor.js`), probed
|
|
361
|
+
* before use because the editor may be somebody else's.
|
|
362
|
+
*/
|
|
363
|
+
type EditableEditor = {
|
|
364
|
+
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
|
|
365
|
+
snappedFromCursorCol?: number | null;
|
|
366
|
+
/** `getText()` (editor.js:826) — the join of `state.lines`. */
|
|
367
|
+
getText?: () => string;
|
|
368
|
+
/** `setCursorCol(col)` (editor.js:1158); also clears `preferredVisualCol`. */
|
|
369
|
+
setCursorCol?: (col: number) => void;
|
|
370
|
+
/** `handleForwardDelete()` (editor.js:1410) — one grapheme, or one line join. */
|
|
371
|
+
handleForwardDelete?: () => void;
|
|
372
|
+
/** `pushUndoSnapshot()` (editor.js:1702). */
|
|
373
|
+
pushUndoSnapshot?: () => void;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Delete a buffer range through the editor's own editing methods.
|
|
378
|
+
*
|
|
379
|
+
* ## Why `handleForwardDelete` in a loop
|
|
380
|
+
*
|
|
381
|
+
* Pi's `Editor` has no range delete. Its editing surface is
|
|
382
|
+
* `handleBackspace`, `handleForwardDelete`, `deleteToStartOfLine`,
|
|
383
|
+
* `deleteToEndOfLine`, `deleteWordBackwards` and `deleteWordForward`, and none
|
|
384
|
+
* of them takes two positions. So a range is deleted by putting the caret at
|
|
385
|
+
* its start and deleting forward until the range's worth of characters is gone.
|
|
386
|
+
*
|
|
387
|
+
* `handleForwardDelete` is the right one of the six. It is grapheme-aware, so a
|
|
388
|
+
* flag or a combining sequence goes in one piece; it joins the next line when
|
|
389
|
+
* the caret is at the end of one, so a multi-line range needs no special case;
|
|
390
|
+
* and — unlike the `deleteTo…` and `deleteWord…` family — it does **not** push
|
|
391
|
+
* to the kill ring, so deleting a selection cannot silently clobber what the
|
|
392
|
+
* user has yanked.
|
|
393
|
+
*
|
|
394
|
+
* ## Why one undo snapshot
|
|
395
|
+
*
|
|
396
|
+
* Each of those calls would push its own snapshot, leaving `ctrl+z` to walk the
|
|
397
|
+
* range back a letter at a time. So one snapshot is pushed up front through the
|
|
398
|
+
* editor's own `pushUndoSnapshot`, and the editor's own is shadowed for the
|
|
399
|
+
* duration of the loop and put back in a `finally` — the same shadow-and-restore
|
|
400
|
+
* shape `copyWithNotice` uses on `flash` and `installPasteCollapse` uses on
|
|
401
|
+
* `handlePaste`. This is a plain assignment on Starline's own editor instance,
|
|
402
|
+
* never on anything reached through Pi's renderer Proxy.
|
|
403
|
+
*
|
|
404
|
+
* The loop is bounded twice over: by the character count of the range, and by a
|
|
405
|
+
* no-progress check, so an editor that stops deleting cannot spin.
|
|
406
|
+
*/
|
|
407
|
+
function deleteEditorRange(value: unknown, start: CaretPosition, length: number): boolean {
|
|
408
|
+
if (typeof value !== "object" || value === null) return false;
|
|
409
|
+
const editor = value as EditableEditor;
|
|
410
|
+
const lines = editor.state?.lines;
|
|
411
|
+
if (!editor.state || !Array.isArray(lines)) return false;
|
|
412
|
+
if (typeof editor.getText !== "function") return false;
|
|
413
|
+
if (typeof editor.setCursorCol !== "function") return false;
|
|
414
|
+
if (typeof editor.handleForwardDelete !== "function") return false;
|
|
415
|
+
if (start.line < 0 || start.line >= lines.length) return false;
|
|
416
|
+
|
|
417
|
+
const target = editor.getText().length - length;
|
|
418
|
+
editor.pushUndoSnapshot?.call(editor);
|
|
419
|
+
|
|
420
|
+
const hadOwnSnapshot = Object.hasOwn(editor, "pushUndoSnapshot");
|
|
421
|
+
const ownSnapshot = editor.pushUndoSnapshot;
|
|
422
|
+
editor.pushUndoSnapshot = () => {};
|
|
423
|
+
try {
|
|
424
|
+
editor.state.cursorLine = start.line;
|
|
425
|
+
editor.setCursorCol.call(editor, clamp(start.column, 0, (lines[start.line] ?? "").length));
|
|
426
|
+
// The sticky column belongs to wherever the caret used to be.
|
|
427
|
+
editor.snappedFromCursorCol = null;
|
|
428
|
+
|
|
429
|
+
let guard = length + 1;
|
|
430
|
+
while (guard-- > 0) {
|
|
431
|
+
const before = editor.getText().length;
|
|
432
|
+
if (before <= target) break;
|
|
433
|
+
editor.handleForwardDelete.call(editor);
|
|
434
|
+
// Nothing left to delete forward (the end of the last line) — stop
|
|
435
|
+
// rather than spin.
|
|
436
|
+
if (editor.getText().length >= before) break;
|
|
437
|
+
}
|
|
438
|
+
} finally {
|
|
439
|
+
if (hadOwnSnapshot) editor.pushUndoSnapshot = ownSnapshot;
|
|
440
|
+
else delete editor.pushUndoSnapshot;
|
|
441
|
+
}
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Delete the draft text a screen selection covers, and report whether it did.
|
|
447
|
+
*
|
|
448
|
+
* `false` means the selection was not the editor's to delete — a selection in
|
|
449
|
+
* the transcript, one reaching outside the text rows, one over an editor this
|
|
450
|
+
* module cannot drive, or one that resolves to no characters. Every one of those
|
|
451
|
+
* has to leave the key to Pi, so an ordinary backspace goes on deleting one
|
|
452
|
+
* character.
|
|
453
|
+
*/
|
|
454
|
+
export function deleteEditorSelection(
|
|
455
|
+
receiver: CaretReceiver,
|
|
456
|
+
config: PolishedTuiConfig,
|
|
457
|
+
bounds: SelectionBoundsLike,
|
|
458
|
+
): boolean {
|
|
459
|
+
try {
|
|
460
|
+
const resolved = activeEditorViewport(receiver, config);
|
|
461
|
+
if (!resolved) return false;
|
|
462
|
+
const range = editorSelectionRange(resolved.editor, resolved.viewport, bounds);
|
|
463
|
+
if (!range) return false;
|
|
464
|
+
const text = editorSelectionText(resolved.editor, range.from, range.to);
|
|
465
|
+
if (text.length === 0) return false;
|
|
466
|
+
const start = comparePositions(range.from, range.to) <= 0 ? range.from : range.to;
|
|
467
|
+
return deleteEditorRange(resolved.editor, start, text.length);
|
|
468
|
+
} catch {
|
|
469
|
+
// Driving somebody else's editor is best effort. Anything this trips over
|
|
470
|
+
// leaves the key to Pi.
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The draft's own text for a selection that lies inside the input box, or
|
|
477
|
+
* undefined when the selection is somebody else's.
|
|
478
|
+
*
|
|
479
|
+
* One function for both callers: the copy itself, and the pending-mode hint
|
|
480
|
+
* that has to promise the same character count the copy will deliver.
|
|
481
|
+
*/
|
|
482
|
+
export function editorSelectionTextFor(
|
|
483
|
+
receiver: CaretReceiver,
|
|
484
|
+
config: PolishedTuiConfig,
|
|
485
|
+
bounds: SelectionBoundsLike,
|
|
486
|
+
): string | undefined {
|
|
487
|
+
try {
|
|
488
|
+
const resolved = activeEditorViewport(receiver, config);
|
|
489
|
+
if (!resolved) return undefined;
|
|
490
|
+
const range = editorSelectionRange(resolved.editor, resolved.viewport, bounds);
|
|
491
|
+
if (!range) return undefined;
|
|
492
|
+
return editorSelectionText(resolved.editor, range.from, range.to);
|
|
493
|
+
} catch {
|
|
494
|
+
// undefined is "not the editor's", which is the right answer for a
|
|
495
|
+
// selection this module could not read: Pi copies it as it always did.
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate the editor box inside the rendered cluster, and map a click in it to
|
|
3
|
+
* editor-text coordinates.
|
|
4
|
+
*
|
|
5
|
+
* The cluster is a picture by the time it reaches here — status, widgets, the
|
|
6
|
+
* framed editor and the footer, already rendered to lines. Finding the box
|
|
7
|
+
* means recognising its two horizontal rules; everything between them is laid
|
|
8
|
+
* out by renderPolishedFrame, so the row arithmetic mirrors what it emitted.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
const RULE_GLYPHS = new Set(["─", "━", "═"]);
|
|
16
|
+
|
|
17
|
+
function stripAnsi(line: string): string {
|
|
18
|
+
return line.replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A full-width run of box-drawing rule, which is how the editor frames itself. */
|
|
22
|
+
export function isEditorRule(line: string): boolean {
|
|
23
|
+
const plain = stripAnsi(line).trim();
|
|
24
|
+
if (plain.length < 4) return false;
|
|
25
|
+
for (const char of plain) {
|
|
26
|
+
if (!RULE_GLYPHS.has(char)) return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type EditorBoxGeometry = {
|
|
32
|
+
/** Cluster line index of the first editor text row. */
|
|
33
|
+
firstTextRow: number;
|
|
34
|
+
/** Cluster line index of the last editor text row. */
|
|
35
|
+
lastTextRow: number;
|
|
36
|
+
/** Visible column where editor text starts, past the rail or prompt. */
|
|
37
|
+
textColumn: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find the editor box in the cluster.
|
|
42
|
+
*
|
|
43
|
+
* Scanning up from the bottom finds the editor's own rules first: everything
|
|
44
|
+
* below it (the footer) is text, and its content rows all carry the rail, so
|
|
45
|
+
* they cannot be mistaken for a rule.
|
|
46
|
+
*
|
|
47
|
+
* Layout between the rules, per renderPolishedFrame:
|
|
48
|
+
* rule, [pad], ...text, [pad], metadata, rule
|
|
49
|
+
*/
|
|
50
|
+
export function findEditorBox(
|
|
51
|
+
clusterLines: string[],
|
|
52
|
+
paddingY: number,
|
|
53
|
+
textColumn: number,
|
|
54
|
+
): EditorBoxGeometry | null {
|
|
55
|
+
let bottom = -1;
|
|
56
|
+
for (let index = clusterLines.length - 1; index >= 0; index--) {
|
|
57
|
+
if (isEditorRule(clusterLines[index] ?? "")) {
|
|
58
|
+
bottom = index;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (bottom < 0) return null;
|
|
63
|
+
|
|
64
|
+
let top = -1;
|
|
65
|
+
for (let index = bottom - 1; index >= 0; index--) {
|
|
66
|
+
if (isEditorRule(clusterLines[index] ?? "")) {
|
|
67
|
+
top = index;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (top < 0) return null;
|
|
72
|
+
|
|
73
|
+
const pad = paddingY > 0 ? 1 : 0;
|
|
74
|
+
const firstTextRow = top + 1 + pad;
|
|
75
|
+
// Below the text: the padding row, then the metadata row, then the rule.
|
|
76
|
+
const lastTextRow = bottom - 2 - pad;
|
|
77
|
+
if (lastTextRow < firstTextRow) return null;
|
|
78
|
+
|
|
79
|
+
return { firstTextRow, lastTextRow, textColumn };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Map a cluster row/column onto the editor's visual grid, or null when the
|
|
84
|
+
* point is outside the text area. Columns are visible columns, so the caller
|
|
85
|
+
* still has to resolve wide characters against the actual line.
|
|
86
|
+
*/
|
|
87
|
+
export function hitTestEditorBox(
|
|
88
|
+
box: EditorBoxGeometry,
|
|
89
|
+
clusterRow: number,
|
|
90
|
+
clusterCol: number,
|
|
91
|
+
): { visualRow: number; visualCol: number } | null {
|
|
92
|
+
if (clusterRow < box.firstTextRow || clusterRow > box.lastTextRow) return null;
|
|
93
|
+
const visualCol = clusterCol - box.textColumn;
|
|
94
|
+
if (visualCol < 0) return null;
|
|
95
|
+
return { visualRow: clusterRow - box.firstTextRow, visualCol };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Visible column where the editor's own text begins, past the chrome that
|
|
100
|
+
* renderPolishedFrame prefixes to every content row.
|
|
101
|
+
*/
|
|
102
|
+
export function editorTextColumn(options: {
|
|
103
|
+
copyFriendly: boolean;
|
|
104
|
+
railIcon: string;
|
|
105
|
+
promptIcon: string;
|
|
106
|
+
}): number {
|
|
107
|
+
if (options.copyFriendly) {
|
|
108
|
+
return options.promptIcon ? visibleWidth(options.promptIcon) + 1 : 0;
|
|
109
|
+
}
|
|
110
|
+
// The rail is always drawn, and always followed by a space, even when the
|
|
111
|
+
// glyph itself is empty.
|
|
112
|
+
return visibleWidth(options.railIcon) + 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Index into `line` at `targetCol` visible columns past `fromIndex`.
|
|
117
|
+
* Walks by code point so wide glyphs and astral characters land correctly.
|
|
118
|
+
*/
|
|
119
|
+
export function displayColumnToStringIndex(
|
|
120
|
+
line: string,
|
|
121
|
+
fromIndex: number,
|
|
122
|
+
targetCol: number,
|
|
123
|
+
): number {
|
|
124
|
+
let col = 0;
|
|
125
|
+
let index = Math.max(0, Math.min(fromIndex, line.length));
|
|
126
|
+
while (index < line.length && col < targetCol) {
|
|
127
|
+
const codePoint = line.codePointAt(index) ?? 0;
|
|
128
|
+
const char = String.fromCodePoint(codePoint);
|
|
129
|
+
col += Math.max(1, visibleWidth(char));
|
|
130
|
+
index += char.length;
|
|
131
|
+
}
|
|
132
|
+
return index;
|
|
133
|
+
}
|