@earendil-works/pi-tui 0.84.4 → 0.85.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +53 -4
  2. package/dist/alt-screen-search.d.ts +21 -1
  3. package/dist/alt-screen-search.d.ts.map +1 -1
  4. package/dist/alt-screen-search.js +202 -42
  5. package/dist/alt-screen-search.js.map +1 -1
  6. package/dist/components/box.d.ts +3 -1
  7. package/dist/components/box.d.ts.map +1 -1
  8. package/dist/components/box.js +29 -0
  9. package/dist/components/box.js.map +1 -1
  10. package/dist/components/editor.d.ts +6 -1
  11. package/dist/components/editor.d.ts.map +1 -1
  12. package/dist/components/editor.js +98 -18
  13. package/dist/components/editor.js.map +1 -1
  14. package/dist/components/input.d.ts +12 -1
  15. package/dist/components/input.d.ts.map +1 -1
  16. package/dist/components/input.js +44 -5
  17. package/dist/components/input.js.map +1 -1
  18. package/dist/components/loader.d.ts +2 -0
  19. package/dist/components/loader.d.ts.map +1 -1
  20. package/dist/components/loader.js +10 -3
  21. package/dist/components/loader.js.map +1 -1
  22. package/dist/components/mouse-region.d.ts +12 -0
  23. package/dist/components/mouse-region.d.ts.map +1 -0
  24. package/dist/components/mouse-region.js +21 -0
  25. package/dist/components/mouse-region.js.map +1 -0
  26. package/dist/components/scroll-view.d.ts +6 -3
  27. package/dist/components/scroll-view.d.ts.map +1 -1
  28. package/dist/components/scroll-view.js +8 -2
  29. package/dist/components/scroll-view.js.map +1 -1
  30. package/dist/components/select-list.d.ts +4 -1
  31. package/dist/components/select-list.d.ts.map +1 -1
  32. package/dist/components/select-list.js +49 -2
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts +5 -1
  35. package/dist/components/settings-list.d.ts.map +1 -1
  36. package/dist/components/settings-list.js +55 -5
  37. package/dist/components/settings-list.js.map +1 -1
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/latex.d.ts.map +1 -1
  43. package/dist/latex.js +14 -0
  44. package/dist/latex.js.map +1 -1
  45. package/dist/layout.d.ts +3 -1
  46. package/dist/layout.d.ts.map +1 -1
  47. package/dist/layout.js +32 -9
  48. package/dist/layout.js.map +1 -1
  49. package/dist/terminal-image.d.ts.map +1 -1
  50. package/dist/terminal-image.js +1 -4
  51. package/dist/terminal-image.js.map +1 -1
  52. package/dist/terminal.d.ts +6 -0
  53. package/dist/terminal.d.ts.map +1 -1
  54. package/dist/terminal.js +17 -4
  55. package/dist/terminal.js.map +1 -1
  56. package/dist/tui-alt-screen.d.ts +31 -1
  57. package/dist/tui-alt-screen.d.ts.map +1 -1
  58. package/dist/tui-alt-screen.js +342 -41
  59. package/dist/tui-alt-screen.js.map +1 -1
  60. package/dist/tui-main-screen.d.ts.map +1 -1
  61. package/dist/tui-main-screen.js +6 -5
  62. package/dist/tui-main-screen.js.map +1 -1
  63. package/dist/tui.d.ts +84 -6
  64. package/dist/tui.d.ts.map +1 -1
  65. package/dist/tui.js +123 -9
  66. package/dist/tui.js.map +1 -1
  67. package/dist/utils.d.ts +2 -0
  68. package/dist/utils.d.ts.map +1 -1
  69. package/dist/utils.js +9 -0
  70. package/dist/utils.js.map +1 -1
  71. package/package.json +1 -1
@@ -1,12 +1,13 @@
1
- import { AltScreenSearchComponent, findAltScreenSearchMatches, getAltScreenSearchMatchKey, } from "./alt-screen-search.js";
1
+ import { AltScreenSearchComponent, AltScreenSearchIndex, getAltScreenSearchMatchKey, } from "./alt-screen-search.js";
2
2
  import { AltScreenFlashContainer } from "./components/alt-screen-flash.js";
