From 6d846935add8f7aad8ebf0e58186b5eff5f1676a Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 15:51:45 +0000 Subject: [PATCH 01/11] fix: selection highlight persisting and add auto-scroll during drag selection Bug fixes: 1. Fixed selection highlight not clearing when clicking elsewhere - Root cause: mousemove handler was overwriting previousSelection before the renderer could read it, causing the old selection rows to never get redrawn - Solution: Changed from single previousSelection object to a Set of dirty rows (dirtySelectionRows) that accumulates rows needing redraw 2. Added auto-scroll during drag selection - When dragging selection above or below the terminal viewport, the terminal now automatically scrolls to allow selecting text beyond the visible area - Implemented edge detection (30px from edges triggers scroll) - Added document-level mousemove handler to track mouse position even when outside the canvas during drag - Selection extends to viewport edges during auto-scroll Implementation details: - SelectionManager now uses dirtySelectionRows Set instead of previousSelection - Added markCurrentSelectionDirty() helper method - Added auto-scroll state management (interval, direction, constants) - Added mouseenter/mouseleave handlers for scroll control - Updated renderer to use getDirtySelectionRows()/clearDirtySelectionRows() - Properly cleans up interval and event listeners on dispose() --- lib/renderer.ts | 12 +- lib/selection-manager.ts | 336 ++++++++++++++++++++++++++++++--------- 2 files changed, 265 insertions(+), 83 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index 86800e8..bf04f94 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -308,15 +308,15 @@ export class CanvasRenderer { } } - // Always mark previous selection rows for redraw (to clear old overlay) + // Always mark dirty selection rows for redraw (to clear old overlay) if (this.selectionManager) { - const prevCoords = this.selectionManager.getPreviousSelectionCoords(); - if (prevCoords) { - for (let row = prevCoords.startRow; row <= prevCoords.endRow; row++) { + const dirtyRows = this.selectionManager.getDirtySelectionRows(); + if (dirtyRows.size > 0) { + for (const row of dirtyRows) { selectionRows.add(row); } - // Clear the previous selection tracking after marking for redraw - this.selectionManager.clearPreviousSelection(); + // Clear the dirty rows tracking after marking for redraw + this.selectionManager.clearDirtySelectionRows(); } } diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 3390bdf..b8b45b9 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -7,6 +7,7 @@ * - Text extraction from terminal buffer * - Automatic clipboard copy * - Visual selection overlay (rendered by CanvasRenderer) + * - Auto-scroll during drag selection */ import { EventEmitter } from './event-emitter'; @@ -42,8 +43,10 @@ export class SelectionManager { private selectionEnd: { col: number; row: number } | null = null; private isSelecting: boolean = false; - // Track previous selection for clearing - private previousSelection: SelectionCoordinates | null = null; + // 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 = new Set(); // Event emitter private selectionChangedEmitter = new EventEmitter(); @@ -52,6 +55,14 @@ export class SelectionManager { 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 | 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 + private static readonly AUTO_SCROLL_SPEED = 3; // lines per interval + private static readonly AUTO_SCROLL_INTERVAL = 50; // ms between scroll steps constructor( terminal: Terminal, @@ -106,9 +117,8 @@ export class SelectionManager { 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); + // Row is in visible screen portion (bottom part of viewport) + line = this.wasmTerm.getLine(row - viewportY); } } else { // Not scrolled - use screen buffer directly @@ -117,24 +127,38 @@ export class SelectionManager { if (!line) continue; + // Track the last non-empty column for trimming trailing spaces + let lastNonEmpty = -1; + + // Determine column range for this row const colStart = row === startRow ? startCol : 0; const colEnd = row === endRow ? endCol : line.length - 1; + // Build the line text + let lineText = ''; 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); + if (cell && cell.codepoint !== 0) { + const char = String.fromCodePoint(cell.codepoint); + lineText += char; + if (char.trim()) { + lastNonEmpty = lineText.length; + } } else { - text += ' '; // Treat empty cells as spaces + lineText += ' '; } } - // Add newline between rows (but not after last row) + // 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 (row < endRow) { text += '\n'; } @@ -147,14 +171,13 @@ export class SelectionManager { * Check if there's an active selection */ hasSelection(): boolean { - return this.selectionStart !== null && this.selectionEnd !== null; - } + if (!this.selectionStart || !this.selectionEnd) return false; - /** - * Check if currently in the process of selecting (mouse is down) - */ - isActivelySelecting(): boolean { - return this.isSelecting; + // Check if start and end are the same (single cell, no real selection) + return !( + this.selectionStart.col === this.selectionEnd.col && + this.selectionStart.row === this.selectionEnd.row + ); } /** @@ -163,8 +186,13 @@ export class SelectionManager { clearSelection(): void { if (!this.hasSelection()) return; - // Save current selection so we can force redraw of those lines - this.previousSelection = this.normalizeSelection(); + // 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; @@ -199,15 +227,14 @@ export class SelectionManager { let endRow = row; let endCol = column + length - 1; - // Handle wrapping to next line(s) + // Handle wrapping if selection extends past end of line while (endCol >= dims.cols) { endCol -= dims.cols; endRow++; } - // Clamp end position + // Clamp end row 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 }; @@ -254,24 +281,43 @@ export class SelectionManager { } /** - * Get normalized selection coordinates (for rendering) + * 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 previous selection coordinates (for clearing old highlight) + * Get dirty selection rows that need redraw (for clearing old highlight) */ - getPreviousSelectionCoords(): SelectionCoordinates | null { - return this.previousSelection; + getDirtySelectionRows(): Set { + return this.dirtySelectionRows; } /** - * Clear the previous selection tracking (after redraw) + * Clear the dirty selection rows tracking (after redraw) */ - clearPreviousSelection(): void { - this.previousSelection = null; + clearDirtySelectionRows(): void { + this.dirtySelectionRows.clear(); } /** @@ -287,12 +333,21 @@ export class SelectionManager { 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(); @@ -345,31 +400,87 @@ export class SelectionManager { } }); - // Mouse move - update selection + // Mouse move on canvas - update selection canvas.addEventListener('mousemove', (e: MouseEvent) => { if (this.isSelecting) { - // Save previous selection state before updating - this.previousSelection = this.normalizeSelection(); + // Mark current selection rows as dirty before updating + this.markCurrentSelectionDirty(); const cell = this.pixelToCell(e.offsetX, e.offsetY); this.selectionEnd = cell; this.requestRender(); + + // Check if near edges for auto-scroll + this.updateAutoScroll(e.offsetY, canvas.clientHeight); } }); - // Mouse leave - stop selecting if mouse leaves canvas while dragging + // Mouse leave - check for auto-scroll when leaving canvas during drag 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 + // 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 + ) { + // Mark current selection rows as dirty before updating + this.markCurrentSelectionDirty(); + + const cell = this.pixelToCell(offsetX, offsetY); + this.selectionEnd = cell; + this.requestRender(); + + // 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(); + } + } + } + }; + document.addEventListener('mousemove', this.boundDocumentMouseMoveHandler); + // 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) { @@ -476,6 +587,90 @@ export class SelectionManager { 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 + const scrollAmount = SelectionManager.AUTO_SCROLL_SPEED * this.autoScrollDirection; + (this.terminal as any).scrollLines(-scrollAmount); // Negative because scrollLines convention + + // Update selection end to extend with scroll + // When scrolling up (direction=-1), extend selection to top row + // When scrolling down (direction=1), extend selection to bottom row + if (this.selectionEnd) { + const dims = this.wasmTerm.getDimensions(); + if (this.autoScrollDirection < 0) { + // Scrolling up - extend selection to top + this.selectionEnd = { col: 0, row: 0 }; + } else { + // Scrolling down - extend selection to bottom + this.selectionEnd = { col: dims.cols - 1, row: dims.rows - 1 }; + } + } + + 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 */ @@ -545,58 +740,45 @@ export class SelectionManager { /** * Copy text to clipboard */ - private copyToClipboard(text: string): void { - if (!text) return; - - // Try modern Clipboard API first (requires secure context) + private async copyToClipboard(text: string): Promise { + // First try: modern async clipboard API 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); + 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 + } } - } - - /** - * 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; + // Second try: legacy execCommand method via textarea + const previouslyFocused = document.activeElement as HTMLElement; try { - // Create a temporary textarea element - const textarea = document.createElement('textarea'); + // 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 = '-9999px'; - document.body.appendChild(textarea); + textarea.style.top = '0'; + textarea.style.width = '1px'; + textarea.style.height = '1px'; + textarea.style.opacity = '0'; - // Select and copy the text - textarea.focus(); // Must focus to select + // Select all text and copy + textarea.focus(); textarea.select(); - textarea.setSelectionRange(0, text.length); // For mobile devices + textarea.setSelectionRange(0, text.length); - const successful = document.execCommand('copy'); - document.body.removeChild(textarea); + const success = document.execCommand('copy'); - // CRITICAL: Restore focus to the terminal + // Restore focus if (previouslyFocused) { previouslyFocused.focus(); } - if (!successful) { - console.error('❌ Copy failed (both methods)'); + if (!success) { + console.error('❌ execCommand copy failed'); } } catch (err) { console.error('❌ Fallback copy failed:', err); @@ -614,7 +796,7 @@ export class SelectionManager { // 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 + // Note: When clearSelection() is called, it adds dirty rows to dirtySelectionRows // which the renderer can use to know which lines to redraw. } } From 619b563651bc707dfb40fcaaf7008a65c662bb62 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 16:33:01 +0000 Subject: [PATCH 02/11] fix: preserve selection during data writes and clarify scroll direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: 1. Selection no longer disappears when terminal receives new data - Removed the clearSelection() call in writeInternal() - Modern terminals (iTerm2, xterm.js, etc.) preserve selection when new data arrives - it should only clear on user interaction 2. Clarified auto-scroll direction comments - drag up → scroll up into history (scrollLines negative) - drag down → scroll down to newer content (scrollLines positive) --- lib/selection-manager.ts | 7 +++++-- lib/terminal.ts | 7 +++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index b8b45b9..2868585 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -638,9 +638,12 @@ export class SelectionManager { return; } - // Scroll the terminal + // 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); // Negative because scrollLines convention + (this.terminal as any).scrollLines(scrollAmount); // Update selection end to extend with scroll // When scrolling up (direction=-1), extend selection to top row diff --git a/lib/terminal.ts b/lib/terminal.ts index 82cd0c1..fb9d1ad 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -456,10 +456,9 @@ export class Terminal implements ITerminalCore { * Internal write implementation (extracted from write()) */ private writeInternal(data: string | Uint8Array, callback?: () => void): void { - // Clear selection when writing new data (standard terminal behavior) - if (this.selectionManager?.hasSelection()) { - this.selectionManager.clearSelection(); - } + // Note: We intentionally do NOT clear selection on write - most modern terminals + // preserve selection when new data arrives. Selection is cleared by user actions + // like clicking or typing, not by incoming data. // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); From 1b70ef4bafe108890d70413b56b60cff3c150d4a Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 16:38:02 +0000 Subject: [PATCH 03/11] fix: prevent click handler from clearing selection after drag ends When completing a drag selection (especially outside the window), a click event can fire immediately after mouseup. Added a brief 100ms flag (justFinishedSelecting) to prevent the click handler from clearing the selection in this case. --- lib/selection-manager.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 2868585..0d78a42 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -42,6 +42,7 @@ export class SelectionManager { private selectionStart: { col: number; row: number } | null = null; private selectionEnd: { col: number; row: number } | null = null; private isSelecting: boolean = false; + private justFinishedSelecting: boolean = false; // Brief flag to prevent click from clearing selection // Track rows that need redraw for clearing old selection // Using a Set prevents the overwrite bug where mousemove would clobber @@ -482,6 +483,13 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); + // Set a brief flag to prevent the click handler from immediately clearing + // the selection (click events fire shortly after mouseup) + this.justFinishedSelecting = true; + setTimeout(() => { + this.justFinishedSelecting = false; + }, 100); + const text = this.getSelection(); if (text) { this.copyToClipboard(text); @@ -574,6 +582,12 @@ export class SelectionManager { // 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 or just finished + // (click events can fire shortly after mouseup from drag selection) + if (this.isSelecting || this.justFinishedSelecting) { + return; + } + // Check if the click is outside the canvas const target = e.target as Node; if (!canvas.contains(target)) { From cfcc87633ecc4541dd616d807bc6a045a0e04430 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 16:39:32 +0000 Subject: [PATCH 04/11] fix: track mousedown target instead of using setTimeout Instead of a race-prone setTimeout, track where mousedown occurred. Only clear selection on click if mousedown was also outside the canvas. This properly handles drag selection that ends outside the window. --- lib/selection-manager.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 0d78a42..67d370e 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -42,7 +42,7 @@ export class SelectionManager { private selectionStart: { col: number; row: number } | null = null; private selectionEnd: { col: number; row: number } | null = null; private isSelecting: boolean = false; - private justFinishedSelecting: boolean = false; // Brief flag to prevent click from clearing selection + 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 @@ -476,6 +476,11 @@ export class SelectionManager { }; 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) => { @@ -483,13 +488,6 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); - // Set a brief flag to prevent the click handler from immediately clearing - // the selection (click events fire shortly after mouseup) - this.justFinishedSelecting = true; - setTimeout(() => { - this.justFinishedSelecting = false; - }, 100); - const text = this.getSelection(); if (text) { this.copyToClipboard(text); @@ -582,9 +580,17 @@ export class SelectionManager { // 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 or just finished - // (click events can fire shortly after mouseup from drag selection) - if (this.isSelecting || this.justFinishedSelecting) { + // 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; } From 605e3ad2d9e96ea9e53e7ace3ee94498df4ccbc4 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 16:44:15 +0000 Subject: [PATCH 05/11] fix: use absolute buffer coordinates for selection Selection now uses absolute buffer coordinates (viewportY + viewportRow) instead of viewport-relative coordinates. This ensures: 1. Selection persists correctly when scrolling during drag 2. Text that scrolls off-screen remains highlighted 3. Auto-scroll properly extends selection into scrollback Key changes: - selectionStart/End now store { col, absoluteRow } instead of { col, row } - Added helper methods: getViewportY(), viewportRowToAbsolute(), absoluteRowToViewport() - normalizeSelection() converts back to viewport coords for rendering - All selection setters now convert viewport rows to absolute rows --- lib/selection-manager.ts | 96 +++++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 27 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 67d370e..2cb6051 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -38,9 +38,10 @@ export class SelectionManager { private wasmTerm: GhosttyTerminal; private textarea: HTMLTextAreaElement; - // Selection state - private selectionStart: { col: number; row: number } | null = null; - private selectionEnd: { col: number; row: number } | null = null; + // 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 @@ -62,6 +63,31 @@ export class SelectionManager { private autoScrollInterval: ReturnType | 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 + */ + private viewportRowToAbsolute(viewportRow: number): number { + return this.getViewportY() + viewportRow; + } + + /** + * Convert absolute buffer row to viewport row (may be outside visible range) + */ + private absoluteRowToViewport(absoluteRow: number): number { + return absoluteRow - this.getViewportY(); + } private static readonly AUTO_SCROLL_SPEED = 3; // lines per interval private static readonly AUTO_SCROLL_INTERVAL = 50; // ms between scroll steps @@ -177,7 +203,7 @@ export class SelectionManager { // Check if start and end are the same (single cell, no real selection) return !( this.selectionStart.col === this.selectionEnd.col && - this.selectionStart.row === this.selectionEnd.row + this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow ); } @@ -208,8 +234,9 @@ export class SelectionManager { */ selectAll(): void { const dims = this.wasmTerm.getDimensions(); - this.selectionStart = { col: 0, row: 0 }; - this.selectionEnd = { col: dims.cols - 1, row: dims.rows - 1 }; + 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(); } @@ -237,8 +264,10 @@ export class SelectionManager { // Clamp end row endRow = Math.min(endRow, dims.rows - 1); - this.selectionStart = { col: column, row }; - this.selectionEnd = { col: endCol, row: endRow }; + // 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(); } @@ -259,8 +288,10 @@ export class SelectionManager { [start, end] = [end, start]; } - this.selectionStart = { col: 0, row: start }; - this.selectionEnd = { col: dims.cols - 1, row: end }; + // 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(); } @@ -394,9 +425,10 @@ export class SelectionManager { this.clearSelection(); } - // Start new selection - this.selectionStart = cell; - this.selectionEnd = cell; + // 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; } }); @@ -408,7 +440,8 @@ export class SelectionManager { this.markCurrentSelectionDirty(); const cell = this.pixelToCell(e.offsetX, e.offsetY); - this.selectionEnd = cell; + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionEnd = { col: cell.col, absoluteRow }; this.requestRender(); // Check if near edges for auto-scroll @@ -460,7 +493,8 @@ export class SelectionManager { this.markCurrentSelectionDirty(); const cell = this.pixelToCell(offsetX, offsetY); - this.selectionEnd = cell; + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionEnd = { col: cell.col, absoluteRow }; this.requestRender(); // Update auto-scroll direction based on mouse position @@ -503,8 +537,9 @@ export class SelectionManager { 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 }; + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionStart = { col: word.startCol, absoluteRow }; + this.selectionEnd = { col: word.endCol, absoluteRow }; this.requestRender(); const text = this.getSelection(); @@ -666,16 +701,18 @@ export class SelectionManager { (this.terminal as any).scrollLines(scrollAmount); // Update selection end to extend with scroll - // When scrolling up (direction=-1), extend selection to top row - // When scrolling down (direction=1), extend selection to bottom row + // When scrolling up (direction=-1), extend selection to top of viewport + // When scrolling down (direction=1), extend selection to bottom of viewport + // Use absolute coordinates so selection persists across scroll if (this.selectionEnd) { const dims = this.wasmTerm.getDimensions(); + const viewportY = this.getViewportY(); if (this.autoScrollDirection < 0) { - // Scrolling up - extend selection to top - this.selectionEnd = { col: 0, row: 0 }; + // Scrolling up - extend selection to top of current viewport + this.selectionEnd = { col: 0, absoluteRow: viewportY }; } else { - // Scrolling down - extend selection to bottom - this.selectionEnd = { col: dims.cols - 1, row: dims.rows - 1 }; + // Scrolling down - extend selection to bottom of current viewport + this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + dims.rows - 1 }; } } @@ -712,19 +749,24 @@ export class SelectionManager { /** * Normalize selection coordinates (handle backward selection) + * Returns coordinates in VIEWPORT space for rendering */ 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; + let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; + let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd; // Swap if selection goes backwards - if (startRow > endRow || (startRow === endRow && startCol > endCol)) { + if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { [startCol, endCol] = [endCol, startCol]; - [startRow, endRow] = [endRow, startRow]; + [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; } + // Convert to viewport coordinates + const startRow = this.absoluteRowToViewport(startAbsRow); + const endRow = this.absoluteRowToViewport(endAbsRow); + return { startCol, startRow, endCol, endRow }; } From 894208dd1717a0db6e4cc2051788d1fd1382539f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 16:52:56 +0000 Subject: [PATCH 06/11] fix: clamp selection viewport coordinates to visible range When selection extends beyond visible viewport (during scroll), clamp the rendered coordinates to the visible area. This prevents: - Negative row indices - Row indices beyond terminal height - Renderer trying to draw millions of rows Selection still tracks full absolute range, but rendering is clamped. --- lib/selection-manager.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 2cb6051..b725298 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -749,7 +749,7 @@ export class SelectionManager { /** * Normalize selection coordinates (handle backward selection) - * Returns coordinates in VIEWPORT space for rendering + * Returns coordinates in VIEWPORT space for rendering, clamped to visible area */ private normalizeSelection(): SelectionCoordinates | null { if (!this.selectionStart || !this.selectionEnd) return null; @@ -764,8 +764,27 @@ export class SelectionManager { } // Convert to viewport coordinates - const startRow = this.absoluteRowToViewport(startAbsRow); - const endRow = this.absoluteRowToViewport(endAbsRow); + 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 }; } From 25152845db2df6b14cf6607aa11f315f2d9a0860 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 16:55:24 +0000 Subject: [PATCH 07/11] fix: extend selection during auto-scroll instead of resetting The bug: during auto-scroll, selectionEnd was being reset to the viewport edge on each tick. As the viewport moved, the same edge position meant a smaller selection range. The fix: only update selectionEnd if the new position actually extends the selection (lower absoluteRow when scrolling up, higher when scrolling down). This makes the selection grow as you scroll, not shrink. --- lib/selection-manager.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index b725298..8ea5296 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -700,19 +700,25 @@ export class SelectionManager { const scrollAmount = SelectionManager.AUTO_SCROLL_SPEED * this.autoScrollDirection; (this.terminal as any).scrollLines(scrollAmount); - // Update selection end to extend with scroll - // When scrolling up (direction=-1), extend selection to top of viewport - // When scrolling down (direction=1), extend selection to bottom of viewport - // Use absolute coordinates so selection persists across scroll + // 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 to top of current viewport - this.selectionEnd = { col: 0, absoluteRow: viewportY }; + // 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 to bottom of current viewport - this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + dims.rows - 1 }; + // 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 }; + } } } From 9468ca8047def174b593d57984bf9e270cac85c9 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 17:07:26 +0000 Subject: [PATCH 08/11] fix: don't update selectionEnd from mousemove during auto-scroll The bug: document mousemove handler was constantly resetting selectionEnd to the clamped mouse position (viewport edge) during auto-scroll. This overwrote the extended selection from the scroll handler. The fix: skip selectionEnd updates in mousemove when auto-scroll is active. The scroll interval handler is responsible for extending the selection during auto-scroll. --- lib/selection-manager.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 8ea5296..8aedd52 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -489,14 +489,6 @@ export class SelectionManager { e.clientY < rect.top || e.clientY > rect.bottom ) { - // 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(); - // Update auto-scroll direction based on mouse position if (e.clientY < rect.top) { this.startAutoScroll(-1); @@ -505,6 +497,18 @@ export class SelectionManager { } 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(); + } } } }; From ef8b3bb0e98f326f30b65c864db94338250bc5bf Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 17:10:07 +0000 Subject: [PATCH 09/11] fix: correct viewport-to-absolute row conversion formula The formula was wrong. Absolute row should be an index into the combined buffer (scrollback + screen). The correct conversions: - viewportRow -> absolute: scrollbackLength + viewportRow - viewportY - absolute -> viewportRow: absoluteRow - scrollbackLength + viewportY This ensures that when you scroll, the same absolute row maps to different viewport rows, keeping the selection tied to the actual content. --- lib/selection-manager.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 8aedd52..fffa3e2 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -77,16 +77,21 @@ export class SelectionManager { /** * 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 { - return this.getViewportY() + viewportRow; + 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 { - return absoluteRow - this.getViewportY(); + 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 From 8f5a89a2216a439daeaafc8aff46fd56f6f06897 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 17:13:13 +0000 Subject: [PATCH 10/11] fix: getSelection() now uses absolute coordinates to fetch all selected content Previously getSelection() was using viewport-relative coordinates from normalizeSelection(), which meant it only copied the visible portion. Now it directly uses the absolute row coordinates from selectionStart/End, iterating through scrollback and screen buffer based on absolute position. This correctly copies all selected text even when the selection extends beyond the visible viewport. --- lib/selection-manager.ts | 54 +++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index fffa3e2..5b2af73 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -119,42 +119,34 @@ export class SelectionManager { * Get the selected text as a string */ getSelection(): string { - const coords = this.normalizeSelection(); - if (!coords) return ''; + if (!this.selectionStart || !this.selectionEnd) return ''; - const { startCol, startRow, endCol, endRow } = coords; + // 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]; + } - // Get viewport state to handle scrollback correctly - // Note: viewportY can be fractional during smooth scrolling, but the renderer - // always uses Math.floor(viewportY) when mapping viewport rows to scrollback - // vs screen. We mirror that logic here so copied text matches the visual - // selection exactly. - const rawViewportY = - typeof (this.terminal as any).getViewportY === 'function' - ? (this.terminal as any).getViewportY() - : (this.terminal as any).viewportY || 0; - const viewportY = Math.max(0, Math.floor(rawViewportY)); 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 + 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 (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 visible screen portion (bottom part of viewport) - line = this.wasmTerm.getLine(row - viewportY); - } + if (absRow < scrollbackLength) { + // Row is in scrollback + line = this.wasmTerm.getScrollbackLine(absRow); } else { - // Not scrolled - use screen buffer directly - line = this.wasmTerm.getLine(row); + // Row is in screen buffer + const screenRow = absRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); } if (!line) continue; @@ -163,8 +155,8 @@ export class SelectionManager { let lastNonEmpty = -1; // Determine column range for this row - const colStart = row === startRow ? startCol : 0; - const colEnd = row === endRow ? endCol : line.length - 1; + const colStart = absRow === startAbsRow ? startCol : 0; + const colEnd = absRow === endAbsRow ? endCol : line.length - 1; // Build the line text let lineText = ''; @@ -191,7 +183,7 @@ export class SelectionManager { text += lineText; // Add newline between rows (but not after the last row) - if (row < endRow) { + if (absRow < endAbsRow) { text += '\n'; } } From 40fb5d9212d4f9fab2d0f608d8dcd1b56541cd64 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Nov 2025 17:18:57 +0000 Subject: [PATCH 11/11] test: add comprehensive selection manager tests Added 22 tests covering: - Basic API methods (hasSelection, clearSelection, getSelection, etc.) - Selection with absolute coordinates - Text extraction from screen buffer, scrollback, and spanning both - Selection persistence during scroll - Viewport coordinate conversion after scrolling - Dirty row tracking for efficient redraws - Backward selection (right-to-left, bottom-to-top) - selectAll, select(), and selectLines() APIs Also updated existing terminal.test.ts selection tests to use the new absolute coordinate format via helper function. --- lib/selection-manager.test.ts | 548 ++++++++++++++++++++++++++++++++-- lib/terminal.test.ts | 73 +++-- 2 files changed, 578 insertions(+), 43 deletions(-) diff --git a/lib/selection-manager.test.ts b/lib/selection-manager.test.ts index 4af9b67..ad30fac 100644 --- a/lib/selection-manager.test.ts +++ b/lib/selection-manager.test.ts @@ -1,34 +1,536 @@ -import { describe, expect, test } from 'bun:test'; -import { SelectionManager } from './selection-manager'; +/** + * Selection Manager Tests + * + * Tests for text selection functionality including: + * - Basic selection operations + * - Absolute coordinate system for scroll persistence + * - Selection clearing behavior + * - Auto-scroll during drag selection + * - Copy functionality with scrollback + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { Terminal } from './terminal'; +/** + * Helper to open terminal and wait for WASM to be ready. + */ +async function openAndWaitForReady(term: Terminal, container: HTMLElement): Promise { + term.open(container); + await new Promise((resolve) => term.onReady(resolve)); +} + +/** + * Helper to set selection using absolute coordinates + */ +function setSelectionAbsolute( + term: Terminal, + startCol: number, + startAbsRow: number, + endCol: number, + endAbsRow: number +): void { + const selMgr = (term as any).selectionManager; + if (selMgr) { + (selMgr as any).selectionStart = { col: startCol, absoluteRow: startAbsRow }; + (selMgr as any).selectionEnd = { col: endCol, absoluteRow: endAbsRow }; + } +} + +/** + * Helper to convert viewport row to absolute row + */ +function viewportToAbsolute(term: Terminal, viewportRow: number): number { + const scrollbackLength = term.wasmTerm?.getScrollbackLength() ?? 0; + const viewportY = term.getViewportY(); + return scrollbackLength + viewportRow - Math.floor(viewportY); +} + describe('SelectionManager', () => { + let container: HTMLElement | null = null; + + beforeEach(() => { + if (typeof document !== 'undefined') { + container = document.createElement('div'); + document.body.appendChild(container); + } + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + container = null; + } + }); + describe('Construction', () => { test('creates without errors', () => { const term = new Terminal({ cols: 80, rows: 24 }); - // Note: In real tests, you'd need to mock the renderer and wasmTerm - // For now, just verify the module can be imported - expect(SelectionManager).toBeDefined(); + expect(term).toBeDefined(); }); }); describe('API', () => { - test('has required public methods', () => { - expect(typeof SelectionManager.prototype.getSelection).toBe('function'); - expect(typeof SelectionManager.prototype.hasSelection).toBe('function'); - expect(typeof SelectionManager.prototype.clearSelection).toBe('function'); - expect(typeof SelectionManager.prototype.selectAll).toBe('function'); - expect(typeof SelectionManager.prototype.getSelectionCoords).toBe('function'); - expect(typeof SelectionManager.prototype.dispose).toBe('function'); - }); - }); - - // Note: Full integration tests would require: - // 1. Creating a terminal with open() - // 2. Simulating mouse events on the canvas - // 3. Writing test data to the terminal - // 4. Verifying selected text extraction - // - // These are better suited for browser-based integration tests - // since they require a real DOM canvas element. + test('has required public methods', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + const selMgr = (term as any).selectionManager; + expect(typeof selMgr.getSelection).toBe('function'); + expect(typeof selMgr.hasSelection).toBe('function'); + expect(typeof selMgr.clearSelection).toBe('function'); + expect(typeof selMgr.selectAll).toBe('function'); + expect(typeof selMgr.getSelectionCoords).toBe('function'); + expect(typeof selMgr.dispose).toBe('function'); + expect(typeof selMgr.getDirtySelectionRows).toBe('function'); + expect(typeof selMgr.clearDirtySelectionRows).toBe('function'); + + term.dispose(); + }); + }); + + describe('Selection with absolute coordinates', () => { + test('hasSelection returns false when no selection', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + const selMgr = (term as any).selectionManager; + expect(selMgr.hasSelection()).toBe(false); + + term.dispose(); + }); + + test('hasSelection returns true when selection exists', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Hello World\r\n'); + + // Set selection using absolute coordinates + setSelectionAbsolute(term, 0, 0, 5, 0); + + const selMgr = (term as any).selectionManager; + expect(selMgr.hasSelection()).toBe(true); + + term.dispose(); + }); + + test('hasSelection returns false for single cell selection', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + // Same start and end = no real selection + setSelectionAbsolute(term, 5, 0, 5, 0); + + const selMgr = (term as any).selectionManager; + expect(selMgr.hasSelection()).toBe(false); + + term.dispose(); + }); + + test('clearSelection clears selection and marks rows dirty', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 2); + + const selMgr = (term as any).selectionManager; + expect(selMgr.hasSelection()).toBe(true); + + selMgr.clearSelection(); + + expect(selMgr.hasSelection()).toBe(false); + // Dirty rows should be marked for redraw + const dirtyRows = selMgr.getDirtySelectionRows(); + expect(dirtyRows.size).toBeGreaterThan(0); + + term.dispose(); + }); + }); + + describe('Selection text extraction', () => { + test('getSelection returns empty string when no selection', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + const selMgr = (term as any).selectionManager; + expect(selMgr.getSelection()).toBe(''); + + term.dispose(); + }); + + test('getSelection extracts text from screen buffer', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Hello World\r\n'); + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + // Select "Hello" (first 5 characters) + setSelectionAbsolute(term, 0, scrollbackLen, 4, scrollbackLen); + + const selMgr = (term as any).selectionManager; + expect(selMgr.getSelection()).toBe('Hello'); + + term.dispose(); + }); + + test('getSelection extracts multi-line text', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + // Select all three lines + setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 2); + + const selMgr = (term as any).selectionManager; + const text = selMgr.getSelection(); + + expect(text).toContain('Line 1'); + expect(text).toContain('Line 2'); + expect(text).toContain('Line 3'); + + term.dispose(); + }); + + test('getSelection extracts text from scrollback', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); + await openAndWaitForReady(term, container); + + // Write enough lines to create scrollback + for (let i = 0; i < 50; i++) { + term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); + } + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + expect(scrollbackLen).toBeGreaterThan(0); + + // Select from scrollback (first few lines) + setSelectionAbsolute(term, 0, 0, 10, 2); + + const selMgr = (term as any).selectionManager; + const text = selMgr.getSelection(); + + expect(text).toContain('Line 000'); + expect(text).toContain('Line 001'); + expect(text).toContain('Line 002'); + + term.dispose(); + }); + + test('getSelection extracts text spanning scrollback and screen', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); + await openAndWaitForReady(term, container); + + // Write enough lines to fill scrollback and screen + for (let i = 0; i < 50; i++) { + term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); + } + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + + // Select spanning scrollback and screen + // End of scrollback through beginning of screen + setSelectionAbsolute(term, 0, scrollbackLen - 2, 10, scrollbackLen + 2); + + const selMgr = (term as any).selectionManager; + const text = selMgr.getSelection(); + + // Should contain lines from both regions + expect(text.split('\n').length).toBeGreaterThanOrEqual(4); + + term.dispose(); + }); + }); + + describe('Selection persistence during scroll', () => { + test('selection coordinates are preserved when scrolling', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); + await openAndWaitForReady(term, container); + + // Write content + for (let i = 0; i < 50; i++) { + term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); + } + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + + // Set selection at specific absolute position + const startAbsRow = scrollbackLen + 5; + const endAbsRow = scrollbackLen + 10; + setSelectionAbsolute(term, 0, startAbsRow, 10, endAbsRow); + + const selMgr = (term as any).selectionManager; + const textBefore = selMgr.getSelection(); + + // Scroll up + term.scrollLines(-10); + + // Selection should still return the same text + const textAfter = selMgr.getSelection(); + expect(textAfter).toBe(textBefore); + + term.dispose(); + }); + + test('selection coords convert correctly after scrolling', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); + await openAndWaitForReady(term, container); + + // Write content + for (let i = 0; i < 50; i++) { + term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); + } + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + + // Set selection in screen buffer area + setSelectionAbsolute(term, 0, scrollbackLen, 10, scrollbackLen + 5); + + const selMgr = (term as any).selectionManager; + + // Get viewport coords before scroll + const coordsBefore = selMgr.getSelectionCoords(); + expect(coordsBefore).not.toBeNull(); + + // Scroll up 10 lines + term.scrollLines(-10); + + // Get viewport coords after scroll - they should have shifted + const coordsAfter = selMgr.getSelectionCoords(); + expect(coordsAfter).not.toBeNull(); + + // Viewport row should have increased by the scroll amount + expect(coordsAfter!.startRow).toBe(coordsBefore!.startRow + 10); + expect(coordsAfter!.endRow).toBe(coordsBefore!.endRow + 10); + + term.dispose(); + }); + + test('selection outside viewport returns null coords but preserves text', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); + await openAndWaitForReady(term, container); + + // Write content + for (let i = 0; i < 100; i++) { + term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); + } + + // Select near the bottom of the buffer + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + setSelectionAbsolute(term, 0, scrollbackLen + 10, 10, scrollbackLen + 15); + + const selMgr = (term as any).selectionManager; + const text = selMgr.getSelection(); + + // Scroll to top - selection should be way off screen + term.scrollToTop(); + + // Coords should be null (off screen) but text should still work + const coords = selMgr.getSelectionCoords(); + expect(coords).toBeNull(); + + // Text extraction should still work + expect(selMgr.getSelection()).toBe(text); + + term.dispose(); + }); + }); + + describe('Dirty row tracking', () => { + test('getDirtySelectionRows returns empty set initially', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + const selMgr = (term as any).selectionManager; + expect(selMgr.getDirtySelectionRows().size).toBe(0); + + term.dispose(); + }); + + test('clearSelection marks selection rows as dirty', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Test content\r\n'); + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 3); + + const selMgr = (term as any).selectionManager; + selMgr.clearSelection(); + + const dirtyRows = selMgr.getDirtySelectionRows(); + expect(dirtyRows.size).toBeGreaterThan(0); + + term.dispose(); + }); + + test('clearDirtySelectionRows clears the set', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Test\r\n'); + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen); + + const selMgr = (term as any).selectionManager; + selMgr.clearSelection(); + + expect(selMgr.getDirtySelectionRows().size).toBeGreaterThan(0); + + selMgr.clearDirtySelectionRows(); + + expect(selMgr.getDirtySelectionRows().size).toBe(0); + + term.dispose(); + }); + }); + + describe('Backward selection', () => { + test('handles selection from right to left', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Hello World\r\n'); + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + // Select backwards (end before start) + setSelectionAbsolute(term, 10, scrollbackLen, 0, scrollbackLen); + + const selMgr = (term as any).selectionManager; + const text = selMgr.getSelection(); + + expect(text).toBe('Hello World'); + + term.dispose(); + }); + + test('handles selection from bottom to top', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); + + const scrollbackLen = term.wasmTerm!.getScrollbackLength(); + // Select backwards (end row before start row) + setSelectionAbsolute(term, 5, scrollbackLen + 2, 0, scrollbackLen); + + const selMgr = (term as any).selectionManager; + const text = selMgr.getSelection(); + + expect(text).toContain('Line 1'); + expect(text).toContain('Line 2'); + expect(text).toContain('Line 3'); + + term.dispose(); + }); + }); + + describe('selectAll', () => { + test('selectAll selects entire viewport', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Hello\r\nWorld\r\n'); + + const selMgr = (term as any).selectionManager; + selMgr.selectAll(); + + expect(selMgr.hasSelection()).toBe(true); + + const coords = selMgr.getSelectionCoords(); + expect(coords).not.toBeNull(); + expect(coords!.startRow).toBe(0); + expect(coords!.startCol).toBe(0); + expect(coords!.endRow).toBe(23); // rows - 1 + + term.dispose(); + }); + }); + + describe('select() API', () => { + test('select() creates selection at specified position', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Hello World\r\n'); + + const selMgr = (term as any).selectionManager; + selMgr.select(0, 0, 5); + + expect(selMgr.hasSelection()).toBe(true); + expect(selMgr.getSelection()).toBe('Hello'); + + term.dispose(); + }); + }); + + describe('selectLines() API', () => { + test('selectLines() selects entire lines', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + term.write('Line 1\r\nLine 2\r\nLine 3\r\n'); + + const selMgr = (term as any).selectionManager; + selMgr.selectLines(0, 1); + + expect(selMgr.hasSelection()).toBe(true); + + const text = selMgr.getSelection(); + expect(text).toContain('Line 1'); + expect(text).toContain('Line 2'); + + term.dispose(); + }); + }); }); diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index ae62e77..c5ff769 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -21,6 +21,39 @@ async function openAndWaitForReady(term: Terminal, container: HTMLElement): Prom await new Promise((resolve) => term.onReady(resolve)); } +/** + * Helper to convert viewport row to absolute buffer row for selection tests. + * Absolute row = scrollbackLength + viewportRow - viewportY + */ +function viewportRowToAbsolute(term: Terminal, viewportRow: number): number { + const scrollbackLength = term.wasmTerm?.getScrollbackLength() ?? 0; + const viewportY = Math.floor(term.getViewportY()); + return scrollbackLength + viewportRow - viewportY; +} + +/** + * Helper to set selection using viewport-relative rows (converts to absolute internally) + */ +function setSelectionViewportRelative( + term: Terminal, + startCol: number, + startViewportRow: number, + endCol: number, + endViewportRow: number +): void { + const selMgr = (term as any).selectionManager; + if (selMgr) { + (selMgr as any).selectionStart = { + col: startCol, + absoluteRow: viewportRowToAbsolute(term, startViewportRow), + }; + (selMgr as any).selectionEnd = { + col: endCol, + absoluteRow: viewportRowToAbsolute(term, endViewportRow), + }; + } +} + describe('Terminal', () => { let container: HTMLElement | null = null; @@ -1344,11 +1377,11 @@ describe('Selection with Scrollback', () => { // - Viewport row 7 = Line 034 (first 20 chars) // Use the internal selection manager to set selection - if ((term as any).selectionManager) { - const selMgr = (term as any).selectionManager; - (selMgr as any).selectionStart = { col: 0, row: 5 }; - (selMgr as any).selectionEnd = { col: 20, row: 7 }; + // Using helper to convert viewport rows to absolute coordinates + setSelectionViewportRelative(term, 0, 5, 20, 7); + const selMgr = (term as any).selectionManager; + if (selMgr) { const selectedText = selMgr.getSelection(); // Should contain "Line 032", "Line 033", and start of "Line 034" @@ -1385,11 +1418,11 @@ describe('Selection with Scrollback', () => { // - Bottom 14 rows: screen buffer content (lines 77-90) // Select from row 8 (in scrollback) to row 12 (in screen buffer) - if ((term as any).selectionManager) { - const selMgr = (term as any).selectionManager; - (selMgr as any).selectionStart = { col: 0, row: 8 }; - (selMgr as any).selectionEnd = { col: 10, row: 12 }; + // Using helper to convert viewport rows to absolute coordinates + setSelectionViewportRelative(term, 0, 8, 10, 12); + const selMgr = (term as any).selectionManager; + if (selMgr) { const selectedText = selMgr.getSelection(); // Row 8 is in scrollback (scrollback offset: 77-10+8 = 75) @@ -1420,11 +1453,11 @@ describe('Selection with Scrollback', () => { expect(term.viewportY).toBe(0); // Select from screen buffer (last visible lines) - if ((term as any).selectionManager) { - const selMgr = (term as any).selectionManager; - (selMgr as any).selectionStart = { col: 0, row: 0 }; - (selMgr as any).selectionEnd = { col: 10, row: 2 }; + // Using helper to convert viewport rows to absolute coordinates + setSelectionViewportRelative(term, 0, 0, 10, 2); + const selMgr = (term as any).selectionManager; + if (selMgr) { const selectedText = selMgr.getSelection(); // Should get lines from screen buffer (lines 77-99 visible, we select first 3) @@ -1462,11 +1495,11 @@ describe('Selection with Scrollback', () => { // For viewport row 1: // scrollbackOffset = 77 - 10 + 1 = 68 => "Line 068" - if ((term as any).selectionManager) { - const selMgr = (term as any).selectionManager; - (selMgr as any).selectionStart = { col: 0, row: 0 }; - (selMgr as any).selectionEnd = { col: 10, row: 1 }; + // Using helper to convert viewport rows to absolute coordinates + setSelectionViewportRelative(term, 0, 0, 10, 1); + const selMgr = (term as any).selectionManager; + if (selMgr) { const selectedText = selMgr.getSelection(); expect(selectedText).toContain('Line 067'); @@ -1497,11 +1530,11 @@ describe('Selection with Scrollback', () => { expect(viewportY).toBeGreaterThan(0); // Select first few lines (all in scrollback) - if ((term as any).selectionManager) { - const selMgr = (term as any).selectionManager; - (selMgr as any).selectionStart = { col: 0, row: 0 }; - (selMgr as any).selectionEnd = { col: 20, row: 2 }; + // Using helper to convert viewport rows to absolute coordinates + setSelectionViewportRelative(term, 0, 0, 20, 2); + const selMgr = (term as any).selectionManager; + if (selMgr) { const selectedText = selMgr.getSelection(); // Should get the oldest scrollback lines