Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
18 kB · 590 lines
TypeScript
at testing
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591/** * 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) */
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 private selectionStart: { col: number; row: number } | null = null; private selectionEnd: { col: number; row: number } | null = null; private isSelecting: boolean = false;
// Track previous selection for clearing private previousSelection: SelectionCoordinates | null = null;
// 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;
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 { const coords = this.normalizeSelection(); if (!coords) return '';
const { startCol, startRow, endCol, endRow } = coords;
// Get viewport state to handle scrollback correctly const viewportY = (this.terminal as any).viewportY || 0; const scrollbackLength = this.wasmTerm.getScrollbackLength(); let text = '';
for (let row = startRow; row <= endRow; row++) { // Fetch line based on viewport position (same logic as terminal link handling) // When scrolled up (viewportY > 0), we need to fetch from scrollback or screen // depending on which part of the viewport the row is in let line: GhosttyCell[] | null = null;
if (viewportY > 0) { if (row < viewportY) { // Row is in scrollback portion (top part of viewport) const scrollbackOffset = scrollbackLength - viewportY + row; line = this.wasmTerm.getScrollbackLine(scrollbackOffset); } else { // Row is in screen portion (bottom part of viewport) const screenRow = row - viewportY; line = this.wasmTerm.getLine(screenRow); } } else { // Not scrolled - use screen buffer directly line = this.wasmTerm.getLine(row); }
if (!line) continue;
const colStart = row === startRow ? startCol : 0; const colEnd = row === endRow ? endCol : line.length - 1;
for (let col = colStart; col <= colEnd; col++) { const cell = line[col];
// Skip padding cells for wide characters (width=0) if (!cell || cell.width === 0) continue;
// Convert codepoint to character if (cell.codepoint !== 0) { text += String.fromCodePoint(cell.codepoint); } else { text += ' '; // Treat empty cells as spaces } }
// Add newline between rows (but not after last row) if (row < endRow) { text += '\n'; } }
return text; }
/** * Check if there's an active selection */ hasSelection(): boolean { return this.selectionStart !== null && this.selectionEnd !== null; }
/** * Check if currently in the process of selecting (mouse is down) */ isActivelySelecting(): boolean { return this.isSelecting; }
/** * Clear the selection */ clearSelection(): void { if (!this.hasSelection()) return;
// Save current selection so we can force redraw of those lines this.previousSelection = this.normalizeSelection();
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(); this.selectionStart = { col: 0, row: 0 }; this.selectionEnd = { col: dims.cols - 1, row: 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 to next line(s) while (endCol >= dims.cols) { endCol -= dims.cols; endRow++; }
// Clamp end position endRow = Math.min(endRow, dims.rows - 1); endCol = Math.max(0, Math.min(endCol, dims.cols - 1));
this.selectionStart = { col: column, row }; this.selectionEnd = { col: endCol, row: 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]; }
this.selectionStart = { col: 0, row: start }; this.selectionEnd = { col: dims.cols - 1, row: 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 }, }; }
/** * Get normalized selection coordinates (for rendering) */ getSelectionCoords(): SelectionCoordinates | null { return this.normalizeSelection(); }
/** * Get previous selection coordinates (for clearing old highlight) */ getPreviousSelectionCoords(): SelectionCoordinates | null { return this.previousSelection; }
/** * Clear the previous selection tracking (after redraw) */ clearPreviousSelection(): void { this.previousSelection = null; }
/** * Get selection change event accessor */ get onSelectionChange(): IEvent<void> { return this.selectionChangedEmitter.event; }
/** * Cleanup resources */ dispose(): void { this.selectionChangedEmitter.dispose();
// Clean up document event listener if (this.boundMouseUpHandler) { document.removeEventListener('mouseup', this.boundMouseUpHandler); this.boundMouseUpHandler = null; }
// Clean up context menu event listener if (this.boundContextMenuHandler) { const canvas = this.renderer.getCanvas(); canvas.removeEventListener('contextmenu', this.boundContextMenuHandler); this.boundContextMenuHandler = 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 this.selectionStart = cell; this.selectionEnd = cell; this.isSelecting = true; } });
// Mouse move - update selection canvas.addEventListener('mousemove', (e: MouseEvent) => { if (this.isSelecting) { // Save previous selection state before updating this.previousSelection = this.normalizeSelection();
const cell = this.pixelToCell(e.offsetX, e.offsetY); this.selectionEnd = cell; this.requestRender(); } });
// Mouse leave - stop selecting if mouse leaves canvas while dragging canvas.addEventListener('mouseleave', (e: MouseEvent) => { if (this.isSelecting) { // DON'T clear isSelecting here - allow dragging outside canvas // The document mouseup handler will catch the release } });
// 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;
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) { this.selectionStart = { col: word.startCol, row: cell.row }; this.selectionEnd = { col: word.endCol, row: cell.row }; 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); }
/** * 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) */ private normalizeSelection(): SelectionCoordinates | null { if (!this.selectionStart || !this.selectionEnd) return null;
let { col: startCol, row: startRow } = this.selectionStart; let { col: endCol, row: endRow } = this.selectionEnd;
// Swap if selection goes backwards if (startRow > endRow || (startRow === endRow && startCol > endCol)) { [startCol, endCol] = [endCol, startCol]; [startRow, endRow] = [endRow, startRow]; }
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 copyToClipboard(text: string): void { if (!text) return;
// Try modern Clipboard API first (requires secure context) if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard .writeText(text) .then(() => { // Successfully copied }) .catch((err) => { console.error('❌ Clipboard API failed:', err); // Fall back to execCommand this.copyToClipboardFallback(text); }); } else { // Fallback to execCommand for non-secure contexts (like mux.coder) this.copyToClipboardFallback(text); } }
/** * Fallback clipboard copy using execCommand (works in more contexts) */ private copyToClipboardFallback(text: string): void { // Save the currently focused element so we can restore it const previouslyFocused = document.activeElement as HTMLElement | null;
try { // Create a temporary textarea element const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; // Avoid scrolling to bottom textarea.style.left = '-9999px'; textarea.style.top = '-9999px'; document.body.appendChild(textarea);
// Select and copy the text textarea.focus(); // Must focus to select textarea.select(); textarea.setSelectionRange(0, text.length); // For mobile devices
const successful = document.execCommand('copy'); document.body.removeChild(textarea);
// CRITICAL: Restore focus to the terminal if (previouslyFocused) { previouslyFocused.focus(); }
if (!successful) { console.error('❌ Copy failed (both methods)'); } } 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 sets previousSelection // which the renderer can use to know which lines to redraw. }}