3
3
  import { ScrollView } from "./components/scroll-view.js";
4
4
  import { getKeybindings } from "./keybindings.js";
5
5
  import { isKeyRelease } from "./keys.js";
6
- import { getScrollbarGeometry, getScrollViewBox, getScrollViewsAt, renderLayoutFrame, } from "./layout.js";
6
+ import { getLayoutBoxesAt, getScrollbarGeometry, getScrollViewBox, getScrollViewsAt, renderLayoutFrame, } from "./layout.js";
7
+ import { getLayoutNode } from "./layout-node.js";
7
8
  import { deleteAllKittyImages, deleteAllKittyPlacements, deleteKittyImage, getCapabilities, getKittyImagePlacement, isImageLine, setCapabilities, } from "./terminal-image.js";
8
- import { CURSOR_MARKER, compositeTuiLine, TuiBase, VIEWPORT_TUI, } from "./tui.js";
9
- import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, visibleWidth, } from "./utils.js";
9
+ import { Container, CURSOR_MARKER, compositeTuiLine, dispatchMouseEvent, retargetMouseEvent, TuiBase, VIEWPORT_TUI, } from "./tui.js";
10
+ import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "./utils.js";
10
11
  const ENTER_ALT_SCREEN = "\x1b[?1049h";
11
12
  const EXIT_ALT_SCREEN = "\x1b[?1049l";
12
13
  const DISABLE_AUTOWRAP = "\x1b[?7l";
@@ -21,6 +22,7 @@ const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l";
21
22
  const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
22
23
  const OSC133_PROMPT_START = /^\x1b\]133;A(?:\x07|\x1b\\)/;
23
24
  const PAGE_SCROLL_OVERLAP = 4;
25
+ const ALT_WHEEL_SCROLL_MULTIPLIER = 5;
24
26
  const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
25
27
  const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
26
28
  const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
