@earendil-works/pi-tui 0.84.0 → 0.84.2
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 +1 -1
- package/dist/alt-screen-search.d.ts +26 -0
- package/dist/alt-screen-search.d.ts.map +1 -0
- package/dist/alt-screen-search.js +122 -0
- package/dist/alt-screen-search.js.map +1 -0
- package/dist/components/scroll-view.d.ts +6 -1
- package/dist/components/scroll-view.d.ts.map +1 -1
- package/dist/components/scroll-view.js +23 -7
- package/dist/components/scroll-view.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +40 -0
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +32 -0
- package/dist/keybindings.js.map +1 -1
- package/dist/latex.d.ts.map +1 -1
- package/dist/latex.js +196 -49
- package/dist/latex.js.map +1 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +9 -2
- package/dist/layout.js.map +1 -1
- package/dist/stdin-buffer.d.ts +8 -2
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +7 -2
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal.d.ts +6 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +18 -1
- package/dist/terminal.js.map +1 -1
- package/dist/tui-alt-screen.d.ts +33 -0
- package/dist/tui-alt-screen.d.ts.map +1 -1
- package/dist/tui-alt-screen.js +393 -17
- package/dist/tui-alt-screen.js.map +1 -1
- package/dist/tui.d.ts +2 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +4 -0
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
package/dist/tui-alt-screen.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AltScreenSearchComponent, findAltScreenSearchMatches, getAltScreenSearchMatchKey, } from "./alt-screen-search.js";
|
|
1
2
|
import { AltScreenFlashContainer } from "./components/alt-screen-flash.js";
|
|
2
3
|
import { ScrollView } from "./components/scroll-view.js";
|
|
3
4
|
import { getKeybindings } from "./keybindings.js";
|
|
@@ -5,12 +6,13 @@ import { isKeyRelease } from "./keys.js";
|
|
|
5
6
|
import { getScrollbarGeometry, getScrollViewBox, getScrollViewsAt, renderLayoutFrame, } from "./layout.js";
|
|
6
7
|
import { deleteAllKittyImages, deleteAllKittyPlacements, deleteKittyImage, getCapabilities, getKittyImagePlacement, isImageLine, setCapabilities, } from "./terminal-image.js";
|
|
7
8
|
import { CURSOR_MARKER, compositeTuiLine, TuiBase, VIEWPORT_TUI, } from "./tui.js";
|
|
8
|
-
import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, sliceByColumn, stripTerminalSequences, visibleWidth, } from "./utils.js";
|
|
9
|
+
import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, visibleWidth, } from "./utils.js";
|
|
9
10
|
const ENTER_ALT_SCREEN = "\x1b[?1049h";
|
|
10
11
|
const EXIT_ALT_SCREEN = "\x1b[?1049l";
|
|
11
12
|
const DISABLE_AUTOWRAP = "\x1b[?7l";
|
|
12
13
|
const ENABLE_AUTOWRAP = "\x1b[?7h";
|
|
13
|
-
const
|
|
14
|
+
const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h";
|
|
15
|
+
const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
|
|
14
16
|
const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
|
|
15
17
|
const FOCUS_IN = "\x1b[I";
|
|
16
18
|
const FOCUS_OUT = "\x1b[O";
|
|
@@ -22,6 +24,8 @@ const PAGE_SCROLL_OVERLAP = 4;
|
|
|
22
24
|
const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
|
|
23
25
|
const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
|
|
24
26
|
const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
const DOUBLE_CLICK_INTERVAL_MS = 500;
|
|
28
|
+
const wordSegmenter = getWordSegmenter();
|
|
25
29
|
/** Alternate-screen TUI with a scrollable, application-owned viewport. */
|
|
26
30
|
export class TuiAltScreen extends TuiBase {
|
|
27
31
|
mode = "fullscreen";
|
|
@@ -41,17 +45,25 @@ export class TuiAltScreen extends TuiBase {
|
|
|
41
45
|
uploadedKittyImages = new Map();
|
|
42
46
|
selectionAnchor;
|
|
43
47
|
selectionFocus;
|
|
48
|
+
selectionGranularity = "character";
|
|
49
|
+
selectionInitialRange;
|
|
50
|
+
lastClick;
|
|
44
51
|
selectionDragPointer;
|
|
45
52
|
selectionAutoScrollDirection = 0;
|
|
46
53
|
selectionAutoScrollTimer;
|
|
47
54
|
selectionPressActive = false;
|
|
48
55
|
scrollbarDrag;
|
|
49
56
|
scrollbarHover;
|
|
57
|
+
activeSearch;
|
|
50
58
|
pressedUrl;
|
|
51
59
|
selectionDragged = false;
|
|
52
60
|
wheelScrollLines;
|
|
53
61
|
mouseEnabled;
|
|
62
|
+
searchMatchStyle;
|
|
63
|
+
searchCurrentMatchStyle;
|
|
54
64
|
openUrl;
|
|
65
|
+
onRightClickPaste;
|
|
66
|
+
copySelection;
|
|
55
67
|
constructor(terminal, showHardwareCursor, logDirectory, options = {}) {
|
|
56
68
|
super(terminal, showHardwareCursor, logDirectory);
|
|
57
69
|
this.implicitDocument = {
|
|
@@ -65,7 +77,11 @@ export class TuiAltScreen extends TuiBase {
|
|
|
65
77
|
this.flashes = new AltScreenFlashContainer(() => this.requestRender());
|
|
66
78
|
this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
|
|
67
79
|
this.mouseEnabled = options.mouse ?? true;
|
|
80
|
+
this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
|
|
81
|
+
this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
|
|
68
82
|
this.openUrl = options.openUrl;
|
|
83
|
+
this.onRightClickPaste = options.onRightClickPaste;
|
|
84
|
+
this.copySelection = options.copySelection;
|
|
69
85
|
this.addInputListener((data) => this.handleViewportInput(data));
|
|
70
86
|
}
|
|
71
87
|
get viewportTop() {
|
|
@@ -108,12 +124,26 @@ export class TuiAltScreen extends TuiBase {
|
|
|
108
124
|
this.lastDocument = [];
|
|
109
125
|
this.selectionAnchor = undefined;
|
|
110
126
|
this.selectionFocus = undefined;
|
|
127
|
+
this.selectionGranularity = "character";
|
|
128
|
+
this.selectionInitialRange = undefined;
|
|
129
|
+
this.lastClick = undefined;
|
|
111
130
|
this.pressedUrl = undefined;
|
|
112
131
|
this.selectionDragged = false;
|
|
113
132
|
this.resetRenderState();
|
|
114
|
-
|
|
133
|
+
const term = process.env.TERM?.toLowerCase() ?? "";
|
|
134
|
+
// Multiplexers can lag when every pointer movement is forwarded. Button-motion
|
|
135
|
+
// tracking preserves clicks, wheel events, selections, and scrollbar dragging.
|
|
136
|
+
const mouseSequence = process.env.TMUX !== undefined ||
|
|
137
|
+
process.env.ZELLIJ !== undefined ||
|
|
138
|
+
process.env.STY !== undefined ||
|
|
139
|
+
term.startsWith("tmux") ||
|
|
140
|
+
term.startsWith("screen")
|
|
141
|
+
? ENABLE_BUTTON_MOTION_MOUSE
|
|
142
|
+
: ENABLE_ALL_MOTION_MOUSE;
|
|
143
|
+
this.terminal.write(`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`);
|
|
115
144
|
}
|
|
116
145
|
beforeTerminalStop(_options) {
|
|
146
|
+
this.closeSearch();
|
|
117
147
|
this.stopSelectionAutoScroll();
|
|
118
148
|
this.selectionPressActive = false;
|
|
119
149
|
this.stopScrollbarHover();
|
|
@@ -232,13 +262,126 @@ export class TuiAltScreen extends TuiBase {
|
|
|
232
262
|
return;
|
|
233
263
|
}
|
|
234
264
|
}
|
|
265
|
+
openSearch() {
|
|
266
|
+
if (this.activeSearch) {
|
|
267
|
+
this.activeSearch.overlay?.focus();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query));
|
|
271
|
+
const search = {
|
|
272
|
+
component,
|
|
273
|
+
query: "",
|
|
274
|
+
matches: [],
|
|
275
|
+
selectedIndex: -1,
|
|
276
|
+
anchorRow: this.getPrimaryScrollView().scrollTop,
|
|
277
|
+
selectionMode: "query",
|
|
278
|
+
};
|
|
279
|
+
this.activeSearch = search;
|
|
280
|
+
search.overlay = this.showOverlay(component, {
|
|
281
|
+
anchor: "top-right",
|
|
282
|
+
width: "40%",
|
|
283
|
+
minWidth: 24,
|
|
284
|
+
margin: 1,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
closeSearch() {
|
|
288
|
+
const search = this.activeSearch;
|
|
289
|
+
if (!search)
|
|
290
|
+
return;
|
|
291
|
+
this.activeSearch = undefined;
|
|
292
|
+
search.overlay?.hide();
|
|
293
|
+
this.requestRender();
|
|
294
|
+
}
|
|
295
|
+
updateSearchQuery(query) {
|
|
296
|
+
const search = this.activeSearch;
|
|
297
|
+
if (!search || query === search.query)
|
|
298
|
+
return;
|
|
299
|
+
const selected = search.matches[search.selectedIndex];
|
|
300
|
+
search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop;
|
|
301
|
+
search.query = query;
|
|
302
|
+
search.selectionMode = "query";
|
|
303
|
+
search.component.setResult(-1, 0);
|
|
304
|
+
this.requestRender();
|
|
305
|
+
}
|
|
306
|
+
navigateSearch(direction) {
|
|
307
|
+
const search = this.activeSearch;
|
|
308
|
+
if (!search?.query)
|
|
309
|
+
return;
|
|
310
|
+
search.selectionMode = direction < 0 ? "previous" : "next";
|
|
311
|
+
this.requestRender();
|
|
312
|
+
}
|
|
313
|
+
refreshSearch(layout) {
|
|
314
|
+
const search = this.activeSearch;
|
|
315
|
+
if (!search)
|
|
316
|
+
return false;
|
|
317
|
+
const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
|
|
318
|
+
const box = getScrollViewBox(layout, scrollView);
|
|
319
|
+
const lines = box?.scrollContentLines;
|
|
320
|
+
if (!lines || !search.query.trim()) {
|
|
321
|
+
search.matches = [];
|
|
322
|
+
search.selectedIndex = -1;
|
|
323
|
+
search.selectedKey = undefined;
|
|
324
|
+
search.selectionMode = "retain";
|
|
325
|
+
search.component.setResult(-1, 0);
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const shouldRevealSelection = search.selectionMode !== "retain";
|
|
329
|
+
const matches = findAltScreenSearchMatches(lines, search.query);
|
|
330
|
+
const exactIndex = search.selectedKey
|
|
331
|
+
? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
|
|
332
|
+
: -1;
|
|
333
|
+
let selectedIndex = -1;
|
|
334
|
+
if (matches.length > 0) {
|
|
335
|
+
if (search.selectionMode === "query") {
|
|
336
|
+
selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow);
|
|
337
|
+
if (selectedIndex < 0)
|
|
338
|
+
selectedIndex = 0;
|
|
339
|
+
}
|
|
340
|
+
else if (search.selectionMode === "next") {
|
|
341
|
+
const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
|
|
342
|
+
selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length;
|
|
343
|
+
}
|
|
344
|
+
else if (search.selectionMode === "previous") {
|
|
345
|
+
const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
|
|
346
|
+
selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
selectedIndex =
|
|
350
|
+
exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
search.matches = matches;
|
|
354
|
+
search.selectedIndex = selectedIndex;
|
|
355
|
+
search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]) : undefined;
|
|
356
|
+
search.selectionMode = "retain";
|
|
357
|
+
search.component.setResult(selectedIndex, matches.length);
|
|
358
|
+
if (!shouldRevealSelection)
|
|
359
|
+
return false;
|
|
360
|
+
const selected = matches[selectedIndex];
|
|
361
|
+
const firstSegment = selected?.segments[0];
|
|
362
|
+
const lastSegment = selected?.segments[selected.segments.length - 1];
|
|
363
|
+
if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0)
|
|
364
|
+
return false;
|
|
365
|
+
const before = scrollView.scrollTop;
|
|
366
|
+
const visibleBottom = before + scrollView.viewportHeight - 1;
|
|
367
|
+
let target = before;
|
|
368
|
+
if (firstSegment.row < before || lastSegment.row > visibleBottom) {
|
|
369
|
+
target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3);
|
|
370
|
+
}
|
|
371
|
+
scrollView.scrollTo(target, { disableFollow: true });
|
|
372
|
+
return scrollView.scrollTop !== before;
|
|
373
|
+
}
|
|
235
374
|
/** Show a transient message in the alternate-screen flash stack. */
|
|
236
375
|
flash(message, durationMs) {
|
|
237
376
|
this.flashes.flash(message, durationMs);
|
|
238
377
|
}
|
|
378
|
+
shouldDeferViewportInputToOverlay() {
|
|
379
|
+
return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
|
|
380
|
+
}
|
|
239
381
|
handleViewportInput(data) {
|
|
240
382
|
if (data === FOCUS_OUT) {
|
|
241
383
|
const hadActiveSelection = this.selectionPressActive;
|
|
384
|
+
const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined;
|
|
242
385
|
this.selectionPressActive = false;
|
|
243
386
|
this.stopSelectionAutoScroll();
|
|
244
387
|
this.stopScrollbarHover();
|
|
@@ -248,19 +391,27 @@ export class TuiAltScreen extends TuiBase {
|
|
|
248
391
|
if (hadActiveSelection) {
|
|
249
392
|
this.selectionAnchor = undefined;
|
|
250
393
|
this.selectionFocus = undefined;
|
|
394
|
+
this.selectionGranularity = "character";
|
|
395
|
+
this.selectionInitialRange = undefined;
|
|
396
|
+
if (hadNonEmptyActiveSelection)
|
|
397
|
+
this.requestRender();
|
|
251
398
|
}
|
|
252
|
-
this.
|
|
399
|
+
this.lastClick = undefined;
|
|
253
400
|
return { consume: true };
|
|
254
401
|
}
|
|
255
402
|
if (data === FOCUS_IN)
|
|
256
403
|
return { consume: true };
|
|
257
404
|
const wheelEvent = this.parseWheelEvent(data);
|
|
258
405
|
if (wheelEvent) {
|
|
406
|
+
if (this.shouldDeferViewportInputToOverlay())
|
|
407
|
+
return undefined;
|
|
259
408
|
this.routeWheel(wheelEvent);
|
|
260
409
|
return { consume: true };
|
|
261
410
|
}
|
|
262
411
|
const mouseEvent = this.parseSgrMouseEvent(data);
|
|
263
412
|
if (mouseEvent) {
|
|
413
|
+
if (this.handleRightClickPaste(mouseEvent))
|
|
414
|
+
return { consume: true };
|
|
264
415
|
const handled = this.handleScrollbarMouseEvent(mouseEvent);
|
|
265
416
|
if (!this.scrollbarDrag)
|
|
266
417
|
this.updateScrollbarHover(mouseEvent.x, mouseEvent.y);
|
|
@@ -272,6 +423,30 @@ export class TuiAltScreen extends TuiBase {
|
|
|
272
423
|
return { consume: true };
|
|
273
424
|
const keybindings = getKeybindings();
|
|
274
425
|
const isRelease = isKeyRelease(data);
|
|
426
|
+
if (keybindings.matches(data, "tui.altScreen.search")) {
|
|
427
|
+
if (!isRelease)
|
|
428
|
+
this.openSearch();
|
|
429
|
+
return { consume: true };
|
|
430
|
+
}
|
|
431
|
+
if (this.activeSearch?.overlay?.isFocused()) {
|
|
432
|
+
if (keybindings.matches(data, "tui.altScreen.searchNext")) {
|
|
433
|
+
if (!isRelease)
|
|
434
|
+
this.navigateSearch(1);
|
|
435
|
+
return { consume: true };
|
|
436
|
+
}
|
|
437
|
+
if (keybindings.matches(data, "tui.altScreen.searchPrevious")) {
|
|
438
|
+
if (!isRelease)
|
|
439
|
+
this.navigateSearch(-1);
|
|
440
|
+
return { consume: true };
|
|
441
|
+
}
|
|
442
|
+
if (keybindings.matches(data, "tui.altScreen.searchClose")) {
|
|
443
|
+
if (!isRelease)
|
|
444
|
+
this.closeSearch();
|
|
445
|
+
return { consume: true };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (this.shouldDeferViewportInputToOverlay())
|
|
449
|
+
return undefined;
|
|
275
450
|
if (keybindings.matches(data, "tui.altScreen.pageUp")) {
|
|
276
451
|
if (!isRelease) {
|
|
277
452
|
this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP));
|
|
@@ -284,6 +459,26 @@ export class TuiAltScreen extends TuiBase {
|
|
|
284
459
|
}
|
|
285
460
|
return { consume: true };
|
|
286
461
|
}
|
|
462
|
+
if (keybindings.matches(data, "tui.altScreen.halfPageUp")) {
|
|
463
|
+
if (!isRelease)
|
|
464
|
+
this.scrollBy(-Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
|
|
465
|
+
return { consume: true };
|
|
466
|
+
}
|
|
467
|
+
if (keybindings.matches(data, "tui.altScreen.halfPageDown")) {
|
|
468
|
+
if (!isRelease)
|
|
469
|
+
this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
|
|
470
|
+
return { consume: true };
|
|
471
|
+
}
|
|
472
|
+
if (keybindings.matches(data, "tui.altScreen.lineUp")) {
|
|
473
|
+
if (!isRelease)
|
|
474
|
+
this.scrollBy(-1);
|
|
475
|
+
return { consume: true };
|
|
476
|
+
}
|
|
477
|
+
if (keybindings.matches(data, "tui.altScreen.lineDown")) {
|
|
478
|
+
if (!isRelease)
|
|
479
|
+
this.scrollBy(1);
|
|
480
|
+
return { consume: true };
|
|
481
|
+
}
|
|
287
482
|
if (keybindings.matches(data, "tui.altScreen.previousPrompt")) {
|
|
288
483
|
if (!isRelease)
|
|
289
484
|
this.scrollToPrompt(-1);
|
|
@@ -362,6 +557,18 @@ export class TuiAltScreen extends TuiBase {
|
|
|
362
557
|
release: match[4] === "m",
|
|
363
558
|
};
|
|
364
559
|
}
|
|
560
|
+
handleRightClickPaste(event) {
|
|
561
|
+
if (!this.onRightClickPaste || process.platform !== "win32" || event.release || event.button !== 2) {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
this.onRightClickPaste();
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// Clipboard paste is best-effort.
|
|
569
|
+
}
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
365
572
|
getScrollbarTargetAt(x, y) {
|
|
366
573
|
if (this.hasOverlay() || !this.currentLayout)
|
|
367
574
|
return undefined;
|
|
@@ -417,6 +624,9 @@ export class TuiAltScreen extends TuiBase {
|
|
|
417
624
|
this.selectionPressActive = false;
|
|
418
625
|
this.selectionAnchor = undefined;
|
|
419
626
|
this.selectionFocus = undefined;
|
|
627
|
+
this.selectionGranularity = "character";
|
|
628
|
+
this.selectionInitialRange = undefined;
|
|
629
|
+
this.lastClick = undefined;
|
|
420
630
|
this.pressedUrl = undefined;
|
|
421
631
|
this.selectionDragged = false;
|
|
422
632
|
this.setScrollbarHover(target.scrollView);
|
|
@@ -458,6 +668,79 @@ export class TuiAltScreen extends TuiBase {
|
|
|
458
668
|
col: Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
|
|
459
669
|
};
|
|
460
670
|
}
|
|
671
|
+
getSelectionSourceLine(point) {
|
|
672
|
+
if (point.scrollView && this.currentLayout) {
|
|
673
|
+
const lines = getScrollViewBox(this.currentLayout, point.scrollView)?.scrollContentLines;
|
|
674
|
+
if (lines)
|
|
675
|
+
return lines[point.row] ?? "";
|
|
676
|
+
}
|
|
677
|
+
return this.previousScreen[point.row] ?? "";
|
|
678
|
+
}
|
|
679
|
+
getWordSelection(point) {
|
|
680
|
+
const line = stripTerminalSequences(this.getSelectionSourceLine(point));
|
|
681
|
+
let start = 0;
|
|
682
|
+
for (const segment of wordSegmenter.segment(line)) {
|
|
683
|
+
const end = start + visibleWidth(segment.segment);
|
|
684
|
+
if (point.col >= start && point.col < end) {
|
|
685
|
+
return {
|
|
686
|
+
start: { ...point, col: start },
|
|
687
|
+
end: { ...point, col: end, boundary: true },
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
start = end;
|
|
691
|
+
}
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
getLineSelection(point) {
|
|
695
|
+
return {
|
|
696
|
+
start: { ...point, col: 0 },
|
|
697
|
+
end: { ...point, col: visibleWidth(this.getSelectionSourceLine(point)), boundary: true },
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
updateSelectionFocus(point) {
|
|
701
|
+
if (this.selectionGranularity === "character" || !this.selectionInitialRange) {
|
|
702
|
+
this.selectionFocus = point;
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const range = this.selectionGranularity === "word" ? this.getWordSelection(point) : this.getLineSelection(point);
|
|
706
|
+
if (!range)
|
|
707
|
+
return;
|
|
708
|
+
const initial = this.selectionInitialRange;
|
|
709
|
+
const targetBeforeInitial = range.start.row < initial.start.row ||
|
|
710
|
+
(range.start.row === initial.start.row && range.start.col < initial.start.col);
|
|
711
|
+
if (targetBeforeInitial) {
|
|
712
|
+
this.selectionAnchor = initial.end;
|
|
713
|
+
this.selectionFocus = range.start;
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
this.selectionAnchor = initial.start;
|
|
717
|
+
this.selectionFocus = range.end;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
getClickCount(point, word) {
|
|
721
|
+
const now = Date.now();
|
|
722
|
+
const previous = this.lastClick;
|
|
723
|
+
const count = word &&
|
|
724
|
+
previous &&
|
|
725
|
+
now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
|
|
726
|
+
previous.row === point.row &&
|
|
727
|
+
previous.scrollView === point.scrollView &&
|
|
728
|
+
previous.wordStart === word.start.col &&
|
|
729
|
+
previous.wordEnd === word.end.col
|
|
730
|
+
? (previous.count % 3) + 1
|
|
731
|
+
: 1;
|
|
732
|
+
this.lastClick = word
|
|
733
|
+
? {
|
|
734
|
+
timestamp: now,
|
|
735
|
+
count,
|
|
736
|
+
row: point.row,
|
|
737
|
+
scrollView: point.scrollView,
|
|
738
|
+
wordStart: word.start.col,
|
|
739
|
+
wordEnd: word.end.col,
|
|
740
|
+
}
|
|
741
|
+
: undefined;
|
|
742
|
+
return count;
|
|
743
|
+
}
|
|
461
744
|
updateSelectionAutoScroll(event) {
|
|
462
745
|
const scrollView = this.selectionAnchor?.scrollView;
|
|
463
746
|
if (!scrollView || !this.currentLayout) {
|
|
@@ -497,7 +780,7 @@ export class TuiAltScreen extends TuiBase {
|
|
|
497
780
|
}
|
|
498
781
|
const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y);
|
|
499
782
|
if (point)
|
|
500
|
-
this.
|
|
783
|
+
this.updateSelectionFocus(point);
|
|
501
784
|
this.requestRender();
|
|
502
785
|
}
|
|
503
786
|
stopSelectionAutoScroll() {
|
|
@@ -509,7 +792,8 @@ export class TuiAltScreen extends TuiBase {
|
|
|
509
792
|
this.selectionDragPointer = undefined;
|
|
510
793
|
}
|
|
511
794
|
handleSelectionMouseEvent(event) {
|
|
512
|
-
|
|
795
|
+
const button = event.button & 3;
|
|
796
|
+
if (button !== 0 && !(event.release && button === 3))
|
|
513
797
|
return;
|
|
514
798
|
const anchorScrollView = this.selectionAnchor?.scrollView;
|
|
515
799
|
const point = this.getSelectionPoint(event, anchorScrollView);
|
|
@@ -520,7 +804,7 @@ export class TuiAltScreen extends TuiBase {
|
|
|
520
804
|
this.stopSelectionAutoScroll();
|
|
521
805
|
if (!this.selectionAnchor)
|
|
522
806
|
return;
|
|
523
|
-
this.
|
|
807
|
+
this.updateSelectionFocus(point);
|
|
524
808
|
const clickedUrl = !this.selectionDragged &&
|
|
525
809
|
this.selectionAnchor.scrollView === point.scrollView &&
|
|
526
810
|
this.selectionAnchor.row === point.row &&
|
|
@@ -540,7 +824,7 @@ export class TuiAltScreen extends TuiBase {
|
|
|
540
824
|
this.requestRender();
|
|
541
825
|
return;
|
|
542
826
|
}
|
|
543
|
-
this.copySelectionToClipboard();
|
|
827
|
+
void this.copySelectionToClipboard();
|
|
544
828
|
this.requestRender();
|
|
545
829
|
return;
|
|
546
830
|
}
|
|
@@ -548,8 +832,9 @@ export class TuiAltScreen extends TuiBase {
|
|
|
548
832
|
if (!this.selectionPressActive || !this.selectionAnchor)
|
|
549
833
|
return;
|
|
550
834
|
this.selectionDragged = true;
|
|
835
|
+
this.lastClick = undefined;
|
|
551
836
|
this.pressedUrl = undefined;
|
|
552
|
-
this.
|
|
837
|
+
this.updateSelectionFocus(point);
|
|
553
838
|
this.updateSelectionAutoScroll(event);
|
|
554
839
|
this.requestRender();
|
|
555
840
|
return;
|
|
@@ -560,10 +845,17 @@ export class TuiAltScreen extends TuiBase {
|
|
|
560
845
|
? getScrollViewsAt(this.currentLayout, event.x, event.y)[0]
|
|
561
846
|
: undefined;
|
|
562
847
|
const anchor = this.getSelectionPoint(event, scrollView);
|
|
563
|
-
|
|
564
|
-
|
|
848
|
+
const word = this.getWordSelection(anchor);
|
|
849
|
+
const clickCount = this.getClickCount(anchor, word);
|
|
850
|
+
const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined;
|
|
851
|
+
this.selectionGranularity = range ? (clickCount === 2 ? "word" : "line") : "character";
|
|
852
|
+
this.selectionInitialRange = range;
|
|
853
|
+
this.selectionAnchor = range?.start ?? anchor;
|
|
854
|
+
this.selectionFocus = range?.end ?? anchor;
|
|
565
855
|
this.selectionDragged = false;
|
|
566
|
-
this.pressedUrl =
|
|
856
|
+
this.pressedUrl = range
|
|
857
|
+
? undefined
|
|
858
|
+
: getOsc8LinkAtColumn(this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", Math.max(0, Math.min(this.terminal.columns - 1, event.x)));
|
|
567
859
|
this.requestRender();
|
|
568
860
|
}
|
|
569
861
|
getSelectionBounds() {
|
|
@@ -589,11 +881,13 @@ export class TuiAltScreen extends TuiBase {
|
|
|
589
881
|
start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth);
|
|
590
882
|
}
|
|
591
883
|
if (row === selection.end.row) {
|
|
592
|
-
end =
|
|
884
|
+
end = selection.end.boundary
|
|
885
|
+
? Math.min(selection.end.col, lineWidth)
|
|
886
|
+
: (getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth));
|
|
593
887
|
}
|
|
594
888
|
return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
|
|
595
889
|
}
|
|
596
|
-
copySelectionToClipboard() {
|
|
890
|
+
async copySelectionToClipboard() {
|
|
597
891
|
const selection = this.getSelectionBounds();
|
|
598
892
|
if (!selection)
|
|
599
893
|
return;
|
|
@@ -615,9 +909,87 @@ export class TuiAltScreen extends TuiBase {
|
|
|
615
909
|
const text = lines.join("\n");
|
|
616
910
|
if (text.length === 0)
|
|
617
911
|
return;
|
|
912
|
+
// Prefer an injected clipboard implementation (native clipboard + platform tools with a
|
|
913
|
+
// verified success path) when the host app provides one. A bare OSC 52 write can show
|
|
914
|
+
// "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
|
|
915
|
+
// without OSC 52 clipboard passthrough), so only report success when it actually copies.
|
|
916
|
+
if (this.copySelection) {
|
|
917
|
+
const ok = await this.copySelection(text);
|
|
918
|
+
this.flash(ok ? "Copied!" : "Copy failed");
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
618
921
|
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
619
922
|
this.flash("Copied!");
|
|
620
923
|
}
|
|
924
|
+
applySearchTextHighlight(text, current) {
|
|
925
|
+
const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle;
|
|
926
|
+
let result = "";
|
|
927
|
+
let plainStart = 0;
|
|
928
|
+
let index = 0;
|
|
929
|
+
while (index < text.length) {
|
|
930
|
+
const ansi = extractAnsiCode(text, index);
|
|
931
|
+
if (!ansi) {
|
|
932
|
+
index += 1;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (index > plainStart)
|
|
936
|
+
result += style(text.slice(plainStart, index));
|
|
937
|
+
result += ansi.code;
|
|
938
|
+
index += ansi.length;
|
|
939
|
+
plainStart = index;
|
|
940
|
+
}
|
|
941
|
+
if (plainStart < text.length)
|
|
942
|
+
result += style(text.slice(plainStart));
|
|
943
|
+
return result;
|
|
944
|
+
}
|
|
945
|
+
applySearchHighlights(screen, layout) {
|
|
946
|
+
const search = this.activeSearch;
|
|
947
|
+
if (!search || search.selectedIndex < 0 || search.matches.length === 0)
|
|
948
|
+
return screen;
|
|
949
|
+
const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
|
|
950
|
+
const box = getScrollViewBox(layout, scrollView);
|
|
951
|
+
if (!box)
|
|
952
|
+
return screen;
|
|
953
|
+
const rangesByRow = new Map();
|
|
954
|
+
const scrollbarColumn = getScrollbarGeometry(box)?.column;
|
|
955
|
+
const minRow = Math.max(0, box.rect.y, box.clip.y);
|
|
956
|
+
const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height);
|
|
957
|
+
const minColumn = Math.max(0, box.rect.x, box.clip.x);
|
|
958
|
+
const maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width, scrollbarColumn ?? Number.POSITIVE_INFINITY);
|
|
959
|
+
for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) {
|
|
960
|
+
for (const segment of search.matches[matchIndex].segments) {
|
|
961
|
+
const row = box.rect.y + segment.row - scrollView.scrollTop;
|
|
962
|
+
if (row < minRow || row >= maxRow)
|
|
963
|
+
continue;
|
|
964
|
+
const startCol = Math.max(minColumn, box.rect.x + segment.startCol);
|
|
965
|
+
const endCol = Math.min(maxColumn, box.rect.x + segment.endCol);
|
|
966
|
+
if (endCol <= startCol)
|
|
967
|
+
continue;
|
|
968
|
+
const ranges = rangesByRow.get(row) ?? [];
|
|
969
|
+
ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex });
|
|
970
|
+
rangesByRow.set(row, ranges);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const result = [...screen];
|
|
974
|
+
for (const [row, ranges] of rangesByRow) {
|
|
975
|
+
let line = result[row] ?? "";
|
|
976
|
+
if (isImageLine(line))
|
|
977
|
+
continue;
|
|
978
|
+
const lineWidth = visibleWidth(line);
|
|
979
|
+
for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) {
|
|
980
|
+
const startCol = Math.min(range.startCol, lineWidth);
|
|
981
|
+
const endCol = Math.min(range.endCol, lineWidth);
|
|
982
|
+
if (endCol <= startCol)
|
|
983
|
+
continue;
|
|
984
|
+
const before = sliceByColumn(line, 0, startCol, true);
|
|
985
|
+
const highlighted = sliceByColumn(line, startCol, endCol - startCol, true);
|
|
986
|
+
const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true);
|
|
987
|
+
line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`;
|
|
988
|
+
}
|
|
989
|
+
result[row] = line;
|
|
990
|
+
}
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
621
993
|
applySelectionHighlight(text) {
|
|
622
994
|
let result = "\x1b[7m";
|
|
623
995
|
let index = 0;
|
|
@@ -656,14 +1028,14 @@ export class TuiAltScreen extends TuiBase {
|
|
|
656
1028
|
maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width);
|
|
657
1029
|
screenSelection = {
|
|
658
1030
|
start: {
|
|
1031
|
+
...selection.start,
|
|
659
1032
|
row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop,
|
|
660
1033
|
col: box.rect.x + selection.start.col,
|
|
661
|
-
scrollView: selection.start.scrollView,
|
|
662
1034
|
},
|
|
663
1035
|
end: {
|
|
1036
|
+
...selection.end,
|
|
664
1037
|
row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop,
|
|
665
1038
|
col: box.rect.x + selection.end.col,
|
|
666
|
-
scrollView: selection.start.scrollView,
|
|
667
1039
|
},
|
|
668
1040
|
};
|
|
669
1041
|
}
|
|
@@ -710,8 +1082,12 @@ export class TuiAltScreen extends TuiBase {
|
|
|
710
1082
|
const width = Math.max(1, this.terminal.columns);
|
|
711
1083
|
const height = Math.max(1, this.terminal.rows);
|
|
712
1084
|
const root = this.layoutRoot ?? this.implicitScrollView;
|
|
713
|
-
|
|
1085
|
+
let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
|
|
1086
|
+
if (this.refreshSearch(nextLayout)) {
|
|
1087
|
+
nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
|
|
1088
|
+
}
|
|
714
1089
|
let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
|
1090
|
+
screen = this.applySearchHighlights(screen, nextLayout);
|
|
715
1091
|
screen = this.compositeOverlays(screen, width, height);
|
|
716
1092
|
if (screen.length > height)
|
|
717
1093
|
screen = screen.slice(screen.length - height);
|