Skip to content

Commit 2262634

Browse files
authored
feat: implement text selection for terminal (#14)
* feat: implement text selection for terminal - Add SelectionManager class for mouse-based text selection * Click and drag to select text * Double-click to select word * Auto-copy to clipboard on selection * Visual selection overlay with semi-transparent highlight - Integrate SelectionManager into Terminal class * Add getSelection(), hasSelection(), clearSelection(), selectAll() API * Add onSelectionChange event * Auto-clear selection when writing new data - Update CanvasRenderer to draw selection overlay * Add setSelectionManager() and getCanvas() methods * Render semi-transparent selection highlight Testing: Selection should now be semi-transparent blue overlay * fix: selection clearing and clipboard feedback Issues Fixed: 1. Selection highlight persists after clicking elsewhere - Now tracks previousSelection coordinates - Renderer redraws those lines to clear old overlay - Selection clears immediately on mousedown 2. No feedback for clipboard operations - Added console.log for successful copy - Added error messages with helpful hints - Shows if Clipboard API is unavailable 3. Improved mousedown behavior - Always clears selection on new click - Starts fresh selection from click point Changes: - Track previousSelection in SelectionManager - Add getPreviousSelectionCoords() and clearPreviousSelection() methods - Renderer checks previousSelection and redraws those lines - Enhanced copyToClipboard() with success/error logging - Simplified mousedown handler Testing: Selection should now clear when clicking elsewhere * fix: add clipboard fallback for non-secure contexts Issue: Clipboard API requires HTTPS or specifically 'localhost' hostname. Custom domains like 'mux.coder' are not considered secure contexts even when they resolve to localhost. Solution: - Try modern navigator.clipboard API first (works on HTTPS/localhost) - Fall back to document.execCommand('copy') for non-secure contexts - execCommand works on custom domains and older browsers - Provide clear console feedback about which method was used This uses the "textarea trick": 1. Create hidden textarea with selected text 2. Select its content 3. Call document.execCommand('copy') 4. Remove textarea Result: Copy works on mux.coder and similar custom domains. Testing: Should now see '✅ Copied to clipboard (fallback)' message and be able to paste the selected text. * fix: restore keyboard input by focusing container on mousedown CRITICAL BUG: Keyboard input stopped working after adding selection. Root Cause: - InputHandler attaches to container element (has tabindex, receives keys) - SelectionManager adds mousedown listener to canvas element - Clicks on canvas don't propagate focus to container - Container loses focus → no keyboard events → no typing Solution: - On mousedown, explicitly focus the parent container - This ensures keyboard events continue to work - Selection still works as expected Testing: - Click on terminal → should be able to type - Select text → should still be able to type afterward - Focus is maintained on the container at all times * fix: restore focus after clipboard copy and optimize selection rendering Issue 1 - Can't type after copy: - copyToClipboardFallback creates textarea and calls textarea.focus() - This steals focus from terminal container - User can't type because container doesn't have focus anymore Fix: - Save document.activeElement before copy - Restore focus after copy completes - Works even if copy fails (try/catch) Issue 2 - Slow input: - Selection lines were being redrawn EVERY frame (60fps) - Even when selection was static and not changing - Caused noticeable input lag Fix: - Only redraw selection lines when actively selecting (mouse is down) - Or when clearing previous selection - When typing, terminal.write() clears selection anyway - This prevents unnecessary redraws during normal typing Performance: - Before: 24 lines redrawn every frame with selectAll() - After: 0 lines redrawn when selection is static - Result: Input is fast again! Testing: Input should be responsive, paste should work repeatedly * Fix: Selection continues after mouse release outside canvas Problem: When dragging to select text and releasing mouse outside canvas bounds, the selection would continue on mousemove because the canvas mouseup event never fired. Solution: Move mouseup listener from canvas to document to catch releases anywhere on the page. This is the standard pattern for drag operations. Changes: - Move mouseup event listener to document scope - Add mouseleave handler for better debugging - Add console.log statements to track isSelecting state - Store boundMouseUpHandler for proper cleanup in dispose() - Update AGENTS.md to avoid committing summary markdown files Test: Drag selection and release mouse outside terminal bounds - selection should stop properly instead of continuing on hover. * chore: remove temporary test and doc files Remove agent-generated files that shouldn't be tracked: - DEBUG_SELECTION.md - IMPLEMENTATION_SUMMARY.md - SELECTION_TESTING.md - test-selection.html - test-selection-alpha.html These were used for debugging during development.
1 parent e3d8e74 commit 2262634

7 files changed

Lines changed: 659 additions & 78 deletions

File tree

AGENTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,13 @@ bun test -t "test name pattern"
529529
530530
**Best practices for agents:**
531531
532+
0. **ALWAYS pull from main before starting work:**
533+
```bash
534+
git fetch origin
535+
git merge origin/main --no-edit
536+
```
537+
This ensures you're working with the latest code and all features are available.
538+
532539
1. **Always run tests after changes:**
533540
```bash
534541
bun test lib/buffer.test.ts
@@ -553,3 +560,9 @@ bun test -t "test name pattern"
553560
5. **Iterate quickly:**
554561
- Vite has hot reload - save file, browser auto-updates
555562
- Keep console open to catch errors immediately
563+
564+
6. **DO NOT commit summary markdown files:**
565+
- Never commit `BUGFIX-*.md`, `SUMMARY-*.md`, or similar documentation files
566+
- These are for immediate context only, not permanent documentation
567+
- Add them to `.gitignore` if creating them frequently
568+
- Commit messages should be comprehensive instead

demo/index.html

Lines changed: 10 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -481,80 +481,14 @@ <h3>📚 Available Commands</h3>
481481
// =========================================================================
482482
// Text Selection Support
483483
// =========================================================================
484-
485-
function enableTextSelection() {
486-
const canvas = term.element?.querySelector('canvas');
487-
if (!canvas) {
488-
console.warn('Canvas not found for selection');
489-
return;
490-
}
491-
492-
let isSelecting = false;
493-
let selectionStart = null;
494-
495-
canvas.addEventListener('mousedown', (e) => {
496-
// Only left click
497-
if (e.button === 0) {
498-
isSelecting = true;
499-
selectionStart = { x: e.offsetX, y: e.offsetY };
500-
501-
// Clear any existing selection
502-
if (window.getSelection) {
503-
window.getSelection().removeAllRanges();
504-
}
505-
}
506-
});
507-
508-
canvas.addEventListener('mousemove', (e) => {
509-
if (isSelecting) {
510-
// Visual feedback that we're selecting
511-
canvas.style.cursor = 'text';
512-
}
513-
});
514-
515-
canvas.addEventListener('mouseup', (e) => {
516-
if (isSelecting) {
517-
isSelecting = false;
518-
canvas.style.cursor = 'default';
519-
520-
// Get selected text from terminal buffer
521-
const text = getSelectedText(selectionStart, { x: e.offsetX, y: e.offsetY });
522-
if (text) {
523-
copyToClipboard(text);
524-
console.log('Selected text:', text);
525-
}
526-
}
527-
});
528-
529-
// Also handle double-click to select word
530-
canvas.addEventListener('dblclick', (e) => {
531-
const text = getWordAtPosition(e.offsetX, e.offsetY);
532-
if (text) {
533-
copyToClipboard(text);
534-
console.log('Selected word:', text);
535-
}
536-
});
537-
}
538-
539-
function getSelectedText(start, end) {
540-
// This is a simplified version - proper implementation would
541-
// calculate character positions from pixel coordinates
542-
// For now, we'll indicate selection is happening
543-
return null; // Would need proper coordinate-to-cell conversion
544-
}
545-
546-
function getWordAtPosition(x, y) {
547-
// Simplified - would need proper implementation
548-
return null;
549-
}
550-
551-
function copyToClipboard(text) {
552-
if (navigator.clipboard && text) {
553-
navigator.clipboard.writeText(text)
554-
.then(() => console.log('Copied to clipboard'))
555-
.catch(err => console.error('Failed to copy:', err));
556-
}
557-
}
484+
485+
// Text selection is now built-in to the Terminal library!
486+
// Use term.getSelection() to access selected text programmatically.
487+
// Features:
488+
// - Click and drag to select text
489+
// - Double-click to select a word
490+
// - Auto-copy to clipboard on selection
491+
// - Ctrl+C / Cmd+C to copy (via browser context menu)
558492

559493
// =========================================================================
560494
// Initialization
@@ -624,8 +558,8 @@ <h3>📚 Available Commands</h3>
624558
// Handle input
625559
term.onData(handleInput);
626560

627-
// Enable text selection with mouse
628-
enableTextSelection();
561+
// Text selection is now built-in - no need to enable it!
562+
// You can use term.getSelection(), term.selectAll(), etc.
629563

630564
// Connect to WebSocket server
631565
connect();

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export { CanvasRenderer } from './renderer';
3737
export type { RendererOptions, FontMetrics, IRenderable } from './renderer';
3838
export { InputHandler } from './input-handler';
3939
export { EventEmitter } from './event-emitter';
40+
export { SelectionManager } from './selection-manager';
41+
export type { SelectionCoordinates } from './selection-manager';
4042

4143
// Addons
4244
export { FitAddon } from './addons/fit';

lib/renderer.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import type { GhosttyCell } from './types';
1414
import { CellFlags } from './types';
1515
import type { ITheme } from './interfaces';
16+
import type { SelectionManager } from './selection-manager';
1617

1718
// Interface for objects that can be rendered
1819
export interface IRenderable {
@@ -92,6 +93,9 @@ export class CanvasRenderer {
9293
private cursorBlinkInterval?: number;
9394
private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
9495

96+
// Selection manager (for rendering selection overlay)
97+
private selectionManager?: SelectionManager;
98+
9599
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
96100
this.canvas = canvas;
97101
const ctx = canvas.getContext('2d', { alpha: false });
@@ -253,19 +257,61 @@ export class CanvasRenderer {
253257
}
254258
}
255259

260+
// Check if we need to redraw selection-related lines
261+
// Only force redraws when actively selecting or clearing selection
262+
const hasSelection = this.selectionManager && this.selectionManager.hasSelection();
263+
const isActivelySelecting = this.selectionManager && this.selectionManager.isActivelySelecting();
264+
const selectionRows = new Set<number>();
265+
266+
// Mark selection rows for redraw ONLY when actively selecting (mouse is down)
267+
// This prevents slowdown when just typing with a static selection
268+
if (hasSelection && isActivelySelecting) {
269+
const coords = this.selectionManager!.getSelectionCoords();
270+
if (coords) {
271+
for (let row = coords.startRow; row <= coords.endRow; row++) {
272+
selectionRows.add(row);
273+
}
274+
}
275+
}
276+
277+
// Always mark previous selection rows for redraw (to clear old overlay)
278+
if (this.selectionManager) {
279+
const prevCoords = this.selectionManager.getPreviousSelectionCoords();
280+
if (prevCoords) {
281+
for (let row = prevCoords.startRow; row <= prevCoords.endRow; row++) {
282+
selectionRows.add(row);
283+
}
284+
// Clear the previous selection tracking after marking for redraw
285+
this.selectionManager.clearPreviousSelection();
286+
}
287+
}
288+
289+
// Track if anything was actually rendered
290+
let anyLinesRendered = false;
291+
256292
// Render each line
257293
for (let y = 0; y < dims.rows; y++) {
258-
// Only render dirty lines for performance (unless forcing all)
259-
if (!forceAll && !buffer.isRowDirty(y)) {
294+
// Render if forcing all, or if dirty, or if it has selection
295+
const needsRender = forceAll || buffer.isRowDirty(y) || selectionRows.has(y);
296+
297+
if (!needsRender) {
260298
continue;
261299
}
262300

301+
anyLinesRendered = true;
263302
const line = buffer.getLine(y);
264303
if (line) {
265304
this.renderLine(line, y, dims.cols);
266305
}
267306
}
268307

308+
// Render selection highlight AFTER all text (so it overlays)
309+
// Only render if we actually rendered some lines
310+
if (hasSelection && anyLinesRendered) {
311+
// Draw selection overlay - only when we've redrawn the underlying text
312+
this.renderSelection(dims.cols);
313+
}
314+
269315
// Render cursor
270316
if (cursor.visible && this.cursorVisible) {
271317
this.renderCursor(cursor.x, cursor.y);
@@ -504,6 +550,20 @@ export class CanvasRenderer {
504550
return { ...this.metrics };
505551
}
506552

553+
/**
554+
* Get canvas element (needed by SelectionManager)
555+
*/
556+
public getCanvas(): HTMLCanvasElement {
557+
return this.canvas;
558+
}
559+
560+
/**
561+
* Set selection manager (for rendering selection overlay)
562+
*/
563+
public setSelectionManager(manager: SelectionManager): void {
564+
this.selectionManager = manager;
565+
}
566+
507567
/**
508568
* Clear entire canvas
509569
*/
@@ -512,6 +572,35 @@ export class CanvasRenderer {
512572
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
513573
}
514574

575+
/**
576+
* Render selection overlay
577+
*/
578+
private renderSelection(cols: number): void {
579+
const coords = this.selectionManager!.getSelectionCoords();
580+
if (!coords) return;
581+
582+
const { startCol, startRow, endCol, endRow } = coords;
583+
584+
// Use semi-transparent fill for selection
585+
this.ctx.save();
586+
this.ctx.fillStyle = this.theme.selectionBackground;
587+
this.ctx.globalAlpha = 0.5; // Make it semi-transparent so text is visible
588+
589+
for (let row = startRow; row <= endRow; row++) {
590+
const colStart = (row === startRow) ? startCol : 0;
591+
const colEnd = (row === endRow) ? endCol : cols - 1;
592+
593+
const x = colStart * this.metrics.width;
594+
const y = row * this.metrics.height;
595+
const width = (colEnd - colStart + 1) * this.metrics.width;
596+
const height = this.metrics.height;
597+
598+
this.ctx.fillRect(x, y, width, height);
599+
}
600+
601+
this.ctx.restore();
602+
}
603+
515604
/**
516605
* Cleanup resources
517606
*/

lib/selection-manager.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, test, expect } from 'bun:test';
2+
import { SelectionManager } from './selection-manager';
3+
import { Terminal } from './terminal';
4+
5+
describe('SelectionManager', () => {
6+
describe('Construction', () => {
7+
test('creates without errors', () => {
8+
const term = new Terminal({ cols: 80, rows: 24 });
9+
// Note: In real tests, you'd need to mock the renderer and wasmTerm
10+
// For now, just verify the module can be imported
11+
expect(SelectionManager).toBeDefined();
12+
});
13+
});
14+
15+
describe('API', () => {
16+
test('has required public methods', () => {
17+
expect(typeof SelectionManager.prototype.getSelection).toBe('function');
18+
expect(typeof SelectionManager.prototype.hasSelection).toBe('function');
19+
expect(typeof SelectionManager.prototype.clearSelection).toBe('function');
20+
expect(typeof SelectionManager.prototype.selectAll).toBe('function');
21+
expect(typeof SelectionManager.prototype.getSelectionCoords).toBe('function');
22+
expect(typeof SelectionManager.prototype.dispose).toBe('function');
23+
});
24+
});
25+
26+
// Note: Full integration tests would require:
27+
// 1. Creating a terminal with open()
28+
// 2. Simulating mouse events on the canvas
29+
// 3. Writing test data to the terminal
30+
// 4. Verifying selected text extraction
31+
//
32+
// These are better suited for browser-based integration tests
33+
// since they require a real DOM canvas element.
34+
});

0 commit comments

Comments
 (0)