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.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/selection-manager.ts b/lib/selection-manager.ts index 3390bdf..5b2af73 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'; @@ -37,13 +38,17 @@ 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 - // 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 +57,44 @@ 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 + + /** + * 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, @@ -76,66 +119,71 @@ 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 screen portion (bottom part of viewport) - const screenRow = row - viewportY; - line = this.wasmTerm.getLine(screenRow); - } + 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; - const colStart = row === startRow ? startCol : 0; - const colEnd = row === endRow ? endCol : line.length - 1; + // 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]; - - // 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) - if (row < endRow) { + // 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'; } } @@ -147,14 +195,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.absoluteRow === this.selectionEnd.absoluteRow + ); } /** @@ -163,8 +210,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; @@ -179,8 +231,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(); } @@ -199,18 +252,19 @@ 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 }; + // 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(); } @@ -231,8 +285,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(); } @@ -254,24 +310,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 +362,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(); @@ -338,38 +422,106 @@ 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; } }); - // 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; + 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 - 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 + ) { + // 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) { @@ -386,8 +538,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(); @@ -463,6 +616,20 @@ 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 + 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)) { @@ -476,6 +643,101 @@ 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 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 */ @@ -494,17 +756,41 @@ export class SelectionManager { /** * 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, 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 + 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 }; @@ -545,58 +831,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 +887,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. } } 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 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);