init claude-code
This commit is contained in:
@@ -0,0 +1,917 @@
|
||||
/**
|
||||
* Text selection state for fullscreen mode.
|
||||
*
|
||||
* Tracks a linear selection in screen-buffer coordinates (0-indexed col/row).
|
||||
* Selection is line-based: cells from (startCol, startRow) through
|
||||
* (endCol, endRow) inclusive, wrapping across line boundaries. This matches
|
||||
* terminal-native selection behavior (not rectangular/block).
|
||||
*
|
||||
* The selection is stored as ANCHOR (where the drag started) + FOCUS (where
|
||||
* the cursor is now). The rendered highlight normalizes to start ≤ end.
|
||||
*/
|
||||
|
||||
import { clamp } from './layout/geometry.js'
|
||||
import type { Screen, StylePool } from './screen.js'
|
||||
import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js'
|
||||
|
||||
type Point = { col: number; row: number }
|
||||
|
||||
export type SelectionState = {
|
||||
/** Where the mouse-down occurred. Null when no selection. */
|
||||
anchor: Point | null
|
||||
/** Current drag position (updated on mouse-move while dragging). */
|
||||
focus: Point | null
|
||||
/** True between mouse-down and mouse-up. */
|
||||
isDragging: boolean
|
||||
/** For word/line mode: the initial word/line bounds from the first
|
||||
* multi-click. Drag extends from this span to the word/line at the
|
||||
* current mouse position so the original word/line stays selected
|
||||
* even when dragging backward past it. Null ⇔ char mode. The kind
|
||||
* tells extendSelection whether to snap to word or line boundaries. */
|
||||
anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
|
||||
/** Text from rows that scrolled out ABOVE the viewport during
|
||||
* drag-to-scroll. The screen buffer only holds the current viewport,
|
||||
* so without this accumulator, dragging down past the bottom edge
|
||||
* loses the top of the selection once the anchor clamps. Prepended
|
||||
* to the on-screen text by getSelectedText. Reset on start/clear. */
|
||||
scrolledOffAbove: string[]
|
||||
/** Symmetric: rows scrolled out BELOW when dragging up. Appended. */
|
||||
scrolledOffBelow: string[]
|
||||
/** Soft-wrap bits parallel to scrolledOffAbove — true means the row
|
||||
* is a continuation of the one before it (the `\n` was inserted by
|
||||
* word-wrap, not in the source). Captured alongside the text at
|
||||
* scroll time since the screen's softWrap bitmap shifts with content.
|
||||
* getSelectedText uses these to join wrapped rows back into logical
|
||||
* lines. */
|
||||
scrolledOffAboveSW: boolean[]
|
||||
/** Parallel to scrolledOffBelow. */
|
||||
scrolledOffBelowSW: boolean[]
|
||||
/** Pre-clamp anchor row. Set when shiftSelection clamps anchor so a
|
||||
* reverse scroll can restore the true position and pop accumulators.
|
||||
* Without this, PgDn (clamps anchor) → PgUp leaves anchor at the wrong
|
||||
* row AND scrolledOffAbove stale — highlight ≠ copy. Undefined when
|
||||
* anchor is in-bounds (no clamp debt). Cleared on start/clear. */
|
||||
virtualAnchorRow?: number
|
||||
/** Same for focus. */
|
||||
virtualFocusRow?: number
|
||||
/** True if the mouse-down that started this selection had the alt
|
||||
* modifier set (SGR button bit 0x08). On macOS xterm.js this is a
|
||||
* signal that VS Code's macOptionClickForcesSelection is OFF — if it
|
||||
* were on, xterm.js would have consumed the event for native selection
|
||||
* and we'd never receive it. Used by the footer to show the right hint. */
|
||||
lastPressHadAlt: boolean
|
||||
}
|
||||
|
||||
export function createSelectionState(): SelectionState {
|
||||
return {
|
||||
anchor: null,
|
||||
focus: null,
|
||||
isDragging: false,
|
||||
anchorSpan: null,
|
||||
scrolledOffAbove: [],
|
||||
scrolledOffBelow: [],
|
||||
scrolledOffAboveSW: [],
|
||||
scrolledOffBelowSW: [],
|
||||
lastPressHadAlt: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function startSelection(
|
||||
s: SelectionState,
|
||||
col: number,
|
||||
row: number,
|
||||
): void {
|
||||
s.anchor = { col, row }
|
||||
// Focus is not set until the first drag motion. A click-release with no
|
||||
// drag leaves focus null → hasSelection/selectionBounds return false/null
|
||||
// via the `!s.focus` check, so a bare click never highlights a cell.
|
||||
s.focus = null
|
||||
s.isDragging = true
|
||||
s.anchorSpan = null
|
||||
s.scrolledOffAbove = []
|
||||
s.scrolledOffBelow = []
|
||||
s.scrolledOffAboveSW = []
|
||||
s.scrolledOffBelowSW = []
|
||||
s.virtualAnchorRow = undefined
|
||||
s.virtualFocusRow = undefined
|
||||
s.lastPressHadAlt = false
|
||||
}
|
||||
|
||||
export function updateSelection(
|
||||
s: SelectionState,
|
||||
col: number,
|
||||
row: number,
|
||||
): void {
|
||||
if (!s.isDragging) return
|
||||
// First motion at the same cell as anchor is a no-op. Terminals in mode
|
||||
// 1002 can fire a drag event at the anchor cell (sub-pixel tremor, or a
|
||||
// motion-release pair). Setting focus here would turn a bare click into
|
||||
// a 1-cell selection and clobber the clipboard via useCopyOnSelect. Once
|
||||
// focus is set (real drag), we track normally including back to anchor.
|
||||
if (!s.focus && s.anchor && s.anchor.col === col && s.anchor.row === row)
|
||||
return
|
||||
s.focus = { col, row }
|
||||
}
|
||||
|
||||
export function finishSelection(s: SelectionState): void {
|
||||
s.isDragging = false
|
||||
// Keep anchor/focus so highlight stays visible and text can be copied.
|
||||
// Clear via clearSelection() on Esc or after copy.
|
||||
}
|
||||
|
||||
export function clearSelection(s: SelectionState): void {
|
||||
s.anchor = null
|
||||
s.focus = null
|
||||
s.isDragging = false
|
||||
s.anchorSpan = null
|
||||
s.scrolledOffAbove = []
|
||||
s.scrolledOffBelow = []
|
||||
s.scrolledOffAboveSW = []
|
||||
s.scrolledOffBelowSW = []
|
||||
s.virtualAnchorRow = undefined
|
||||
s.virtualFocusRow = undefined
|
||||
s.lastPressHadAlt = false
|
||||
}
|
||||
|
||||
// Unicode-aware word character matcher: letters (any script), digits,
|
||||
// and the punctuation set iTerm2 treats as word-part by default.
|
||||
// Matching iTerm2's default means double-clicking a path like
|
||||
// `/usr/bin/bash` or `~/.claude/config.json` selects the whole thing,
|
||||
// which is the muscle memory most macOS terminal users have.
|
||||
// iTerm2 default "characters considered part of a word": /-+\~_.
|
||||
const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u
|
||||
|
||||
/**
|
||||
* Character class for double-click word-expansion. Cells with the same
|
||||
* class as the clicked cell are included in the selection; a class change
|
||||
* is a boundary. Matches typical terminal-emulator behavior (iTerm2 etc.):
|
||||
* double-click on `foo` selects `foo`, on `->` selects `->`, on spaces
|
||||
* selects the whitespace run.
|
||||
*/
|
||||
function charClass(c: string): 0 | 1 | 2 {
|
||||
if (c === ' ' || c === '') return 0
|
||||
if (WORD_CHAR.test(c)) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bounds of the same-class character run at (col, row). Returns
|
||||
* null if the click is out of bounds or lands on a noSelect cell. Used by
|
||||
* selectWordAt (initial double-click) and extendWordSelection (drag).
|
||||
*/
|
||||
function wordBoundsAt(
|
||||
screen: Screen,
|
||||
col: number,
|
||||
row: number,
|
||||
): { lo: number; hi: number } | null {
|
||||
if (row < 0 || row >= screen.height) return null
|
||||
const width = screen.width
|
||||
const noSelect = screen.noSelect
|
||||
const rowOff = row * width
|
||||
|
||||
// If the click landed on the spacer tail of a wide char, step back to
|
||||
// the head so the class check sees the actual grapheme.
|
||||
let c = col
|
||||
if (c > 0) {
|
||||
const cell = cellAt(screen, c, row)
|
||||
if (cell && cell.width === CellWidth.SpacerTail) c -= 1
|
||||
}
|
||||
if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return null
|
||||
|
||||
const startCell = cellAt(screen, c, row)
|
||||
if (!startCell) return null
|
||||
const cls = charClass(startCell.char)
|
||||
|
||||
// Expand left: include cells of the same class, stop at noSelect or
|
||||
// class change. SpacerTail cells are stepped over (the wide-char head
|
||||
// at the preceding column determines the class).
|
||||
let lo = c
|
||||
while (lo > 0) {
|
||||
const prev = lo - 1
|
||||
if (noSelect[rowOff + prev] === 1) break
|
||||
const pc = cellAt(screen, prev, row)
|
||||
if (!pc) break
|
||||
if (pc.width === CellWidth.SpacerTail) {
|
||||
// Step over the spacer to the wide-char head
|
||||
if (prev === 0 || noSelect[rowOff + prev - 1] === 1) break
|
||||
const head = cellAt(screen, prev - 1, row)
|
||||
if (!head || charClass(head.char) !== cls) break
|
||||
lo = prev - 1
|
||||
continue
|
||||
}
|
||||
if (charClass(pc.char) !== cls) break
|
||||
lo = prev
|
||||
}
|
||||
|
||||
// Expand right: same logic, skipping spacer tails.
|
||||
let hi = c
|
||||
while (hi < width - 1) {
|
||||
const next = hi + 1
|
||||
if (noSelect[rowOff + next] === 1) break
|
||||
const nc = cellAt(screen, next, row)
|
||||
if (!nc) break
|
||||
if (nc.width === CellWidth.SpacerTail) {
|
||||
// Include the spacer tail in the selection range (it belongs to
|
||||
// the wide char at hi) and continue past it.
|
||||
hi = next
|
||||
continue
|
||||
}
|
||||
if (charClass(nc.char) !== cls) break
|
||||
hi = next
|
||||
}
|
||||
|
||||
return { lo, hi }
|
||||
}
|
||||
|
||||
/** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */
|
||||
function comparePoints(a: Point, b: Point): number {
|
||||
if (a.row !== b.row) return a.row < b.row ? -1 : 1
|
||||
if (a.col !== b.col) return a.col < b.col ? -1 : 1
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the word at (col, row) by scanning the screen buffer for the
|
||||
* bounds of the same-class character run. Mutates the selection in place.
|
||||
* No-op if the click is out of bounds or lands on a noSelect cell.
|
||||
* Sets isDragging=true and anchorSpan so a subsequent drag extends the
|
||||
* selection word-by-word (native macOS behavior).
|
||||
*/
|
||||
export function selectWordAt(
|
||||
s: SelectionState,
|
||||
screen: Screen,
|
||||
col: number,
|
||||
row: number,
|
||||
): void {
|
||||
const b = wordBoundsAt(screen, col, row)
|
||||
if (!b) return
|
||||
const lo = { col: b.lo, row }
|
||||
const hi = { col: b.hi, row }
|
||||
s.anchor = lo
|
||||
s.focus = hi
|
||||
s.isDragging = true
|
||||
s.anchorSpan = { lo, hi, kind: 'word' }
|
||||
}
|
||||
|
||||
// Printable ASCII minus terminal URL delimiters. Restricting to single-
|
||||
// codeunit ASCII keeps cell-count === string-index, so the column-span
|
||||
// check below is exact (no wide-char/grapheme drift).
|
||||
const URL_BOUNDARY = new Set([...'<>"\'` '])
|
||||
function isUrlChar(c: string): boolean {
|
||||
if (c.length !== 1) return false
|
||||
const code = c.charCodeAt(0)
|
||||
return code >= 0x21 && code <= 0x7e && !URL_BOUNDARY.has(c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the screen buffer for a plain-text URL at (col, row). Mirrors the
|
||||
* terminal's native Cmd+Click URL detection, which fullscreen mode's mouse
|
||||
* tracking intercepts. Called from getHyperlinkAt as a fallback when the
|
||||
* cell has no OSC 8 hyperlink.
|
||||
*/
|
||||
export function findPlainTextUrlAt(
|
||||
screen: Screen,
|
||||
col: number,
|
||||
row: number,
|
||||
): string | undefined {
|
||||
if (row < 0 || row >= screen.height) return undefined
|
||||
const width = screen.width
|
||||
const noSelect = screen.noSelect
|
||||
const rowOff = row * width
|
||||
|
||||
let c = col
|
||||
if (c > 0) {
|
||||
const cell = cellAt(screen, c, row)
|
||||
if (cell && cell.width === CellWidth.SpacerTail) c -= 1
|
||||
}
|
||||
if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return undefined
|
||||
|
||||
const startCell = cellAt(screen, c, row)
|
||||
if (!startCell || !isUrlChar(startCell.char)) return undefined
|
||||
|
||||
// Expand left/right to the bounds of the URL-char run. URLs are ASCII
|
||||
// (CellWidth.Narrow, 1 codeunit), so hitting a non-ASCII/wide/spacer
|
||||
// cell is a boundary — no need to step over spacers like wordBoundsAt.
|
||||
let lo = c
|
||||
while (lo > 0) {
|
||||
const prev = lo - 1
|
||||
if (noSelect[rowOff + prev] === 1) break
|
||||
const pc = cellAt(screen, prev, row)
|
||||
if (!pc || pc.width !== CellWidth.Narrow || !isUrlChar(pc.char)) break
|
||||
lo = prev
|
||||
}
|
||||
let hi = c
|
||||
while (hi < width - 1) {
|
||||
const next = hi + 1
|
||||
if (noSelect[rowOff + next] === 1) break
|
||||
const nc = cellAt(screen, next, row)
|
||||
if (!nc || nc.width !== CellWidth.Narrow || !isUrlChar(nc.char)) break
|
||||
hi = next
|
||||
}
|
||||
|
||||
let token = ''
|
||||
for (let i = lo; i <= hi; i++) token += cellAt(screen, i, row)!.char
|
||||
|
||||
// 1 cell = 1 char across [lo, hi] (ASCII-only run), so string index =
|
||||
// column offset. Find the last scheme anchor at or before the click —
|
||||
// a run like `https://a.com,https://b.com` has two, and clicking the
|
||||
// second should return the second URL, not the greedy match of both.
|
||||
const clickIdx = c - lo
|
||||
const schemeRe = /(?:https?|file):\/\//g
|
||||
let urlStart = -1
|
||||
let urlEnd = token.length
|
||||
for (let m; (m = schemeRe.exec(token)); ) {
|
||||
if (m.index > clickIdx) {
|
||||
urlEnd = m.index
|
||||
break
|
||||
}
|
||||
urlStart = m.index
|
||||
}
|
||||
if (urlStart < 0) return undefined
|
||||
let url = token.slice(urlStart, urlEnd)
|
||||
|
||||
// Strip trailing sentence punctuation. For closers () ] }, only strip
|
||||
// if unbalanced — `/wiki/Foo_(bar)` keeps `)`, `/arr[0]` keeps `]`.
|
||||
const OPENER: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
|
||||
while (url.length > 0) {
|
||||
const last = url.at(-1)!
|
||||
if ('.,;:!?'.includes(last)) {
|
||||
url = url.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
const opener = OPENER[last]
|
||||
if (!opener) break
|
||||
let opens = 0
|
||||
let closes = 0
|
||||
for (let i = 0; i < url.length; i++) {
|
||||
const ch = url.charAt(i)
|
||||
if (ch === opener) opens++
|
||||
else if (ch === last) closes++
|
||||
}
|
||||
if (closes > opens) url = url.slice(0, -1)
|
||||
else break
|
||||
}
|
||||
|
||||
// urlStart already guarantees click >= URL start; check right edge.
|
||||
if (clickIdx >= urlStart + url.length) return undefined
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the entire row. Sets isDragging=true and anchorSpan so a
|
||||
* subsequent drag extends the selection line-by-line. The anchor/focus
|
||||
* span from col 0 to width-1; getSelectedText handles noSelect skipping
|
||||
* and trailing-whitespace trimming so the copied text is just the visible
|
||||
* line content.
|
||||
*/
|
||||
export function selectLineAt(
|
||||
s: SelectionState,
|
||||
screen: Screen,
|
||||
row: number,
|
||||
): void {
|
||||
if (row < 0 || row >= screen.height) return
|
||||
const lo = { col: 0, row }
|
||||
const hi = { col: screen.width - 1, row }
|
||||
s.anchor = lo
|
||||
s.focus = hi
|
||||
s.isDragging = true
|
||||
s.anchorSpan = { lo, hi, kind: 'line' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend a word/line-mode selection to the word/line at (col, row). The
|
||||
* anchor span (the original multi-clicked word/line) stays selected; the
|
||||
* selection grows from that span to the word/line at the current mouse
|
||||
* position. Word mode falls back to the raw cell when the mouse is over a
|
||||
* noSelect cell or out of bounds, so dragging into gutters still extends.
|
||||
*/
|
||||
export function extendSelection(
|
||||
s: SelectionState,
|
||||
screen: Screen,
|
||||
col: number,
|
||||
row: number,
|
||||
): void {
|
||||
if (!s.isDragging || !s.anchorSpan) return
|
||||
const span = s.anchorSpan
|
||||
let mLo: Point
|
||||
let mHi: Point
|
||||
if (span.kind === 'word') {
|
||||
const b = wordBoundsAt(screen, col, row)
|
||||
mLo = { col: b ? b.lo : col, row }
|
||||
mHi = { col: b ? b.hi : col, row }
|
||||
} else {
|
||||
const r = clamp(row, 0, screen.height - 1)
|
||||
mLo = { col: 0, row: r }
|
||||
mHi = { col: screen.width - 1, row: r }
|
||||
}
|
||||
if (comparePoints(mHi, span.lo) < 0) {
|
||||
// Mouse target ends before anchor span: extend backward.
|
||||
s.anchor = span.hi
|
||||
s.focus = mLo
|
||||
} else if (comparePoints(mLo, span.hi) > 0) {
|
||||
// Mouse target starts after anchor span: extend forward.
|
||||
s.anchor = span.lo
|
||||
s.focus = mHi
|
||||
} else {
|
||||
// Mouse overlaps the anchor span: just select the anchor span.
|
||||
s.anchor = span.lo
|
||||
s.focus = span.hi
|
||||
}
|
||||
}
|
||||
|
||||
/** Semantic keyboard focus moves. See moveSelectionFocus in ink.tsx for
|
||||
* how screen bounds + row-wrap are applied. */
|
||||
export type FocusMove =
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'lineStart'
|
||||
| 'lineEnd'
|
||||
|
||||
/**
|
||||
* Set focus to (col, row) for keyboard selection extension (shift+arrow).
|
||||
* Anchor stays fixed; selection grows or shrinks depending on where focus
|
||||
* moves relative to anchor. Drops to char mode (clears anchorSpan) —
|
||||
* native macOS does this too: shift+arrow after a double-click word-select
|
||||
* extends char-by-char from the word edge, not word-by-word. Scrolled-off
|
||||
* accumulators are preserved: keyboard-extending a drag-scrolled selection
|
||||
* keeps the off-screen rows. Caller supplies coords already clamped/wrapped.
|
||||
*/
|
||||
export function moveFocus(s: SelectionState, col: number, row: number): void {
|
||||
if (!s.focus) return
|
||||
s.anchorSpan = null
|
||||
s.focus = { col, row }
|
||||
// Explicit user repositioning — any stale virtual focus (from a prior
|
||||
// shiftSelection clamp) no longer reflects intent. Anchor stays put so
|
||||
// virtualAnchorRow is still valid for its own round-trip.
|
||||
s.virtualFocusRow = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift anchor AND focus by dRow, clamped to [minRow, maxRow]. Used for
|
||||
* keyboard scroll (PgUp/PgDn/ctrl+u/d/b/f): the whole selection must track
|
||||
* the content, unlike drag-to-scroll where focus stays at the mouse. Any
|
||||
* point that hits a clamp bound gets its col reset to the full-width edge —
|
||||
* its original content scrolled off-screen and was captured by
|
||||
* captureScrolledRows, so the col constraint was already consumed. Keeping
|
||||
* it would truncate the NEW content now at that screen row. Clamp col is 0
|
||||
* for dRow<0 (scrolling down, top leaves, 'above' semantics) or width-1 for
|
||||
* dRow>0 (scrolling up, bottom leaves, 'below' semantics).
|
||||
*
|
||||
* If both ends overshoot the SAME viewport edge (select text → Home/End/g/G
|
||||
* jumps far enough that both are out of view), clear — otherwise both clamp
|
||||
* to the same corner cell and a ghost 1-cell highlight lingers, and
|
||||
* getSelectedText returns one unrelated char from that corner. Symmetric
|
||||
* with shiftSelectionForFollow's top-edge check, but bidirectional: keyboard
|
||||
* scroll can jump either way.
|
||||
*/
|
||||
export function shiftSelection(
|
||||
s: SelectionState,
|
||||
dRow: number,
|
||||
minRow: number,
|
||||
maxRow: number,
|
||||
width: number,
|
||||
): void {
|
||||
if (!s.anchor || !s.focus) return
|
||||
// Virtual rows track pre-clamp positions so reverse scrolls restore
|
||||
// correctly. Without this, clamp(5→0) + shift(+10) = 10, not the true 5,
|
||||
// and scrolledOffAbove stays stale (highlight ≠ copy).
|
||||
const vAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow
|
||||
const vFocus = (s.virtualFocusRow ?? s.focus.row) + dRow
|
||||
if (
|
||||
(vAnchor < minRow && vFocus < minRow) ||
|
||||
(vAnchor > maxRow && vFocus > maxRow)
|
||||
) {
|
||||
clearSelection(s)
|
||||
return
|
||||
}
|
||||
// Debt = how far the nearer endpoint overshoots each edge. When debt
|
||||
// shrinks (reverse scroll), those rows are back on-screen — pop from
|
||||
// the accumulator so getSelectedText doesn't double-count them.
|
||||
const oldMin = Math.min(
|
||||
s.virtualAnchorRow ?? s.anchor.row,
|
||||
s.virtualFocusRow ?? s.focus.row,
|
||||
)
|
||||
const oldMax = Math.max(
|
||||
s.virtualAnchorRow ?? s.anchor.row,
|
||||
s.virtualFocusRow ?? s.focus.row,
|
||||
)
|
||||
const oldAboveDebt = Math.max(0, minRow - oldMin)
|
||||
const oldBelowDebt = Math.max(0, oldMax - maxRow)
|
||||
const newAboveDebt = Math.max(0, minRow - Math.min(vAnchor, vFocus))
|
||||
const newBelowDebt = Math.max(0, Math.max(vAnchor, vFocus) - maxRow)
|
||||
if (newAboveDebt < oldAboveDebt) {
|
||||
// scrolledOffAbove pushes newest at the end (closest to on-screen).
|
||||
const drop = oldAboveDebt - newAboveDebt
|
||||
s.scrolledOffAbove.length -= drop
|
||||
s.scrolledOffAboveSW.length = s.scrolledOffAbove.length
|
||||
}
|
||||
if (newBelowDebt < oldBelowDebt) {
|
||||
// scrolledOffBelow unshifts newest at the front (closest to on-screen).
|
||||
const drop = oldBelowDebt - newBelowDebt
|
||||
s.scrolledOffBelow.splice(0, drop)
|
||||
s.scrolledOffBelowSW.splice(0, drop)
|
||||
}
|
||||
// Invariant: accumulator length ≤ debt. If the accumulator exceeds debt,
|
||||
// the excess is stale — e.g., moveFocus cleared virtualFocusRow without
|
||||
// trimming the accumulator, orphaning entries the pop above can never
|
||||
// reach because oldDebt was ALREADY 0. Truncate to debt (keeping the
|
||||
// newest = closest-to-on-screen entries). Check newDebt (not oldDebt):
|
||||
// captureScrolledRows runs BEFORE this shift in the real flow (ink.tsx),
|
||||
// so at entry the accumulator is populated but oldDebt is still 0 —
|
||||
// that's the normal establish-debt path, not stale.
|
||||
if (s.scrolledOffAbove.length > newAboveDebt) {
|
||||
// Above pushes newest at END → keep END.
|
||||
s.scrolledOffAbove =
|
||||
newAboveDebt > 0 ? s.scrolledOffAbove.slice(-newAboveDebt) : []
|
||||
s.scrolledOffAboveSW =
|
||||
newAboveDebt > 0 ? s.scrolledOffAboveSW.slice(-newAboveDebt) : []
|
||||
}
|
||||
if (s.scrolledOffBelow.length > newBelowDebt) {
|
||||
// Below unshifts newest at FRONT → keep FRONT.
|
||||
s.scrolledOffBelow = s.scrolledOffBelow.slice(0, newBelowDebt)
|
||||
s.scrolledOffBelowSW = s.scrolledOffBelowSW.slice(0, newBelowDebt)
|
||||
}
|
||||
// Clamp col depends on which EDGE (not dRow direction): virtual tracking
|
||||
// means a top-clamped point can stay top-clamped during a dRow>0 reverse
|
||||
// shift — dRow-based clampCol would give it the bottom col.
|
||||
const shift = (p: Point, vRow: number): Point => {
|
||||
if (vRow < minRow) return { col: 0, row: minRow }
|
||||
if (vRow > maxRow) return { col: width - 1, row: maxRow }
|
||||
return { col: p.col, row: vRow }
|
||||
}
|
||||
s.anchor = shift(s.anchor, vAnchor)
|
||||
s.focus = shift(s.focus, vFocus)
|
||||
s.virtualAnchorRow =
|
||||
vAnchor < minRow || vAnchor > maxRow ? vAnchor : undefined
|
||||
s.virtualFocusRow = vFocus < minRow || vFocus > maxRow ? vFocus : undefined
|
||||
// anchorSpan not virtual-tracked: it's for word/line extend-on-drag,
|
||||
// irrelevant to the keyboard-scroll round-trip case.
|
||||
if (s.anchorSpan) {
|
||||
const sp = (p: Point): Point => {
|
||||
const r = p.row + dRow
|
||||
if (r < minRow) return { col: 0, row: minRow }
|
||||
if (r > maxRow) return { col: width - 1, row: maxRow }
|
||||
return { col: p.col, row: r }
|
||||
}
|
||||
s.anchorSpan = {
|
||||
lo: sp(s.anchorSpan.lo),
|
||||
hi: sp(s.anchorSpan.hi),
|
||||
kind: s.anchorSpan.kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift the anchor row by dRow, clamped to [minRow, maxRow]. Used during
|
||||
* drag-to-scroll: when the ScrollBox scrolls by N rows, the content that
|
||||
* was under the anchor is now at a different viewport row, so the anchor
|
||||
* must follow it. Focus is left unchanged (it stays at the mouse position).
|
||||
*/
|
||||
export function shiftAnchor(
|
||||
s: SelectionState,
|
||||
dRow: number,
|
||||
minRow: number,
|
||||
maxRow: number,
|
||||
): void {
|
||||
if (!s.anchor) return
|
||||
// Same virtual-row tracking as shiftSelection/shiftSelectionForFollow: the
|
||||
// drag→follow transition hands off to shiftSelectionForFollow, which reads
|
||||
// (virtualAnchorRow ?? anchor.row). Without this, drag-phase clamping
|
||||
// leaves virtual undefined → follow initializes from the already-clamped
|
||||
// row, under-counting total drift → shiftSelection's invariant-restore
|
||||
// prematurely clears valid drag-phase accumulator entries.
|
||||
const raw = (s.virtualAnchorRow ?? s.anchor.row) + dRow
|
||||
s.anchor = { col: s.anchor.col, row: clamp(raw, minRow, maxRow) }
|
||||
s.virtualAnchorRow = raw < minRow || raw > maxRow ? raw : undefined
|
||||
// anchorSpan not virtual-tracked (word/line extend, irrelevant to
|
||||
// keyboard-scroll round-trip) — plain clamp from current row.
|
||||
if (s.anchorSpan) {
|
||||
const shift = (p: Point): Point => ({
|
||||
col: p.col,
|
||||
row: clamp(p.row + dRow, minRow, maxRow),
|
||||
})
|
||||
s.anchorSpan = {
|
||||
lo: shift(s.anchorSpan.lo),
|
||||
hi: shift(s.anchorSpan.hi),
|
||||
kind: s.anchorSpan.kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift the whole selection (anchor + focus + anchorSpan) by dRow, clamped
|
||||
* to [minRow, maxRow]. Used when sticky/auto-follow scrolls the ScrollBox
|
||||
* while a selection is active — native terminal behavior is for the
|
||||
* highlight to walk up the screen with the text (not stay at the same
|
||||
* screen position).
|
||||
*
|
||||
* Differs from shiftAnchor: during drag-to-scroll, focus tracks the live
|
||||
* mouse position and only anchor follows the text. During streaming-follow,
|
||||
* the selection is text-anchored at both ends — both must move. The
|
||||
* isDragging check in ink.tsx picks which shift to apply.
|
||||
*
|
||||
* If both ends would shift strictly BELOW minRow (unclamped), the selected
|
||||
* text has scrolled entirely off the top. Clear it — otherwise a single
|
||||
* inverted cell lingers at the viewport top as a ghost (native terminals
|
||||
* drop the selection when it leaves scrollback). Landing AT minRow is
|
||||
* still valid: that cell holds the correct text. Returns true if the
|
||||
* selection was cleared so the caller can notify React-land subscribers
|
||||
* (useHasSelection) — the caller is inside onRender so it can't use
|
||||
* notifySelectionChange (recursion), must fire listeners directly.
|
||||
*/
|
||||
export function shiftSelectionForFollow(
|
||||
s: SelectionState,
|
||||
dRow: number,
|
||||
minRow: number,
|
||||
maxRow: number,
|
||||
): boolean {
|
||||
if (!s.anchor) return false
|
||||
// Mirror shiftSelection: compute raw (unclamped) positions from virtual
|
||||
// if set, else current. This handles BOTH the update path (virtual already
|
||||
// set from a prior keyboard scroll) AND the initialize path (first clamp
|
||||
// happens HERE via follow-scroll, no prior keyboard scroll). Without the
|
||||
// initialize path, follow-scroll-first leaves virtual undefined even
|
||||
// though the clamp below occurred → a later PgUp computes debt from the
|
||||
// clamped row instead of the true pre-clamp row and never pops the
|
||||
// accumulator — getSelectedText double-counts the off-screen rows.
|
||||
const rawAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow
|
||||
const rawFocus = s.focus
|
||||
? (s.virtualFocusRow ?? s.focus.row) + dRow
|
||||
: undefined
|
||||
if (rawAnchor < minRow && rawFocus !== undefined && rawFocus < minRow) {
|
||||
clearSelection(s)
|
||||
return true
|
||||
}
|
||||
// Clamp from raw, not p.row+dRow — so a virtual position coming back
|
||||
// in-bounds lands at the TRUE position, not the stale clamped one.
|
||||
s.anchor = { col: s.anchor.col, row: clamp(rawAnchor, minRow, maxRow) }
|
||||
if (s.focus && rawFocus !== undefined) {
|
||||
s.focus = { col: s.focus.col, row: clamp(rawFocus, minRow, maxRow) }
|
||||
}
|
||||
s.virtualAnchorRow =
|
||||
rawAnchor < minRow || rawAnchor > maxRow ? rawAnchor : undefined
|
||||
s.virtualFocusRow =
|
||||
rawFocus !== undefined && (rawFocus < minRow || rawFocus > maxRow)
|
||||
? rawFocus
|
||||
: undefined
|
||||
// anchorSpan not virtual-tracked (word/line extend, irrelevant to
|
||||
// keyboard-scroll round-trip) — plain clamp from current row.
|
||||
if (s.anchorSpan) {
|
||||
const shift = (p: Point): Point => ({
|
||||
col: p.col,
|
||||
row: clamp(p.row + dRow, minRow, maxRow),
|
||||
})
|
||||
s.anchorSpan = {
|
||||
lo: shift(s.anchorSpan.lo),
|
||||
hi: shift(s.anchorSpan.hi),
|
||||
kind: s.anchorSpan.kind,
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasSelection(s: SelectionState): boolean {
|
||||
return s.anchor !== null && s.focus !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized selection bounds: start is always before end in reading order.
|
||||
* Returns null if no active selection.
|
||||
*/
|
||||
export function selectionBounds(s: SelectionState): {
|
||||
start: { col: number; row: number }
|
||||
end: { col: number; row: number }
|
||||
} | null {
|
||||
if (!s.anchor || !s.focus) return null
|
||||
return comparePoints(s.anchor, s.focus) <= 0
|
||||
? { start: s.anchor, end: s.focus }
|
||||
: { start: s.focus, end: s.anchor }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cell at (col, row) is within the current selection range.
|
||||
* Used by the renderer to apply inverse style.
|
||||
*/
|
||||
export function isCellSelected(
|
||||
s: SelectionState,
|
||||
col: number,
|
||||
row: number,
|
||||
): boolean {
|
||||
const b = selectionBounds(s)
|
||||
if (!b) return false
|
||||
const { start, end } = b
|
||||
if (row < start.row || row > end.row) return false
|
||||
if (row === start.row && col < start.col) return false
|
||||
if (row === end.row && col > end.col) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Extract text from one screen row. When the next row is a soft-wrap
|
||||
* continuation (screen.softWrap[row+1]>0), clamp to that content-end
|
||||
* column and skip the trailing trim so the word-separator space survives
|
||||
* the join. See Screen.softWrap for why the clamp is necessary. */
|
||||
function extractRowText(
|
||||
screen: Screen,
|
||||
row: number,
|
||||
colStart: number,
|
||||
colEnd: number,
|
||||
): string {
|
||||
const noSelect = screen.noSelect
|
||||
const rowOff = row * screen.width
|
||||
const contentEnd = row + 1 < screen.height ? screen.softWrap[row + 1]! : 0
|
||||
const lastCol = contentEnd > 0 ? Math.min(colEnd, contentEnd - 1) : colEnd
|
||||
let line = ''
|
||||
for (let col = colStart; col <= lastCol; col++) {
|
||||
// Skip cells marked noSelect (gutters, line numbers, diff sigils).
|
||||
// Check before cellAt to avoid the decode cost for excluded cells.
|
||||
if (noSelect[rowOff + col] === 1) continue
|
||||
const cell = cellAt(screen, col, row)
|
||||
if (!cell) continue
|
||||
// Skip spacer tails (second half of wide chars) — the head already
|
||||
// contains the full grapheme. SpacerHead is a blank at line-end.
|
||||
if (
|
||||
cell.width === CellWidth.SpacerTail ||
|
||||
cell.width === CellWidth.SpacerHead
|
||||
) {
|
||||
continue
|
||||
}
|
||||
line += cell.char
|
||||
}
|
||||
return contentEnd > 0 ? line : line.replace(/\s+$/, '')
|
||||
}
|
||||
|
||||
/** Accumulator for selected text that merges soft-wrapped rows back
|
||||
* into logical lines. push(text, sw) appends a newline before text
|
||||
* only when sw=false (i.e. the row starts a new logical line). Rows
|
||||
* with sw=true are concatenated onto the previous row. */
|
||||
function joinRows(
|
||||
lines: string[],
|
||||
text: string,
|
||||
sw: boolean | undefined,
|
||||
): void {
|
||||
if (sw && lines.length > 0) {
|
||||
lines[lines.length - 1] += text
|
||||
} else {
|
||||
lines.push(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from the screen buffer within the selection range.
|
||||
* Rows are joined with newlines unless the screen's softWrap bitmap
|
||||
* marks a row as a word-wrap continuation — those rows are concatenated
|
||||
* onto the previous row so the copied text matches the logical source
|
||||
* line, not the visual wrapped layout. Trailing whitespace on the last
|
||||
* fragment of each logical line is trimmed. Wide-char spacer cells are
|
||||
* skipped. Rows that scrolled out of the viewport during drag-to-scroll
|
||||
* are joined back in from the scrolledOffAbove/Below accumulators along
|
||||
* with their captured softWrap bits.
|
||||
*/
|
||||
export function getSelectedText(s: SelectionState, screen: Screen): string {
|
||||
const b = selectionBounds(s)
|
||||
if (!b) return ''
|
||||
const { start, end } = b
|
||||
const sw = screen.softWrap
|
||||
const lines: string[] = []
|
||||
|
||||
for (let i = 0; i < s.scrolledOffAbove.length; i++) {
|
||||
joinRows(lines, s.scrolledOffAbove[i]!, s.scrolledOffAboveSW[i])
|
||||
}
|
||||
|
||||
for (let row = start.row; row <= end.row; row++) {
|
||||
const rowStart = row === start.row ? start.col : 0
|
||||
const rowEnd = row === end.row ? end.col : screen.width - 1
|
||||
joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0)
|
||||
}
|
||||
|
||||
for (let i = 0; i < s.scrolledOffBelow.length; i++) {
|
||||
joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i])
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture text from rows about to scroll out of the viewport during
|
||||
* drag-to-scroll, BEFORE scrollBy overwrites them. Only the rows that
|
||||
* intersect the selection are captured, using the selection's col bounds
|
||||
* for the anchor-side boundary row. After capturing the anchor row, the
|
||||
* anchor.col AND anchorSpan cols are reset to the full-width boundary so
|
||||
* subsequent captures and the final getSelectedText don't re-apply a stale
|
||||
* col constraint to content that's no longer under the original anchor.
|
||||
* Both span cols are reset (not just the near side): after a blocked
|
||||
* reversal the drag can flip direction, and extendSelection then reads the
|
||||
* OPPOSITE span side — which would otherwise still hold the original word
|
||||
* boundary and truncate one subsequently-captured row.
|
||||
*
|
||||
* side='above': rows scrolling out the top (dragging down, anchor=start).
|
||||
* side='below': rows scrolling out the bottom (dragging up, anchor=end).
|
||||
*/
|
||||
export function captureScrolledRows(
|
||||
s: SelectionState,
|
||||
screen: Screen,
|
||||
firstRow: number,
|
||||
lastRow: number,
|
||||
side: 'above' | 'below',
|
||||
): void {
|
||||
const b = selectionBounds(s)
|
||||
if (!b || firstRow > lastRow) return
|
||||
const { start, end } = b
|
||||
// Intersect [firstRow, lastRow] with [start.row, end.row]. Rows outside
|
||||
// the selection aren't captured — they weren't selected.
|
||||
const lo = Math.max(firstRow, start.row)
|
||||
const hi = Math.min(lastRow, end.row)
|
||||
if (lo > hi) return
|
||||
|
||||
const width = screen.width
|
||||
const sw = screen.softWrap
|
||||
const captured: string[] = []
|
||||
const capturedSW: boolean[] = []
|
||||
for (let row = lo; row <= hi; row++) {
|
||||
const colStart = row === start.row ? start.col : 0
|
||||
const colEnd = row === end.row ? end.col : width - 1
|
||||
captured.push(extractRowText(screen, row, colStart, colEnd))
|
||||
capturedSW.push(sw[row]! > 0)
|
||||
}
|
||||
|
||||
if (side === 'above') {
|
||||
// Newest rows go at the bottom of the above-accumulator (closest to
|
||||
// the on-screen content in reading order).
|
||||
s.scrolledOffAbove.push(...captured)
|
||||
s.scrolledOffAboveSW.push(...capturedSW)
|
||||
// We just captured the top of the selection. The anchor (=start when
|
||||
// dragging down) is now pointing at content that will scroll out; its
|
||||
// col constraint was applied to the captured row. Reset to col 0 so
|
||||
// the NEXT tick and the final getSelectedText read the full row.
|
||||
if (s.anchor && s.anchor.row === start.row && lo === start.row) {
|
||||
s.anchor = { col: 0, row: s.anchor.row }
|
||||
if (s.anchorSpan) {
|
||||
s.anchorSpan = {
|
||||
kind: s.anchorSpan.kind,
|
||||
lo: { col: 0, row: s.anchorSpan.lo.row },
|
||||
hi: { col: width - 1, row: s.anchorSpan.hi.row },
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Newest rows go at the TOP of the below-accumulator — they're
|
||||
// closest to the on-screen content.
|
||||
s.scrolledOffBelow.unshift(...captured)
|
||||
s.scrolledOffBelowSW.unshift(...capturedSW)
|
||||
if (s.anchor && s.anchor.row === end.row && hi === end.row) {
|
||||
s.anchor = { col: width - 1, row: s.anchor.row }
|
||||
if (s.anchorSpan) {
|
||||
s.anchorSpan = {
|
||||
kind: s.anchorSpan.kind,
|
||||
lo: { col: 0, row: s.anchorSpan.lo.row },
|
||||
hi: { col: width - 1, row: s.anchorSpan.hi.row },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the selection overlay directly to the screen buffer by changing
|
||||
* the style of every cell in the selection range. Called after the
|
||||
* renderer produces the Frame but before the diff — the normal diffEach
|
||||
* then picks up the restyled cells as ordinary changes, so LogUpdate
|
||||
* stays a pure diff engine with no selection awareness.
|
||||
*
|
||||
* Uses a SOLID selection background (theme-provided via StylePool.
|
||||
* setSelectionBg) that REPLACES each cell's bg while PRESERVING its fg —
|
||||
* matches native terminal selection. Previously SGR-7 inverse (swapped
|
||||
* fg/bg per cell), which fragmented badly over syntax-highlighted text:
|
||||
* every distinct fg color became a different bg stripe.
|
||||
*
|
||||
* Uses StylePool caches so on drag the only work per cell is a Map
|
||||
* lookup + packed-int write.
|
||||
*/
|
||||
export function applySelectionOverlay(
|
||||
screen: Screen,
|
||||
selection: SelectionState,
|
||||
stylePool: StylePool,
|
||||
): void {
|
||||
const b = selectionBounds(selection)
|
||||
if (!b) return
|
||||
const { start, end } = b
|
||||
const width = screen.width
|
||||
const noSelect = screen.noSelect
|
||||
for (let row = start.row; row <= end.row && row < screen.height; row++) {
|
||||
const colStart = row === start.row ? start.col : 0
|
||||
const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1
|
||||
const rowOff = row * width
|
||||
for (let col = colStart; col <= colEnd; col++) {
|
||||
const idx = rowOff + col
|
||||
// Skip noSelect cells — gutters stay visually unchanged so it's
|
||||
// clear they're not part of the copy. Surrounding selectable cells
|
||||
// still highlight so the selection extent remains visible.
|
||||
if (noSelect[idx] === 1) continue
|
||||
const cell = cellAtIndex(screen, idx)
|
||||
setCellStyleId(screen, col, row, stylePool.withSelectionBg(cell.styleId))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user