/** * 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 = { ')': '(', ']': '[', '}': '{' } 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)) } } }