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