@xzzpig/pi-starline 0.1.1 → 0.3.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/README.md +13 -12
- package/extensions/starline/config.ts +0 -17
- package/extensions/starline/index.ts +34 -33
- package/extensions/starline/mouse/api-consumer.ts +31 -0
- package/extensions/starline/mouse/capabilities.ts +83 -129
- package/extensions/starline/mouse/editor-caret.ts +3 -3
- package/extensions/starline/mouse/index.ts +504 -690
- package/extensions/starline/mouse/selection-state.ts +17 -39
- package/extensions/starline/mouse/tool-box.ts +112 -83
- package/extensions/starline/mouse/transcript-copy.ts +1 -1
- package/extensions/starline/prototype-patch-registry.ts +8 -6
- package/extensions/starline/ui.ts +1 -1
- package/package.json +15 -8
- package/extensions/starline/mouse/word-select.ts +0 -145
|
@@ -1,96 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* `editorWheelScroll`, one patch:
|
|
45
|
-
* - `routeWheel` — Pi's own wheel routing, which knows only about scroll views
|
|
46
|
-
* and therefore always scrolls the transcript. Wrapped so a notch that landed
|
|
47
|
-
* on the input box scrolls the *draft* instead, when the draft is taller than
|
|
48
|
-
* the box. `editor-mouse.ts` carries how the box is located (not where the
|
|
49
|
-
* plan for this said it would be) and how the live editor is reached; the
|
|
50
|
-
* scroll itself is `editor-scroll.ts` — see its header for why it lives in
|
|
51
|
-
* its own module. Every other notch calls through.
|
|
52
|
-
*
|
|
53
|
-
* `editorClickToCaret`, across two shared patches:
|
|
54
|
-
* - `handleSelectionMouseEvent` — a left-button press inside the input box moves
|
|
55
|
-
* the caret to the character under it, then calls through, so Pi still drops
|
|
56
|
-
* its selection anchor there and a drag from that point still selects and
|
|
57
|
-
* highlights as it always did. `editor-caret.ts` carries the screen-to-buffer
|
|
58
|
-
* arithmetic and why the box's rectangle is not the text.
|
|
59
|
-
* - `handleViewportInput` — backspace or delete over a live selection inside the
|
|
2
|
+
* Starline's mouse feature set, as a consumer of the `pi-mouse-events`
|
|
3
|
+
* extension.
|
|
4
|
+
*
|
|
5
|
+
* Starline used to install this feature set itself, by wrapping four methods
|
|
6
|
+
* on `TuiAltScreen.prototype`. It no longer touches any prototype: the
|
|
7
|
+
* `pi-mouse-events` extension owns that surface now, and everything here
|
|
8
|
+
* rides on the API it publishes (`mouse/api-consumer.ts` reads it off
|
|
9
|
+
* `globalThis`). When that extension is not installed there is no fallback —
|
|
10
|
+
* `installMouseFeaturesOn` is never called, no handler is registered, the
|
|
11
|
+
* hints derive from nothing, and every mouse feature is simply off. That is
|
|
12
|
+
* the deal: one owner for the prototype, Starline as a plain consumer.
|
|
13
|
+
*
|
|
14
|
+
* The features themselves are unchanged, and each maps onto one API slot:
|
|
15
|
+
*
|
|
16
|
+
* `editorWheelScroll` → `addMouseHandler`
|
|
17
|
+
* - A wheel notch that landed on the input box scrolls the *draft*, when the
|
|
18
|
+
* draft is taller than the box. `pi-mouse-events` delivers the notch before
|
|
19
|
+
* Pi's built-in wheel routing; consuming it here is what stops the
|
|
20
|
+
* transcript from scrolling too. `editor-mouse.ts` carries how the box is
|
|
21
|
+
* located and how the live editor is reached; the scroll itself is
|
|
22
|
+
* `editor-scroll.ts`.
|
|
23
|
+
*
|
|
24
|
+
* `clickToExpandTools` → `addMouseHandler`
|
|
25
|
+
* - A left-button press anywhere on an expandable component records the
|
|
26
|
+
* candidate; when the button is released on the same cell with no motion in
|
|
27
|
+
* between — a plain click — that one component is toggled and the release
|
|
28
|
+
* is consumed, so the click does not also drop a selection anchor into the
|
|
29
|
+
* box it just opened. The press itself is never consumed: a drag that
|
|
30
|
+
* starts on a box, hint row included, still selects and copies, a click on
|
|
31
|
+
* an OSC 8 link still opens the link, and a second same-cell click inside
|
|
32
|
+
* Pi's double-click window stays a word selection. Direction comes from the
|
|
33
|
+
* hint row when one is rendered and from the component's own `expanded`
|
|
34
|
+
* state otherwise; `tool-box.ts` carries the reasoning and the accepted
|
|
35
|
+
* limitation about a box's own output reading like its hint.
|
|
36
|
+
*
|
|
37
|
+
* `editorClickToCaret` → `addMouseHandler` + `ctx.ui.onTerminalInput`
|
|
38
|
+
* - The press half: a left-button press inside the input box moves the caret
|
|
39
|
+
* to the character under it and never consumes, so Pi still drops its
|
|
40
|
+
* selection anchor there and a drag from that point still selects and
|
|
41
|
+
* highlights as it always did. `editor-caret.ts` carries the
|
|
42
|
+
* screen-to-buffer arithmetic.
|
|
43
|
+
* - The keyboard half: backspace or delete over a live selection inside the
|
|
60
44
|
* input box removes the whole range instead of one character, through the
|
|
61
|
-
* editor's own `handleForwardDelete` under a single undo snapshot.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* paint around their content removed
|
|
76
|
-
* border rules,
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
45
|
+
* editor's own `handleForwardDelete` under a single undo snapshot. This
|
|
46
|
+
* used to ride the same viewport-input patch as everything else; it is
|
|
47
|
+
* keyboard, and `ctx.ui.onTerminalInput` — Pi's official extension input
|
|
48
|
+
* listener, which the renderer's mouse handling never swallows — is the
|
|
49
|
+
* honest place for it. `isRangeDeleteKey` refuses ctrl+c and ctrl+d
|
|
50
|
+
* outright, so the interrupt and exit chords cannot be swallowed by it from
|
|
51
|
+
* either direction. Every backspace it does not act on falls through and
|
|
52
|
+
* deletes one character.
|
|
53
|
+
*
|
|
54
|
+
* `editorBufferCopy` + `transcriptCleanCopy` → `addCopyHandler`
|
|
55
|
+
* - The copy key's path. A selection lying inside the input box is copied
|
|
56
|
+
* from the *draft* rather than from the rendered rows — the rows carry the
|
|
57
|
+
* rail glyph, the frame's padding, and a hard newline wherever the draft
|
|
58
|
+
* happened to wrap. A selection in the transcript is copied with the chrome
|
|
59
|
+
* two components paint around their content removed (Starline's rails and
|
|
60
|
+
* border rules, `pi-toolbox`'s rounded frames); the cleaning itself is
|
|
61
|
+
* `transcript-copy.ts`, and a selection with no recognised decoration falls
|
|
62
|
+
* back to Pi's verbatim copy.
|
|
63
|
+
*
|
|
64
|
+
* `selectionHint` → derived, no slot at all
|
|
65
|
+
* - `activeSelectionHintText()` renders the live renderer's state — an active
|
|
66
|
+
* selection that is not being auto-copied (`getCopyOnSelect() === false`) —
|
|
67
|
+
* so select-without-copy is Pi's own `fullscreenCopyOnSelect`, and this
|
|
68
|
+
* module only tells the user what to press. It is computed on demand at
|
|
69
|
+
* render time from the lazily bound renderer reference (`bindReceiver`);
|
|
70
|
+
* there is nothing to intercept.
|
|
85
71
|
*/
|
|
86
|
-
import {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
sliceByColumn,
|
|
90
|
-
stripTerminalSequences,
|
|
91
|
-
} from "@earendil-works/pi-tui";
|
|
72
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
73
|
+
import { getKeybindings, sliceByColumn, stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
74
|
+
import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
|
|
92
75
|
import type { PolishedTuiConfig } from "../config";
|
|
93
|
-
import { installPrototypePatch } from "../prototype-patch-registry";
|
|
94
76
|
import {
|
|
95
77
|
disabledFeatureWarning,
|
|
96
78
|
enabledFeatures,
|
|
@@ -103,50 +85,37 @@ import {
|
|
|
103
85
|
editorSelectionTextFor,
|
|
104
86
|
moveEditorCaretTo,
|
|
105
87
|
} from "./editor-caret";
|
|
106
|
-
import { activeEditor,
|
|
88
|
+
import { activeEditor, pointerOverEditor } from "./editor-mouse";
|
|
107
89
|
import { scrollEditorBy } from "./editor-scroll";
|
|
108
90
|
import { editorVisualRowCount } from "./editor-text-cursor";
|
|
109
91
|
import { type BoxLike, scrollContentLinesFor } from "./hit-test";
|
|
110
|
-
import { externalEditorName,
|
|
92
|
+
import { externalEditorName, selectionHintText } from "./selection-state";
|
|
111
93
|
import { type ExpandTarget, expandKeyText, expandTargetAt, keyTextFor } from "./tool-box";
|
|
112
94
|
import { cleanTranscriptRows } from "./transcript-copy";
|
|
113
|
-
import { wordRangeAt } from "./word-select";
|
|
114
95
|
|
|
96
|
+
/** Pi's interrupt chord — refused by the range-delete branch, never consumed. */
|
|
115
97
|
const CTRL_C = "\x03";
|
|
116
98
|
/** Pi's exit chord, and `tui.editor.deleteCharForward`'s second default key. */
|
|
117
99
|
const CTRL_D = "\x04";
|
|
118
100
|
|
|
101
|
+
/** The binding Pi's selection copy lives on (`app.message.copy`, default ctrl+x). */
|
|
102
|
+
const COPY_KEYBINDING = "app.message.copy";
|
|
103
|
+
|
|
119
104
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* never as the bare `\x03` a plain legacy terminal sends. The pending copy
|
|
125
|
-
* must recognise all three, or ctrl+c falls through to Pi's own binding
|
|
126
|
-
* (`app.clear`, clear the editor) exactly when it was about to copy. Pi's
|
|
127
|
-
* own key matcher is the authority here: it parses every protocol this
|
|
128
|
-
* build negotiates, and its `ctrl+c` arm accepts the raw control character
|
|
129
|
-
* too, so a legacy terminal keeps working unchanged.
|
|
105
|
+
* Pi's own double-click window (`DOUBLE_CLICK_INTERVAL_MS` in
|
|
106
|
+
* `tui-alt-screen.js`). A second same-cell click inside it is a word
|
|
107
|
+
* selection to Pi; without the debounce the expand flow would read it as
|
|
108
|
+
* another plain click and toggle the box right back.
|
|
130
109
|
*/
|
|
131
|
-
|
|
132
|
-
if (typeof data !== "string" || data.length === 0) return false;
|
|
133
|
-
try {
|
|
134
|
-
return matchesKey(data, "ctrl+c");
|
|
135
|
-
} catch {
|
|
136
|
-
// A pi-tui build that has moved `matchesKey` must not break the copy:
|
|
137
|
-
// fall back to the raw byte, which is what this branch has always read.
|
|
138
|
-
return data === CTRL_C;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
110
|
+
const DOUBLE_CLICK_MS = 500;
|
|
141
111
|
|
|
142
112
|
/** Opens the draft in `$EDITOR` — the hint an editor selection's hint carries. */
|
|
143
113
|
const EXTERNAL_EDITOR_KEYBINDING = "app.editor.external";
|
|
144
114
|
|
|
145
115
|
/**
|
|
146
|
-
* SGR mouse bits, as `parseSgrMouseEvent` decodes them
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* `m`/`M` terminator.
|
|
116
|
+
* SGR mouse bits, as pi-tui's `parseSgrMouseEvent` decodes them. `button & 3`
|
|
117
|
+
* is the button — 0 is left — bit 32 marks a motion (drag) report and bit 64
|
|
118
|
+
* a wheel notch. `release` is the `m`/`M` terminator.
|
|
150
119
|
*/
|
|
151
120
|
const BUTTON_MASK = 3;
|
|
152
121
|
const LEFT_BUTTON = 0;
|
|
@@ -155,77 +124,37 @@ const WHEEL_BIT = 64;
|
|
|
155
124
|
|
|
156
125
|
type MouseEventLike = { button: number; x: number; y: number; release?: boolean };
|
|
157
126
|
|
|
158
|
-
/** `parseWheelEvent`'s output (`tui-alt-screen.js:345`): -1 is up, 1 is down. */
|
|
159
|
-
type WheelEventLike = { direction: number; x: number; y: number };
|
|
160
|
-
|
|
161
127
|
type SelectionPoint = { scrollView?: unknown; row: number; col: number; boundary?: boolean };
|
|
162
128
|
type SelectionBounds = { start: SelectionPoint; end: SelectionPoint };
|
|
163
129
|
type SelectionColumns = { start: number; end: number };
|
|
164
130
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
131
|
+
/**
|
|
132
|
+
* The slice of the live renderer this module reads. Reached through the
|
|
133
|
+
* `pi-mouse-events` handler context (`tui`) and its `liveReceiver()` hook —
|
|
134
|
+
* pi-tui 0.84.x internals, TS-private but runtime-present, the same surface
|
|
135
|
+
* its own consumers reach.
|
|
136
|
+
*/
|
|
137
|
+
export type MouseCapableReceiver = {
|
|
138
|
+
getSelectionBounds?(): SelectionBounds | undefined;
|
|
168
139
|
getSelectionColumns(
|
|
169
140
|
this: unknown,
|
|
170
141
|
line: string,
|
|
171
142
|
row: number,
|
|
172
143
|
selection: SelectionBounds,
|
|
173
144
|
): SelectionColumns;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
flash(this: unknown, message: string, durationMs?: number): void;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
hasOverlay(this: unknown): boolean;
|
|
180
|
-
/**
|
|
181
|
-
* `TuiAltScreen.routeWheel(event)` (`tui-alt-screen.js:375`). `event` is
|
|
182
|
-
* `parseWheelEvent`'s output — `{ direction: -1 | 1, x, y }`, zero-based —
|
|
183
|
-
* and the method returns nothing.
|
|
184
|
-
*/
|
|
185
|
-
routeWheel(this: unknown, event: WheelEventLike): void;
|
|
186
|
-
/**
|
|
187
|
-
* `TuiBase.requestRender(force = false)` (`tui.js:495`), inherited by
|
|
188
|
-
* `TuiAltScreen` and public. The only method here this module calls rather
|
|
189
|
-
* than patches — see `capabilities.ts`.
|
|
190
|
-
*/
|
|
191
|
-
requestRender(this: unknown, force?: boolean): void;
|
|
145
|
+
getCopyOnSelect?(this: unknown): boolean;
|
|
146
|
+
hasActiveSelection?(this: unknown): boolean;
|
|
147
|
+
flash?(this: unknown, message: string, durationMs?: number): void;
|
|
148
|
+
hasOverlay?(this: unknown): boolean;
|
|
149
|
+
requestRender?(this: unknown, force?: boolean): void;
|
|
192
150
|
previousScreen?: readonly string[];
|
|
193
|
-
// Not probed capabilities (see capabilities.ts): plain instance fields
|
|
194
|
-
// this module only ever reads.
|
|
195
151
|
currentLayout?: { root: BoxLike };
|
|
196
|
-
/**
|
|
197
|
-
* `TuiAltScreen.selectionAnchor` / `.selectionFocus` (`tui-alt-screen.js:45`).
|
|
198
|
-
* Plain instance fields, which Pi assigns and clears throughout its own
|
|
199
|
-
* selection handling; the range delete clears them the same way once the text
|
|
200
|
-
* they described is gone.
|
|
201
|
-
*/
|
|
202
152
|
selectionAnchor?: unknown;
|
|
203
153
|
selectionFocus?: unknown;
|
|
204
|
-
/**
|
|
205
|
-
* `TuiBase.terminal` (`tui.js:101`); `rows` is a getter on it, and `write` is
|
|
206
|
-
* how `copySelectionToClipboard` reaches the clipboard — it emits OSC 52
|
|
207
|
-
* through the terminal rather than through any renderer method.
|
|
208
|
-
*/
|
|
209
154
|
terminal?: { rows?: number; write?: (data: string) => void };
|
|
210
|
-
/**
|
|
211
|
-
* How many lines one notch moves, `max(1, options.wheelScrollLines ?? 1)`
|
|
212
|
-
* (`tui-alt-screen.js:73`). Read so the editor scrolls by the same amount
|
|
213
|
-
* the transcript would have.
|
|
214
|
-
*/
|
|
215
155
|
wheelScrollLines?: number;
|
|
216
156
|
};
|
|
217
157
|
|
|
218
|
-
/**
|
|
219
|
-
* Repaints go through the receiver, not through a callback the caller supplies.
|
|
220
|
-
*
|
|
221
|
-
* The renderer these patches run inside is the thing that needs to repaint, and
|
|
222
|
-
* `TuiAltScreen` calls `this.requestRender()` all through its own mouse handling
|
|
223
|
-
* for exactly this. Routing it back out to the extension instead made the hint
|
|
224
|
-
* depend on whatever repaint path the extension happened to own — which, until
|
|
225
|
-
* this was fixed, was the footer's, so with `features.statusLine` off the
|
|
226
|
-
* pending hint never appeared until some unrelated frame came along. The hint
|
|
227
|
-
* lives in the editor's metadata row; it was never the footer's to schedule.
|
|
228
|
-
*/
|
|
229
158
|
export type InstallMouseDeps = {
|
|
230
159
|
getConfig: () => PolishedTuiConfig;
|
|
231
160
|
};
|
|
@@ -234,15 +163,120 @@ export type InstallMouseDeps = {
|
|
|
234
163
|
let hasWarned = false;
|
|
235
164
|
|
|
236
165
|
/**
|
|
237
|
-
*
|
|
238
|
-
|
|
239
|
-
|
|
166
|
+
* The live renderer, bound lazily — see `bindReceiver`.
|
|
167
|
+
*/
|
|
168
|
+
let activeReceiver: MouseCapableReceiver | undefined;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reader for the derived selection hint, mirroring `pasteExpandHintText`'s
|
|
172
|
+
* pattern in `../paste-collapse.ts`. `ui.ts` composes this with the paste hint
|
|
240
173
|
* on every render; there is nothing to wire when no mouse install is active.
|
|
174
|
+
*
|
|
175
|
+
* The hint is computed on demand from the live renderer, never cached:
|
|
176
|
+
* "selected, ctrl+x to copy" is true exactly while Pi is not auto-copying and
|
|
177
|
+
* something is actually selected. The character count goes through the same
|
|
178
|
+
* two copy paths the copy key itself would answer, so the hint's promise and
|
|
179
|
+
* the clipboard's bytes stay in agreement.
|
|
180
|
+
*/
|
|
181
|
+
let activeHint:
|
|
182
|
+
| { getConfig: () => PolishedTuiConfig; bufferCopy: boolean; cleanCopy: boolean }
|
|
183
|
+
| undefined;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Per-install state: how to reach the live renderer (`api.liveReceiver()`),
|
|
187
|
+
* whether the copy slot exists, and the config reader the hint needs. Set at
|
|
188
|
+
* install, cleared on dispose.
|
|
189
|
+
*/
|
|
190
|
+
let installRefs:
|
|
191
|
+
| {
|
|
192
|
+
liveReceiver: () => unknown;
|
|
193
|
+
copySlot: boolean;
|
|
194
|
+
deps: InstallMouseDeps;
|
|
195
|
+
}
|
|
196
|
+
| undefined;
|
|
197
|
+
|
|
198
|
+
/** Whether the probed receiver supports `feature` — false before a first bind. */
|
|
199
|
+
function featureOn(feature: MouseFeature): boolean {
|
|
200
|
+
return featureSet?.has(feature) ?? false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The press a release-click is waiting to complete. `component` is the
|
|
205
|
+
* candidate resolved at press time — the release re-resolves and requires the
|
|
206
|
+
* same object, so content that scrolled between the two cannot redirect the
|
|
207
|
+
* toggle. `dragged` mirrors Pi's own `selectionDragged`: any button motion
|
|
208
|
+
* between press and release makes it a selection, not a click.
|
|
241
209
|
*/
|
|
242
|
-
let
|
|
210
|
+
let expandPress: { x: number; y: number; component: object; dragged: boolean } | undefined;
|
|
211
|
+
|
|
212
|
+
/** The last completed toggle, for the double-click debounce. */
|
|
213
|
+
let lastToggle: { x: number; y: number; at: number } | undefined;
|
|
214
|
+
|
|
215
|
+
let featureSet: ReadonlySet<MouseFeature> | undefined;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Binds the renderer this mouse event (or the API's live reference) points at.
|
|
219
|
+
*
|
|
220
|
+
* In a real session there is no renderer to probe at install time: `ctx.ui`
|
|
221
|
+
* is Pi's extension-UI surface, not the renderer, and the live `TuiAltScreen`
|
|
222
|
+
* first appears as `tui` inside a handler invocation and in the API's
|
|
223
|
+
* `liveReceiver()` — which the input wrapper refreshes on every keystroke as
|
|
224
|
+
* well as on every mouse report. Features are therefore registered
|
|
225
|
+
* unconditionally at install and gated here, on the capability set the first
|
|
226
|
+
* bind of each distinct renderer instance computes.
|
|
227
|
+
*
|
|
228
|
+
* With no candidate the last bound receiver stands, so the render-time hint
|
|
229
|
+
* readers and the keyboard listener can work from it without an event of
|
|
230
|
+
* their own.
|
|
231
|
+
*/
|
|
232
|
+
function bindReceiver(candidate?: unknown): MouseCapableReceiver | undefined {
|
|
233
|
+
if (!installRefs) return undefined;
|
|
234
|
+
const receiver = candidate ?? installRefs.liveReceiver();
|
|
235
|
+
if (!receiver) return activeReceiver;
|
|
236
|
+
if (receiver !== activeReceiver) {
|
|
237
|
+
activeReceiver = receiver as MouseCapableReceiver;
|
|
238
|
+
featureSet = enabledFeatures(probeCapabilities(activeReceiver), installRefs.copySlot);
|
|
239
|
+
const warning = disabledFeatureWarning(featureSet);
|
|
240
|
+
if (warning && !hasWarned) {
|
|
241
|
+
hasWarned = true;
|
|
242
|
+
console.warn(warning);
|
|
243
|
+
}
|
|
244
|
+
const { deps } = installRefs;
|
|
245
|
+
const bufferCopy = featureOn("editorBufferCopy");
|
|
246
|
+
const cleanCopy = featureOn("transcriptCleanCopy");
|
|
247
|
+
activeHint =
|
|
248
|
+
bufferCopy || cleanCopy ? { getConfig: deps.getConfig, bufferCopy, cleanCopy } : undefined;
|
|
249
|
+
}
|
|
250
|
+
return activeReceiver;
|
|
251
|
+
}
|
|
243
252
|
|
|
244
253
|
export function activeSelectionHintText(): string | null {
|
|
245
|
-
|
|
254
|
+
const receiver = bindReceiver();
|
|
255
|
+
if (!receiver || !activeHint) return null;
|
|
256
|
+
const { getConfig, bufferCopy, cleanCopy } = activeHint;
|
|
257
|
+
try {
|
|
258
|
+
// Pi auto-copies on release; a selection that is already on the
|
|
259
|
+
// clipboard needs no hint. `getCopyOnSelect()` is the live value, so a
|
|
260
|
+
// setting flipped at runtime flips the hint with it.
|
|
261
|
+
if (receiver.getCopyOnSelect?.()) return null;
|
|
262
|
+
if (!receiver.hasActiveSelection?.()) return null;
|
|
263
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
264
|
+
if (!bounds) return null;
|
|
265
|
+
const pending = pendingSelectionText(receiver, getConfig(), bounds, bufferCopy, cleanCopy);
|
|
266
|
+
return selectionHintText(
|
|
267
|
+
pending.text.length,
|
|
268
|
+
copyKeyText(),
|
|
269
|
+
// An editor selection cannot grow past the visible window — there is
|
|
270
|
+
// no drag-scroll — so the hint for one points at the external editor,
|
|
271
|
+
// the way to act on the whole draft. "" (unbound) shows no suffix.
|
|
272
|
+
pending.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
|
|
273
|
+
externalEditorName(),
|
|
274
|
+
);
|
|
275
|
+
} catch {
|
|
276
|
+
// The renderer is best effort; a receiver that has moved on offers no
|
|
277
|
+
// hint rather than breaking the metadata row.
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
246
280
|
}
|
|
247
281
|
|
|
248
282
|
/**
|
|
@@ -253,46 +287,48 @@ export function activeSelectionHintText(): string | null {
|
|
|
253
287
|
* some of it is unreachable by mouse no matter how you drag. That is exactly
|
|
254
288
|
* when the external editor is the way to act on the whole draft, so the hint
|
|
255
289
|
* is offered whenever the draft outgrows the box, not only while a selection
|
|
256
|
-
* is live.
|
|
257
|
-
*
|
|
258
|
-
*
|
|
290
|
+
* is live. Computed on demand at render time (it used to be refreshed from
|
|
291
|
+
* the viewport patch on every key; the value is a pure read of the live
|
|
292
|
+
* renderer, so deriving it when asked is always at least as fresh).
|
|
259
293
|
*/
|
|
260
|
-
let externalEditorHint: string | null = null;
|
|
261
|
-
|
|
262
294
|
export function externalEditorHintText(): string | null {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
function refreshExternalEditorHint(
|
|
267
|
-
receiver: MouseCapablePrototype,
|
|
268
|
-
config: PolishedTuiConfig,
|
|
269
|
-
): void {
|
|
270
|
-
externalEditorHint = null;
|
|
295
|
+
const receiver = bindReceiver();
|
|
296
|
+
if (!receiver || !activeHint) return null;
|
|
271
297
|
try {
|
|
272
|
-
const viewport = activeEditorViewport(receiver,
|
|
273
|
-
if (!viewport) return;
|
|
298
|
+
const viewport = activeEditorViewport(receiver, activeHint.getConfig());
|
|
299
|
+
if (!viewport) return null;
|
|
274
300
|
const visualRows = editorVisualRowCount(viewport.editor);
|
|
275
301
|
if (visualRows > viewport.viewport.contentRows) {
|
|
276
|
-
|
|
302
|
+
return `${keyTextFor(EXTERNAL_EDITOR_KEYBINDING)} to edit in ${
|
|
277
303
|
externalEditorName() ?? "$EDITOR"
|
|
278
304
|
}`;
|
|
279
305
|
}
|
|
306
|
+
return null;
|
|
280
307
|
} catch {
|
|
281
308
|
// Best effort: an editor this module cannot read offers no hint.
|
|
309
|
+
return null;
|
|
282
310
|
}
|
|
283
311
|
}
|
|
284
312
|
|
|
285
313
|
/**
|
|
286
|
-
* The
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
314
|
+
* The rendered name of the copy key, for the hint. `app.message.copy` is what
|
|
315
|
+
* Pi's Ctrl+X path is bound to (see `handleCopyCommand` in
|
|
316
|
+
* interactive-mode.ts), and the registry resolves a user rebind so the hint
|
|
317
|
+
* always quotes the key that really copies.
|
|
318
|
+
*/
|
|
319
|
+
function copyKeyText(): string {
|
|
320
|
+
return keyTextFor(COPY_KEYBINDING);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The exact text Pi's selection copy would produce, built the same way it
|
|
325
|
+
* builds it: per row, through the receiver's own `getSelectionColumns`, then
|
|
326
|
+
* `sliceByColumn` and `stripTerminalSequences` (both exported by pi-tui),
|
|
327
|
+
* joined with "\n". Reusing Pi's own helpers instead of re-deriving the
|
|
328
|
+
* column math is what keeps this exact rather than an estimate. The
|
|
329
|
+
* scroll-view case needs the box behind `bounds.start.scrollView`;
|
|
330
|
+
* `getScrollViewBox` is not exported from pi-tui's published entry point, so
|
|
331
|
+
* `scrollContentLinesFor` mirrors its (trivial) tree walk in `hit-test.ts`.
|
|
296
332
|
*
|
|
297
333
|
* This is a *measurement*, not a copy: nothing here writes a clipboard. The
|
|
298
334
|
* pending hint needs to say how many characters ctrl+c would put there. Rows
|
|
@@ -302,7 +338,7 @@ function refreshExternalEditorHint(
|
|
|
302
338
|
* counts the cleaned text — the hint's promise and the clipboard's bytes are
|
|
303
339
|
* kept in agreement there, by counting whichever text the copy will send.
|
|
304
340
|
*/
|
|
305
|
-
function selectionText(receiver:
|
|
341
|
+
function selectionText(receiver: MouseCapableReceiver, bounds: SelectionBounds): string {
|
|
306
342
|
const scrollView = bounds.start.scrollView;
|
|
307
343
|
const sourceLines = scrollView
|
|
308
344
|
? scrollContentLinesFor(receiver.currentLayout?.root, scrollView)
|
|
@@ -311,7 +347,8 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
|
|
|
311
347
|
const rows: string[] = [];
|
|
312
348
|
for (let row = bounds.start.row; row <= bounds.end.row; row++) {
|
|
313
349
|
const line = sourceLines[row] ?? "";
|
|
314
|
-
const columns = receiver.getSelectionColumns(line, row, bounds);
|
|
350
|
+
const columns = receiver.getSelectionColumns?.(line, row, bounds);
|
|
351
|
+
if (!columns) return "";
|
|
315
352
|
rows.push(
|
|
316
353
|
stripTerminalSequences(
|
|
317
354
|
sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true),
|
|
@@ -321,64 +358,23 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
|
|
|
321
358
|
return rows.join("\n");
|
|
322
359
|
}
|
|
323
360
|
|
|
324
|
-
/**
|
|
325
|
-
* Runs `copySelectionToClipboard` (Pi's real one, reached through the
|
|
326
|
-
* receiver so the `mouse-copy` patch's re-entrancy guard applies) with its
|
|
327
|
-
* own "Copied!" flash suppressed when `copyNotice` is off.
|
|
328
|
-
*
|
|
329
|
-
* Pi's copy method flashes unconditionally — see
|
|
330
|
-
* `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Respecting
|
|
331
|
-
* `copyNotice: false` therefore means shadowing `flash` on the receiver for
|
|
332
|
-
* the duration of this one call and restoring it immediately after, the same
|
|
333
|
-
* shadow-and-restore shape `installPasteCollapse` already uses on `handlePaste`.
|
|
334
|
-
* `receiver` here is Pi's own instance (the wrapper runs inside Pi's own
|
|
335
|
-
* method call, not through the extension-facing Proxy), so a plain
|
|
336
|
-
* assignment is safe.
|
|
337
|
-
*
|
|
338
|
-
* 0.84.2 (#8110) boundary: Pi's copy is now async, and when the host injects
|
|
339
|
-
* a clipboard (`copySelection` — coding-agent does since 0.84.2), the flash
|
|
340
|
-
* lands after `await`, outside this synchronous shadow. `copyNotice: false`
|
|
341
|
-
* therefore still suppresses the toast on the OSC 52 fallback path (no host
|
|
342
|
-
* clipboard) but not on the host-clipboard path. The ctrl+c caller must stay
|
|
343
|
-
* synchronous — it returns `{ consume: true }` to Pi's `handleInput` — so
|
|
344
|
-
* awaiting Pi's copy here is not an option.
|
|
345
|
-
*/
|
|
346
|
-
function copyWithNotice(receiver: MouseCapablePrototype, showNotice: boolean): void {
|
|
347
|
-
if (showNotice) {
|
|
348
|
-
receiver.copySelectionToClipboard();
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
const originalFlash = receiver.flash;
|
|
352
|
-
receiver.flash = () => {};
|
|
353
|
-
try {
|
|
354
|
-
receiver.copySelectionToClipboard();
|
|
355
|
-
} finally {
|
|
356
|
-
receiver.flash = originalFlash;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
361
|
/**
|
|
361
362
|
* Writes the draft's own text to the clipboard for a selection that lies inside
|
|
362
363
|
* the input box, and reports whether it did — `false` means this selection was
|
|
363
364
|
* not the editor's and Pi's own copy must run instead.
|
|
364
365
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
* the
|
|
369
|
-
*
|
|
370
|
-
* copy sends them — OSC 52 through `terminal.write`, see
|
|
371
|
-
* `copySelectionToClipboard` in
|
|
372
|
-
* `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js` — so nothing
|
|
373
|
-
* downstream can tell the two copies apart.
|
|
366
|
+
* Pi copies *rendered* rows, so a selection in the input box picks up the rail
|
|
367
|
+
* glyph, the padding the frame fills each row out to, and a newline wherever
|
|
368
|
+
* the draft happened to wrap. None of that is in the draft. The bytes go out
|
|
369
|
+
* the same way Pi's own copy sends them — OSC 52 through `terminal.write` — so
|
|
370
|
+
* nothing downstream can tell the two copies apart.
|
|
374
371
|
*
|
|
375
|
-
* The flash is
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* and not at all on the other.
|
|
372
|
+
* The flash is gated on `copyNotice` at the write itself, exactly once — Pi's
|
|
373
|
+
* own copy flashes unconditionally, so this check is the whole notice path for
|
|
374
|
+
* a clean copy.
|
|
379
375
|
*/
|
|
380
376
|
function editorTextForSelection(
|
|
381
|
-
receiver:
|
|
377
|
+
receiver: MouseCapableReceiver,
|
|
382
378
|
config: PolishedTuiConfig,
|
|
383
379
|
bounds: SelectionBounds,
|
|
384
380
|
): string | undefined {
|
|
@@ -387,16 +383,16 @@ function editorTextForSelection(
|
|
|
387
383
|
// so without this a selection dropped on a dialog would be read as text
|
|
388
384
|
// from the draft hidden behind it. Both callers ask it, or the hint could
|
|
389
385
|
// count one text while the copy sends another.
|
|
390
|
-
if (receiver.hasOverlay()) return undefined;
|
|
386
|
+
if (receiver.hasOverlay?.()) return undefined;
|
|
391
387
|
return editorSelectionTextFor(receiver, config, bounds);
|
|
392
388
|
} catch {
|
|
393
389
|
return undefined;
|
|
394
390
|
}
|
|
395
391
|
}
|
|
396
392
|
|
|
397
|
-
function copyEditorSelection(receiver:
|
|
393
|
+
function copyEditorSelection(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
|
|
398
394
|
try {
|
|
399
|
-
const bounds = receiver.getSelectionBounds();
|
|
395
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
400
396
|
if (!bounds) return false;
|
|
401
397
|
const text = editorTextForSelection(receiver, config, bounds);
|
|
402
398
|
// undefined is "not the editor's"; "" is the editor's and empty, which is
|
|
@@ -407,7 +403,7 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
|
|
|
407
403
|
const write = receiver.terminal?.write;
|
|
408
404
|
if (typeof write !== "function") return false;
|
|
409
405
|
write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
410
|
-
receiver.flash("Copied!");
|
|
406
|
+
if (config.mouse.copyNotice) receiver.flash?.("Copied!");
|
|
411
407
|
return true;
|
|
412
408
|
} catch {
|
|
413
409
|
// Reading the editor and the layout is best effort. Anything this trips
|
|
@@ -432,18 +428,17 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
|
|
|
432
428
|
* Column mapping: `leftTrim` columns came off the left of a cleaned row, so
|
|
433
429
|
* the selection's start and end columns shift by the same amount on the rows
|
|
434
430
|
* they touch. The shifted bounds go through the receiver's own
|
|
435
|
-
* `getSelectionColumns`, which aligns both ends to grapheme cell ranges
|
|
436
|
-
*
|
|
437
|
-
* than it can in Pi's own copy.
|
|
431
|
+
* `getSelectionColumns`, which aligns both ends to grapheme cell ranges — the
|
|
432
|
+
* end column can no more cut a grapheme here than it can in Pi's own copy.
|
|
438
433
|
*/
|
|
439
434
|
function transcriptSelectionText(
|
|
440
|
-
receiver:
|
|
435
|
+
receiver: MouseCapableReceiver,
|
|
441
436
|
config: PolishedTuiConfig,
|
|
442
437
|
bounds: SelectionBounds,
|
|
443
438
|
): string | undefined {
|
|
444
439
|
const scrollView = bounds.start.scrollView;
|
|
445
440
|
if (!scrollView) return undefined;
|
|
446
|
-
if (receiver.hasOverlay()) return undefined;
|
|
441
|
+
if (receiver.hasOverlay?.()) return undefined;
|
|
447
442
|
const sourceLines = scrollContentLinesFor(receiver.currentLayout?.root, scrollView);
|
|
448
443
|
if (!sourceLines) return undefined;
|
|
449
444
|
const cleaned = cleanTranscriptRows(
|
|
@@ -470,7 +465,8 @@ function transcriptSelectionText(
|
|
|
470
465
|
adjusted.end = { ...bounds.end, col: Math.max(0, bounds.end.col - entry.leftTrim) };
|
|
471
466
|
}
|
|
472
467
|
}
|
|
473
|
-
const columns = receiver.getSelectionColumns(entry.text, row, adjusted);
|
|
468
|
+
const columns = receiver.getSelectionColumns?.(entry.text, row, adjusted);
|
|
469
|
+
if (!columns) return undefined;
|
|
474
470
|
rows.push(
|
|
475
471
|
sliceByColumn(
|
|
476
472
|
entry.text,
|
|
@@ -488,19 +484,18 @@ function transcriptSelectionText(
|
|
|
488
484
|
* carried recognised chrome, and reports whether it did — `false` means
|
|
489
485
|
* there was nothing to clean and Pi's own copy must run instead. The bytes
|
|
490
486
|
* go out the same way Pi's own copy sends them (OSC 52 through
|
|
491
|
-
* `terminal.write`), for the same reason `copyEditorSelection` does
|
|
492
|
-
* downstream can tell the two copies apart.
|
|
487
|
+
* `terminal.write`), for the same reason `copyEditorSelection` does.
|
|
493
488
|
*
|
|
494
489
|
* A selection over pure decoration cleans to "" and is consumed without a
|
|
495
490
|
* clipboard write — Pi's own copy has the same shape (`if (text.length === 0)
|
|
496
491
|
* return`), it just gets there after building a string of rails.
|
|
497
492
|
*/
|
|
498
493
|
function copyTranscriptSelection(
|
|
499
|
-
receiver:
|
|
494
|
+
receiver: MouseCapableReceiver,
|
|
500
495
|
config: PolishedTuiConfig,
|
|
501
496
|
): boolean {
|
|
502
497
|
try {
|
|
503
|
-
const bounds = receiver.getSelectionBounds();
|
|
498
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
504
499
|
if (!bounds) return false;
|
|
505
500
|
const text = transcriptSelectionText(receiver, config, bounds);
|
|
506
501
|
if (text === undefined) return false;
|
|
@@ -508,7 +503,7 @@ function copyTranscriptSelection(
|
|
|
508
503
|
const write = receiver.terminal?.write;
|
|
509
504
|
if (typeof write !== "function") return false;
|
|
510
505
|
write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
511
|
-
receiver.flash("Copied!");
|
|
506
|
+
if (config.mouse.copyNotice) receiver.flash?.("Copied!");
|
|
512
507
|
return true;
|
|
513
508
|
} catch {
|
|
514
509
|
// Cleaning reads the layout and the selection, both best effort.
|
|
@@ -521,19 +516,15 @@ function copyTranscriptSelection(
|
|
|
521
516
|
* Whether `data` is the backspace or delete the range delete acts on.
|
|
522
517
|
*
|
|
523
518
|
* The question is asked of Pi's own keybinding registry, against the two
|
|
524
|
-
* bindings Pi's editor itself dispatches on
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* this return false, which falls through to Pi's own one-character delete. That
|
|
529
|
-
* is the safe direction to be wrong in.
|
|
519
|
+
* bindings Pi's editor itself dispatches on, so a user who has rebound either
|
|
520
|
+
* gets the key they bound rather than a hardcoded byte. A registry that
|
|
521
|
+
* disagrees makes this return false, which falls through to Pi's own
|
|
522
|
+
* one-character delete. That is the safe direction to be wrong in.
|
|
530
523
|
*
|
|
531
524
|
* ctrl+c and ctrl+d are refused outright, before the registry is consulted at
|
|
532
525
|
* all. `tui.editor.deleteCharForward` binds `ctrl+d` by default, and ctrl+c and
|
|
533
526
|
* ctrl+d are Pi's interrupt and exit chords; consuming either because a
|
|
534
527
|
* selection happens to be live would break a global key to save a keystroke.
|
|
535
|
-
* The ctrl+c branch above already returns before this is reached, but stating
|
|
536
|
-
* it here means the guarantee does not depend on branch order.
|
|
537
528
|
*/
|
|
538
529
|
function isRangeDeleteKey(data: unknown): boolean {
|
|
539
530
|
if (typeof data !== "string" || data.length === 0) return false;
|
|
@@ -558,13 +549,13 @@ function isRangeDeleteKey(data: unknown): boolean {
|
|
|
558
549
|
* Returns false for every selection that is not the input box's, which leaves
|
|
559
550
|
* the key to Pi and an ordinary backspace deleting one character.
|
|
560
551
|
*/
|
|
561
|
-
function deleteSelectedRange(receiver:
|
|
552
|
+
function deleteSelectedRange(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
|
|
562
553
|
try {
|
|
563
554
|
if (!config.editorClickCursor) return false;
|
|
564
555
|
// The same overlay question the rest of this feature asks: a selection
|
|
565
556
|
// dropped on a dialog must not delete the draft behind it.
|
|
566
|
-
if (receiver.hasOverlay()) return false;
|
|
567
|
-
const bounds = receiver.getSelectionBounds();
|
|
557
|
+
if (receiver.hasOverlay?.()) return false;
|
|
558
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
568
559
|
if (!bounds) return false;
|
|
569
560
|
if (!deleteEditorSelection(receiver, config, bounds)) return false;
|
|
570
561
|
// The highlight described text that is gone. Pi clears these two fields
|
|
@@ -581,14 +572,14 @@ function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTu
|
|
|
581
572
|
}
|
|
582
573
|
|
|
583
574
|
/**
|
|
584
|
-
* The text a copy of this selection would put on the clipboard, for the
|
|
585
|
-
*
|
|
575
|
+
* The text a copy of this selection would put on the clipboard, for the hint's
|
|
576
|
+
* character count.
|
|
586
577
|
*
|
|
587
578
|
* It has to ask the same two paths the copy itself asks, in the same order, or
|
|
588
|
-
* the hint promises a number
|
|
579
|
+
* the hint promises a number the copy does not deliver.
|
|
589
580
|
*/
|
|
590
581
|
function pendingSelectionText(
|
|
591
|
-
receiver:
|
|
582
|
+
receiver: MouseCapableReceiver,
|
|
592
583
|
config: PolishedTuiConfig,
|
|
593
584
|
bounds: SelectionBounds,
|
|
594
585
|
bufferCopy: boolean,
|
|
@@ -596,8 +587,8 @@ function pendingSelectionText(
|
|
|
596
587
|
): { text: string; inEditor: boolean } {
|
|
597
588
|
const buffered = bufferCopy ? editorTextForSelection(receiver, config, bounds) : undefined;
|
|
598
589
|
if (buffered !== undefined) return { text: buffered, inEditor: true };
|
|
599
|
-
// The count the hint shows must be the number
|
|
600
|
-
// a selection the copy would clean is counted after cleaning.
|
|
590
|
+
// The count the hint shows must be the number the copy key actually
|
|
591
|
+
// delivers, so a selection the copy would clean is counted after cleaning.
|
|
601
592
|
if (cleanCopy && config.mouse.transcriptCleanCopy) {
|
|
602
593
|
try {
|
|
603
594
|
const cleaned = transcriptSelectionText(receiver, config, bounds);
|
|
@@ -611,272 +602,72 @@ function pendingSelectionText(
|
|
|
611
602
|
}
|
|
612
603
|
|
|
613
604
|
/**
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
* `
|
|
617
|
-
*
|
|
618
|
-
* the `handleViewportInput` patch is what a waiting selection is eventually
|
|
619
|
-
* released by.
|
|
620
|
-
*
|
|
621
|
-
* `editorBufferCopy` sits in front of the real copy on that same method. They
|
|
622
|
-
* share one patch rather than taking a key each because
|
|
623
|
-
* `installPrototypePatch` holds exactly one behaviour per adapter key: a second
|
|
624
|
-
* registration under `mouse-copy` would silently replace the first, and two
|
|
625
|
-
* different keys would leave the order they run in — and which of them gets to
|
|
626
|
-
* consume the call — implicit. Here the precedence is written down: the buffer
|
|
627
|
-
* copy answers first, and only when it declines does the pending mode or
|
|
628
|
-
* predecessor see the call.
|
|
629
|
-
*
|
|
630
|
-
* Either feature can be off. With `selectionPendingMode` unavailable there is
|
|
631
|
-
* no state and every call is a real copy; with `editorBufferCopy` unavailable
|
|
632
|
-
* the clipboard is Pi's own bytes, exactly as before.
|
|
605
|
+
* A left-button press: not a release, not a drag, not another button, not a
|
|
606
|
+
* wheel notch. `pi-mouse-events` classifies the event before handing it here
|
|
607
|
+
* (`event.kind === "down"`); the shape is still checked rather than assumed,
|
|
608
|
+
* and a malformed event must fall through, never throw.
|
|
633
609
|
*/
|
|
634
|
-
function
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const pendingMode = features.has("selectionPendingMode");
|
|
643
|
-
const state = pendingMode ? new SelectionPendingState() : undefined;
|
|
644
|
-
const previousState = activeState;
|
|
645
|
-
if (state) activeState = state;
|
|
646
|
-
|
|
647
|
-
// Set while this module is driving `copySelectionToClipboard` itself (the
|
|
648
|
-
// ctrl+c path below), so the `mouse-copy` patch performs the copy instead of
|
|
649
|
-
// re-arming the state it is itself in the middle of clearing.
|
|
650
|
-
let performingRealCopy = false;
|
|
651
|
-
|
|
652
|
-
const cleanups: Array<() => void> = [];
|
|
653
|
-
|
|
654
|
-
// Only installed when a feature that answers `copySelectionToClipboard` is
|
|
655
|
-
// on. With just `editorClickToCaret` the method is not ours to touch, and a
|
|
656
|
-
// Pi that has moved it must not make this install throw.
|
|
657
|
-
if (pendingMode || bufferCopy || cleanCopy) {
|
|
658
|
-
cleanups.push(
|
|
659
|
-
installPrototypePatch(
|
|
660
|
-
prototype,
|
|
661
|
-
"copySelectionToClipboard",
|
|
662
|
-
"mouse-copy",
|
|
663
|
-
({ predecessor, receiver, args }) => {
|
|
664
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
665
|
-
const config = deps.getConfig();
|
|
666
|
-
if (!state || config.mouse.copyOnSelect || performingRealCopy) {
|
|
667
|
-
// The real copy, whether Pi triggered it directly on release
|
|
668
|
-
// (`copyOnSelect: true`), this module is driving it itself for
|
|
669
|
-
// ctrl+c below, or pending mode is not installed at all.
|
|
670
|
-
if (bufferCopy && copyEditorSelection(typedReceiver, config)) return undefined;
|
|
671
|
-
if (
|
|
672
|
-
cleanCopy &&
|
|
673
|
-
config.mouse.transcriptCleanCopy &&
|
|
674
|
-
copyTranscriptSelection(typedReceiver, config)
|
|
675
|
-
) {
|
|
676
|
-
return undefined;
|
|
677
|
-
}
|
|
678
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
679
|
-
}
|
|
680
|
-
const bounds = typedReceiver.getSelectionBounds();
|
|
681
|
-
if (!bounds) {
|
|
682
|
-
// A collapsed or empty selection (e.g. a plain click after a prior
|
|
683
|
-
// drag) still reaches this call — Pi runs it unconditionally on
|
|
684
|
-
// every release and relies on its own `if (!selection) return;`
|
|
685
|
-
// guard. Any stale arm from an earlier selection must not survive
|
|
686
|
-
// this: left in place, it would make a later ctrl+c consume the
|
|
687
|
-
// key for a no-op copy instead of falling through to interrupt.
|
|
688
|
-
if (state.pending) {
|
|
689
|
-
state.clear();
|
|
690
|
-
typedReceiver.requestRender();
|
|
691
|
-
}
|
|
692
|
-
return undefined;
|
|
693
|
-
}
|
|
694
|
-
const pendingText = pendingSelectionText(
|
|
695
|
-
typedReceiver,
|
|
696
|
-
config,
|
|
697
|
-
bounds,
|
|
698
|
-
bufferCopy,
|
|
699
|
-
cleanCopy,
|
|
700
|
-
);
|
|
701
|
-
state.arm(
|
|
702
|
-
pendingText.text.length,
|
|
703
|
-
// An editor selection cannot grow past the visible window —
|
|
704
|
-
// there is no drag-scroll — so the hint for one points at the
|
|
705
|
-
// external editor, the way to act on the whole draft. ""
|
|
706
|
-
// (unbound) simply shows no suffix.
|
|
707
|
-
pendingText.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
|
|
708
|
-
);
|
|
709
|
-
typedReceiver.requestRender();
|
|
710
|
-
return undefined;
|
|
711
|
-
},
|
|
712
|
-
),
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
if (!state && !rangeDelete) {
|
|
717
|
-
return () => {
|
|
718
|
-
for (const cleanup of cleanups) cleanup();
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
cleanups.push(
|
|
723
|
-
installPrototypePatch(
|
|
724
|
-
prototype,
|
|
725
|
-
"handleViewportInput",
|
|
726
|
-
"mouse-viewport-input",
|
|
727
|
-
({ predecessor, receiver, args }) => {
|
|
728
|
-
const data = args[0];
|
|
729
|
-
// Keystrokes and mouse events both land here, so this is the one
|
|
730
|
-
// place that sees every draft change; keep the outgrew-the-box
|
|
731
|
-
// hint current with it.
|
|
732
|
-
refreshExternalEditorHint(receiver as MouseCapablePrototype, deps.getConfig());
|
|
733
|
-
// ---- Branch 1: ctrl+c, `selectionPendingMode`'s. ----------------
|
|
734
|
-
//
|
|
735
|
-
// This branch is FIRST, and deliberately so. The one outcome that
|
|
736
|
-
// must never regress is a bare ctrl+c with nothing pending reaching
|
|
737
|
-
// Pi and interrupting; evaluating its branch before any other means
|
|
738
|
-
// no feature added to this method later can intercept it by being
|
|
739
|
-
// installed in a different order. `isRangeDeleteKey` refuses ctrl+c
|
|
740
|
-
// and ctrl+d outright as well, so the guarantee holds structurally
|
|
741
|
-
// from both directions rather than by ordering alone.
|
|
742
|
-
if (state && isCtrlC(data) && state.pending) {
|
|
743
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
744
|
-
// `state.pending` can be stale: Pi clears its own selection
|
|
745
|
-
// through paths this module never sees (e.g. starting a new
|
|
746
|
-
// drag overwrites `selectionAnchor`/`selectionFocus` directly,
|
|
747
|
-
// with no call to `copySelectionToClipboard`). Re-read the real
|
|
748
|
-
// bounds before deciding: a copy that would be a no-op must not
|
|
749
|
-
// consume ctrl+c, or an in-flight interrupt gets swallowed for
|
|
750
|
-
// nothing.
|
|
751
|
-
if (!typedReceiver.getSelectionBounds()) {
|
|
752
|
-
state.clear();
|
|
753
|
-
typedReceiver.requestRender();
|
|
754
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
755
|
-
}
|
|
756
|
-
performingRealCopy = true;
|
|
757
|
-
try {
|
|
758
|
-
copyWithNotice(typedReceiver, deps.getConfig().mouse.copyNotice);
|
|
759
|
-
} finally {
|
|
760
|
-
performingRealCopy = false;
|
|
761
|
-
}
|
|
762
|
-
state.clear();
|
|
763
|
-
typedReceiver.requestRender();
|
|
764
|
-
return { consume: true };
|
|
765
|
-
}
|
|
766
|
-
// ---- Branch 2: backspace and delete, `editorClickToCaret`'s. ----
|
|
767
|
-
//
|
|
768
|
-
// The two branches key off disjoint input — ctrl+c above, backspace
|
|
769
|
-
// and delete here — so they compose rather than compete. Range
|
|
770
|
-
// delete consumes the key only when it really removed something;
|
|
771
|
-
// every other backspace falls through and goes on deleting one
|
|
772
|
-
// character, which is the behaviour it must not break.
|
|
773
|
-
if (rangeDelete && isRangeDeleteKey(data)) {
|
|
774
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
775
|
-
if (deleteSelectedRange(typedReceiver, deps.getConfig())) {
|
|
776
|
-
// Pi's `handleInput` returns as soon as a listener consumes,
|
|
777
|
-
// so the focused editor never sees the key and Pi never
|
|
778
|
-
// reaches its own `requestImmediateRender` (`tui.js:620`).
|
|
779
|
-
// Redrawing the changed draft is this branch's job.
|
|
780
|
-
state?.clear();
|
|
781
|
-
typedReceiver.requestRender();
|
|
782
|
-
return { consume: true };
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
786
|
-
},
|
|
787
|
-
),
|
|
788
|
-
);
|
|
789
|
-
|
|
790
|
-
return () => {
|
|
791
|
-
for (const cleanup of cleanups) cleanup();
|
|
792
|
-
if (activeState === state) activeState = previousState;
|
|
793
|
-
};
|
|
610
|
+
function isLeftButtonPress(event: unknown): boolean {
|
|
611
|
+
if (typeof event !== "object" || event === null) return false;
|
|
612
|
+
const candidate = event as Partial<MouseEventLike>;
|
|
613
|
+
if (typeof candidate.button !== "number") return false;
|
|
614
|
+
if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
|
|
615
|
+
if (candidate.release) return false;
|
|
616
|
+
if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
|
|
617
|
+
return (candidate.button & BUTTON_MASK) === LEFT_BUTTON;
|
|
794
618
|
}
|
|
795
619
|
|
|
796
620
|
/**
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
* point; `stripTerminalSequences` is applied the same way predecessor itself
|
|
801
|
-
* applies it (see `getWordSelection` in
|
|
802
|
-
* `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`), so
|
|
803
|
-
* `wordRangeAt` sees the same plain text Pi's own segmenter would. `col` is
|
|
804
|
-
* the only thing the wrapper changes on the way out — `row`, `scrollView`
|
|
805
|
-
* and any other point field ride through unmodified, which is what keeps a
|
|
806
|
-
* scroll-view selection landing in the right place.
|
|
621
|
+
* A left-button release: the `m` terminator, encoded as button 0 by SGR
|
|
622
|
+
* terminals and as button 3 by the legacy fallback — Pi's own click handling
|
|
623
|
+
* accepts either (`tui-alt-screen.js`'s release branch), so both are here.
|
|
807
624
|
*/
|
|
808
|
-
function
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
return
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
if (!deps.getConfig().mouse.pathAwareWords) {
|
|
818
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
819
|
-
}
|
|
820
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
821
|
-
const point = args[0] as SelectionPoint;
|
|
822
|
-
const line = stripTerminalSequences(typedReceiver.getSelectionSourceLine(point));
|
|
823
|
-
const range = wordRangeAt(line, point.col);
|
|
824
|
-
if (!range) return Reflect.apply(predecessor, receiver, args);
|
|
825
|
-
return {
|
|
826
|
-
start: { ...point, col: range.start },
|
|
827
|
-
end: { ...point, col: range.end, boundary: true },
|
|
828
|
-
};
|
|
829
|
-
},
|
|
830
|
-
);
|
|
625
|
+
function isLeftButtonRelease(event: unknown): boolean {
|
|
626
|
+
if (typeof event !== "object" || event === null) return false;
|
|
627
|
+
const candidate = event as Partial<MouseEventLike>;
|
|
628
|
+
if (typeof candidate.button !== "number") return false;
|
|
629
|
+
if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
|
|
630
|
+
if (!candidate.release) return false;
|
|
631
|
+
if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
|
|
632
|
+
const button = candidate.button & BUTTON_MASK;
|
|
633
|
+
return button === LEFT_BUTTON || button === 3;
|
|
831
634
|
}
|
|
832
635
|
|
|
833
636
|
/**
|
|
834
|
-
* A
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
* press opens a selection, so only the press is the one this feature may take
|
|
839
|
-
* instead.
|
|
840
|
-
*
|
|
841
|
-
* Bit 64 is excluded too, even though `handleInput` peels wheel reports off
|
|
842
|
-
* before this method is reached (`parseWheelEvent` claims anything with bit 64
|
|
843
|
-
* and a direction of 0 or 1): a notch of scroll that landed on a hint row
|
|
844
|
-
* would otherwise expand a box the pointer was only passing over, and that
|
|
845
|
-
* depends on a dispatch order in a file this package does not own.
|
|
846
|
-
*
|
|
847
|
-
* The shape is checked rather than assumed — this runs on whatever Pi passes,
|
|
848
|
-
* and a malformed event must fall through, never throw.
|
|
637
|
+
* A motion report with a button held — the drag that turns a press into a
|
|
638
|
+
* selection rather than a click. Motion without a button bit never arrives
|
|
639
|
+
* from SGR terminals while tracking is button-only, but the bit is checked
|
|
640
|
+
* rather than assumed.
|
|
849
641
|
*/
|
|
850
|
-
function
|
|
642
|
+
function isButtonMotion(event: unknown): boolean {
|
|
851
643
|
if (typeof event !== "object" || event === null) return false;
|
|
852
644
|
const candidate = event as Partial<MouseEventLike>;
|
|
853
645
|
if (typeof candidate.button !== "number") return false;
|
|
854
|
-
if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
|
|
855
646
|
if (candidate.release) return false;
|
|
856
|
-
|
|
857
|
-
return (candidate.button & BUTTON_MASK) === LEFT_BUTTON;
|
|
647
|
+
return (candidate.button & MOTION_BIT) !== 0;
|
|
858
648
|
}
|
|
859
649
|
|
|
860
650
|
/**
|
|
861
|
-
* The
|
|
862
|
-
*
|
|
651
|
+
* The expandable component a click should toggle, and the expansion to set —
|
|
652
|
+
* resolved identically at press (identity only) and at release (action), so
|
|
653
|
+
* the release can tell content that scrolled from the box it started on.
|
|
863
654
|
*
|
|
864
655
|
* Everything is resolved here, inside the one call: the layout is read as it
|
|
865
|
-
* is right now, the component tree is built and dropped, and nothing
|
|
866
|
-
*
|
|
867
|
-
*
|
|
656
|
+
* is right now, the component tree is built and dropped, and nothing inside
|
|
657
|
+
* the answer outlives the event. A tool that is still running re-renders
|
|
658
|
+
* between press and release, which is exactly why the release re-resolves
|
|
659
|
+
* instead of trusting the press's answer.
|
|
868
660
|
*/
|
|
869
|
-
function
|
|
870
|
-
receiver:
|
|
871
|
-
event:
|
|
661
|
+
function expandClickTarget(
|
|
662
|
+
receiver: MouseCapableReceiver,
|
|
663
|
+
event: MouseEventLike,
|
|
872
664
|
deps: InstallMouseDeps,
|
|
873
665
|
): ExpandTarget | undefined {
|
|
874
666
|
try {
|
|
875
667
|
if (!deps.getConfig().mouse.clickToExpandTools) return undefined;
|
|
876
|
-
if (!isLeftButtonPress(event)) return undefined;
|
|
877
668
|
// Pi resolves no scroll view while an overlay is up, so neither does this
|
|
878
669
|
// — a click on a dialog must not reach the transcript behind it.
|
|
879
|
-
if (receiver.hasOverlay()) return undefined;
|
|
670
|
+
if (receiver.hasOverlay?.()) return undefined;
|
|
880
671
|
return expandTargetAt(
|
|
881
672
|
{ root: receiver.currentLayout?.root, keyText: expandKeyText() },
|
|
882
673
|
event.x,
|
|
@@ -884,23 +675,32 @@ function pressExpandTarget(
|
|
|
884
675
|
);
|
|
885
676
|
} catch {
|
|
886
677
|
// Resolution is a best-effort read of Pi's internals. Anything it trips
|
|
887
|
-
// over means this
|
|
678
|
+
// over means this click was an ordinary click.
|
|
888
679
|
return undefined;
|
|
889
680
|
}
|
|
890
681
|
}
|
|
891
682
|
|
|
683
|
+
/**
|
|
684
|
+
* The OSC 8 link Pi's built-in press branch stored for this press, if any.
|
|
685
|
+
* The built-in runs after this handler declines the press, so by release time
|
|
686
|
+
* a non-empty `pressedUrl` means Pi is about to open a link — the box stays
|
|
687
|
+
* as it is and the release goes through unconsumed.
|
|
688
|
+
*/
|
|
689
|
+
function pressedUrlAt(receiver: MouseCapableReceiver): unknown {
|
|
690
|
+
return (receiver as { pressedUrl?: unknown }).pressedUrl;
|
|
691
|
+
}
|
|
692
|
+
|
|
892
693
|
/**
|
|
893
694
|
* Puts the caret where a press landed, when the press landed in the input box.
|
|
894
695
|
*
|
|
895
696
|
* Nothing is consumed: the press goes on to Pi, which drops its selection
|
|
896
697
|
* anchor there exactly as it always did, so a drag from that point still
|
|
897
698
|
* highlights and still copies. That is also what pays for the repaint — Pi's
|
|
898
|
-
* own press branch ends in `requestRender()` unconditionally
|
|
899
|
-
*
|
|
900
|
-
* and `requestRender` is correspondingly absent from its requirements.
|
|
699
|
+
* own press branch ends in `requestRender()` unconditionally, so this feature
|
|
700
|
+
* never has to ask for one itself.
|
|
901
701
|
*/
|
|
902
702
|
function moveCaretForPress(
|
|
903
|
-
receiver:
|
|
703
|
+
receiver: MouseCapableReceiver,
|
|
904
704
|
event: unknown,
|
|
905
705
|
deps: InstallMouseDeps,
|
|
906
706
|
): void {
|
|
@@ -910,8 +710,8 @@ function moveCaretForPress(
|
|
|
910
710
|
if (!isLeftButtonPress(event)) return;
|
|
911
711
|
// Pi resolves no scroll view while an overlay is up, so neither does this
|
|
912
712
|
// — a click on a dialog must not move a caret in the box behind it.
|
|
913
|
-
if (receiver.hasOverlay()) return;
|
|
914
|
-
moveEditorCaretTo(receiver, config, event.x, event.y);
|
|
713
|
+
if (receiver.hasOverlay?.()) return;
|
|
714
|
+
moveEditorCaretTo(receiver, config, (event as MouseEventLike).x, (event as MouseEventLike).y);
|
|
915
715
|
} catch {
|
|
916
716
|
// Moving the caret is a best-effort read of Pi's internals and of an
|
|
917
717
|
// editor this module did not necessarily build. Anything it trips over
|
|
@@ -919,80 +719,13 @@ function moveCaretForPress(
|
|
|
919
719
|
}
|
|
920
720
|
}
|
|
921
721
|
|
|
922
|
-
/**
|
|
923
|
-
* Installs the two features that live on `handleSelectionMouseEvent`.
|
|
924
|
-
*
|
|
925
|
-
* They share one patch because `installPrototypePatch` holds exactly one
|
|
926
|
-
* behaviour per adapter key — a second registration under
|
|
927
|
-
* `mouse-selection-event` would silently replace the first, and giving them a
|
|
928
|
-
* key each would leave the order they run in implicit, which matters here
|
|
929
|
-
* because only one of them may consume the press. Written as one patch the
|
|
930
|
-
* precedence is explicit and testable:
|
|
931
|
-
*
|
|
932
|
-
* 1. `clickToExpandTools` gets the press first, and *consumes* it when it lands
|
|
933
|
-
* on a tool box's hint row. That is what keeps the click from also dropping
|
|
934
|
-
* a selection anchor into the box it just opened.
|
|
935
|
-
* 2. `editorClickToCaret` gets every press expand did not take, moves the caret
|
|
936
|
-
* if the press was inside the input box, and never consumes.
|
|
937
|
-
* 3. Pi gets the press either way, unless expand took it.
|
|
938
|
-
*
|
|
939
|
-
* The two cannot collide in practice — the hint rows are in the transcript and
|
|
940
|
-
* the caret rows are in the dock — but the ordering is what makes that a fact
|
|
941
|
-
* rather than a hope, and either feature may be off without disturbing the
|
|
942
|
-
* other.
|
|
943
|
-
*/
|
|
944
|
-
function installSelectionMouse(
|
|
945
|
-
prototype: MouseCapablePrototype,
|
|
946
|
-
deps: InstallMouseDeps,
|
|
947
|
-
features: ReadonlySet<MouseFeature>,
|
|
948
|
-
): () => void {
|
|
949
|
-
const expandTools = features.has("clickToExpandTools");
|
|
950
|
-
const clickToCaret = features.has("editorClickToCaret");
|
|
951
|
-
return installPrototypePatch(
|
|
952
|
-
prototype,
|
|
953
|
-
"handleSelectionMouseEvent",
|
|
954
|
-
"mouse-selection-event",
|
|
955
|
-
({ predecessor, receiver, args }) => {
|
|
956
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
957
|
-
if (expandTools) {
|
|
958
|
-
const target = pressExpandTarget(typedReceiver, args[0], deps);
|
|
959
|
-
if (target) {
|
|
960
|
-
target.component.setExpanded(target.expanded);
|
|
961
|
-
typedReceiver.requestRender();
|
|
962
|
-
return undefined;
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
if (clickToCaret) moveCaretForPress(typedReceiver, args[0], deps);
|
|
966
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
967
|
-
},
|
|
968
|
-
);
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* The shape `parseWheelEvent` produces, checked rather than assumed — this runs
|
|
973
|
-
* on whatever Pi hands `routeWheel`, and anything unrecognised must fall
|
|
974
|
-
* through to Pi's own routing, never throw.
|
|
975
|
-
*/
|
|
976
|
-
function isWheelEvent(event: unknown): event is WheelEventLike {
|
|
977
|
-
if (typeof event !== "object" || event === null) return false;
|
|
978
|
-
const candidate = event as Partial<WheelEventLike>;
|
|
979
|
-
return (
|
|
980
|
-
typeof candidate.direction === "number" &&
|
|
981
|
-
typeof candidate.x === "number" &&
|
|
982
|
-
typeof candidate.y === "number"
|
|
983
|
-
);
|
|
984
|
-
}
|
|
985
|
-
|
|
986
722
|
/**
|
|
987
723
|
* Scrolls the input box for this notch, or reports that the notch was not the
|
|
988
724
|
* input box's — in which case Pi routes it as it always did.
|
|
989
725
|
*
|
|
990
726
|
* Pi does not scroll the editor: the editor's `scrollOffset` is re-derived from
|
|
991
|
-
* the caret on every render
|
|
992
|
-
*
|
|
993
|
-
* offset *and* a caret move, which is what `scrollEditorBy` does — the same
|
|
994
|
-
* function that has been in daily use since pi-powerline-footer, so a notch
|
|
995
|
-
* behaves the way it always has.
|
|
727
|
+
* the caret on every render. So a scroll here is an offset *and* a caret move,
|
|
728
|
+
* which is what `scrollEditorBy` does.
|
|
996
729
|
*
|
|
997
730
|
* The window it scrolls within is Pi's own `max(5, rows * 0.3)`, taken from the
|
|
998
731
|
* terminal rather than from the box's rect: the rect includes Starline's border
|
|
@@ -1001,25 +734,20 @@ function isWheelEvent(event: unknown): event is WheelEventLike {
|
|
|
1001
734
|
* it from, so the notch falls through.
|
|
1002
735
|
*/
|
|
1003
736
|
function scrollEditorForWheel(
|
|
1004
|
-
receiver:
|
|
1005
|
-
event:
|
|
737
|
+
receiver: MouseCapableReceiver,
|
|
738
|
+
event: { direction: number; x: number; y: number },
|
|
1006
739
|
deps: InstallMouseDeps,
|
|
1007
740
|
): boolean {
|
|
1008
741
|
try {
|
|
1009
742
|
if (!deps.getConfig().mouse.wheelRouting) return false;
|
|
1010
|
-
if (!isWheelEvent(event)) return false;
|
|
1011
743
|
// An overlay is composited over a layout that still contains the editor,
|
|
1012
744
|
// so the box is still "under" a pointer aimed at the dialog on top of it.
|
|
1013
|
-
if (receiver.hasOverlay()) return false;
|
|
745
|
+
if (receiver.hasOverlay?.()) return false;
|
|
1014
746
|
const editor = activeEditor();
|
|
1015
747
|
if (!editor) return false;
|
|
1016
748
|
const rows = receiver.terminal?.rows;
|
|
1017
749
|
if (typeof rows !== "number") return false;
|
|
1018
|
-
if (
|
|
1019
|
-
wheelTarget(receiver.currentLayout?.root, editor.component, event.x, event.y) !== "editor"
|
|
1020
|
-
) {
|
|
1021
|
-
return false;
|
|
1022
|
-
}
|
|
750
|
+
if (!pointerOverEditorBox(receiver, editor.component, event.x, event.y)) return false;
|
|
1023
751
|
const lines = event.direction * Math.max(1, receiver.wheelScrollLines ?? 1);
|
|
1024
752
|
return scrollEditorBy(editor.scrollable, lines, rows);
|
|
1025
753
|
} catch {
|
|
@@ -1030,98 +758,184 @@ function scrollEditorForWheel(
|
|
|
1030
758
|
}
|
|
1031
759
|
}
|
|
1032
760
|
|
|
761
|
+
/** Whether the input box is what is painted at `(x, y)` — see `editor-mouse.ts`. */
|
|
762
|
+
function pointerOverEditorBox(
|
|
763
|
+
receiver: MouseCapableReceiver,
|
|
764
|
+
editor: unknown,
|
|
765
|
+
x: number,
|
|
766
|
+
y: number,
|
|
767
|
+
): boolean {
|
|
768
|
+
return pointerOverEditor(receiver.currentLayout?.root, editor, x, y);
|
|
769
|
+
}
|
|
770
|
+
|
|
1033
771
|
/**
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
* pointer — see `editor-scroll.ts`.
|
|
772
|
+
* Registers the mouse features with the `pi-mouse-events` extension and
|
|
773
|
+
* returns a disposer that unregisters them all.
|
|
774
|
+
*
|
|
775
|
+
* Every handler is registered unconditionally and gated inside on the
|
|
776
|
+
* capability set `bindReceiver` computes from the live renderer — there is
|
|
777
|
+
* nothing to probe at install time (see `bindReceiver`). The one install-time
|
|
778
|
+
* gate left is `api.copySlotAvailable` for the two copy features: without the
|
|
779
|
+
* copy slot they would register into a method that is never reached (pi-tui
|
|
780
|
+
* < 0.84.3 has no `copyActiveSelectionToClipboard`), and a feature that
|
|
781
|
+
* silently cannot answer its own copy is exactly the half-working install
|
|
782
|
+
* `capabilities.ts` exists to prevent.
|
|
1046
783
|
*/
|
|
1047
|
-
function
|
|
1048
|
-
|
|
784
|
+
export function installMouseFeaturesOn(
|
|
785
|
+
api: MouseEventsApi,
|
|
786
|
+
ctx: ExtensionContext,
|
|
1049
787
|
deps: InstallMouseDeps,
|
|
1050
788
|
): () => void {
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
789
|
+
installRefs = {
|
|
790
|
+
liveReceiver: () => api.liveReceiver?.(),
|
|
791
|
+
copySlot: api.copySlotAvailable,
|
|
792
|
+
deps,
|
|
793
|
+
};
|
|
794
|
+
activeReceiver = undefined;
|
|
795
|
+
featureSet = undefined;
|
|
796
|
+
activeHint = undefined;
|
|
797
|
+
|
|
798
|
+
const cleanups: Array<() => void> = [];
|
|
799
|
+
|
|
800
|
+
// The expand flow never consumes the press — Pi's selection machinery
|
|
801
|
+
// anchors on it, so a drag that starts anywhere on a box still selects —
|
|
802
|
+
// and consumes the release of a plain click, which Pi's own release path
|
|
803
|
+
// treats as a no-op. It still registers ahead of the caret handler, the
|
|
804
|
+
// same precedence the old shared selection patch made explicit.
|
|
805
|
+
cleanups.push(
|
|
806
|
+
api.addMouseHandler(
|
|
807
|
+
({ event, tui }) => {
|
|
808
|
+
const receiver = bindReceiver(tui);
|
|
809
|
+
if (!receiver || !featureOn("clickToExpandTools")) {
|
|
810
|
+
expandPress = undefined;
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
// One cast, here: the guards below classify without narrowing, and
|
|
814
|
+
// the parsed event already carries every field this reads.
|
|
815
|
+
const mouse = event as MouseEventLike;
|
|
816
|
+
if (isLeftButtonPress(mouse)) {
|
|
817
|
+
// Identity only: the release re-resolves, because a running tool
|
|
818
|
+
// re-renders between the two and rows move under the pointer.
|
|
819
|
+
const candidate = expandClickTarget(receiver, mouse, deps);
|
|
820
|
+
expandPress = candidate
|
|
821
|
+
? { x: mouse.x, y: mouse.y, component: candidate.component, dragged: false }
|
|
822
|
+
: undefined;
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
if (expandPress && isButtonMotion(mouse)) {
|
|
826
|
+
expandPress.dragged = true;
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
if (!isLeftButtonRelease(mouse)) return undefined;
|
|
830
|
+
const press = expandPress;
|
|
831
|
+
expandPress = undefined;
|
|
832
|
+
if (!press || press.dragged) return undefined;
|
|
833
|
+
if (press.x !== mouse.x || press.y !== mouse.y) return undefined;
|
|
834
|
+
// A press on an OSC 8 link: Pi's own release opens it, and the box
|
|
835
|
+
// keeps its state.
|
|
836
|
+
if (pressedUrlAt(receiver) !== undefined) return undefined;
|
|
837
|
+
const target = expandClickTarget(receiver, event, deps);
|
|
838
|
+
if (!target || target.component !== press.component) return undefined;
|
|
839
|
+
const now = Date.now();
|
|
840
|
+
if (
|
|
841
|
+
lastToggle &&
|
|
842
|
+
lastToggle.x === press.x &&
|
|
843
|
+
lastToggle.y === press.y &&
|
|
844
|
+
now - lastToggle.at < DOUBLE_CLICK_MS
|
|
845
|
+
) {
|
|
846
|
+
return undefined;
|
|
847
|
+
}
|
|
848
|
+
target.component.setExpanded(target.expanded);
|
|
849
|
+
receiver.requestRender?.();
|
|
850
|
+
lastToggle = { x: press.x, y: press.y, at: now };
|
|
851
|
+
return { handled: true };
|
|
852
|
+
},
|
|
853
|
+
{ priority: 20 },
|
|
854
|
+
),
|
|
1065
855
|
);
|
|
1066
|
-
}
|
|
1067
856
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
857
|
+
cleanups.push(
|
|
858
|
+
api.addMouseHandler(
|
|
859
|
+
({ event, tui }) => {
|
|
860
|
+
if (event.wheel === undefined) return undefined;
|
|
861
|
+
const receiver = bindReceiver(tui);
|
|
862
|
+
if (!receiver || !featureOn("editorWheelScroll")) return undefined;
|
|
863
|
+
const notch = { direction: event.wheel, x: event.x, y: event.y };
|
|
864
|
+
if (!scrollEditorForWheel(receiver, notch, deps)) {
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
867
|
+
// Pi's own wheel routing repaints at the end of every notch;
|
|
868
|
+
// consuming the event means reaching that repaint is now this
|
|
869
|
+
// handler's job.
|
|
870
|
+
receiver.requestRender?.();
|
|
871
|
+
return { handled: true };
|
|
872
|
+
},
|
|
873
|
+
{ priority: 10 },
|
|
874
|
+
),
|
|
875
|
+
);
|
|
1076
876
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
877
|
+
cleanups.push(
|
|
878
|
+
api.addMouseHandler(
|
|
879
|
+
({ event, tui }) => {
|
|
880
|
+
const receiver = bindReceiver(tui);
|
|
881
|
+
if (!receiver || !featureOn("editorClickToCaret")) return undefined;
|
|
882
|
+
moveCaretForPress(receiver, event, deps);
|
|
883
|
+
return undefined;
|
|
884
|
+
},
|
|
885
|
+
{ priority: 0 },
|
|
886
|
+
),
|
|
887
|
+
);
|
|
1082
888
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
enabled.has("selectionPendingMode") ||
|
|
1106
|
-
enabled.has("editorBufferCopy") ||
|
|
1107
|
-
enabled.has("editorClickToCaret") ||
|
|
1108
|
-
enabled.has("transcriptCleanCopy")
|
|
1109
|
-
) {
|
|
1110
|
-
cleanups.push(installCopying(typedPrototype, deps, enabled));
|
|
1111
|
-
}
|
|
1112
|
-
if (enabled.has("pathAwareWords")) {
|
|
1113
|
-
cleanups.push(installPathAwareWords(typedPrototype, deps));
|
|
1114
|
-
}
|
|
1115
|
-
if (enabled.has("clickToExpandTools") || enabled.has("editorClickToCaret")) {
|
|
1116
|
-
cleanups.push(installSelectionMouse(typedPrototype, deps, enabled));
|
|
889
|
+
if (api.copySlotAvailable) {
|
|
890
|
+
cleanups.push(
|
|
891
|
+
api.addCopyHandler(({ tui }) => {
|
|
892
|
+
const receiver = bindReceiver(tui);
|
|
893
|
+
if (!receiver) return undefined;
|
|
894
|
+
const config = deps.getConfig();
|
|
895
|
+
// Precedence written down, as it always was: the buffer copy
|
|
896
|
+
// answers first, and only when it declines does the clean copy or
|
|
897
|
+
// Pi's own copy see the call.
|
|
898
|
+
if (featureOn("editorBufferCopy") && copyEditorSelection(receiver, config)) {
|
|
899
|
+
return { handled: true };
|
|
900
|
+
}
|
|
901
|
+
if (
|
|
902
|
+
featureOn("transcriptCleanCopy") &&
|
|
903
|
+
config.mouse.transcriptCleanCopy &&
|
|
904
|
+
copyTranscriptSelection(receiver, config)
|
|
905
|
+
) {
|
|
906
|
+
return { handled: true };
|
|
907
|
+
}
|
|
908
|
+
return undefined;
|
|
909
|
+
}),
|
|
910
|
+
);
|
|
1117
911
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
912
|
+
|
|
913
|
+
// The keyboard half of click-to-caret: `ctx.ui.onTerminalInput` is Pi's
|
|
914
|
+
// official extension input listener, and the renderer's own handling never
|
|
915
|
+
// swallows a keystroke before it reaches the listener — mouse reports do,
|
|
916
|
+
// which is exactly why they are not handled here.
|
|
917
|
+
if (typeof ctx.ui.onTerminalInput === "function") {
|
|
918
|
+
cleanups.push(
|
|
919
|
+
ctx.ui.onTerminalInput((data: string) => {
|
|
920
|
+
if (!isRangeDeleteKey(data)) return undefined;
|
|
921
|
+
const receiver = bindReceiver();
|
|
922
|
+
if (!receiver || !featureOn("editorClickToCaret")) return undefined;
|
|
923
|
+
if (!deleteSelectedRange(receiver, deps.getConfig())) return undefined;
|
|
924
|
+
// Consuming the key means Pi's `handleInput` never reaches its own
|
|
925
|
+
// repaint; redrawing the changed draft is this branch's job.
|
|
926
|
+
receiver.requestRender?.();
|
|
927
|
+
return { consume: true };
|
|
928
|
+
}),
|
|
929
|
+
);
|
|
1120
930
|
}
|
|
1121
931
|
|
|
1122
|
-
if (cleanups.length === 0) return () => {};
|
|
1123
932
|
return () => {
|
|
1124
|
-
externalEditorHint = null;
|
|
1125
933
|
for (const cleanup of cleanups) cleanup();
|
|
934
|
+
if (activeHint?.getConfig === deps.getConfig) activeHint = undefined;
|
|
935
|
+
installRefs = undefined;
|
|
936
|
+
activeReceiver = undefined;
|
|
937
|
+
featureSet = undefined;
|
|
938
|
+
expandPress = undefined;
|
|
939
|
+
lastToggle = undefined;
|
|
1126
940
|
};
|
|
1127
941
|
}
|