@xzzpig/pi-starline 0.2.0 → 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 +7 -6
- package/extensions/starline/index.ts +34 -33
- package/extensions/starline/mouse/api-consumer.ts +31 -0
- package/extensions/starline/mouse/capabilities.ts +69 -115
- package/extensions/starline/mouse/index.ts +467 -536
- package/extensions/starline/mouse/tool-box.ts +112 -83
- package/extensions/starline/prototype-patch-registry.ts +8 -5
- package/package.json +10 -3
|
@@ -1,81 +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
|
-
* - `handleSelectionMouseEvent` — a left-button press inside the input box moves
|
|
45
|
-
* the caret to the character under it, then calls through, so Pi still drops
|
|
46
|
-
* its selection anchor there and a drag from that point still selects and
|
|
47
|
-
* highlights as it always did. `editor-caret.ts` carries the screen-to-buffer
|
|
48
|
-
* arithmetic and why the box's rectangle is not the text.
|
|
49
|
-
* - `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
|
|
50
44
|
* input box removes the whole range instead of one character, through the
|
|
51
|
-
* editor's own `handleForwardDelete` under a single undo snapshot.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* paint around their content removed
|
|
66
|
-
* border rules,
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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.
|
|
75
71
|
*/
|
|
72
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
76
73
|
import { getKeybindings, sliceByColumn, stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
74
|
+
import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
|
|
77
75
|
import type { PolishedTuiConfig } from "../config";
|
|
78
|
-
import { installPrototypePatch } from "../prototype-patch-registry";
|
|
79
76
|
import {
|
|
80
77
|
disabledFeatureWarning,
|
|
81
78
|
enabledFeatures,
|
|
@@ -88,7 +85,7 @@ import {
|
|
|
88
85
|
editorSelectionTextFor,
|
|
89
86
|
moveEditorCaretTo,
|
|
90
87
|
} from "./editor-caret";
|
|
91
|
-
import { activeEditor,
|
|
88
|
+
import { activeEditor, pointerOverEditor } from "./editor-mouse";
|
|
92
89
|
import { scrollEditorBy } from "./editor-scroll";
|
|
93
90
|
import { editorVisualRowCount } from "./editor-text-cursor";
|
|
94
91
|
import { type BoxLike, scrollContentLinesFor } from "./hit-test";
|
|
@@ -104,14 +101,21 @@ const CTRL_D = "\x04";
|
|
|
104
101
|
/** The binding Pi's selection copy lives on (`app.message.copy`, default ctrl+x). */
|
|
105
102
|
const COPY_KEYBINDING = "app.message.copy";
|
|
106
103
|
|
|
104
|
+
/**
|
|
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.
|
|
109
|
+
*/
|
|
110
|
+
const DOUBLE_CLICK_MS = 500;
|
|
111
|
+
|
|
107
112
|
/** Opens the draft in `$EDITOR` — the hint an editor selection's hint carries. */
|
|
108
113
|
const EXTERNAL_EDITOR_KEYBINDING = "app.editor.external";
|
|
109
114
|
|
|
110
115
|
/**
|
|
111
|
-
* SGR mouse bits, as `parseSgrMouseEvent` decodes them
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `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.
|
|
115
119
|
*/
|
|
116
120
|
const BUTTON_MASK = 3;
|
|
117
121
|
const LEFT_BUTTON = 0;
|
|
@@ -120,89 +124,37 @@ const WHEEL_BIT = 64;
|
|
|
120
124
|
|
|
121
125
|
type MouseEventLike = { button: number; x: number; y: number; release?: boolean };
|
|
122
126
|
|
|
123
|
-
/** `parseWheelEvent`'s output (`tui-alt-screen.js:345`): -1 is up, 1 is down. */
|
|
124
|
-
type WheelEventLike = { direction: number; x: number; y: number };
|
|
125
|
-
|
|
126
127
|
type SelectionPoint = { scrollView?: unknown; row: number; col: number; boundary?: boolean };
|
|
127
128
|
type SelectionBounds = { start: SelectionPoint; end: SelectionPoint };
|
|
128
129
|
type SelectionColumns = { start: number; end: number };
|
|
129
130
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
132
|
-
|
|
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;
|
|
133
139
|
getSelectionColumns(
|
|
134
140
|
this: unknown,
|
|
135
141
|
line: string,
|
|
136
142
|
row: number,
|
|
137
143
|
selection: SelectionBounds,
|
|
138
144
|
): SelectionColumns;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
*/
|
|
145
|
-
copyActiveSelectionToClipboard(this: unknown): Promise<boolean>;
|
|
146
|
-
/**
|
|
147
|
-
* Pi 0.84.4's select-without-copy state. The hint reads it to decide
|
|
148
|
-
* whether a selection is waiting for the copy key or was already copied on
|
|
149
|
-
* release.
|
|
150
|
-
*/
|
|
151
|
-
getCopyOnSelect(this: unknown): boolean;
|
|
152
|
-
/** Whether the fullscreen viewport has a non-empty active text selection. */
|
|
153
|
-
hasActiveSelection(this: unknown): boolean;
|
|
154
|
-
handleViewportInput(this: unknown, data: string): { consume: boolean } | undefined;
|
|
155
|
-
flash(this: unknown, message: string, durationMs?: number): void;
|
|
156
|
-
hasOverlay(this: unknown): boolean;
|
|
157
|
-
/**
|
|
158
|
-
* `TuiAltScreen.routeWheel(event)` (`tui-alt-screen.js:375`). `event` is
|
|
159
|
-
* `parseWheelEvent`'s output — `{ direction: -1 | 1, x, y }`, zero-based —
|
|
160
|
-
* and the method returns nothing.
|
|
161
|
-
*/
|
|
162
|
-
routeWheel(this: unknown, event: WheelEventLike): void;
|
|
163
|
-
/**
|
|
164
|
-
* `TuiBase.requestRender(force = false)` (`tui.js:495`), inherited by
|
|
165
|
-
* `TuiAltScreen` and public. The only method here this module calls rather
|
|
166
|
-
* than patches — see `capabilities.ts`.
|
|
167
|
-
*/
|
|
168
|
-
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;
|
|
169
150
|
previousScreen?: readonly string[];
|
|
170
|
-
// Not probed capabilities (see capabilities.ts): plain instance fields
|
|
171
|
-
// this module only ever reads.
|
|
172
151
|
currentLayout?: { root: BoxLike };
|
|
173
|
-
/**
|
|
174
|
-
* `TuiAltScreen.selectionAnchor` / `.selectionFocus` (`tui-alt-screen.js:45`).
|
|
175
|
-
* Plain instance fields, which Pi assigns and clears throughout its own
|
|
176
|
-
* selection handling; the range delete clears them the same way once the text
|
|
177
|
-
* they described is gone.
|
|
178
|
-
*/
|
|
179
152
|
selectionAnchor?: unknown;
|
|
180
153
|
selectionFocus?: unknown;
|
|
181
|
-
/**
|
|
182
|
-
* `TuiBase.terminal` (`tui.js:101`); `rows` is a getter on it, and `write` is
|
|
183
|
-
* how `copyActiveSelectionToClipboard` reaches the clipboard — it emits OSC 52
|
|
184
|
-
* through the terminal rather than through any renderer method.
|
|
185
|
-
*/
|
|
186
154
|
terminal?: { rows?: number; write?: (data: string) => void };
|
|
187
|
-
/**
|
|
188
|
-
* How many lines one notch moves, `max(1, options.wheelScrollLines ?? 1)`
|
|
189
|
-
* (`tui-alt-screen.js:73`). Read so the editor scrolls by the same amount
|
|
190
|
-
* the transcript would have.
|
|
191
|
-
*/
|
|
192
155
|
wheelScrollLines?: number;
|
|
193
156
|
};
|
|
194
157
|
|
|
195
|
-
/**
|
|
196
|
-
* Repaints go through the receiver, not through a callback the caller supplies.
|
|
197
|
-
*
|
|
198
|
-
* The renderer these patches run inside is the thing that needs to repaint, and
|
|
199
|
-
* `TuiAltScreen` calls `this.requestRender()` all through its own mouse handling
|
|
200
|
-
* for exactly this. Routing it back out to the extension instead made the hint
|
|
201
|
-
* depend on whatever repaint path the extension happened to own — which, until
|
|
202
|
-
* this was fixed, was the footer's, so with `features.statusLine` off the
|
|
203
|
-
* pending hint never appeared until some unrelated frame came along. The hint
|
|
204
|
-
* lives in the editor's metadata row; it was never the footer's to schedule.
|
|
205
|
-
*/
|
|
206
158
|
export type InstallMouseDeps = {
|
|
207
159
|
getConfig: () => PolishedTuiConfig;
|
|
208
160
|
};
|
|
@@ -211,48 +163,106 @@ export type InstallMouseDeps = {
|
|
|
211
163
|
let hasWarned = false;
|
|
212
164
|
|
|
213
165
|
/**
|
|
214
|
-
* The live renderer
|
|
215
|
-
* prototype, so the instance is captured here from the first patched call
|
|
216
|
-
* that sees one — `handleViewportInput` sees every key and mouse event, and
|
|
217
|
-
* repaints ride Pi's own `requestRender`, so the hint is never more than one
|
|
218
|
-
* input away from the current renderer.
|
|
166
|
+
* The live renderer, bound lazily — see `bindReceiver`.
|
|
219
167
|
*/
|
|
220
|
-
let activeReceiver:
|
|
168
|
+
let activeReceiver: MouseCapableReceiver | undefined;
|
|
221
169
|
|
|
222
170
|
/**
|
|
223
|
-
* Reader for the derived selection hint
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* render; there is nothing to wire when no mouse install is active.
|
|
171
|
+
* Reader for the derived selection hint, mirroring `pasteExpandHintText`'s
|
|
172
|
+
* pattern in `../paste-collapse.ts`. `ui.ts` composes this with the paste hint
|
|
173
|
+
* on every render; there is nothing to wire when no mouse install is active.
|
|
227
174
|
*
|
|
228
175
|
* The hint is computed on demand from the live renderer, never cached:
|
|
229
|
-
* "selected, ctrl+x to copy" is true exactly while Pi is not auto-copying
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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.
|
|
233
180
|
*/
|
|
234
181
|
let activeHint:
|
|
235
182
|
| { getConfig: () => PolishedTuiConfig; bufferCopy: boolean; cleanCopy: boolean }
|
|
236
183
|
| undefined;
|
|
237
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.
|
|
209
|
+
*/
|
|
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
|
+
}
|
|
252
|
+
|
|
238
253
|
export function activeSelectionHintText(): string | null {
|
|
239
|
-
|
|
254
|
+
const receiver = bindReceiver();
|
|
255
|
+
if (!receiver || !activeHint) return null;
|
|
240
256
|
const { getConfig, bufferCopy, cleanCopy } = activeHint;
|
|
241
257
|
try {
|
|
242
258
|
// Pi auto-copies on release; a selection that is already on the
|
|
243
259
|
// clipboard needs no hint. `getCopyOnSelect()` is the live value, so a
|
|
244
260
|
// setting flipped at runtime flips the hint with it.
|
|
245
|
-
if (
|
|
246
|
-
if (!
|
|
247
|
-
const bounds =
|
|
261
|
+
if (receiver.getCopyOnSelect?.()) return null;
|
|
262
|
+
if (!receiver.hasActiveSelection?.()) return null;
|
|
263
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
248
264
|
if (!bounds) return null;
|
|
249
|
-
const pending = pendingSelectionText(
|
|
250
|
-
activeReceiver,
|
|
251
|
-
getConfig(),
|
|
252
|
-
bounds,
|
|
253
|
-
bufferCopy,
|
|
254
|
-
cleanCopy,
|
|
255
|
-
);
|
|
265
|
+
const pending = pendingSelectionText(receiver, getConfig(), bounds, bufferCopy, cleanCopy);
|
|
256
266
|
return selectionHintText(
|
|
257
267
|
pending.text.length,
|
|
258
268
|
copyKeyText(),
|
|
@@ -269,16 +279,6 @@ export function activeSelectionHintText(): string | null {
|
|
|
269
279
|
}
|
|
270
280
|
}
|
|
271
281
|
|
|
272
|
-
/**
|
|
273
|
-
* The rendered name of the copy key, for the hint. `app.message.copy` is what
|
|
274
|
-
* Pi 0.84.4's Ctrl+X path is bound to (see `handleCopyCommand` in
|
|
275
|
-
* interactive-mode.ts), and the registry resolves a user rebind so the hint
|
|
276
|
-
* always quotes the key that really copies.
|
|
277
|
-
*/
|
|
278
|
-
function copyKeyText(): string {
|
|
279
|
-
return keyTextFor(COPY_KEYBINDING);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
282
|
/**
|
|
283
283
|
* The "ctrl+g to edit in $EDITOR" hint while the draft outgrows the box.
|
|
284
284
|
*
|
|
@@ -287,46 +287,48 @@ function copyKeyText(): string {
|
|
|
287
287
|
* some of it is unreachable by mouse no matter how you drag. That is exactly
|
|
288
288
|
* when the external editor is the way to act on the whole draft, so the hint
|
|
289
289
|
* is offered whenever the draft outgrows the box, not only while a selection
|
|
290
|
-
* is live.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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).
|
|
293
293
|
*/
|
|
294
|
-
let externalEditorHint: string | null = null;
|
|
295
|
-
|
|
296
294
|
export function externalEditorHintText(): string | null {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
function refreshExternalEditorHint(
|
|
301
|
-
receiver: MouseCapablePrototype,
|
|
302
|
-
config: PolishedTuiConfig,
|
|
303
|
-
): void {
|
|
304
|
-
externalEditorHint = null;
|
|
295
|
+
const receiver = bindReceiver();
|
|
296
|
+
if (!receiver || !activeHint) return null;
|
|
305
297
|
try {
|
|
306
|
-
const viewport = activeEditorViewport(receiver,
|
|
307
|
-
if (!viewport) return;
|
|
298
|
+
const viewport = activeEditorViewport(receiver, activeHint.getConfig());
|
|
299
|
+
if (!viewport) return null;
|
|
308
300
|
const visualRows = editorVisualRowCount(viewport.editor);
|
|
309
301
|
if (visualRows > viewport.viewport.contentRows) {
|
|
310
|
-
|
|
302
|
+
return `${keyTextFor(EXTERNAL_EDITOR_KEYBINDING)} to edit in ${
|
|
311
303
|
externalEditorName() ?? "$EDITOR"
|
|
312
304
|
}`;
|
|
313
305
|
}
|
|
306
|
+
return null;
|
|
314
307
|
} catch {
|
|
315
308
|
// Best effort: an editor this module cannot read offers no hint.
|
|
309
|
+
return null;
|
|
316
310
|
}
|
|
317
311
|
}
|
|
318
312
|
|
|
313
|
+
/**
|
|
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
|
+
|
|
319
323
|
/**
|
|
320
324
|
* The exact text Pi's selection copy would produce, built the same way it
|
|
321
325
|
* builds it: per row, through the receiver's own `getSelectionColumns`, then
|
|
322
326
|
* `sliceByColumn` and `stripTerminalSequences` (both exported by pi-tui),
|
|
323
|
-
* joined with "\n"
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* `
|
|
328
|
-
* from pi-tui's published entry point, so `scrollContentLinesFor` mirrors its
|
|
329
|
-
* (trivial) tree walk in `hit-test.ts`.
|
|
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`.
|
|
330
332
|
*
|
|
331
333
|
* This is a *measurement*, not a copy: nothing here writes a clipboard. The
|
|
332
334
|
* pending hint needs to say how many characters ctrl+c would put there. Rows
|
|
@@ -336,7 +338,7 @@ function refreshExternalEditorHint(
|
|
|
336
338
|
* counts the cleaned text — the hint's promise and the clipboard's bytes are
|
|
337
339
|
* kept in agreement there, by counting whichever text the copy will send.
|
|
338
340
|
*/
|
|
339
|
-
function selectionText(receiver:
|
|
341
|
+
function selectionText(receiver: MouseCapableReceiver, bounds: SelectionBounds): string {
|
|
340
342
|
const scrollView = bounds.start.scrollView;
|
|
341
343
|
const sourceLines = scrollView
|
|
342
344
|
? scrollContentLinesFor(receiver.currentLayout?.root, scrollView)
|
|
@@ -345,7 +347,8 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
|
|
|
345
347
|
const rows: string[] = [];
|
|
346
348
|
for (let row = bounds.start.row; row <= bounds.end.row; row++) {
|
|
347
349
|
const line = sourceLines[row] ?? "";
|
|
348
|
-
const columns = receiver.getSelectionColumns(line, row, bounds);
|
|
350
|
+
const columns = receiver.getSelectionColumns?.(line, row, bounds);
|
|
351
|
+
if (!columns) return "";
|
|
349
352
|
rows.push(
|
|
350
353
|
stripTerminalSequences(
|
|
351
354
|
sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true),
|
|
@@ -360,22 +363,18 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
|
|
|
360
363
|
* the input box, and reports whether it did — `false` means this selection was
|
|
361
364
|
* not the editor's and Pi's own copy must run instead.
|
|
362
365
|
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* the
|
|
367
|
-
*
|
|
368
|
-
* copy sends them — OSC 52 through `terminal.write`, see
|
|
369
|
-
* `copyActiveSelectionToClipboard` in
|
|
370
|
-
* `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js` — so nothing
|
|
371
|
-
* 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.
|
|
372
371
|
*
|
|
373
372
|
* The flash is gated on `copyNotice` at the write itself, exactly once — Pi's
|
|
374
373
|
* own copy flashes unconditionally, so this check is the whole notice path for
|
|
375
374
|
* a clean copy.
|
|
376
375
|
*/
|
|
377
376
|
function editorTextForSelection(
|
|
378
|
-
receiver:
|
|
377
|
+
receiver: MouseCapableReceiver,
|
|
379
378
|
config: PolishedTuiConfig,
|
|
380
379
|
bounds: SelectionBounds,
|
|
381
380
|
): string | undefined {
|
|
@@ -384,16 +383,16 @@ function editorTextForSelection(
|
|
|
384
383
|
// so without this a selection dropped on a dialog would be read as text
|
|
385
384
|
// from the draft hidden behind it. Both callers ask it, or the hint could
|
|
386
385
|
// count one text while the copy sends another.
|
|
387
|
-
if (receiver.hasOverlay()) return undefined;
|
|
386
|
+
if (receiver.hasOverlay?.()) return undefined;
|
|
388
387
|
return editorSelectionTextFor(receiver, config, bounds);
|
|
389
388
|
} catch {
|
|
390
389
|
return undefined;
|
|
391
390
|
}
|
|
392
391
|
}
|
|
393
392
|
|
|
394
|
-
function copyEditorSelection(receiver:
|
|
393
|
+
function copyEditorSelection(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
|
|
395
394
|
try {
|
|
396
|
-
const bounds = receiver.getSelectionBounds();
|
|
395
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
397
396
|
if (!bounds) return false;
|
|
398
397
|
const text = editorTextForSelection(receiver, config, bounds);
|
|
399
398
|
// undefined is "not the editor's"; "" is the editor's and empty, which is
|
|
@@ -404,7 +403,7 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
|
|
|
404
403
|
const write = receiver.terminal?.write;
|
|
405
404
|
if (typeof write !== "function") return false;
|
|
406
405
|
write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
407
|
-
if (config.mouse.copyNotice) receiver.flash("Copied!");
|
|
406
|
+
if (config.mouse.copyNotice) receiver.flash?.("Copied!");
|
|
408
407
|
return true;
|
|
409
408
|
} catch {
|
|
410
409
|
// Reading the editor and the layout is best effort. Anything this trips
|
|
@@ -429,18 +428,17 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
|
|
|
429
428
|
* Column mapping: `leftTrim` columns came off the left of a cleaned row, so
|
|
430
429
|
* the selection's start and end columns shift by the same amount on the rows
|
|
431
430
|
* they touch. The shifted bounds go through the receiver's own
|
|
432
|
-
* `getSelectionColumns`, which aligns both ends to grapheme cell ranges
|
|
433
|
-
*
|
|
434
|
-
* 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.
|
|
435
433
|
*/
|
|
436
434
|
function transcriptSelectionText(
|
|
437
|
-
receiver:
|
|
435
|
+
receiver: MouseCapableReceiver,
|
|
438
436
|
config: PolishedTuiConfig,
|
|
439
437
|
bounds: SelectionBounds,
|
|
440
438
|
): string | undefined {
|
|
441
439
|
const scrollView = bounds.start.scrollView;
|
|
442
440
|
if (!scrollView) return undefined;
|
|
443
|
-
if (receiver.hasOverlay()) return undefined;
|
|
441
|
+
if (receiver.hasOverlay?.()) return undefined;
|
|
444
442
|
const sourceLines = scrollContentLinesFor(receiver.currentLayout?.root, scrollView);
|
|
445
443
|
if (!sourceLines) return undefined;
|
|
446
444
|
const cleaned = cleanTranscriptRows(
|
|
@@ -467,7 +465,8 @@ function transcriptSelectionText(
|
|
|
467
465
|
adjusted.end = { ...bounds.end, col: Math.max(0, bounds.end.col - entry.leftTrim) };
|
|
468
466
|
}
|
|
469
467
|
}
|
|
470
|
-
const columns = receiver.getSelectionColumns(entry.text, row, adjusted);
|
|
468
|
+
const columns = receiver.getSelectionColumns?.(entry.text, row, adjusted);
|
|
469
|
+
if (!columns) return undefined;
|
|
471
470
|
rows.push(
|
|
472
471
|
sliceByColumn(
|
|
473
472
|
entry.text,
|
|
@@ -485,19 +484,18 @@ function transcriptSelectionText(
|
|
|
485
484
|
* carried recognised chrome, and reports whether it did — `false` means
|
|
486
485
|
* there was nothing to clean and Pi's own copy must run instead. The bytes
|
|
487
486
|
* go out the same way Pi's own copy sends them (OSC 52 through
|
|
488
|
-
* `terminal.write`), for the same reason `copyEditorSelection` does
|
|
489
|
-
* downstream can tell the two copies apart.
|
|
487
|
+
* `terminal.write`), for the same reason `copyEditorSelection` does.
|
|
490
488
|
*
|
|
491
489
|
* A selection over pure decoration cleans to "" and is consumed without a
|
|
492
490
|
* clipboard write — Pi's own copy has the same shape (`if (text.length === 0)
|
|
493
491
|
* return`), it just gets there after building a string of rails.
|
|
494
492
|
*/
|
|
495
493
|
function copyTranscriptSelection(
|
|
496
|
-
receiver:
|
|
494
|
+
receiver: MouseCapableReceiver,
|
|
497
495
|
config: PolishedTuiConfig,
|
|
498
496
|
): boolean {
|
|
499
497
|
try {
|
|
500
|
-
const bounds = receiver.getSelectionBounds();
|
|
498
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
501
499
|
if (!bounds) return false;
|
|
502
500
|
const text = transcriptSelectionText(receiver, config, bounds);
|
|
503
501
|
if (text === undefined) return false;
|
|
@@ -505,7 +503,7 @@ function copyTranscriptSelection(
|
|
|
505
503
|
const write = receiver.terminal?.write;
|
|
506
504
|
if (typeof write !== "function") return false;
|
|
507
505
|
write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
508
|
-
if (config.mouse.copyNotice) receiver.flash("Copied!");
|
|
506
|
+
if (config.mouse.copyNotice) receiver.flash?.("Copied!");
|
|
509
507
|
return true;
|
|
510
508
|
} catch {
|
|
511
509
|
// Cleaning reads the layout and the selection, both best effort.
|
|
@@ -518,12 +516,10 @@ function copyTranscriptSelection(
|
|
|
518
516
|
* Whether `data` is the backspace or delete the range delete acts on.
|
|
519
517
|
*
|
|
520
518
|
* The question is asked of Pi's own keybinding registry, against the two
|
|
521
|
-
* bindings Pi's editor itself dispatches on
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
* this return false, which falls through to Pi's own one-character delete. That
|
|
526
|
-
* 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.
|
|
527
523
|
*
|
|
528
524
|
* ctrl+c and ctrl+d are refused outright, before the registry is consulted at
|
|
529
525
|
* all. `tui.editor.deleteCharForward` binds `ctrl+d` by default, and ctrl+c and
|
|
@@ -553,13 +549,13 @@ function isRangeDeleteKey(data: unknown): boolean {
|
|
|
553
549
|
* Returns false for every selection that is not the input box's, which leaves
|
|
554
550
|
* the key to Pi and an ordinary backspace deleting one character.
|
|
555
551
|
*/
|
|
556
|
-
function deleteSelectedRange(receiver:
|
|
552
|
+
function deleteSelectedRange(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
|
|
557
553
|
try {
|
|
558
554
|
if (!config.editorClickCursor) return false;
|
|
559
555
|
// The same overlay question the rest of this feature asks: a selection
|
|
560
556
|
// dropped on a dialog must not delete the draft behind it.
|
|
561
|
-
if (receiver.hasOverlay()) return false;
|
|
562
|
-
const bounds = receiver.getSelectionBounds();
|
|
557
|
+
if (receiver.hasOverlay?.()) return false;
|
|
558
|
+
const bounds = receiver.getSelectionBounds?.();
|
|
563
559
|
if (!bounds) return false;
|
|
564
560
|
if (!deleteEditorSelection(receiver, config, bounds)) return false;
|
|
565
561
|
// The highlight described text that is gone. Pi clears these two fields
|
|
@@ -583,7 +579,7 @@ function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTu
|
|
|
583
579
|
* the hint promises a number the copy does not deliver.
|
|
584
580
|
*/
|
|
585
581
|
function pendingSelectionText(
|
|
586
|
-
receiver:
|
|
582
|
+
receiver: MouseCapableReceiver,
|
|
587
583
|
config: PolishedTuiConfig,
|
|
588
584
|
bounds: SelectionBounds,
|
|
589
585
|
bufferCopy: boolean,
|
|
@@ -606,161 +602,72 @@ function pendingSelectionText(
|
|
|
606
602
|
}
|
|
607
603
|
|
|
608
604
|
/**
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
* `copyActiveSelectionToClipboard` — the method coding-agent's Ctrl+X handler
|
|
614
|
-
* reaches (`handleCopyCommand` with `preferSelection`) — so the clean copy
|
|
615
|
-
* answers exactly the copies that key performs. They share one patch rather
|
|
616
|
-
* than taking a key each because `installPrototypePatch` holds exactly one
|
|
617
|
-
* behaviour per adapter key: a second registration under `mouse-copy` would
|
|
618
|
-
* silently replace the first, and two different keys would leave the order
|
|
619
|
-
* they run in — and which of them gets to consume the call — implicit. Here
|
|
620
|
-
* the precedence is written down: the buffer copy answers first, and only
|
|
621
|
-
* when it declines does the clean copy or predecessor see the call.
|
|
622
|
-
*
|
|
623
|
-
* `selectionHint` owns no state at all: it records the config reader and the
|
|
624
|
-
* clean-copy flags so `activeSelectionHintText()` can derive the hint on
|
|
625
|
-
* demand from the live renderer. Either clean-copy feature can be off; with
|
|
626
|
-
* neither available 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.
|
|
627
609
|
*/
|
|
628
|
-
function
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const hint = features.has("selectionHint");
|
|
637
|
-
const previousHint = activeHint;
|
|
638
|
-
if (hint) activeHint = { getConfig: deps.getConfig, bufferCopy, cleanCopy };
|
|
639
|
-
|
|
640
|
-
const cleanups: Array<() => void> = [];
|
|
641
|
-
|
|
642
|
-
// Only installed when a feature that answers `copyActiveSelectionToClipboard`
|
|
643
|
-
// is on. With just `editorClickToCaret` or `selectionHint` the method is
|
|
644
|
-
// not ours to touch, and a Pi that has moved it must not make this install
|
|
645
|
-
// throw.
|
|
646
|
-
if (bufferCopy || cleanCopy) {
|
|
647
|
-
cleanups.push(
|
|
648
|
-
installPrototypePatch(
|
|
649
|
-
prototype,
|
|
650
|
-
"copyActiveSelectionToClipboard",
|
|
651
|
-
"mouse-copy",
|
|
652
|
-
({ predecessor, receiver, args }) => {
|
|
653
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
654
|
-
const config = deps.getConfig();
|
|
655
|
-
if (bufferCopy && copyEditorSelection(typedReceiver, config)) return true;
|
|
656
|
-
if (
|
|
657
|
-
cleanCopy &&
|
|
658
|
-
config.mouse.transcriptCleanCopy &&
|
|
659
|
-
copyTranscriptSelection(typedReceiver, config)
|
|
660
|
-
) {
|
|
661
|
-
return true;
|
|
662
|
-
}
|
|
663
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
664
|
-
},
|
|
665
|
-
),
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
if (!hint && !rangeDelete) {
|
|
670
|
-
return () => {
|
|
671
|
-
for (const cleanup of cleanups) cleanup();
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
cleanups.push(
|
|
676
|
-
installPrototypePatch(
|
|
677
|
-
prototype,
|
|
678
|
-
"handleViewportInput",
|
|
679
|
-
"mouse-viewport-input",
|
|
680
|
-
({ predecessor, receiver, args }) => {
|
|
681
|
-
const data = args[0];
|
|
682
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
683
|
-
// Keystrokes and mouse events both land here, so this is the one
|
|
684
|
-
// place that sees every draft change; keep the outgrew-the-box
|
|
685
|
-
// hint current with it, and keep the selection hint's renderer
|
|
686
|
-
// reference pointed at the live instance.
|
|
687
|
-
activeReceiver = typedReceiver;
|
|
688
|
-
refreshExternalEditorHint(typedReceiver, deps.getConfig());
|
|
689
|
-
// ---- Backspace and delete, `editorClickToCaret`'s. ------------
|
|
690
|
-
//
|
|
691
|
-
// Range delete consumes the key only when it really removed
|
|
692
|
-
// something; every other backspace falls through and goes on
|
|
693
|
-
// deleting one character, which is the behaviour it must not
|
|
694
|
-
// break. `isRangeDeleteKey` refuses ctrl+c and ctrl+d outright,
|
|
695
|
-
// so Pi's interrupt and exit chords can never be swallowed by
|
|
696
|
-
// this branch.
|
|
697
|
-
if (rangeDelete && isRangeDeleteKey(data)) {
|
|
698
|
-
if (deleteSelectedRange(typedReceiver, deps.getConfig())) {
|
|
699
|
-
// Pi's `handleInput` returns as soon as a listener consumes,
|
|
700
|
-
// so the focused editor never sees the key and Pi never
|
|
701
|
-
// reaches its own `requestImmediateRender` (`tui.js:620`).
|
|
702
|
-
// Redrawing the changed draft is this branch's job.
|
|
703
|
-
typedReceiver.requestRender();
|
|
704
|
-
return { consume: true };
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
708
|
-
},
|
|
709
|
-
),
|
|
710
|
-
);
|
|
711
|
-
|
|
712
|
-
return () => {
|
|
713
|
-
for (const cleanup of cleanups) cleanup();
|
|
714
|
-
};
|
|
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;
|
|
715
618
|
}
|
|
716
619
|
|
|
717
620
|
/**
|
|
718
|
-
* A left-button
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
* `release` as the end of a drag and bit 32 as motion during one. Only the
|
|
722
|
-
* press opens a selection, so only the press is the one this feature may take
|
|
723
|
-
* instead.
|
|
724
|
-
*
|
|
725
|
-
* Bit 64 is excluded too, even though `handleInput` peels wheel reports off
|
|
726
|
-
* before this method is reached (`parseWheelEvent` claims anything with bit 64
|
|
727
|
-
* and a direction of 0 or 1): a notch of scroll that landed on a hint row
|
|
728
|
-
* would otherwise expand a box the pointer was only passing over, and that
|
|
729
|
-
* depends on a dispatch order in a file this package does not own.
|
|
730
|
-
*
|
|
731
|
-
* The shape is checked rather than assumed — this runs on whatever Pi passes,
|
|
732
|
-
* and a malformed event must fall through, never throw.
|
|
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.
|
|
733
624
|
*/
|
|
734
|
-
function
|
|
625
|
+
function isLeftButtonRelease(event: unknown): boolean {
|
|
735
626
|
if (typeof event !== "object" || event === null) return false;
|
|
736
627
|
const candidate = event as Partial<MouseEventLike>;
|
|
737
628
|
if (typeof candidate.button !== "number") return false;
|
|
738
629
|
if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
|
|
739
|
-
if (candidate.release) return false;
|
|
630
|
+
if (!candidate.release) return false;
|
|
740
631
|
if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
|
|
741
|
-
|
|
632
|
+
const button = candidate.button & BUTTON_MASK;
|
|
633
|
+
return button === LEFT_BUTTON || button === 3;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
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.
|
|
641
|
+
*/
|
|
642
|
+
function isButtonMotion(event: unknown): boolean {
|
|
643
|
+
if (typeof event !== "object" || event === null) return false;
|
|
644
|
+
const candidate = event as Partial<MouseEventLike>;
|
|
645
|
+
if (typeof candidate.button !== "number") return false;
|
|
646
|
+
if (candidate.release) return false;
|
|
647
|
+
return (candidate.button & MOTION_BIT) !== 0;
|
|
742
648
|
}
|
|
743
649
|
|
|
744
650
|
/**
|
|
745
|
-
* The
|
|
746
|
-
*
|
|
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.
|
|
747
654
|
*
|
|
748
655
|
* Everything is resolved here, inside the one call: the layout is read as it
|
|
749
|
-
* is right now, the component tree is built and dropped, and nothing
|
|
750
|
-
*
|
|
751
|
-
*
|
|
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.
|
|
752
660
|
*/
|
|
753
|
-
function
|
|
754
|
-
receiver:
|
|
755
|
-
event:
|
|
661
|
+
function expandClickTarget(
|
|
662
|
+
receiver: MouseCapableReceiver,
|
|
663
|
+
event: MouseEventLike,
|
|
756
664
|
deps: InstallMouseDeps,
|
|
757
665
|
): ExpandTarget | undefined {
|
|
758
666
|
try {
|
|
759
667
|
if (!deps.getConfig().mouse.clickToExpandTools) return undefined;
|
|
760
|
-
if (!isLeftButtonPress(event)) return undefined;
|
|
761
668
|
// Pi resolves no scroll view while an overlay is up, so neither does this
|
|
762
669
|
// — a click on a dialog must not reach the transcript behind it.
|
|
763
|
-
if (receiver.hasOverlay()) return undefined;
|
|
670
|
+
if (receiver.hasOverlay?.()) return undefined;
|
|
764
671
|
return expandTargetAt(
|
|
765
672
|
{ root: receiver.currentLayout?.root, keyText: expandKeyText() },
|
|
766
673
|
event.x,
|
|
@@ -768,23 +675,32 @@ function pressExpandTarget(
|
|
|
768
675
|
);
|
|
769
676
|
} catch {
|
|
770
677
|
// Resolution is a best-effort read of Pi's internals. Anything it trips
|
|
771
|
-
// over means this
|
|
678
|
+
// over means this click was an ordinary click.
|
|
772
679
|
return undefined;
|
|
773
680
|
}
|
|
774
681
|
}
|
|
775
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
|
+
|
|
776
693
|
/**
|
|
777
694
|
* Puts the caret where a press landed, when the press landed in the input box.
|
|
778
695
|
*
|
|
779
696
|
* Nothing is consumed: the press goes on to Pi, which drops its selection
|
|
780
697
|
* anchor there exactly as it always did, so a drag from that point still
|
|
781
698
|
* highlights and still copies. That is also what pays for the repaint — Pi's
|
|
782
|
-
* own press branch ends in `requestRender()` unconditionally
|
|
783
|
-
*
|
|
784
|
-
* 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.
|
|
785
701
|
*/
|
|
786
702
|
function moveCaretForPress(
|
|
787
|
-
receiver:
|
|
703
|
+
receiver: MouseCapableReceiver,
|
|
788
704
|
event: unknown,
|
|
789
705
|
deps: InstallMouseDeps,
|
|
790
706
|
): void {
|
|
@@ -794,8 +710,8 @@ function moveCaretForPress(
|
|
|
794
710
|
if (!isLeftButtonPress(event)) return;
|
|
795
711
|
// Pi resolves no scroll view while an overlay is up, so neither does this
|
|
796
712
|
// — a click on a dialog must not move a caret in the box behind it.
|
|
797
|
-
if (receiver.hasOverlay()) return;
|
|
798
|
-
moveEditorCaretTo(receiver, config, event.x, event.y);
|
|
713
|
+
if (receiver.hasOverlay?.()) return;
|
|
714
|
+
moveEditorCaretTo(receiver, config, (event as MouseEventLike).x, (event as MouseEventLike).y);
|
|
799
715
|
} catch {
|
|
800
716
|
// Moving the caret is a best-effort read of Pi's internals and of an
|
|
801
717
|
// editor this module did not necessarily build. Anything it trips over
|
|
@@ -803,80 +719,13 @@ function moveCaretForPress(
|
|
|
803
719
|
}
|
|
804
720
|
}
|
|
805
721
|
|
|
806
|
-
/**
|
|
807
|
-
* Installs the two features that live on `handleSelectionMouseEvent`.
|
|
808
|
-
*
|
|
809
|
-
* They share one patch because `installPrototypePatch` holds exactly one
|
|
810
|
-
* behaviour per adapter key — a second registration under
|
|
811
|
-
* `mouse-selection-event` would silently replace the first, and giving them a
|
|
812
|
-
* key each would leave the order they run in implicit, which matters here
|
|
813
|
-
* because only one of them may consume the press. Written as one patch the
|
|
814
|
-
* precedence is explicit and testable:
|
|
815
|
-
*
|
|
816
|
-
* 1. `clickToExpandTools` gets the press first, and *consumes* it when it lands
|
|
817
|
-
* on a tool box's hint row. That is what keeps the click from also dropping
|
|
818
|
-
* a selection anchor into the box it just opened.
|
|
819
|
-
* 2. `editorClickToCaret` gets every press expand did not take, moves the caret
|
|
820
|
-
* if the press was inside the input box, and never consumes.
|
|
821
|
-
* 3. Pi gets the press either way, unless expand took it.
|
|
822
|
-
*
|
|
823
|
-
* The two cannot collide in practice — the hint rows are in the transcript and
|
|
824
|
-
* the caret rows are in the dock — but the ordering is what makes that a fact
|
|
825
|
-
* rather than a hope, and either feature may be off without disturbing the
|
|
826
|
-
* other.
|
|
827
|
-
*/
|
|
828
|
-
function installSelectionMouse(
|
|
829
|
-
prototype: MouseCapablePrototype,
|
|
830
|
-
deps: InstallMouseDeps,
|
|
831
|
-
features: ReadonlySet<MouseFeature>,
|
|
832
|
-
): () => void {
|
|
833
|
-
const expandTools = features.has("clickToExpandTools");
|
|
834
|
-
const clickToCaret = features.has("editorClickToCaret");
|
|
835
|
-
return installPrototypePatch(
|
|
836
|
-
prototype,
|
|
837
|
-
"handleSelectionMouseEvent",
|
|
838
|
-
"mouse-selection-event",
|
|
839
|
-
({ predecessor, receiver, args }) => {
|
|
840
|
-
const typedReceiver = receiver as MouseCapablePrototype;
|
|
841
|
-
if (expandTools) {
|
|
842
|
-
const target = pressExpandTarget(typedReceiver, args[0], deps);
|
|
843
|
-
if (target) {
|
|
844
|
-
target.component.setExpanded(target.expanded);
|
|
845
|
-
typedReceiver.requestRender();
|
|
846
|
-
return undefined;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
if (clickToCaret) moveCaretForPress(typedReceiver, args[0], deps);
|
|
850
|
-
return Reflect.apply(predecessor, receiver, args);
|
|
851
|
-
},
|
|
852
|
-
);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/**
|
|
856
|
-
* The shape `parseWheelEvent` produces, checked rather than assumed — this runs
|
|
857
|
-
* on whatever Pi hands `routeWheel`, and anything unrecognised must fall
|
|
858
|
-
* through to Pi's own routing, never throw.
|
|
859
|
-
*/
|
|
860
|
-
function isWheelEvent(event: unknown): event is WheelEventLike {
|
|
861
|
-
if (typeof event !== "object" || event === null) return false;
|
|
862
|
-
const candidate = event as Partial<WheelEventLike>;
|
|
863
|
-
return (
|
|
864
|
-
typeof candidate.direction === "number" &&
|
|
865
|
-
typeof candidate.x === "number" &&
|
|
866
|
-
typeof candidate.y === "number"
|
|
867
|
-
);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
722
|
/**
|
|
871
723
|
* Scrolls the input box for this notch, or reports that the notch was not the
|
|
872
724
|
* input box's — in which case Pi routes it as it always did.
|
|
873
725
|
*
|
|
874
726
|
* Pi does not scroll the editor: the editor's `scrollOffset` is re-derived from
|
|
875
|
-
* the caret on every render
|
|
876
|
-
*
|
|
877
|
-
* offset *and* a caret move, which is what `scrollEditorBy` does — the same
|
|
878
|
-
* function that has been in daily use since pi-powerline-footer, so a notch
|
|
879
|
-
* 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.
|
|
880
729
|
*
|
|
881
730
|
* The window it scrolls within is Pi's own `max(5, rows * 0.3)`, taken from the
|
|
882
731
|
* terminal rather than from the box's rect: the rect includes Starline's border
|
|
@@ -885,25 +734,20 @@ function isWheelEvent(event: unknown): event is WheelEventLike {
|
|
|
885
734
|
* it from, so the notch falls through.
|
|
886
735
|
*/
|
|
887
736
|
function scrollEditorForWheel(
|
|
888
|
-
receiver:
|
|
889
|
-
event:
|
|
737
|
+
receiver: MouseCapableReceiver,
|
|
738
|
+
event: { direction: number; x: number; y: number },
|
|
890
739
|
deps: InstallMouseDeps,
|
|
891
740
|
): boolean {
|
|
892
741
|
try {
|
|
893
742
|
if (!deps.getConfig().mouse.wheelRouting) return false;
|
|
894
|
-
if (!isWheelEvent(event)) return false;
|
|
895
743
|
// An overlay is composited over a layout that still contains the editor,
|
|
896
744
|
// so the box is still "under" a pointer aimed at the dialog on top of it.
|
|
897
|
-
if (receiver.hasOverlay()) return false;
|
|
745
|
+
if (receiver.hasOverlay?.()) return false;
|
|
898
746
|
const editor = activeEditor();
|
|
899
747
|
if (!editor) return false;
|
|
900
748
|
const rows = receiver.terminal?.rows;
|
|
901
749
|
if (typeof rows !== "number") return false;
|
|
902
|
-
if (
|
|
903
|
-
wheelTarget(receiver.currentLayout?.root, editor.component, event.x, event.y) !== "editor"
|
|
904
|
-
) {
|
|
905
|
-
return false;
|
|
906
|
-
}
|
|
750
|
+
if (!pointerOverEditorBox(receiver, editor.component, event.x, event.y)) return false;
|
|
907
751
|
const lines = event.direction * Math.max(1, receiver.wheelScrollLines ?? 1);
|
|
908
752
|
return scrollEditorBy(editor.scrollable, lines, rows);
|
|
909
753
|
} catch {
|
|
@@ -914,97 +758,184 @@ function scrollEditorForWheel(
|
|
|
914
758
|
}
|
|
915
759
|
}
|
|
916
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
|
+
|
|
917
771
|
/**
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
* 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.
|
|
930
783
|
*/
|
|
931
|
-
function
|
|
932
|
-
|
|
784
|
+
export function installMouseFeaturesOn(
|
|
785
|
+
api: MouseEventsApi,
|
|
786
|
+
ctx: ExtensionContext,
|
|
933
787
|
deps: InstallMouseDeps,
|
|
934
788
|
): () => void {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
+
),
|
|
949
855
|
);
|
|
950
|
-
}
|
|
951
856
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
+
);
|
|
966
876
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
+
);
|
|
888
|
+
|
|
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
|
+
);
|
|
998
911
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
+
);
|
|
1001
930
|
}
|
|
1002
931
|
|
|
1003
|
-
if (cleanups.length === 0) return () => {};
|
|
1004
932
|
return () => {
|
|
1005
|
-
externalEditorHint = null;
|
|
1006
|
-
activeReceiver = undefined;
|
|
1007
|
-
activeHint = undefined;
|
|
1008
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;
|
|
1009
940
|
};
|
|
1010
941
|
}
|