selection.ts 34 KB

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