@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.
Files changed (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Where the input box is on screen, and which live editor is in it.
3
+ *
4
+ * A wheel notch has to be routed before it can be acted on, and routing it
5
+ * needs two facts Pi does not hand out: which component the draft belongs to,
6
+ * and which rows on screen that component drew.
7
+ *
8
+ * ## Finding the editor's rectangle
9
+ *
10
+ * The plan for this feature assumed the editor is a direct child of a stack and
11
+ * therefore carries its own `LayoutBox` — the one place in Pi's layout tree
12
+ * where `boxFor` would finally have a production caller. It is not, and
13
+ * `test/mouse/editor-wheel.test.ts` pins that against the real engine.
14
+ * `interactive-mode.js` mounts the editor in a `Container` (`tui.js:39`) and
15
+ * puts *the container* in the dock's `VStack`. `Container` carries no
16
+ * `LAYOUT_NODE`, so `layoutComponent` folds it and everything inside it into a
17
+ * single leaf box with `children: []` — exactly the finding
18
+ * `test/contract/transcript-layout.test.ts` recorded for the transcript.
19
+ * `boxFor(root, editor)` is therefore always `undefined`.
20
+ *
21
+ * What does work is the box one level up: the container's. Its rect is the
22
+ * editor's rows, because the container renders nothing but its children and
23
+ * holds exactly one. So the lookup here is still by identity — the identity of
24
+ * the live editor instance inside `Container.children` — rather than by
25
+ * position in the dock or by the shape of the rendered border. That
26
+ * distinction matters: the container also holds Pi's extension selector, input
27
+ * and alternate editor at various times (`interactive-mode.js:1918-2011`), and
28
+ * while one of those is up the draft is not on screen at all. Asking whether
29
+ * *this* editor is in there answers that for free.
30
+ *
31
+ * ## Finding the editor itself
32
+ *
33
+ * Nothing on Pi's renderer points at the editor: `TuiAltScreen` has no
34
+ * reference to one, and the extension API exposes the *factory*
35
+ * (`getEditorComponent`) rather than the instance it built. But Starline builds
36
+ * the instance itself, in `index.ts`'s editor factory, so the extension is the
37
+ * one thing in the process that already has it. It registers it here on the way
38
+ * out of the factory.
39
+ *
40
+ * Two references are registered, not one, because the mounted component and the
41
+ * scrollable editor are not always the same object. In the standalone path they
42
+ * are (`PolishedEditor` extends Pi's `Editor`); in the wrapped path Starline
43
+ * mounts a `WrappedPolishedEditor` that delegates by composition and exposes
44
+ * none of `state`, `buildVisualLineMap` or `scrollOffset`. Hit-testing needs
45
+ * the mounted one; scrolling needs the inner one.
46
+ */
47
+
48
+ import { type BoxLike, boxesAt, boxMatching } from "./hit-test";
49
+
50
+ export type WheelTarget = "editor" | "transcript";
51
+
52
+ export type MountedEditor = {
53
+ /** The component in the layout — what a screen rectangle belongs to. */
54
+ component: unknown;
55
+ /**
56
+ * The component that owns the draft's visual lines — what `editor-scroll.ts`
57
+ * reads. In the wrapped path this is somebody else's editor, which may not
58
+ * expose that surface at all; `scrollEditorBy` says so by returning false
59
+ * and the notch falls through to the transcript.
60
+ */
61
+ scrollable: unknown;
62
+ };
63
+
64
+ /**
65
+ * The editor Starline's factory built most recently, or undefined before the
66
+ * first one is built and after the extension is torn down.
67
+ *
68
+ * A module-level register rather than an `installMouse` dependency: the mouse
69
+ * patches live on Pi's *prototype* and outlive any one editor instance, while
70
+ * the editor is rebuilt whenever `setCustomEditorComponent` runs. Threading it
71
+ * through `installMouse` would mean re-installing every patch on every editor
72
+ * rebuild. This mirrors `activeSelectionHintText`'s register in `index.ts`.
73
+ */
74
+ let active: MountedEditor | undefined;
75
+
76
+ export function setActiveEditor(editor: MountedEditor | undefined): void {
77
+ active = editor;
78
+ }
79
+
80
+ export function activeEditor(): MountedEditor | undefined {
81
+ return active;
82
+ }
83
+
84
+ /** Whether `component` is a container the editor is currently mounted in. */
85
+ function hosts(component: unknown, editor: unknown): boolean {
86
+ if (typeof component !== "object" || component === null) return false;
87
+ const children = (component as { children?: unknown }).children;
88
+ return Array.isArray(children) && children.includes(editor);
89
+ }
90
+
91
+ /**
92
+ * The box covering the editor's rows: its own if Pi ever gives it one, else
93
+ * the box of the container it is mounted in.
94
+ *
95
+ * The direct lookup is tried first and is not dead code waiting on a
96
+ * hypothetical: a Pi build that gave the editor a `LAYOUT_NODE`, or an
97
+ * embedding that put it straight into the dock stack, would produce exactly
98
+ * that box, and it is the more specific of the two answers.
99
+ */
100
+ export function editorBoxFor(root: BoxLike | undefined, editor: unknown): BoxLike | undefined {
101
+ if (!root || editor === undefined || editor === null) return undefined;
102
+ return (
103
+ boxMatching(root, (component) => component === editor) ??
104
+ boxMatching(root, (component) => hosts(component, editor))
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Whether the input box is what is painted at `(x, y)`.
110
+ *
111
+ * The box has to be under the pointer *and* actually painted there, which is
112
+ * why this asks `boxesAt` rather than testing the rectangle directly:
113
+ * `boxesAt` walks down from the root honouring each box's `clip`, so a dock
114
+ * squeezed out of the viewport cannot claim rows it is not drawing.
115
+ *
116
+ * Two features ask it — the wheel, for which notch belongs to the draft, and
117
+ * click-to-caret, for which press does.
118
+ */
119
+ export function pointerOverEditor(
120
+ root: BoxLike | undefined,
121
+ editor: unknown,
122
+ x: number,
123
+ y: number,
124
+ ): boolean {
125
+ if (!root) return false;
126
+ const box = editorBoxFor(root, editor);
127
+ if (!box) return false;
128
+ return boxesAt(root, x, y).includes(box);
129
+ }
130
+
131
+ /**
132
+ * Whether a wheel notch at `(x, y)` belongs to the input box or to the
133
+ * transcript behind it.
134
+ */
135
+ export function wheelTarget(
136
+ root: BoxLike | undefined,
137
+ editor: unknown,
138
+ x: number,
139
+ y: number,
140
+ ): WheelTarget {
141
+ return pointerOverEditor(root, editor, x, y) ? "editor" : "transcript";
142
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Wheel scrolling inside the editor box.
3
+ *
4
+ * Pi's editor keeps no scroll command of its own: its `scrollOffset` is
5
+ * re-derived from the caret on every render — see the editor component, which
6
+ * pulls the offset back whenever the caret would fall outside the window of
7
+ * `max(5, rows * 0.3)` visual lines. So moving the offset alone is undone by
8
+ * the very next frame; the caret has to come along, exactly as it would if you
9
+ * had held the arrow keys instead. That is what this does.
10
+ *
11
+ * Everything is probed before use and the whole thing is wrapped: when the
12
+ * editor is not the shape we expect, this reports failure and the wheel falls
13
+ * back to scrolling the transcript.
14
+ *
15
+ * The `editorWheelScroll` patch in `mouse/index.ts` drives it; it lives in its
16
+ * own module because `editor-caret.ts` shares the visual-line map it builds.
17
+ *
18
+ * @internal
19
+ */
20
+
21
+ type VisualLine = { logicalLine: number; startCol: number; length: number };
22
+
23
+ type ScrollableEditor = {
24
+ state: { lines: string[]; cursorLine?: number; cursorCol?: number };
25
+ buildVisualLineMap: (width: number) => VisualLine[];
26
+ scrollOffset?: number;
27
+ lastWidth?: number;
28
+ preferredVisualCol?: number | null;
29
+ snappedFromCursorCol?: number | null;
30
+ };
31
+
32
+ /** How many visual lines the editor shows, per its own render. */
33
+ export function editorVisibleLines(terminalRows: number): number {
34
+ return Math.max(5, Math.floor(terminalRows * 0.3));
35
+ }
36
+
37
+ function asScrollableEditor(value: unknown): ScrollableEditor | null {
38
+ if (typeof value !== "object" || value === null) return null;
39
+ const editor = value as ScrollableEditor;
40
+ if (typeof editor.buildVisualLineMap !== "function") return null;
41
+ if (!editor.state || !Array.isArray(editor.state.lines)) return null;
42
+ return editor;
43
+ }
44
+
45
+ /** Which visual row the caret sits on, or -1 when it cannot be placed. */
46
+ function cursorVisualRow(visualLines: VisualLine[], line: number, col: number): number {
47
+ let fallback = -1;
48
+ for (let row = 0; row < visualLines.length; row++) {
49
+ const visual = visualLines[row];
50
+ if (!visual || visual.logicalLine !== line) continue;
51
+ fallback = row;
52
+ if (col >= visual.startCol && col <= visual.startCol + visual.length) return row;
53
+ }
54
+ return fallback;
55
+ }
56
+
57
+ /**
58
+ * Scroll the editor by `delta` visual lines: negative shows earlier lines,
59
+ * positive later ones. Returns false when there is nothing to scroll — no
60
+ * scrollable content, or an editor we cannot read — so the caller can let the
61
+ * transcript have the wheel instead.
62
+ *
63
+ * Reaching the top or bottom of scrollable content still returns true: once the
64
+ * pointer is over an editor that scrolls, the wheel belongs to it, and chaining
65
+ * on to the transcript at the boundary would make the box feel like it slipped.
66
+ */
67
+ export function scrollEditorBy(value: unknown, delta: number, terminalRows: number): boolean {
68
+ return scrollEditorWindow(value, delta, editorVisibleLines(terminalRows));
69
+ }
70
+
71
+ /**
72
+ * The same, for a caller that already knows how many rows the box shows — the
73
+ * box's own geometry, rather than Pi's formula for it.
74
+ */
75
+ export function scrollEditorWindow(value: unknown, delta: number, visibleLines: number): boolean {
76
+ const editor = asScrollableEditor(value);
77
+ if (!editor) return false;
78
+ if (visibleLines < 1) return false;
79
+
80
+ try {
81
+ const width = Math.max(1, editor.lastWidth ?? 80);
82
+ const visualLines = editor.buildVisualLineMap(width);
83
+ if (!Array.isArray(visualLines) || visualLines.length === 0) return false;
84
+
85
+ const maxOffset = visualLines.length - visibleLines;
86
+ // The whole input fits: there is no scrolling to be done here.
87
+ if (maxOffset <= 0) return false;
88
+
89
+ const current = Math.max(0, Math.min(editor.scrollOffset ?? 0, maxOffset));
90
+ const next = Math.max(0, Math.min(current + delta, maxOffset));
91
+ if (next === current) return true;
92
+ editor.scrollOffset = next;
93
+
94
+ // Drag the caret into the new window, or the next render snaps back.
95
+ const cursorLine = editor.state.cursorLine ?? 0;
96
+ const cursorCol = editor.state.cursorCol ?? 0;
97
+ const row = cursorVisualRow(visualLines, cursorLine, cursorCol);
98
+ if (row < 0) return true;
99
+
100
+ const first = next;
101
+ const last = next + visibleLines - 1;
102
+ if (row >= first && row <= last) return true;
103
+
104
+ const targetRow = row < first ? first : last;
105
+ const target = visualLines[targetRow];
106
+ if (!target) return true;
107
+ // Keep the column where it was, as far as the target line allows.
108
+ const offsetInLine = Math.max(0, cursorCol - (visualLines[row]?.startCol ?? 0));
109
+ editor.state.cursorLine = target.logicalLine;
110
+ editor.state.cursorCol = target.startCol + Math.min(offsetInLine, target.length);
111
+ // Clear the sticky column, or the next up/down press jumps back to
112
+ // wherever the caret used to be rather than where it was just put.
113
+ editor.preferredVisualCol = null;
114
+ editor.snappedFromCursorCol = null;
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Map between the editor's rendered rows and its own text: resolve a row and
3
+ * column to a point in the buffer, read a row's text back out, and move the
4
+ * cursor to a point the user clicked.
5
+ *
6
+ * Pi's editor lays its text out onto visual lines and keeps that mapping to
7
+ * itself, and offers a way to read the cursor but not to move it. So this
8
+ * reaches into the live editor instance for both. Everything is probed before
9
+ * use and the whole thing is wrapped: when the shape is not what we expect —
10
+ * a Pi upgrade, a different editor implementation — it reports failure and the
11
+ * click falls back to doing nothing, rather than taking the editor down.
12
+ *
13
+ * Ported from pi-powerline-footer, where this has been in daily use.
14
+ *
15
+ * @internal
16
+ */
17
+
18
+ import { displayColumnToStringIndex } from "./editor-hit-test";
19
+
20
+ type VisualLine = { logicalLine: number; startCol: number; length: number };
21
+
22
+ export type EditorInternals = {
23
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
24
+ buildVisualLineMap?: (width: number) => VisualLine[];
25
+ scrollOffset?: number;
26
+ lastWidth?: number;
27
+ preferredVisualCol?: number | null;
28
+ snappedFromCursorCol?: number | null;
29
+ setCursorCol?: (col: number) => void;
30
+ pushUndoSnapshot?: () => void;
31
+ cancelAutocomplete?: () => void;
32
+ exitHistoryBrowsing?: () => void;
33
+ onChange?: (text: string) => void;
34
+ lastAction?: unknown;
35
+ };
36
+
37
+ /** A point in the editor's own text: which logical line, and where in it. */
38
+ export type EditorTextPoint = { line: number; index: number };
39
+
40
+ function asEditorInternals(value: unknown): EditorInternals | null {
41
+ if (typeof value !== "object" || value === null) return null;
42
+ const editor = value as EditorInternals;
43
+ if (typeof editor.buildVisualLineMap !== "function") return null;
44
+ if (!editor.state || !Array.isArray(editor.state.lines)) return null;
45
+ return editor;
46
+ }
47
+
48
+ /** Whether this editor exposes enough for click-to-position to work at all. */
49
+ export function supportsEditorTextCursor(value: unknown): boolean {
50
+ return asEditorInternals(value) !== null;
51
+ }
52
+
53
+ /**
54
+ * Find the object that actually holds the editor state.
55
+ *
56
+ * What the compositor tracks is the *container* Pi puts the editor in, and
57
+ * Starline may itself wrap the editor in a decorator. So walk down through
58
+ * children and wrapped bases until something answers the probe. The visited
59
+ * set keeps a cyclic component graph from looping.
60
+ */
61
+ export function resolveEditorInternals(root: unknown): unknown {
62
+ const seen = new Set<unknown>();
63
+ const queue: unknown[] = [root];
64
+
65
+ while (queue.length > 0) {
66
+ const node = queue.shift();
67
+ if (typeof node !== "object" || node === null || seen.has(node)) continue;
68
+ seen.add(node);
69
+ if (supportsEditorTextCursor(node)) return node;
70
+
71
+ const record = node as { base?: unknown; children?: unknown };
72
+ if (record.base) queue.push(record.base);
73
+ if (Array.isArray(record.children)) queue.push(...record.children);
74
+ }
75
+
76
+ return undefined;
77
+ }
78
+
79
+ /** The editor's visual line map, or null when it cannot be read. */
80
+ function visualLineMap(editor: EditorInternals, fallbackWidth: number): VisualLine[] | null {
81
+ const width = Math.max(1, editor.lastWidth ?? fallbackWidth);
82
+ const visualLines = editor.buildVisualLineMap?.(width);
83
+ return Array.isArray(visualLines) ? visualLines : null;
84
+ }
85
+
86
+ /**
87
+ * Resolve an absolute visual row/column of the editor to a point in its text.
88
+ *
89
+ * Absolute means an index into the editor's whole visual line map, not into the
90
+ * rows currently on screen. Selection works in these coordinates so that
91
+ * scrolling the box does not move the text a selection is anchored to.
92
+ *
93
+ * Returns null when the point is out of range or the editor cannot be read.
94
+ */
95
+ export function resolveEditorTextPointAt(
96
+ value: unknown,
97
+ absoluteRow: number,
98
+ visualCol: number,
99
+ fallbackWidth = 80,
100
+ ): EditorTextPoint | null {
101
+ const editor = asEditorInternals(value);
102
+ if (!editor?.state?.lines) return null;
103
+
104
+ try {
105
+ const visualLines = visualLineMap(editor, fallbackWidth);
106
+ const visual = visualLines?.[absoluteRow];
107
+ if (!visual) return null;
108
+
109
+ const text = editor.state.lines[visual.logicalLine] ?? "";
110
+ const index = displayColumnToStringIndex(text, visual.startCol, visualCol);
111
+ return {
112
+ line: visual.logicalLine,
113
+ index: Math.max(visual.startCol, Math.min(index, visual.startCol + visual.length)),
114
+ };
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolve a visual row/column of the *rendered* editor to a point in its text —
122
+ * a row as counted from the top of the box, which is what a click gives.
123
+ * Returns null when the point is out of range or the editor cannot be read.
124
+ */
125
+ export function resolveEditorTextPoint(
126
+ value: unknown,
127
+ visualRow: number,
128
+ visualCol: number,
129
+ fallbackWidth = 80,
130
+ ): EditorTextPoint | null {
131
+ const editor = asEditorInternals(value);
132
+ if (!editor) return null;
133
+ return resolveEditorTextPointAt(
134
+ value,
135
+ visualRow + (editor.scrollOffset ?? 0),
136
+ visualCol,
137
+ fallbackWidth,
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Plain text of one absolute visual row of the editor, or null when the row is
143
+ * out of range or the editor cannot be read.
144
+ *
145
+ * This is the editor's own buffer, not the rendered row: it carries no cursor
146
+ * highlight and no frame, and it is there whether the row is on screen or
147
+ * scrolled out of the box — which is what lets a selection reach past what the
148
+ * box shows.
149
+ */
150
+ export function editorVisualRowText(
151
+ value: unknown,
152
+ absoluteRow: number,
153
+ fallbackWidth = 80,
154
+ ): string | null {
155
+ const editor = asEditorInternals(value);
156
+ if (!editor?.state?.lines) return null;
157
+
158
+ try {
159
+ const visualLines = visualLineMap(editor, fallbackWidth);
160
+ const visual = visualLines?.[absoluteRow];
161
+ if (!visual) return null;
162
+ const text = editor.state.lines[visual.logicalLine] ?? "";
163
+ return text.slice(visual.startCol, visual.startCol + visual.length);
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /** How many visual rows the editor's text lays out to, or 0 when unreadable. */
170
+ export function editorVisualRowCount(value: unknown, fallbackWidth = 80): number {
171
+ const editor = asEditorInternals(value);
172
+ if (!editor) return 0;
173
+ try {
174
+ return visualLineMap(editor, fallbackWidth)?.length ?? 0;
175
+ } catch {
176
+ return 0;
177
+ }
178
+ }
179
+
180
+ /** The editor's current scroll position, in visual rows from the top. */
181
+ export function editorScrollOffset(value: unknown): number {
182
+ const editor = asEditorInternals(value);
183
+ const offset = editor?.scrollOffset;
184
+ return typeof offset === "number" && Number.isFinite(offset) ? Math.max(0, offset) : 0;
185
+ }
186
+
187
+ /**
188
+ * Place the cursor at a visual row/column of the editor's own text.
189
+ * Returns false when the point is out of range or the editor cannot be driven.
190
+ */
191
+ export function positionEditorTextCursor(
192
+ value: unknown,
193
+ visualRow: number,
194
+ visualCol: number,
195
+ fallbackWidth = 80,
196
+ ): boolean {
197
+ const editor = asEditorInternals(value);
198
+ if (!editor?.state) return false;
199
+ const point = resolveEditorTextPoint(value, visualRow, visualCol, fallbackWidth);
200
+ if (!point) return false;
201
+
202
+ editor.state.cursorLine = point.line;
203
+ editor.state.cursorCol = point.index;
204
+ // Clear the sticky column, or the next up/down press jumps back to
205
+ // wherever the cursor used to be rather than where it was just put.
206
+ editor.preferredVisualCol = null;
207
+ editor.snappedFromCursorCol = null;
208
+ return true;
209
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Delete a selected range from Pi's editor.
3
+ *
4
+ * Pi's editor has no selection of its own, so it has nothing to delete from and
5
+ * no API for doing it. `setText` is not a way in either: it clears the paste map
6
+ * and counter, which would turn every collapsed `[paste #N]` marker in the box
7
+ * into text that expands to nothing on submit. So the range is spliced out of
8
+ * `state.lines` directly, the same way the paste expansion does it, with an undo
9
+ * snapshot pushed first so ctrl+z still puts it back.
10
+ *
11
+ * Everything is probed before use: an editor that does not look like Pi's is
12
+ * left alone and the key falls through to Pi.
13
+ *
14
+ * @internal
15
+ */
16
+
17
+ import {
18
+ type EditorInternals,
19
+ type EditorTextPoint,
20
+ resolveEditorTextPointAt,
21
+ } from "./editor-text-cursor";
22
+
23
+ function asEditorInternals(value: unknown): EditorInternals | null {
24
+ if (typeof value !== "object" || value === null) return null;
25
+ const editor = value as EditorInternals;
26
+ if (typeof editor.buildVisualLineMap !== "function") return null;
27
+ if (!editor.state || !Array.isArray(editor.state.lines)) return null;
28
+ return editor;
29
+ }
30
+
31
+ function comparePoints(a: EditorTextPoint, b: EditorTextPoint): number {
32
+ return a.line === b.line ? a.index - b.index : a.line - b.line;
33
+ }
34
+
35
+ /**
36
+ * Delete the text between two points of the editor, given as absolute visual
37
+ * row/column pairs — rows counted through the editor's whole text rather than
38
+ * from the top of the box, which is how a selection stores them so that
39
+ * scrolling cannot move it. `end` is exclusive. Returns false without touching
40
+ * anything when the range cannot be resolved or comes out empty.
41
+ */
42
+ export function deleteEditorVisualRange(
43
+ value: unknown,
44
+ start: { visualRow: number; visualCol: number },
45
+ end: { visualRow: number; visualCol: number },
46
+ fallbackWidth = 80,
47
+ ): boolean {
48
+ const editor = asEditorInternals(value);
49
+ const lines = editor?.state?.lines;
50
+ if (!editor?.state || !lines) return false;
51
+
52
+ try {
53
+ const a = resolveEditorTextPointAt(editor, start.visualRow, start.visualCol, fallbackWidth);
54
+ const b = resolveEditorTextPointAt(editor, end.visualRow, end.visualCol, fallbackWidth);
55
+ if (!a || !b) return false;
56
+
57
+ const [from, to] = comparePoints(a, b) <= 0 ? [a, b] : [b, a];
58
+ if (comparePoints(from, to) === 0) return false;
59
+ if (from.line >= lines.length || to.line >= lines.length) return false;
60
+
61
+ editor.cancelAutocomplete?.call(editor);
62
+ editor.exitHistoryBrowsing?.call(editor);
63
+ editor.lastAction = null;
64
+ editor.pushUndoSnapshot?.call(editor);
65
+
66
+ const head = (lines[from.line] ?? "").slice(0, from.index);
67
+ const tail = (lines[to.line] ?? "").slice(to.index);
68
+ const next = [...lines.slice(0, from.line), head + tail, ...lines.slice(to.line + 1)];
69
+ editor.state.lines = next.length === 0 ? [""] : next;
70
+
71
+ editor.state.cursorLine = from.line;
72
+ if (typeof editor.setCursorCol === "function") {
73
+ editor.setCursorCol.call(editor, from.index);
74
+ } else {
75
+ editor.state.cursorCol = from.index;
76
+ }
77
+ // The sticky column belongs to wherever the cursor used to be.
78
+ editor.preferredVisualCol = null;
79
+ editor.snappedFromCursorCol = null;
80
+
81
+ // Anything watching the text — autocomplete, hints — hears about it the
82
+ // same way it would for a keystroke. Paste entries whose marker was inside
83
+ // the deleted range are left in the map: they are unreachable, and Pi only
84
+ // ever expands markers it finds in the text.
85
+ editor.onChange?.call(editor, editor.state.lines.join("\n"));
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The right-hand side of the editor's metadata row.
3
+ *
4
+ * Both hints share one slot. `ui.ts` renders that row whether or not
5
+ * `editorMetadataFormat` has anything in it, which is why 0.2.2 moved the paste
6
+ * hint here off the editor border — blanking the template must not hide it.
7
+ */
8
+ export function composeHints(
9
+ pasteHint: string | null,
10
+ selectionHint: string | null,
11
+ ): string | null {
12
+ if (pasteHint && selectionHint) return `${pasteHint} ⋅ ${selectionHint}`;
13
+ return pasteHint ?? selectionHint ?? null;
14
+ }