Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
30 kB · 893 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894/** * Selection Manager - Handles text selection in the terminal * * Features: * - Mouse drag selection * - Double-click word selection * - Text extraction from terminal buffer * - Automatic clipboard copy * - Visual selection overlay (rendered by CanvasRenderer) * - Auto-scroll during drag selection */
import { EventEmitter } from './event-emitter';import type { GhosttyTerminal } from './ghostty';import type { IEvent } from './interfaces';import type { CanvasRenderer } from './renderer';import type { Terminal } from './terminal';import type { GhosttyCell } from './types';
// ============================================================================// Type Definitions// ============================================================================
export interface SelectionCoordinates { startCol: number; startRow: number; endCol: number; endRow: number;}
// ============================================================================// SelectionManager Class// ============================================================================
export class SelectionManager { private terminal: Terminal; private renderer: CanvasRenderer; private wasmTerm: GhosttyTerminal; private textarea: HTMLTextAreaElement;
// Selection state - coordinates are in ABSOLUTE buffer space (viewportY + viewportRow) // This ensures selection persists correctly when scrolling private selectionStart: { col: number; absoluteRow: number } | null = null; private selectionEnd: { col: number; absoluteRow: number } | null = null; private isSelecting: boolean = false; private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred
// Track rows that need redraw for clearing old selection // Using a Set prevents the overwrite bug where mousemove would clobber // the rows marked by clearSelection() private dirtySelectionRows: Set<number> = new Set();
// Event emitter private selectionChangedEmitter = new EventEmitter<void>();
// Store bound event handlers for cleanup private boundMouseUpHandler: ((e: MouseEvent) => void) | null = null; private boundContextMenuHandler: ((e: MouseEvent) => void) | null = null; private boundClickHandler: ((e: MouseEvent) => void) | null = null; private boundDocumentMouseMoveHandler: ((e: MouseEvent) => void) | null = null;
// Auto-scroll state for drag selection private autoScrollInterval: ReturnType<typeof setInterval> | null = null; private autoScrollDirection: number = 0; // -1 = up, 0 = none, 1 = down private static readonly AUTO_SCROLL_EDGE_SIZE = 30; // pixels from edge to trigger scroll
/** * Get current viewport Y position (how many lines scrolled into history) */ private getViewportY(): number { const rawViewportY = typeof (this.terminal as any).getViewportY === 'function' ? (this.terminal as any).getViewportY() : (this.terminal as any).viewportY || 0; return Math.max(0, Math.floor(rawViewportY)); }
/** * Convert viewport row to absolute buffer row * Absolute row is an index into combined buffer: scrollback (0 to len-1) + screen (len to len+rows-1) */ private viewportRowToAbsolute(viewportRow: number): number { const scrollbackLength = this.wasmTerm.getScrollbackLength(); const viewportY = this.getViewportY(); return scrollbackLength + viewportRow - viewportY; }
/** * Convert absolute buffer row to viewport row (may be outside visible range) */ private absoluteRowToViewport(absoluteRow: number): number { const scrollbackLength = this.wasmTerm.getScrollbackLength(); const viewportY = this.getViewportY(); return absoluteRow - scrollbackLength + viewportY; } private static readonly AUTO_SCROLL_SPEED = 3; // lines per interval private static readonly AUTO_SCROLL_INTERVAL = 50; // ms between scroll steps
constructor( terminal: Terminal, renderer: CanvasRenderer, wasmTerm: GhosttyTerminal, textarea: HTMLTextAreaElement ) { this.terminal = terminal; this.renderer = renderer; this.wasmTerm = wasmTerm; this.textarea = textarea;
// Attach mouse event listeners this.attachEventListeners(); }
// ========================================================================== // Public API // ==========================================================================
/** * Get the selected text as a string */ getSelection(): string { if (!this.selectionStart || !this.selectionEnd) return '';
// Get absolute row coordinates (not clamped to viewport) let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd;
// Swap if selection goes backwards if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { [startCol, endCol] = [endCol, startCol]; [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; }
const scrollbackLength = this.wasmTerm.getScrollbackLength(); let text = '';
for (let absRow = startAbsRow; absRow <= endAbsRow; absRow++) { // Fetch line based on absolute row position // Absolute row < scrollbackLength means it's in scrollback // Absolute row >= scrollbackLength means it's in the screen buffer let line: GhosttyCell[] | null = null;
if (absRow < scrollbackLength) { // Row is in scrollback line = this.wasmTerm.getScrollbackLine(absRow); } else { // Row is in screen buffer const screenRow = absRow - scrollbackLength; line = this.wasmTerm.getLine(screenRow); }
if (!line) continue;
// Track the last non-empty column for trimming trailing spaces let lastNonEmpty = -1;
// Determine column range for this row const colStart = absRow === startAbsRow ? startCol : 0; const colEnd = absRow === endAbsRow ? endCol : line.length - 1;
// Build the line text let lineText = ''; for (let col = colStart; col <= colEnd; col++) { const cell = line[col]; if (cell && cell.codepoint !== 0) { const char = String.fromCodePoint(cell.codepoint); lineText += char; if (char.trim()) { lastNonEmpty = lineText.length; } } else { lineText += ' '; } }
// Trim trailing spaces from each line if (lastNonEmpty >= 0) { lineText = lineText.substring(0, lastNonEmpty); } else { lineText = ''; }
text += lineText;
// Add newline between rows (but not after the last row) if (absRow < endAbsRow) { text += '\n'; } }
return text; }
/** * Check if there's an active selection */ hasSelection(): boolean { if (!this.selectionStart || !this.selectionEnd) return false;
// Check if start and end are the same (single cell, no real selection) return !( this.selectionStart.col === this.selectionEnd.col && this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow ); }
/** * Clear the selection */ clearSelection(): void { if (!this.hasSelection()) return;
// Mark current selection rows as dirty for redraw const coords = this.normalizeSelection(); if (coords) { for (let row = coords.startRow; row <= coords.endRow; row++) { this.dirtySelectionRows.add(row); } }
this.selectionStart = null; this.selectionEnd = null; this.isSelecting = false;
// Force redraw of previously selected lines to clear the overlay this.requestRender(); }
/** * Select all text in the terminal */ selectAll(): void { const dims = this.wasmTerm.getDimensions(); const viewportY = this.getViewportY(); this.selectionStart = { col: 0, absoluteRow: viewportY }; this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + dims.rows - 1 }; this.requestRender(); this.selectionChangedEmitter.fire(); }
/** * Select text at specific column and row with length * xterm.js compatible API */ select(column: number, row: number, length: number): void { // Clamp to valid ranges const dims = this.wasmTerm.getDimensions(); row = Math.max(0, Math.min(row, dims.rows - 1)); column = Math.max(0, Math.min(column, dims.cols - 1));
// Calculate end position let endRow = row; let endCol = column + length - 1;
// Handle wrapping if selection extends past end of line while (endCol >= dims.cols) { endCol -= dims.cols; endRow++; }
// Clamp end row endRow = Math.min(endRow, dims.rows - 1);
// Convert viewport rows to absolute rows const viewportY = this.getViewportY(); this.selectionStart = { col: column, absoluteRow: viewportY + row }; this.selectionEnd = { col: endCol, absoluteRow: viewportY + endRow }; this.requestRender(); this.selectionChangedEmitter.fire(); }
/** * Select entire lines from start to end * xterm.js compatible API */ selectLines(start: number, end: number): void { const dims = this.wasmTerm.getDimensions();
// Clamp to valid row ranges start = Math.max(0, Math.min(start, dims.rows - 1)); end = Math.max(0, Math.min(end, dims.rows - 1));
// Ensure start <= end if (start > end) { [start, end] = [end, start]; }
// Convert viewport rows to absolute rows const viewportY = this.getViewportY(); this.selectionStart = { col: 0, absoluteRow: viewportY + start }; this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + end }; this.requestRender(); this.selectionChangedEmitter.fire(); }
/** * Get selection position as buffer range * xterm.js compatible API */ getSelectionPosition(): | { start: { x: number; y: number }; end: { x: number; y: number } } | undefined { const coords = this.normalizeSelection(); if (!coords) return undefined;
return { start: { x: coords.startCol, y: coords.startRow }, end: { x: coords.endCol, y: coords.endRow }, }; }
/** * Deselect all text * xterm.js compatible API */ deselect(): void { this.clearSelection(); this.selectionChangedEmitter.fire(); }
/** * Focus the terminal (make it receive keyboard input) */ focus(): void { const canvas = this.renderer.getCanvas(); if (canvas.parentElement) { canvas.parentElement.focus(); } }
/** * Get current selection coordinates (for rendering) */ getSelectionCoords(): SelectionCoordinates | null { return this.normalizeSelection(); }
/** * Get dirty selection rows that need redraw (for clearing old highlight) */ getDirtySelectionRows(): Set<number> { return this.dirtySelectionRows; }
/** * Clear the dirty selection rows tracking (after redraw) */ clearDirtySelectionRows(): void { this.dirtySelectionRows.clear(); }
/** * Get selection change event accessor */ get onSelectionChange(): IEvent<void> { return this.selectionChangedEmitter.event; }
/** * Cleanup resources */ dispose(): void { this.selectionChangedEmitter.dispose();
// Stop auto-scroll if active this.stopAutoScroll();
// Clean up document event listener if (this.boundMouseUpHandler) { document.removeEventListener('mouseup', this.boundMouseUpHandler); this.boundMouseUpHandler = null; }
// Clean up document mousemove listener if (this.boundDocumentMouseMoveHandler) { document.removeEventListener('mousemove', this.boundDocumentMouseMoveHandler); this.boundDocumentMouseMoveHandler = null; }
// Clean up context menu event listener if (this.boundContextMenuHandler) { const canvas = this.renderer.getCanvas(); canvas.removeEventListener('contextmenu', this.boundContextMenuHandler); this.boundContextMenuHandler = null; }
// Clean up document click listener if (this.boundClickHandler) { document.removeEventListener('click', this.boundClickHandler); this.boundClickHandler = null; }
// Canvas event listeners will be cleaned up when canvas is removed from DOM }
// ========================================================================== // Private Methods // ==========================================================================
/** * Attach mouse event listeners to canvas */ private attachEventListeners(): void { const canvas = this.renderer.getCanvas();
// Mouse down - start selection or clear existing canvas.addEventListener('mousedown', (e: MouseEvent) => { if (e.button === 0) { // Left click only
// CRITICAL: Focus the terminal so it can receive keyboard input // The canvas doesn't have tabindex, but the parent container does if (canvas.parentElement) { canvas.parentElement.focus(); }
const cell = this.pixelToCell(e.offsetX, e.offsetY);
// Always clear previous selection on new click const hadSelection = this.hasSelection(); if (hadSelection) { this.clearSelection(); }
// Start new selection (convert to absolute coordinates) const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionStart = { col: cell.col, absoluteRow }; this.selectionEnd = { col: cell.col, absoluteRow }; this.isSelecting = true; } });
// Mouse move on canvas - update selection canvas.addEventListener('mousemove', (e: MouseEvent) => { if (this.isSelecting) { // Mark current selection rows as dirty before updating this.markCurrentSelectionDirty();
const cell = this.pixelToCell(e.offsetX, e.offsetY); const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionEnd = { col: cell.col, absoluteRow }; this.requestRender();
// Check if near edges for auto-scroll this.updateAutoScroll(e.offsetY, canvas.clientHeight); } });
// Mouse leave - check for auto-scroll when leaving canvas during drag canvas.addEventListener('mouseleave', (e: MouseEvent) => { if (this.isSelecting) { // Determine scroll direction based on where mouse left const rect = canvas.getBoundingClientRect(); if (e.clientY < rect.top) { this.startAutoScroll(-1); // Scroll up } else if (e.clientY > rect.bottom) { this.startAutoScroll(1); // Scroll down } } });
// Mouse enter - stop auto-scroll when mouse returns to canvas canvas.addEventListener('mouseenter', () => { if (this.isSelecting) { this.stopAutoScroll(); } });
// Document-level mousemove for tracking mouse position during drag outside canvas this.boundDocumentMouseMoveHandler = (e: MouseEvent) => { if (this.isSelecting) { const rect = canvas.getBoundingClientRect();
// Update selection based on clamped position const clampedX = Math.max(rect.left, Math.min(e.clientX, rect.right)); const clampedY = Math.max(rect.top, Math.min(e.clientY, rect.bottom));
// Convert to canvas-relative coordinates const offsetX = clampedX - rect.left; const offsetY = clampedY - rect.top;
// Only update if mouse is outside the canvas if ( e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom ) { // Update auto-scroll direction based on mouse position if (e.clientY < rect.top) { this.startAutoScroll(-1); } else if (e.clientY > rect.bottom) { this.startAutoScroll(1); } else { this.stopAutoScroll(); }
// Only update selection position if NOT auto-scrolling // During auto-scroll, the scroll handler extends the selection if (this.autoScrollDirection === 0) { // Mark current selection rows as dirty before updating this.markCurrentSelectionDirty();
const cell = this.pixelToCell(offsetX, offsetY); const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionEnd = { col: cell.col, absoluteRow }; this.requestRender(); } } } }; document.addEventListener('mousemove', this.boundDocumentMouseMoveHandler);
// Track mousedown on document to know if a click started inside the canvas document.addEventListener('mousedown', (e: MouseEvent) => { this.mouseDownTarget = e.target; });
// CRITICAL FIX: Listen for mouseup on DOCUMENT, not just canvas // This catches mouseup events that happen outside the canvas (common during drag) this.boundMouseUpHandler = (e: MouseEvent) => { if (this.isSelecting) { this.isSelecting = false; this.stopAutoScroll();
const text = this.getSelection(); if (text) { this.copyToClipboard(text); this.selectionChangedEmitter.fire(); } } }; document.addEventListener('mouseup', this.boundMouseUpHandler);
// Double-click - select word canvas.addEventListener('dblclick', (e: MouseEvent) => { const cell = this.pixelToCell(e.offsetX, e.offsetY); const word = this.getWordAtCell(cell.col, cell.row);
if (word) { const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionStart = { col: word.startCol, absoluteRow }; this.selectionEnd = { col: word.endCol, absoluteRow }; this.requestRender();
const text = this.getSelection(); if (text) { this.copyToClipboard(text); this.selectionChangedEmitter.fire(); } } });
// Right-click (context menu) - position textarea to show browser's native menu // This allows Copy/Paste options to appear in the context menu this.boundContextMenuHandler = (e: MouseEvent) => { // Position textarea at mouse cursor const canvas = this.renderer.getCanvas(); const rect = canvas.getBoundingClientRect();
this.textarea.style.position = 'fixed'; this.textarea.style.left = `${e.clientX}px`; this.textarea.style.top = `${e.clientY}px`; this.textarea.style.width = '1px'; this.textarea.style.height = '1px'; this.textarea.style.zIndex = '1000'; this.textarea.style.opacity = '0';
// Enable pointer events temporarily so context menu targets the textarea this.textarea.style.pointerEvents = 'auto';
// If there's a selection, populate textarea with it and select the text if (this.hasSelection()) { const text = this.getSelection(); this.textarea.value = text; this.textarea.select(); this.textarea.setSelectionRange(0, text.length); } else { // No selection - clear textarea but still show menu (for paste) this.textarea.value = ''; }
// Focus the textarea so the context menu appears on it this.textarea.focus();
// After a short delay, restore the textarea to its hidden state // This allows the context menu to appear first setTimeout(() => { // Listen for when the context menu closes (user clicks away or selects an option) const resetTextarea = () => { this.textarea.style.pointerEvents = 'none'; this.textarea.style.zIndex = '-10'; this.textarea.style.width = '0'; this.textarea.style.height = '0'; this.textarea.style.left = '0'; this.textarea.style.top = '0'; this.textarea.value = '';
// Remove the one-time listeners document.removeEventListener('click', resetTextarea); document.removeEventListener('contextmenu', resetTextarea); this.textarea.removeEventListener('blur', resetTextarea); };
// Reset on any of these events (menu closed) document.addEventListener('click', resetTextarea, { once: true }); document.addEventListener('contextmenu', resetTextarea, { once: true }); this.textarea.addEventListener('blur', resetTextarea, { once: true }); }, 10);
// Don't prevent default - let browser show the context menu on the textarea };
canvas.addEventListener('contextmenu', this.boundContextMenuHandler);
// Click outside canvas - clear selection // This allows users to deselect by clicking anywhere outside the terminal this.boundClickHandler = (e: MouseEvent) => { // Don't clear selection if we're actively selecting if (this.isSelecting) { return; }
// A click is only valid for clearing selection if BOTH mousedown and mouseup // happened outside the canvas. If mousedown was inside (drag selection), // don't clear even if mouseup/click is outside. const mouseDownWasInCanvas = this.mouseDownTarget && canvas.contains(this.mouseDownTarget as Node); if (mouseDownWasInCanvas) { return; }
// Check if the click is outside the canvas const target = e.target as Node; if (!canvas.contains(target)) { // Clicked outside the canvas - clear selection if (this.hasSelection()) { this.clearSelection(); } } };
document.addEventListener('click', this.boundClickHandler); }
/** * Mark current selection rows as dirty for redraw */ private markCurrentSelectionDirty(): void { const coords = this.normalizeSelection(); if (coords) { for (let row = coords.startRow; row <= coords.endRow; row++) { this.dirtySelectionRows.add(row); } } }
/** * Update auto-scroll based on mouse Y position within canvas */ private updateAutoScroll(offsetY: number, canvasHeight: number): void { const edgeSize = SelectionManager.AUTO_SCROLL_EDGE_SIZE;
if (offsetY < edgeSize) { // Near top edge - scroll up this.startAutoScroll(-1); } else if (offsetY > canvasHeight - edgeSize) { // Near bottom edge - scroll down this.startAutoScroll(1); } else { // In middle - stop scrolling this.stopAutoScroll(); } }
/** * Start auto-scrolling in the given direction */ private startAutoScroll(direction: number): void { // Don't restart if already scrolling in same direction if (this.autoScrollInterval !== null && this.autoScrollDirection === direction) { return; }
// Stop any existing scroll this.stopAutoScroll();
this.autoScrollDirection = direction;
// Start scrolling interval this.autoScrollInterval = setInterval(() => { if (!this.isSelecting) { this.stopAutoScroll(); return; }
// Scroll the terminal to reveal more content in the direction user is dragging // autoScrollDirection: -1 = dragging up (wants to see history), 1 = dragging down (wants to see newer) // scrollLines convention: negative = scroll up into history, positive = scroll down to newer // So direction maps directly to scrollLines sign const scrollAmount = SelectionManager.AUTO_SCROLL_SPEED * this.autoScrollDirection; (this.terminal as any).scrollLines(scrollAmount);
// Extend selection in the scroll direction // Key insight: we need to EXTEND the selection, not reset it to viewport edge if (this.selectionEnd) { const dims = this.wasmTerm.getDimensions(); const viewportY = this.getViewportY(); if (this.autoScrollDirection < 0) { // Scrolling up - extend selection upward (decrease absoluteRow) // Set to top of viewport, but only if it extends the selection const topOfViewport = viewportY; if (topOfViewport < this.selectionEnd.absoluteRow) { this.selectionEnd = { col: 0, absoluteRow: topOfViewport }; } } else { // Scrolling down - extend selection downward (increase absoluteRow) // Set to bottom of viewport, but only if it extends the selection const bottomOfViewport = viewportY + dims.rows - 1; if (bottomOfViewport > this.selectionEnd.absoluteRow) { this.selectionEnd = { col: dims.cols - 1, absoluteRow: bottomOfViewport }; } } }
this.requestRender(); }, SelectionManager.AUTO_SCROLL_INTERVAL); }
/** * Stop auto-scrolling */ private stopAutoScroll(): void { if (this.autoScrollInterval !== null) { clearInterval(this.autoScrollInterval); this.autoScrollInterval = null; } this.autoScrollDirection = 0; }
/** * Convert pixel coordinates to terminal cell coordinates */ private pixelToCell(x: number, y: number): { col: number; row: number } { const metrics = this.renderer.getMetrics();
const col = Math.floor(x / metrics.width); const row = Math.floor(y / metrics.height);
// Clamp to terminal bounds return { col: Math.max(0, Math.min(col, this.terminal.cols - 1)), row: Math.max(0, Math.min(row, this.terminal.rows - 1)), }; }
/** * Normalize selection coordinates (handle backward selection) * Returns coordinates in VIEWPORT space for rendering, clamped to visible area */ private normalizeSelection(): SelectionCoordinates | null { if (!this.selectionStart || !this.selectionEnd) return null;
let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd;
// Swap if selection goes backwards if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { [startCol, endCol] = [endCol, startCol]; [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; }
// Convert to viewport coordinates let startRow = this.absoluteRowToViewport(startAbsRow); let endRow = this.absoluteRowToViewport(endAbsRow);
// Clamp to visible viewport range const dims = this.wasmTerm.getDimensions(); const maxRow = dims.rows - 1;
// If entire selection is outside viewport, return null if (endRow < 0 || startRow > maxRow) { return null; }
// Clamp rows to visible range, adjusting columns for partial rows if (startRow < 0) { startRow = 0; startCol = 0; // Selection starts from beginning of first visible row } if (endRow > maxRow) { endRow = maxRow; endCol = dims.cols - 1; // Selection extends to end of last visible row }
return { startCol, startRow, endCol, endRow }; }
/** * Get word boundaries at a cell position */ private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null { const line = this.wasmTerm.getLine(row); if (!line) return null;
// Word characters: letters, numbers, underscore, dash const isWordChar = (cell: GhosttyCell) => { if (!cell || cell.codepoint === 0) return false; const char = String.fromCodePoint(cell.codepoint); return /[\w-]/.test(char); };
// Only return if we're actually on a word character if (!isWordChar(line[col])) return null;
// Find start of word let startCol = col; while (startCol > 0 && isWordChar(line[startCol - 1])) { startCol--; }
// Find end of word let endCol = col; while (endCol < line.length - 1 && isWordChar(line[endCol + 1])) { endCol++; }
return { startCol, endCol }; }
/** * Copy text to clipboard */ private async copyToClipboard(text: string): Promise<void> { // First try: modern async clipboard API if (navigator.clipboard && navigator.clipboard.writeText) { try { await navigator.clipboard.writeText(text); return; } catch (err) { // Clipboard API failed (common in non-HTTPS or non-focused contexts) // Fall through to legacy method } }
// Second try: legacy execCommand method via textarea const previouslyFocused = document.activeElement as HTMLElement; try { // Position textarea offscreen but in a way that allows selection const textarea = this.textarea; textarea.value = text; textarea.style.position = 'fixed'; // Avoid scrolling to bottom textarea.style.left = '-9999px'; textarea.style.top = '0'; textarea.style.width = '1px'; textarea.style.height = '1px'; textarea.style.opacity = '0';
// Select all text and copy textarea.focus(); textarea.select(); textarea.setSelectionRange(0, text.length);
const success = document.execCommand('copy');
// Restore focus if (previouslyFocused) { previouslyFocused.focus(); }
if (!success) { console.error('❌ execCommand copy failed'); } } catch (err) { console.error('❌ Fallback copy failed:', err); // Still try to restore focus even on error if (previouslyFocused) { previouslyFocused.focus(); } } }
/** * Request a render update (triggers selection overlay redraw) */ private requestRender(): void { // The render loop will automatically pick up the new selection state // and redraw the affected lines. This happens at 60fps. // // Note: When clearSelection() is called, it adds dirty rows to dirtySelectionRows // which the renderer can use to know which lines to redraw. }}