forked from 777genius/claude-code-source-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathselection.ts
More file actions
918 lines (868 loc) · 34.1 KB
/
Copy pathselection.ts
File metadata and controls
918 lines (868 loc) · 34.1 KB
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
/**
* Text selection state for fullscreen mode.
*
* Tracks a linear selection in screen-buffer coordinates (0-indexed col/row).
* Selection is line-based: cells from (startCol, startRow) through
* (endCol, endRow) inclusive, wrapping across line boundaries. This matches
* terminal-native selection behavior (not rectangular/block).
*
* The selection is stored as ANCHOR (where the drag started) + FOCUS (where
* the cursor is now). The rendered highlight normalizes to start ≤ end.
*/
import { clamp } from './layout/geometry.js'
import type { Screen, StylePool } from './screen.js'
import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js'
type Point = { col: number; row: number }
export type SelectionState = {
/** Where the mouse-down occurred. Null when no selection. */
anchor: Point | null
/** Current drag position (updated on mouse-move while dragging). */
focus: Point | null
/** True between mouse-down and mouse-up. */
isDragging: boolean
/** For word/line mode: the initial word/line bounds from the first
* multi-click. Drag extends from this span to the word/line at the
* current mouse position so the original word/line stays selected
* even when dragging backward past it. Null ⇔ char mode. The kind
* tells extendSelection whether to snap to word or line boundaries. */
anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
/** Text from rows that scrolled out ABOVE the viewport during
* drag-to-scroll. The screen buffer only holds the current viewport,
* so without this accumulator, dragging down past the bottom edge
* loses the top of the selection once the anchor clamps. Prepended
* to the on-screen text by getSelectedText. Reset on start/clear. */
scrolledOffAbove: string[]
/** Symmetric: rows scrolled out BELOW when dragging up. Appended. */
scrolledOffBelow: string[]
/** Soft-wrap bits parallel to scrolledOffAbove — true means the row
* is a continuation of the one before it (the `\n` was inserted by
* word-wrap, not in the source). Captured alongside the text at
* scroll time since the screen's softWrap bitmap shifts with content.
* getSelectedText uses these to join wrapped rows back into logical
* lines. */
scrolledOffAboveSW: boolean[]
/** Parallel to scrolledOffBelow. */
scrolledOffBelowSW: boolean[]
/** Pre-clamp anchor row. Set when shiftSelection clamps anchor so a
* reverse scroll can restore the true position and pop accumulators.
* Without this, PgDn (clamps anchor) → PgUp leaves anchor at the wrong
* row AND scrolledOffAbove stale — highlight ≠ copy. Undefined when
* anchor is in-bounds (no clamp debt). Cleared on start/clear. */
virtualAnchorRow?: number
/** Same for focus. */
virtualFocusRow?: number
/** True if the mouse-down that started this selection had the alt
* modifier set (SGR button bit 0x08). On macOS xterm.js this is a
* signal that VS Code's macOptionClickForcesSelection is OFF — if it
* were on, xterm.js would have consumed the event for native selection
* and we'd never receive it. Used by the footer to show the right hint. */
lastPressHadAlt: boolean
}
export function createSelectionState(): SelectionState {
return {
anchor: null,
focus: null,
isDragging: false,
anchorSpan: null,
scrolledOffAbove: [],
scrolledOffBelow: [],
scrolledOffAboveSW: [],
scrolledOffBelowSW: [],
lastPressHadAlt: false,
}
}
export function startSelection(
s: SelectionState,
col: number,
row: number,
): void {
s.anchor = { col, row }
// Focus is not set until the first drag motion. A click-release with no
// drag leaves focus null → hasSelection/selectionBounds return false/null
// via the `!s.focus` check, so a bare click never highlights a cell.
s.focus = null
s.isDragging = true
s.anchorSpan = null
s.scrolledOffAbove = []
s.scrolledOffBelow = []
s.scrolledOffAboveSW = []
s.scrolledOffBelowSW = []
s.virtualAnchorRow = undefined
s.virtualFocusRow = undefined
s.lastPressHadAlt = false
}
export function updateSelection(
s: SelectionState,
col: number,
row: number,
): void {
if (!s.isDragging) return
// First motion at the same cell as anchor is a no-op. Terminals in mode
// 1002 can fire a drag event at the anchor cell (sub-pixel tremor, or a
// motion-release pair). Setting focus here would turn a bare click into
// a 1-cell selection and clobber the clipboard via useCopyOnSelect. Once
// focus is set (real drag), we track normally including back to anchor.
if (!s.focus && s.anchor && s.anchor.col === col && s.anchor.row === row)
return
s.focus = { col, row }
}
export function finishSelection(s: SelectionState): void {
s.isDragging = false
// Keep anchor/focus so highlight stays visible and text can be copied.
// Clear via clearSelection() on Esc or after copy.
}
export function clearSelection(s: SelectionState): void {
s.anchor = null
s.focus = null
s.isDragging = false
s.anchorSpan = null
s.scrolledOffAbove = []
s.scrolledOffBelow = []
s.scrolledOffAboveSW = []
s.scrolledOffBelowSW = []
s.virtualAnchorRow = undefined
s.virtualFocusRow = undefined
s.lastPressHadAlt = false
}
// Unicode-aware word character matcher: letters (any script), digits,
// and the punctuation set iTerm2 treats as word-part by default.
// Matching iTerm2's default means double-clicking a path like
// `/usr/bin/bash` or `~/.claude/config.json` selects the whole thing,
// which is the muscle memory most macOS terminal users have.
// iTerm2 default "characters considered part of a word": /-+\~_.
const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u
/**
* Character class for double-click word-expansion. Cells with the same
* class as the clicked cell are included in the selection; a class change
* is a boundary. Matches typical terminal-emulator behavior (iTerm2 etc.):
* double-click on `foo` selects `foo`, on `->` selects `->`, on spaces
* selects the whitespace run.
*/
function charClass(c: string): 0 | 1 | 2 {
if (c === ' ' || c === '') return 0
if (WORD_CHAR.test(c)) return 1
return 2
}
/**
* Find the bounds of the same-class character run at (col, row). Returns
* null if the click is out of bounds or lands on a noSelect cell. Used by
* selectWordAt (initial double-click) and extendWordSelection (drag).
*/
function wordBoundsAt(
screen: Screen,
col: number,
row: number,
): { lo: number; hi: number } | null {
if (row < 0 || row >= screen.height) return null
const width = screen.width
const noSelect = screen.noSelect
const rowOff = row * width
// If the click landed on the spacer tail of a wide char, step back to
// the head so the class check sees the actual grapheme.
let c = col
if (c > 0) {
const cell = cellAt(screen, c, row)
if (cell && cell.width === CellWidth.SpacerTail) c -= 1
}
if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return null
const startCell = cellAt(screen, c, row)
if (!startCell) return null
const cls = charClass(startCell.char)
// Expand left: include cells of the same class, stop at noSelect or
// class change. SpacerTail cells are stepped over (the wide-char head
// at the preceding column determines the class).
let lo = c
while (lo > 0) {
const prev = lo - 1
if (noSelect[rowOff + prev] === 1) break
const pc = cellAt(screen, prev, row)
if (!pc) break
if (pc.width === CellWidth.SpacerTail) {
// Step over the spacer to the wide-char head
if (prev === 0 || noSelect[rowOff + prev - 1] === 1) break
const head = cellAt(screen, prev - 1, row)
if (!head || charClass(head.char) !== cls) break
lo = prev - 1
continue
}
if (charClass(pc.char) !== cls) break
lo = prev
}
// Expand right: same logic, skipping spacer tails.
let hi = c
while (hi < width - 1) {
const next = hi + 1
if (noSelect[rowOff + next] === 1) break
const nc = cellAt(screen, next, row)
if (!nc) break
if (nc.width === CellWidth.SpacerTail) {
// Include the spacer tail in the selection range (it belongs to
// the wide char at hi) and continue past it.
hi = next
continue
}
if (charClass(nc.char) !== cls) break
hi = next
}
return { lo, hi }
}
/** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */
function comparePoints(a: Point, b: Point): number {
if (a.row !== b.row) return a.row < b.row ? -1 : 1
if (a.col !== b.col) return a.col < b.col ? -1 : 1
return 0
}
/**
* Select the word at (col, row) by scanning the screen buffer for the
* bounds of the same-class character run. Mutates the selection in place.
* No-op if the click is out of bounds or lands on a noSelect cell.
* Sets isDragging=true and anchorSpan so a subsequent drag extends the
* selection word-by-word (native macOS behavior).
*/
export function selectWordAt(
s: SelectionState,
screen: Screen,
col: number,
row: number,
): void {
const b = wordBoundsAt(screen, col, row)
if (!b) return
const lo = { col: b.lo, row }
const hi = { col: b.hi, row }
s.anchor = lo
s.focus = hi
s.isDragging = true
s.anchorSpan = { lo, hi, kind: 'word' }
}
// Printable ASCII minus terminal URL delimiters. Restricting to single-
// codeunit ASCII keeps cell-count === string-index, so the column-span
// check below is exact (no wide-char/grapheme drift).
const URL_BOUNDARY = new Set([...'<>"\'` '])
function isUrlChar(c: string): boolean {
if (c.length !== 1) return false
const code = c.charCodeAt(0)
return code >= 0x21 && code <= 0x7e && !URL_BOUNDARY.has(c)
}
/**
* Scan the screen buffer for a plain-text URL at (col, row). Mirrors the
* terminal's native Cmd+Click URL detection, which fullscreen mode's mouse
* tracking intercepts. Called from getHyperlinkAt as a fallback when the
* cell has no OSC 8 hyperlink.
*/
export function findPlainTextUrlAt(
screen: Screen,
col: number,
row: number,
): string | undefined {
if (row < 0 || row >= screen.height) return undefined
const width = screen.width
const noSelect = screen.noSelect
const rowOff = row * width
let c = col
if (c > 0) {
const cell = cellAt(screen, c, row)
if (cell && cell.width === CellWidth.SpacerTail) c -= 1
}
if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return undefined
const startCell = cellAt(screen, c, row)
if (!startCell || !isUrlChar(startCell.char)) return undefined
// Expand left/right to the bounds of the URL-char run. URLs are ASCII
// (CellWidth.Narrow, 1 codeunit), so hitting a non-ASCII/wide/spacer
// cell is a boundary — no need to step over spacers like wordBoundsAt.
let lo = c
while (lo > 0) {
const prev = lo - 1
if (noSelect[rowOff + prev] === 1) break
const pc = cellAt(screen, prev, row)
if (!pc || pc.width !== CellWidth.Narrow || !isUrlChar(pc.char)) break
lo = prev
}
let hi = c
while (hi < width - 1) {
const next = hi + 1
if (noSelect[rowOff + next] === 1) break
const nc = cellAt(screen, next, row)
if (!nc || nc.width !== CellWidth.Narrow || !isUrlChar(nc.char)) break
hi = next
}
let token = ''
for (let i = lo; i <= hi; i++) token += cellAt(screen, i, row)!.char
// 1 cell = 1 char across [lo, hi] (ASCII-only run), so string index =
// column offset. Find the last scheme anchor at or before the click —
// a run like `https://a.com,https://b.com` has two, and clicking the
// second should return the second URL, not the greedy match of both.
const clickIdx = c - lo
const schemeRe = /(?:https?|file):\/\//g
let urlStart = -1
let urlEnd = token.length
for (let m; (m = schemeRe.exec(token)); ) {
if (m.index > clickIdx) {
urlEnd = m.index
break
}
urlStart = m.index
}
if (urlStart < 0) return undefined
let url = token.slice(urlStart, urlEnd)
// Strip trailing sentence punctuation. For closers () ] }, only strip
// if unbalanced — `/wiki/Foo_(bar)` keeps `)`, `/arr[0]` keeps `]`.
const OPENER: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
while (url.length > 0) {
const last = url.at(-1)!
if ('.,;:!?'.includes(last)) {
url = url.slice(0, -1)
continue
}
const opener = OPENER[last]
if (!opener) break
let opens = 0
let closes = 0
for (let i = 0; i < url.length; i++) {
const ch = url.charAt(i)
if (ch === opener) opens++
else if (ch === last) closes++
}
if (closes > opens) url = url.slice(0, -1)
else break
}
// urlStart already guarantees click >= URL start; check right edge.
if (clickIdx >= urlStart + url.length) return undefined
return url
}
/**
* Select the entire row. Sets isDragging=true and anchorSpan so a
* subsequent drag extends the selection line-by-line. The anchor/focus
* span from col 0 to width-1; getSelectedText handles noSelect skipping
* and trailing-whitespace trimming so the copied text is just the visible
* line content.
*/
export function selectLineAt(
s: SelectionState,
screen: Screen,
row: number,
): void {
if (row < 0 || row >= screen.height) return
const lo = { col: 0, row }
const hi = { col: screen.width - 1, row }
s.anchor = lo
s.focus = hi
s.isDragging = true
s.anchorSpan = { lo, hi, kind: 'line' }
}
/**
* Extend a word/line-mode selection to the word/line at (col, row). The
* anchor span (the original multi-clicked word/line) stays selected; the
* selection grows from that span to the word/line at the current mouse
* position. Word mode falls back to the raw cell when the mouse is over a
* noSelect cell or out of bounds, so dragging into gutters still extends.
*/
export function extendSelection(
s: SelectionState,
screen: Screen,
col: number,
row: number,
): void {
if (!s.isDragging || !s.anchorSpan) return
const span = s.anchorSpan
let mLo: Point
let mHi: Point
if (span.kind === 'word') {
const b = wordBoundsAt(screen, col, row)
mLo = { col: b ? b.lo : col, row }
mHi = { col: b ? b.hi : col, row }
} else {
const r = clamp(row, 0, screen.height - 1)
mLo = { col: 0, row: r }
mHi = { col: screen.width - 1, row: r }
}
if (comparePoints(mHi, span.lo) < 0) {
// Mouse target ends before anchor span: extend backward.
s.anchor = span.hi
s.focus = mLo
} else if (comparePoints(mLo, span.hi) > 0) {
// Mouse target starts after anchor span: extend forward.
s.anchor = span.lo
s.focus = mHi
} else {
// Mouse overlaps the anchor span: just select the anchor span.
s.anchor = span.lo
s.focus = span.hi
}
}
/** Semantic keyboard focus moves. See moveSelectionFocus in ink.tsx for
* how screen bounds + row-wrap are applied. */
export type FocusMove =
| 'left'
| 'right'
| 'up'
| 'down'
| 'lineStart'
| 'lineEnd'
/**
* Set focus to (col, row) for keyboard selection extension (shift+arrow).
* Anchor stays fixed; selection grows or shrinks depending on where focus
* moves relative to anchor. Drops to char mode (clears anchorSpan) —
* native macOS does this too: shift+arrow after a double-click word-select
* extends char-by-char from the word edge, not word-by-word. Scrolled-off
* accumulators are preserved: keyboard-extending a drag-scrolled selection
* keeps the off-screen rows. Caller supplies coords already clamped/wrapped.
*/
export function moveFocus(s: SelectionState, col: number, row: number): void {
if (!s.focus) return
s.anchorSpan = null
s.focus = { col, row }
// Explicit user repositioning — any stale virtual focus (from a prior
// shiftSelection clamp) no longer reflects intent. Anchor stays put so
// virtualAnchorRow is still valid for its own round-trip.
s.virtualFocusRow = undefined
}
/**
* Shift anchor AND focus by dRow, clamped to [minRow, maxRow]. Used for
* keyboard scroll (PgUp/PgDn/ctrl+u/d/b/f): the whole selection must track
* the content, unlike drag-to-scroll where focus stays at the mouse. Any
* point that hits a clamp bound gets its col reset to the full-width edge —
* its original content scrolled off-screen and was captured by
* captureScrolledRows, so the col constraint was already consumed. Keeping
* it would truncate the NEW content now at that screen row. Clamp col is 0
* for dRow<0 (scrolling down, top leaves, 'above' semantics) or width-1 for
* dRow>0 (scrolling up, bottom leaves, 'below' semantics).
*
* If both ends overshoot the SAME viewport edge (select text → Home/End/g/G
* jumps far enough that both are out of view), clear — otherwise both clamp
* to the same corner cell and a ghost 1-cell highlight lingers, and
* getSelectedText returns one unrelated char from that corner. Symmetric
* with shiftSelectionForFollow's top-edge check, but bidirectional: keyboard
* scroll can jump either way.
*/
export function shiftSelection(
s: SelectionState,
dRow: number,
minRow: number,
maxRow: number,
width: number,
): void {
if (!s.anchor || !s.focus) return
// Virtual rows track pre-clamp positions so reverse scrolls restore
// correctly. Without this, clamp(5→0) + shift(+10) = 10, not the true 5,
// and scrolledOffAbove stays stale (highlight ≠ copy).
const vAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow
const vFocus = (s.virtualFocusRow ?? s.focus.row) + dRow
if (
(vAnchor < minRow && vFocus < minRow) ||
(vAnchor > maxRow && vFocus > maxRow)
) {
clearSelection(s)
return
}
// Debt = how far the nearer endpoint overshoots each edge. When debt
// shrinks (reverse scroll), those rows are back on-screen — pop from
// the accumulator so getSelectedText doesn't double-count them.
const oldMin = Math.min(
s.virtualAnchorRow ?? s.anchor.row,
s.virtualFocusRow ?? s.focus.row,
)
const oldMax = Math.max(
s.virtualAnchorRow ?? s.anchor.row,
s.virtualFocusRow ?? s.focus.row,
)
const oldAboveDebt = Math.max(0, minRow - oldMin)
const oldBelowDebt = Math.max(0, oldMax - maxRow)
const newAboveDebt = Math.max(0, minRow - Math.min(vAnchor, vFocus))
const newBelowDebt = Math.max(0, Math.max(vAnchor, vFocus) - maxRow)
if (newAboveDebt < oldAboveDebt) {
// scrolledOffAbove pushes newest at the end (closest to on-screen).
const drop = oldAboveDebt - newAboveDebt
s.scrolledOffAbove.length -= drop
s.scrolledOffAboveSW.length = s.scrolledOffAbove.length
}
if (newBelowDebt < oldBelowDebt) {
// scrolledOffBelow unshifts newest at the front (closest to on-screen).
const drop = oldBelowDebt - newBelowDebt
s.scrolledOffBelow.splice(0, drop)
s.scrolledOffBelowSW.splice(0, drop)
}
// Invariant: accumulator length ≤ debt. If the accumulator exceeds debt,
// the excess is stale — e.g., moveFocus cleared virtualFocusRow without
// trimming the accumulator, orphaning entries the pop above can never
// reach because oldDebt was ALREADY 0. Truncate to debt (keeping the
// newest = closest-to-on-screen entries). Check newDebt (not oldDebt):
// captureScrolledRows runs BEFORE this shift in the real flow (ink.tsx),
// so at entry the accumulator is populated but oldDebt is still 0 —
// that's the normal establish-debt path, not stale.
if (s.scrolledOffAbove.length > newAboveDebt) {
// Above pushes newest at END → keep END.
s.scrolledOffAbove =
newAboveDebt > 0 ? s.scrolledOffAbove.slice(-newAboveDebt) : []
s.scrolledOffAboveSW =
newAboveDebt > 0 ? s.scrolledOffAboveSW.slice(-newAboveDebt) : []
}
if (s.scrolledOffBelow.length > newBelowDebt) {
// Below unshifts newest at FRONT → keep FRONT.
s.scrolledOffBelow = s.scrolledOffBelow.slice(0, newBelowDebt)
s.scrolledOffBelowSW = s.scrolledOffBelowSW.slice(0, newBelowDebt)
}
// Clamp col depends on which EDGE (not dRow direction): virtual tracking
// means a top-clamped point can stay top-clamped during a dRow>0 reverse
// shift — dRow-based clampCol would give it the bottom col.
const shift = (p: Point, vRow: number): Point => {
if (vRow < minRow) return { col: 0, row: minRow }
if (vRow > maxRow) return { col: width - 1, row: maxRow }
return { col: p.col, row: vRow }
}
s.anchor = shift(s.anchor, vAnchor)
s.focus = shift(s.focus, vFocus)
s.virtualAnchorRow =
vAnchor < minRow || vAnchor > maxRow ? vAnchor : undefined
s.virtualFocusRow = vFocus < minRow || vFocus > maxRow ? vFocus : undefined
// anchorSpan not virtual-tracked: it's for word/line extend-on-drag,
// irrelevant to the keyboard-scroll round-trip case.
if (s.anchorSpan) {
const sp = (p: Point): Point => {
const r = p.row + dRow
if (r < minRow) return { col: 0, row: minRow }
if (r > maxRow) return { col: width - 1, row: maxRow }
return { col: p.col, row: r }
}
s.anchorSpan = {
lo: sp(s.anchorSpan.lo),
hi: sp(s.anchorSpan.hi),
kind: s.anchorSpan.kind,
}
}
}
/**
* Shift the anchor row by dRow, clamped to [minRow, maxRow]. Used during
* drag-to-scroll: when the ScrollBox scrolls by N rows, the content that
* was under the anchor is now at a different viewport row, so the anchor
* must follow it. Focus is left unchanged (it stays at the mouse position).
*/
export function shiftAnchor(
s: SelectionState,
dRow: number,
minRow: number,
maxRow: number,
): void {
if (!s.anchor) return
// Same virtual-row tracking as shiftSelection/shiftSelectionForFollow: the
// drag→follow transition hands off to shiftSelectionForFollow, which reads
// (virtualAnchorRow ?? anchor.row). Without this, drag-phase clamping
// leaves virtual undefined → follow initializes from the already-clamped
// row, under-counting total drift → shiftSelection's invariant-restore
// prematurely clears valid drag-phase accumulator entries.
const raw = (s.virtualAnchorRow ?? s.anchor.row) + dRow
s.anchor = { col: s.anchor.col, row: clamp(raw, minRow, maxRow) }
s.virtualAnchorRow = raw < minRow || raw > maxRow ? raw : undefined
// anchorSpan not virtual-tracked (word/line extend, irrelevant to
// keyboard-scroll round-trip) — plain clamp from current row.
if (s.anchorSpan) {
const shift = (p: Point): Point => ({
col: p.col,
row: clamp(p.row + dRow, minRow, maxRow),
})
s.anchorSpan = {
lo: shift(s.anchorSpan.lo),
hi: shift(s.anchorSpan.hi),
kind: s.anchorSpan.kind,
}
}
}
/**
* Shift the whole selection (anchor + focus + anchorSpan) by dRow, clamped
* to [minRow, maxRow]. Used when sticky/auto-follow scrolls the ScrollBox
* while a selection is active — native terminal behavior is for the
* highlight to walk up the screen with the text (not stay at the same
* screen position).
*
* Differs from shiftAnchor: during drag-to-scroll, focus tracks the live
* mouse position and only anchor follows the text. During streaming-follow,
* the selection is text-anchored at both ends — both must move. The
* isDragging check in ink.tsx picks which shift to apply.
*
* If both ends would shift strictly BELOW minRow (unclamped), the selected
* text has scrolled entirely off the top. Clear it — otherwise a single
* inverted cell lingers at the viewport top as a ghost (native terminals
* drop the selection when it leaves scrollback). Landing AT minRow is
* still valid: that cell holds the correct text. Returns true if the
* selection was cleared so the caller can notify React-land subscribers
* (useHasSelection) — the caller is inside onRender so it can't use
* notifySelectionChange (recursion), must fire listeners directly.
*/
export function shiftSelectionForFollow(
s: SelectionState,
dRow: number,
minRow: number,
maxRow: number,
): boolean {
if (!s.anchor) return false
// Mirror shiftSelection: compute raw (unclamped) positions from virtual
// if set, else current. This handles BOTH the update path (virtual already
// set from a prior keyboard scroll) AND the initialize path (first clamp
// happens HERE via follow-scroll, no prior keyboard scroll). Without the
// initialize path, follow-scroll-first leaves virtual undefined even
// though the clamp below occurred → a later PgUp computes debt from the
// clamped row instead of the true pre-clamp row and never pops the
// accumulator — getSelectedText double-counts the off-screen rows.
const rawAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow
const rawFocus = s.focus
? (s.virtualFocusRow ?? s.focus.row) + dRow
: undefined
if (rawAnchor < minRow && rawFocus !== undefined && rawFocus < minRow) {
clearSelection(s)
return true
}
// Clamp from raw, not p.row+dRow — so a virtual position coming back
// in-bounds lands at the TRUE position, not the stale clamped one.
s.anchor = { col: s.anchor.col, row: clamp(rawAnchor, minRow, maxRow) }
if (s.focus && rawFocus !== undefined) {
s.focus = { col: s.focus.col, row: clamp(rawFocus, minRow, maxRow) }
}
s.virtualAnchorRow =
rawAnchor < minRow || rawAnchor > maxRow ? rawAnchor : undefined
s.virtualFocusRow =
rawFocus !== undefined && (rawFocus < minRow || rawFocus > maxRow)
? rawFocus
: undefined
// anchorSpan not virtual-tracked (word/line extend, irrelevant to
// keyboard-scroll round-trip) — plain clamp from current row.
if (s.anchorSpan) {
const shift = (p: Point): Point => ({
col: p.col,
row: clamp(p.row + dRow, minRow, maxRow),
})
s.anchorSpan = {
lo: shift(s.anchorSpan.lo),
hi: shift(s.anchorSpan.hi),
kind: s.anchorSpan.kind,
}
}
return false
}
export function hasSelection(s: SelectionState): boolean {
return s.anchor !== null && s.focus !== null
}
/**
* Normalized selection bounds: start is always before end in reading order.
* Returns null if no active selection.
*/
export function selectionBounds(s: SelectionState): {
start: { col: number; row: number }
end: { col: number; row: number }
} | null {
if (!s.anchor || !s.focus) return null
return comparePoints(s.anchor, s.focus) <= 0
? { start: s.anchor, end: s.focus }
: { start: s.focus, end: s.anchor }
}
/**
* Check if a cell at (col, row) is within the current selection range.
* Used by the renderer to apply inverse style.
*/
export function isCellSelected(
s: SelectionState,
col: number,
row: number,
): boolean {
const b = selectionBounds(s)
if (!b) return false
const { start, end } = b
if (row < start.row || row > end.row) return false
if (row === start.row && col < start.col) return false
if (row === end.row && col > end.col) return false
return true
}
/** Extract text from one screen row. When the next row is a soft-wrap
* continuation (screen.softWrap[row+1]>0), clamp to that content-end
* column and skip the trailing trim so the word-separator space survives
* the join. See Screen.softWrap for why the clamp is necessary. */
function extractRowText(
screen: Screen,
row: number,
colStart: number,
colEnd: number,
): string {
const noSelect = screen.noSelect
const rowOff = row * screen.width
const contentEnd = row + 1 < screen.height ? screen.softWrap[row + 1]! : 0
const lastCol = contentEnd > 0 ? Math.min(colEnd, contentEnd - 1) : colEnd
let line = ''
for (let col = colStart; col <= lastCol; col++) {
// Skip cells marked noSelect (gutters, line numbers, diff sigils).
// Check before cellAt to avoid the decode cost for excluded cells.
if (noSelect[rowOff + col] === 1) continue
const cell = cellAt(screen, col, row)
if (!cell) continue
// Skip spacer tails (second half of wide chars) — the head already
// contains the full grapheme. SpacerHead is a blank at line-end.
if (
cell.width === CellWidth.SpacerTail ||
cell.width === CellWidth.SpacerHead
) {
continue
}
line += cell.char
}
return contentEnd > 0 ? line : line.replace(/\s+$/, '')
}
/** Accumulator for selected text that merges soft-wrapped rows back
* into logical lines. push(text, sw) appends a newline before text
* only when sw=false (i.e. the row starts a new logical line). Rows
* with sw=true are concatenated onto the previous row. */
function joinRows(
lines: string[],
text: string,
sw: boolean | undefined,
): void {
if (sw && lines.length > 0) {
lines[lines.length - 1] += text
} else {
lines.push(text)
}
}
/**
* Extract text from the screen buffer within the selection range.
* Rows are joined with newlines unless the screen's softWrap bitmap
* marks a row as a word-wrap continuation — those rows are concatenated
* onto the previous row so the copied text matches the logical source
* line, not the visual wrapped layout. Trailing whitespace on the last
* fragment of each logical line is trimmed. Wide-char spacer cells are
* skipped. Rows that scrolled out of the viewport during drag-to-scroll
* are joined back in from the scrolledOffAbove/Below accumulators along
* with their captured softWrap bits.
*/
export function getSelectedText(s: SelectionState, screen: Screen): string {
const b = selectionBounds(s)
if (!b) return ''
const { start, end } = b
const sw = screen.softWrap
const lines: string[] = []
for (let i = 0; i < s.scrolledOffAbove.length; i++) {
joinRows(lines, s.scrolledOffAbove[i]!, s.scrolledOffAboveSW[i])
}
for (let row = start.row; row <= end.row; row++) {
const rowStart = row === start.row ? start.col : 0
const rowEnd = row === end.row ? end.col : screen.width - 1
joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0)
}
for (let i = 0; i < s.scrolledOffBelow.length; i++) {
joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i])
}
return lines.join('\n')
}
/**
* Capture text from rows about to scroll out of the viewport during
* drag-to-scroll, BEFORE scrollBy overwrites them. Only the rows that
* intersect the selection are captured, using the selection's col bounds
* for the anchor-side boundary row. After capturing the anchor row, the
* anchor.col AND anchorSpan cols are reset to the full-width boundary so
* subsequent captures and the final getSelectedText don't re-apply a stale
* col constraint to content that's no longer under the original anchor.
* Both span cols are reset (not just the near side): after a blocked
* reversal the drag can flip direction, and extendSelection then reads the
* OPPOSITE span side — which would otherwise still hold the original word
* boundary and truncate one subsequently-captured row.
*
* side='above': rows scrolling out the top (dragging down, anchor=start).
* side='below': rows scrolling out the bottom (dragging up, anchor=end).
*/
export function captureScrolledRows(
s: SelectionState,
screen: Screen,
firstRow: number,
lastRow: number,
side: 'above' | 'below',
): void {
const b = selectionBounds(s)
if (!b || firstRow > lastRow) return
const { start, end } = b
// Intersect [firstRow, lastRow] with [start.row, end.row]. Rows outside
// the selection aren't captured — they weren't selected.
const lo = Math.max(firstRow, start.row)
const hi = Math.min(lastRow, end.row)
if (lo > hi) return
const width = screen.width
const sw = screen.softWrap
const captured: string[] = []
const capturedSW: boolean[] = []
for (let row = lo; row <= hi; row++) {
const colStart = row === start.row ? start.col : 0
const colEnd = row === end.row ? end.col : width - 1
captured.push(extractRowText(screen, row, colStart, colEnd))
capturedSW.push(sw[row]! > 0)
}
if (side === 'above') {
// Newest rows go at the bottom of the above-accumulator (closest to
// the on-screen content in reading order).
s.scrolledOffAbove.push(...captured)
s.scrolledOffAboveSW.push(...capturedSW)
// We just captured the top of the selection. The anchor (=start when
// dragging down) is now pointing at content that will scroll out; its
// col constraint was applied to the captured row. Reset to col 0 so
// the NEXT tick and the final getSelectedText read the full row.
if (s.anchor && s.anchor.row === start.row && lo === start.row) {
s.anchor = { col: 0, row: s.anchor.row }
if (s.anchorSpan) {
s.anchorSpan = {
kind: s.anchorSpan.kind,
lo: { col: 0, row: s.anchorSpan.lo.row },
hi: { col: width - 1, row: s.anchorSpan.hi.row },
}
}
}
} else {
// Newest rows go at the TOP of the below-accumulator — they're
// closest to the on-screen content.
s.scrolledOffBelow.unshift(...captured)
s.scrolledOffBelowSW.unshift(...capturedSW)
if (s.anchor && s.anchor.row === end.row && hi === end.row) {
s.anchor = { col: width - 1, row: s.anchor.row }
if (s.anchorSpan) {
s.anchorSpan = {
kind: s.anchorSpan.kind,
lo: { col: 0, row: s.anchorSpan.lo.row },
hi: { col: width - 1, row: s.anchorSpan.hi.row },
}
}
}
}
}
/**
* Apply the selection overlay directly to the screen buffer by changing
* the style of every cell in the selection range. Called after the
* renderer produces the Frame but before the diff — the normal diffEach
* then picks up the restyled cells as ordinary changes, so LogUpdate
* stays a pure diff engine with no selection awareness.
*
* Uses a SOLID selection background (theme-provided via StylePool.
* setSelectionBg) that REPLACES each cell's bg while PRESERVING its fg —
* matches native terminal selection. Previously SGR-7 inverse (swapped
* fg/bg per cell), which fragmented badly over syntax-highlighted text:
* every distinct fg color became a different bg stripe.
*
* Uses StylePool caches so on drag the only work per cell is a Map
* lookup + packed-int write.
*/
export function applySelectionOverlay(
screen: Screen,
selection: SelectionState,
stylePool: StylePool,
): void {
const b = selectionBounds(selection)
if (!b) return
const { start, end } = b
const width = screen.width
const noSelect = screen.noSelect
for (let row = start.row; row <= end.row && row < screen.height; row++) {
const colStart = row === start.row ? start.col : 0
const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1
const rowOff = row * width
for (let col = colStart; col <= colEnd; col++) {
const idx = rowOff + col
// Skip noSelect cells — gutters stay visually unchanged so it's
// clear they're not part of the copy. Surrounding selectable cells
// still highlight so the selection extent remains visible.
if (noSelect[idx] === 1) continue
const cell = cellAtIndex(screen, idx)
setCellStyleId(screen, col, row, stylePool.withSelectionBg(cell.styleId))
}
}
}