@@ -57,13 +59,21 @@ export class TuiAltScreen extends TuiBase {
57
59
  selectionPressActive = false;
58
60
  scrollbarDrag;
59
61
  scrollbarHover;
62
+ scrollToEndIndicatorRect;
60
63
  activeSearch;
61
64
  pressedUrl;
62
65
  selectionDragged = false;
66
+ mouseCapture;
67
+ mousePressTarget;
68
+ mousePressPoint;
69
+ mousePressMoved = false;
70
+ lastComponentClick;
63
71
  wheelScrollLines;
64
72
  mouseEnabled;
65
73
  searchMatchStyle;
66
74
  searchCurrentMatchStyle;
75
+ searchNavigationButtonStyle;
76
+ scrollToEndIndicator;
67
77
  openUrl;
68
78
  onRightClickPaste;
69
79
  copyOnSelect;
@@ -72,6 +82,7 @@ export class TuiAltScreen extends TuiBase {
72
82
  super(terminal, showHardwareCursor, logDirectory);
73
83
  this.implicitDocument = {
74
84
  render: (width) => super.render(width),
85
+ handleMouse: (event) => super.handleMouse(event),
75
86
  invalidate: () => {
76
87
  for (const child of this.children)
77
88
  child.invalidate();
@@ -83,6 +94,8 @@ export class TuiAltScreen extends TuiBase {
83
94
  this.mouseEnabled = options.mouse ?? true;
84
95
  this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
85
96
  this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
97
+ this.searchNavigationButtonStyle = options.searchNavigationButtonStyle ?? ((text) => text);
98
+ this.scrollToEndIndicator = options.scrollToEndIndicator;
86
99
  this.openUrl = options.openUrl;
87
100
  this.onRightClickPaste = options.onRightClickPaste;
88
101
  this.copyOnSelect = options.copyOnSelect ?? true;
@@ -151,6 +164,8 @@ export class TuiAltScreen extends TuiBase {
151
164
  this.lastClick = undefined;
152
165
  this.pressedUrl = undefined;
153
166
  this.selectionDragged = false;
167
+ this.clearComponentMouseGesture();
168
+ this.lastComponentClick = undefined;
154
169
  this.resetRenderState();
155
170
  const term = process.env.TERM?.toLowerCase() ?? "";
156
171
  // Multiplexers can lag when every pointer movement is forwarded. Button-motion
@@ -170,6 +185,7 @@ export class TuiAltScreen extends TuiBase {
170
185
  this.selectionPressActive = false;
171
186
  this.stopScrollbarHover();
172
187
  this.stopScrollbarDrag();
188
+ this.clearComponentMouseGesture();
173
189
  this.flashes.dispose();
174
190
  if (!this.altScreenActive)
175
191
  return;
@@ -284,14 +300,15 @@ export class TuiAltScreen extends TuiBase {
284
300
  return;
285
301
  }
286
302
  }
287
- openSearch() {
303
+ toggleSearch() {
288
304
  if (this.activeSearch) {
289
- this.activeSearch.overlay?.focus();
305
+ this.closeSearch();
290
306
  return;
291
307
  }
292
- const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query));
308
+ const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query), this.searchNavigationButtonStyle);
293
309
  const search = {
294
310
  component,
311
+ index: new AltScreenSearchIndex(),
295
312
  query: "",
296
313
  matches: [],
297
314
  selectedIndex: -1,
@@ -302,7 +319,7 @@ export class TuiAltScreen extends TuiBase {
302
319
  search.overlay = this.showOverlay(component, {
303
320
  anchor: "top-right",
304
321
  width: "40%",
305
- minWidth: 24,
322
+ minWidth: 32,
306
323
  margin: 1,
307
324
  });
308
325
  }
@@ -332,6 +349,29 @@ export class TuiAltScreen extends TuiBase {
332
349
  search.selectionMode = direction < 0 ? "previous" : "next";
333
350
  this.requestRender();
334
351
  }
352
+ getSearchNavigationDirectionAt(x, y) {
353
+ const search = this.activeSearch;
354
+ const bounds = search?.overlay?.getBounds();
355
+ if (!search || !bounds)
356
+ return undefined;
357
+ if (x < bounds.col || x >= bounds.col + bounds.width || y < bounds.row || y >= bounds.row + bounds.height) {
358
+ return undefined;
359
+ }
360
+ return search.component.getNavigationDirectionAt(y - bounds.row, x - bounds.col);
361
+ }
362
+ handleSearchMouseEvent(event) {
363
+ const search = this.activeSearch;
364
+ if (!search)
365
+ return false;
366
+ const direction = this.getSearchNavigationDirectionAt(event.x, event.y);
367
+ if (search.component.setHoveredNavigationDirection(direction))
368
+ this.requestRender();
369
+ if (direction === undefined || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) {
370
+ return false;
371
+ }
372
+ this.navigateSearch(direction);
373
+ return true;
374
+ }
335
375
  refreshSearch(layout) {
336
376
  const search = this.activeSearch;
337
377
  if (!search)
@@ -348,16 +388,29 @@ export class TuiAltScreen extends TuiBase {
348
388
  return false;
349
389
  }
350
390
  const shouldRevealSelection = search.selectionMode !== "retain";
351
- const matches = findAltScreenSearchMatches(lines, search.query);
352
- const exactIndex = search.selectedKey
353
- ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
354
- : -1;
391
+ const result = search.index.search(lines, search.query);
392
+ const matches = result.matches;
393
+ search.matches = matches;
394
+ if (!result.changed && search.selectionMode === "retain")
395
+ return false;
396
+ const exactIndex = result.changed
397
+ ? search.selectedKey
398
+ ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
399
+ : -1
400
+ : search.selectedIndex;
355
401
  let selectedIndex = -1;
356
402
  if (matches.length > 0) {
357
403
  if (search.selectionMode === "query") {
358
- selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow);
359
- if (selectedIndex < 0)
360
- selectedIndex = 0;
404
+ let low = 0;
405
+ let high = matches.length;
406
+ while (low < high) {
407
+ const middle = low + Math.floor((high - low) / 2);
408
+ if ((matches[middle].segments[0]?.row ?? 0) < search.anchorRow)
409
+ low = middle + 1;
410
+ else
411
+ high = middle;
412
+ }
413
+ selectedIndex = low < matches.length ? low : 0;
361
414
  }
362
415
  else if (search.selectionMode === "next") {
363
416
  const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
@@ -372,7 +425,6 @@ export class TuiAltScreen extends TuiBase {
372
425
  exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1);
373
426
  }
374
427
  }
375
- search.matches = matches;
376
428
  search.selectedIndex = selectedIndex;
377
429
  search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]) : undefined;
378
430
  search.selectionMode = "retain";
@@ -400,6 +452,12 @@ export class TuiAltScreen extends TuiBase {
400
452
  shouldDeferViewportInputToOverlay() {
401
453
  return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
402
454
  }
455
+ clearComponentMouseGesture() {
456
+ this.mouseCapture = undefined;
457
+ this.mousePressTarget = undefined;
458
+ this.mousePressPoint = undefined;
459
+ this.mousePressMoved = false;
460
+ }
403
461
  handleViewportInput(data) {
404
462
  if (data === FOCUS_OUT) {
405
463
  const hadActiveSelection = this.selectionPressActive;
@@ -407,9 +465,13 @@ export class TuiAltScreen extends TuiBase {
407
465
  this.selectionPressActive = false;
408
466
  this.stopSelectionAutoScroll();
409
467
  this.stopScrollbarHover();
468
+ if (this.activeSearch?.component.setHoveredNavigationDirection(undefined))
469
+ this.requestRender();
410
470
  this.stopScrollbarDrag();
411
471
  this.pressedUrl = undefined;
412
472
  this.selectionDragged = false;
473
+ this.clearComponentMouseGesture();
474
+ this.lastComponentClick = undefined;
413
475
  if (hadActiveSelection) {
414
476
  this.selectionAnchor = undefined;
415
477
  this.selectionFocus = undefined;
@@ -425,6 +487,16 @@ export class TuiAltScreen extends TuiBase {
425
487
  return { consume: true };
426
488
  const wheelEvent = this.parseWheelEvent(data);
427
489
  if (wheelEvent) {
490
+ const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, {
491
+ wheelDelta: wheelEvent.direction * this.getWheelScrollLines(wheelEvent.button),
492
+ });
493
+ const overlay = this.dispatchMouseToOverlay(event);
494
+ const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
495
+ if (result) {
496
+ if (this.applyMouseDispatchResult(event, result))
497
+ this.requestRender();
498
+ return { consume: true };
499
+ }
428
500
  if (this.shouldDeferViewportInputToOverlay())
429
501
  return undefined;
430
502
  this.routeWheel(wheelEvent);
@@ -432,13 +504,7 @@ export class TuiAltScreen extends TuiBase {
432
504
  }
433
505
  const mouseEvent = this.parseSgrMouseEvent(data);
434
506
  if (mouseEvent) {
435
- if (this.handleRightClickPaste(mouseEvent))
436
- return { consume: true };
437
- const handled = this.handleScrollbarMouseEvent(mouseEvent);
438
- if (!this.scrollbarDrag)
439
- this.updateScrollbarHover(mouseEvent.x, mouseEvent.y);
440
- if (!handled)
441
- this.handleSelectionMouseEvent(mouseEvent);
507
+ this.handleMouseEvent(mouseEvent);
442
508
  return { consume: true };
443
509
  }
444
510
  if (this.isMouseSequence(data))
@@ -447,7 +513,7 @@ export class TuiAltScreen extends TuiBase {
447
513
  const isRelease = isKeyRelease(data);
448
514
  if (keybindings.matches(data, "tui.altScreen.search")) {
449
515
  if (!isRelease)
450
- this.openSearch();
516
+ this.toggleSearch();
451
517
  return { consume: true };
452
518
  }
453
519
  if (this.activeSearch?.overlay?.isFocused()) {
@@ -523,6 +589,165 @@ export class TuiAltScreen extends TuiBase {
523
589
  }
524
590
  return undefined;
525
591
  }
592
+ decodeMouseButton(button) {
593
+ switch (button & 3) {
594
+ case 0:
595
+ return "left";
596
+ case 1:
597
+ return "middle";
598
+ case 2:
599
+ return "right";
600
+ default:
601
+ return "none";
602
+ }
603
+ }
604
+ createMouseEvent(type, button, x, y, extra = {}) {
605
+ return {
606
+ type,
607
+ button: type === "wheel" ? "none" : this.decodeMouseButton(button),
608
+ x,
609
+ y,
610
+ screenX: x,
611
+ screenY: y,
612
+ width: Math.max(1, this.terminal.columns),
613
+ height: Math.max(1, this.terminal.rows),
614
+ shift: (button & 4) !== 0,
615
+ alt: (button & 8) !== 0,
616
+ ctrl: (button & 16) !== 0,
617
+ ...(extra.wheelDelta === undefined ? {} : { wheelDelta: extra.wheelDelta }),
618
+ ...(extra.clickCount === undefined ? {} : { clickCount: extra.clickCount }),
619
+ };
620
+ }
621
+ dispatchMouseToLayout(event) {
622
+ if (!this.currentLayout)
623
+ return undefined;
624
+ const visited = new Set();
625
+ const boxes = getLayoutBoxesAt(this.currentLayout, event.screenX, event.screenY);
626
+ for (const box of boxes) {
627
+ if (visited.has(box.component))
628
+ continue;
629
+ if (getLayoutNode(box.component) && box.component.handleMouse === Container.prototype.handleMouse)
630
+ continue;
631
+ visited.add(box.component);
632
+ const result = dispatchMouseEvent(box.component, {
633
+ ...event,
634
+ x: event.screenX - box.rect.x,
635
+ y: event.screenY - box.rect.y,
636
+ width: box.rect.width,
637
+ height: box.rect.height,
638
+ });
639
+ if (result)
640
+ return result;
641
+ }
642
+ return undefined;
643
+ }
644
+ applyMouseDispatchResult(event, result) {
645
+ const focusTarget = this.resolveMouseFocusTarget(result.focusTarget ?? result.target.component);
646
+ const focusChanged = result.focus === true && this.getFocusedComponent() !== focusTarget;
647
+ if (result.focus)
648
+ this.setFocus(focusTarget);
649
+ if (result.capture)
650
+ this.mouseCapture = result.target;
651
+ return (result.render ??
652
+ (focusChanged ||
653
+ event.type === "press" ||
654
+ event.type === "click" ||
655
+ event.type === "drag" ||
656
+ event.type === "wheel"));
657
+ }
658
+ dispatchMouseToTarget(event, target) {
659
+ return dispatchMouseEvent(target.component, retargetMouseEvent(event, target));
660
+ }
661
+ getComponentClickCount(target, x, y) {
662
+ const now = Date.now();
663
+ const previous = this.lastComponentClick;
664
+ const count = previous &&
665
+ now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
666
+ previous.component === target.component &&
667
+ previous.x === x &&
668
+ previous.y === y
669
+ ? (previous.count % 3) + 1
670
+ : 1;
671
+ this.lastComponentClick = { timestamp: now, count, component: target.component, x, y };
672
+ return count;
673
+ }
674
+ clearTextSelection() {
675
+ this.stopSelectionAutoScroll();
676
+ this.selectionPressActive = false;
677
+ this.selectionAnchor = undefined;
678
+ this.selectionFocus = undefined;
679
+ this.selectionGranularity = "character";
680
+ this.selectionInitialRange = undefined;
681
+ this.pressedUrl = undefined;
682
+ this.selectionDragged = false;
683
+ }
684
+ handleMouseEvent(raw) {
685
+ const isMotion = (raw.button & 32) !== 0;
686
+ const type = raw.release
687
+ ? "release"
688
+ : isMotion
689
+ ? this.decodeMouseButton(raw.button) === "none"
690
+ ? "move"
691
+ : "drag"
692
+ : "press";
693
+ const event = this.createMouseEvent(type, raw.button, raw.x, raw.y);
694
+ if (this.mouseCapture || this.mousePressTarget) {
695
+ const target = this.mouseCapture ?? this.mousePressTarget;
696
+ if (this.mousePressPoint && (raw.x !== this.mousePressPoint.x || raw.y !== this.mousePressPoint.y)) {
697
+ this.mousePressMoved = true;
698
+ this.lastComponentClick = undefined;
699
+ }
700
+ let render = false;
701
+ const targetResult = this.dispatchMouseToTarget(event, target);
702
+ if (targetResult)
703
+ render = this.applyMouseDispatchResult(event, targetResult);
704
+ if (raw.release) {
705
+ if (!this.mousePressMoved && this.mousePressPoint?.x === raw.x && this.mousePressPoint.y === raw.y) {
706
+ const clickEvent = this.createMouseEvent("click", raw.button, raw.x, raw.y, {
707
+ clickCount: this.getComponentClickCount(target, raw.x, raw.y),
708
+ });
709
+ const clickResult = this.dispatchMouseToTarget(clickEvent, target);
710
+ if (clickResult)
711
+ render = this.applyMouseDispatchResult(clickEvent, clickResult) || render;
712
+ }
713
+ this.clearComponentMouseGesture();
714
+ }
715
+ if (render)
716
+ this.requestRender();
717
+ return;
718
+ }
719
+ if (this.handleSearchMouseEvent(raw))
720
+ return;
721
+ const overlay = this.dispatchMouseToOverlay(event);
722
+ if (!overlay.hit) {
723
+ if (this.handleScrollToEndIndicatorMouseEvent(raw))
724
+ return;
725
+ const scrollbarHandled = this.handleScrollbarMouseEvent(raw);
726
+ if (!this.scrollbarDrag)
727
+ this.updateScrollbarHover(raw.x, raw.y);
728
+ if (scrollbarHandled)
729
+ return;
730
+ }
731
+ else {
732
+ this.stopScrollbarHover();
733
+ }
734
+ const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
735
+ if (result) {
736
+ const render = this.applyMouseDispatchResult(event, result);
737
+ if (type === "press") {
738
+ this.clearTextSelection();
739
+ this.mousePressTarget = result.target;
740
+ this.mousePressPoint = { x: raw.x, y: raw.y };
741
+ this.mousePressMoved = false;
742
+ }
743
+ if (render)
744
+ this.requestRender();
745
+ return;
746
+ }
747
+ if (this.handleRightClickPaste(raw))
748
+ return;
749
+ this.handleSelectionMouseEvent(raw);
750
+ }
526
751
  parseWheelEvent(data) {
527
752
  const sgr = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
528
753
  if (sgr) {
@@ -536,6 +761,7 @@ export class TuiAltScreen extends TuiBase {
536
761
  direction: direction === 0 ? -1 : 1,
537
762
  x: Number.parseInt(sgr[2], 10) - 1,
538
763
  y: Number.parseInt(sgr[3], 10) - 1,
764
+ button,
539
765
  };
540
766
  }
541
767
  if (data.length === 6 && data.startsWith("\x1b[M")) {
@@ -549,12 +775,17 @@ export class TuiAltScreen extends TuiBase {
549
775
  direction: direction === 0 ? -1 : 1,
550
776
  x: data.charCodeAt(4) - 33,
551
777
  y: data.charCodeAt(5) - 33,
778
+ button,
552
779
  };
553
780
  }
554
781
  return undefined;
555
782
  }
783
+ getWheelScrollLines(button) {
784
+ // SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
785
+ return (button & 8) !== 0 ? this.wheelScrollLines * ALT_WHEEL_SCROLL_MULTIPLIER : this.wheelScrollLines;
786
+ }
556
787
  routeWheel(event) {
557
- let remaining = event.direction * this.wheelScrollLines;
788
+ let remaining = event.direction * this.getWheelScrollLines(event.button);
558
789
  const seen = new Set();
559
790
  for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) {
560
791
  seen.add(scrollView);
@@ -595,16 +826,25 @@ export class TuiAltScreen extends TuiBase {
595
826
  }
596
827
  return true;
597
828
  }
598
- getScrollbarTargetAt(x, y) {
829
+ handleScrollToEndIndicatorMouseEvent(event) {
830
+ const rect = this.scrollToEndIndicatorRect;
831
+ if (!rect || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0)
832
+ return false;
833
+ if (event.y !== rect.row || event.x < rect.column || event.x >= rect.column + rect.width)
834
+ return false;
835
+ this.scrollToBottom();
836
+ return true;
837
+ }
838
+ getScrollbarTargetAt(x, y, includeHiddenAuto = false) {
599
839
  if (this.hasOverlay() || !this.currentLayout)
600
840
  return undefined;
601
841
  for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) {
602
842
  const box = getScrollViewBox(this.currentLayout, scrollView);
603
- const geometry = box ? getScrollbarGeometry(box) : undefined;
843
+ const geometry = box ? getScrollbarGeometry(box, includeHiddenAuto) : undefined;
604
844
  if (geometry &&
605
845
  x === geometry.column &&
606
- y >= geometry.thumbTop &&
607
- y < geometry.thumbTop + geometry.thumbHeight) {
846
+ y >= geometry.trackTop &&
847
+ y < geometry.trackTop + geometry.trackHeight) {
608
848
  return { scrollView, geometry };
609
849
  }
610
850
  }
@@ -618,11 +858,17 @@ export class TuiAltScreen extends TuiBase {
618
858
  this.scrollbarHover?.setScrollbarActive(true);
619
859
  }
620
860
  updateScrollbarHover(x, y) {
621
- this.setScrollbarHover(this.getScrollbarTargetAt(x, y)?.scrollView);
861
+ this.setScrollbarHover(this.getScrollbarTargetAt(x, y, true)?.scrollView);
622
862
  }
623
863
  stopScrollbarHover() {
624
864
  this.setScrollbarHover(undefined);
625
865
  }
866
+ scrollScrollbarToPointer(scrollView, geometry, pointerY, grabOffset) {
867
+ const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
868
+ const thumbOffset = Math.max(0, Math.min(maxThumbOffset, pointerY - geometry.trackTop - grabOffset));
869
+ const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
870
+ scrollView.scrollTo(scrollTop);
871
+ }
626
872
  handleScrollbarMouseEvent(event) {
627
873
  if (this.scrollbarDrag) {
628
874
  if (event.release) {
@@ -634,10 +880,7 @@ export class TuiAltScreen extends TuiBase {
634
880
  : undefined;
635
881
  const geometry = box ? getScrollbarGeometry(box) : undefined;
636
882
  if (geometry) {
637
- const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
638
- const thumbOffset = Math.max(0, Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset));
639
- const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
640
- this.scrollbarDrag.scrollView.scrollTo(scrollTop);
883
+ this.scrollScrollbarToPointer(this.scrollbarDrag.scrollView, geometry, event.y, this.scrollbarDrag.grabOffset);
641
884
  }
642
885
  return true;
643
886
  }
@@ -656,9 +899,13 @@ export class TuiAltScreen extends TuiBase {
656
899
  this.pressedUrl = undefined;
657
900
  this.selectionDragged = false;
658
901
  this.setScrollbarHover(target.scrollView);
902
+ const onThumb = event.y >= target.geometry.thumbTop && event.y < target.geometry.thumbTop + target.geometry.thumbHeight;
903
+ const grabOffset = onThumb ? event.y - target.geometry.thumbTop : Math.floor(target.geometry.thumbHeight / 2);
904
+ if (!onThumb)
905
+ this.scrollScrollbarToPointer(target.scrollView, target.geometry, event.y, grabOffset);
659
906
  this.scrollbarDrag = {
660
907
  scrollView: target.scrollView,
661
- grabOffset: event.y - target.geometry.thumbTop,
908
+ grabOffset,
662
909
  };
663
910
  return true;
664
911
  }
@@ -843,12 +1090,11 @@ export class TuiAltScreen extends TuiBase {
843
1090
  if (!this.selectionAnchor)
844
1091
  return;
845
1092
  this.updateSelectionFocus(point);
846
- const clickedUrl = !this.selectionDragged &&
1093
+ const isClick = !this.selectionDragged &&
847
1094
  this.selectionAnchor.scrollView === point.scrollView &&
848
1095
  this.selectionAnchor.row === point.row &&
849
- this.selectionAnchor.col === point.col
850
- ? this.pressedUrl
851
- : undefined;
1096
+ this.selectionAnchor.col === point.col;
1097
+ const clickedUrl = isClick ? this.pressedUrl : undefined;
852
1098
  this.pressedUrl = undefined;
853
1099
  if (clickedUrl && this.openUrl) {
854
1100
  this.selectionAnchor = undefined;
@@ -862,6 +1108,20 @@ export class TuiAltScreen extends TuiBase {
862
1108
  this.requestRender();
863
1109
  return;
864
1110
  }
1111
+ if (isClick) {
1112
+ const clickEvent = this.createMouseEvent("click", event.button, event.x, event.y, {
1113
+ clickCount: this.lastClick?.count ?? 1,
1114
+ });
1115
+ const overlay = this.dispatchMouseToOverlay(clickEvent);
1116
+ const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(clickEvent));
1117
+ if (result) {
1118
+ const render = this.applyMouseDispatchResult(clickEvent, result);
1119
+ this.clearTextSelection();
1120
+ if (render)
1121
+ this.requestRender();
1122
+ return;
1123
+ }
1124
+ }
865
1125
  if (this.copyOnSelect)
866
1126
  void this.copySelectionToClipboard();
867
1127
  this.requestRender();
@@ -1003,8 +1263,24 @@ export class TuiAltScreen extends TuiBase {
1003
1263
  const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height);
1004
1264
  const minColumn = Math.max(0, box.rect.x, box.clip.x);
1005
1265
  const maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width, scrollbarColumn ?? Number.POSITIVE_INFINITY);
1006
- for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) {
1007
- for (const segment of search.matches[matchIndex].segments) {
1266
+ const minContentRow = scrollView.scrollTop + minRow - box.rect.y;
1267
+ const maxContentRow = scrollView.scrollTop + maxRow - box.rect.y - 1;
1268
+ let low = 0;
1269
+ let high = search.matches.length;
1270
+ while (low < high) {
1271
+ const middle = low + Math.floor((high - low) / 2);
1272
+ const match = search.matches[middle];
1273
+ const lastRow = match.segments[match.segments.length - 1]?.row ?? -1;
1274
+ if (lastRow < minContentRow)
1275
+ low = middle + 1;
1276
+ else
1277
+ high = middle;
1278
+ }
1279
+ for (let matchIndex = low; matchIndex < search.matches.length; matchIndex++) {
1280
+ const match = search.matches[matchIndex];
1281
+ if ((match.segments[0]?.row ?? 0) > maxContentRow)
1282
+ break;
1283
+ for (const segment of match.segments) {
1008
1284
  const row = box.rect.y + segment.row - scrollView.scrollTop;
1009
1285
  if (row < minRow || row >= maxRow)
1010
1286
  continue;
@@ -1107,6 +1383,30 @@ export class TuiAltScreen extends TuiBase {
1107
1383
  isMouseSequence(data) {
1108
1384
  return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) || (data.length === 6 && data.startsWith("\x1b[M"));
1109
1385
  }
1386
+ compositeScrollToEndIndicator(screen, layout, width) {
1387
+ this.scrollToEndIndicatorRect = undefined;
1388
+ const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
1389
+ if (!this.scrollToEndIndicator || !scrollView.followEnd || scrollView.isFollowingEnd)
1390
+ return screen;
1391
+ const box = getScrollViewBox(layout, scrollView);
1392
+ const clip = box?.clip;
1393
+ if (!clip || clip.width <= 0 || clip.height <= 0)
1394
+ return screen;
1395
+ const row = clip.y + clip.height - 1;
1396
+ if (row >= screen.length || isImageLine(screen[row] ?? ""))
1397
+ return screen;
1398
+ const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
1399
+ const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
1400
+ const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
1401
+ const textWidth = visibleWidth(text);
1402
+ if (textWidth === 0)
1403
+ return screen;
1404
+ const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
1405
+ const result = [...screen];
1406
+ result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
1407
+ this.scrollToEndIndicatorRect = { row, column, width: textWidth };
1408
+ return result;
1409
+ }
1110
1410
  compositeFlashes(screen, width, height) {
1111
1411
  const flashLines = this.flashes.render(width).slice(-height);
1112
1412
  if (flashLines.length === 0)
@@ -1135,6 +1435,7 @@ export class TuiAltScreen extends TuiBase {
1135
1435
  }
1136
1436
  let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
1137
1437
  screen = this.applySearchHighlights(screen, nextLayout);
1438
+ screen = this.compositeScrollToEndIndicator(screen, nextLayout, width);
1138
1439
  screen = this.compositeOverlays(screen, width, height);
1139
1440
  if (screen.length > height)
1140
1441
  screen = screen.slice(screen.length - height);