-
Notifications
You must be signed in to change notification settings - Fork 123
feat(selection): Add triple-click and selection improvements #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
f9692f1
268dd2b
2fa0ba5
0a3a668
60cd5d9
fa61c4d
f15e118
3c6ec0c
3746232
817b0c2
9372d9e
a3fb59a
bf1a07f
78a90d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,9 @@ export class SelectionManager { | |
| private selectionStart: { col: number; absoluteRow: number } | null = null; | ||
| private selectionEnd: { col: number; absoluteRow: number } | null = null; | ||
| private isSelecting: boolean = false; | ||
| private mouseDownX: number = 0; | ||
| private mouseDownY: number = 0; | ||
| private dragThresholdMet: boolean = false; | ||
| private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred | ||
|
|
||
| // Track rows that need redraw for clearing old selection | ||
|
|
@@ -209,11 +212,11 @@ export class SelectionManager { | |
| hasSelection(): boolean { | ||
| if (!this.selectionStart || !this.selectionEnd) return false; | ||
|
|
||
| // Check if start and end are the same (single cell, no real selection) | ||
| return !( | ||
| this.selectionStart.col === this.selectionEnd.col && | ||
| this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow | ||
| ); | ||
| // Same start and end means no real selection | ||
| // Note: click-without-drag clears same-cell in mouseup handler, | ||
| // so any same-cell selection here is programmatic (e.g., triple-click single-char) | ||
| // which IS a valid selection | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -313,9 +316,8 @@ export class SelectionManager { | |
| } | ||
|
|
||
| // 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.selectionStart = { col: 0, absoluteRow: this.viewportRowToAbsolute(start) }; | ||
| this.selectionEnd = { col: dims.cols - 1, absoluteRow: this.viewportRowToAbsolute(end) }; | ||
| this.requestRender(); | ||
| this.selectionChangedEmitter.fire(); | ||
| } | ||
|
|
@@ -452,14 +454,29 @@ export class SelectionManager { | |
| // 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.selectionEnd = null; // Don't highlight until drag | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Initializing Useful? React with 👍 / 👎. |
||
| this.isSelecting = true; | ||
| this.mouseDownX = e.offsetX; | ||
| this.mouseDownY = e.offsetY; | ||
| this.dragThresholdMet = false; | ||
| } | ||
| }); | ||
|
|
||
| // Mouse move on canvas - update selection | ||
| canvas.addEventListener('mousemove', (e: MouseEvent) => { | ||
| if (this.isSelecting) { | ||
| // Check if drag threshold has been met | ||
| if (!this.dragThresholdMet) { | ||
| const dx = e.offsetX - this.mouseDownX; | ||
| const dy = e.offsetY - this.mouseDownY; | ||
| // Use 50% of cell width as threshold to scale with font size | ||
| const threshold = this.renderer.getMetrics().width * 0.5; | ||
| if (dx * dx + dy * dy < threshold * threshold) { | ||
| return; // Below threshold, ignore | ||
| } | ||
| this.dragThresholdMet = true; | ||
| } | ||
|
|
||
| // Mark current selection rows as dirty before updating | ||
| this.markCurrentSelectionDirty(); | ||
|
|
||
|
|
@@ -496,6 +513,17 @@ export class SelectionManager { | |
| // Document-level mousemove for tracking mouse position during drag outside canvas | ||
| this.boundDocumentMouseMoveHandler = (e: MouseEvent) => { | ||
| if (this.isSelecting) { | ||
| // Check drag threshold (same as canvas mousemove) | ||
| if (!this.dragThresholdMet) { | ||
| const dx = e.clientX - (canvas.getBoundingClientRect().left + this.mouseDownX); | ||
| const dy = e.clientY - (canvas.getBoundingClientRect().top + this.mouseDownY); | ||
| const threshold = this.renderer.getMetrics().width * 0.5; | ||
| if (dx * dx + dy * dy < threshold * threshold) { | ||
| return; | ||
| } | ||
| this.dragThresholdMet = true; | ||
| } | ||
|
|
||
| const rect = canvas.getBoundingClientRect(); | ||
|
|
||
| // Update selection based on clamped position | ||
|
|
@@ -550,6 +578,13 @@ export class SelectionManager { | |
| this.isSelecting = false; | ||
| this.stopAutoScroll(); | ||
|
|
||
| // Check if this was a click without drag, or sub-cell jitter. | ||
| // If the mouse never moved to a different cell, treat as a click. | ||
| if (!this.selectionEnd || !this.dragThresholdMet) { | ||
| this.clearSelection(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This Useful? React with 👍 / 👎. |
||
| return; | ||
| } | ||
|
|
||
| if (this.hasSelection()) { | ||
| const text = this.getSelection(); | ||
| if (text) { | ||
|
|
@@ -561,21 +596,67 @@ export class SelectionManager { | |
| }; | ||
| document.addEventListener('mouseup', this.boundMouseUpHandler); | ||
|
|
||
| // Double-click - select word | ||
| canvas.addEventListener('dblclick', (e: MouseEvent) => { | ||
| const cell = this.pixelToCell(e.offsetX, e.offsetY); | ||
| const word = this.getWordAtCell(cell.col, cell.row); | ||
| // Handle click events for double-click (word) and triple-click (line) selection | ||
| // Use event.detail which browsers set to click count (1, 2, 3, etc.) | ||
| canvas.addEventListener('click', (e: MouseEvent) => { | ||
| // event.detail: 1 = single, 2 = double, 3 = triple click | ||
| if (e.detail === 2) { | ||
| // Double-click - select word | ||
| const cell = this.pixelToCell(e.offsetX, e.offsetY); | ||
| const word = this.getWordAtCell(cell.col, cell.row); | ||
|
|
||
| if (word) { | ||
| const absoluteRow = this.viewportRowToAbsolute(cell.row); | ||
| this.selectionStart = { col: word.startCol, absoluteRow }; | ||
| this.selectionEnd = { col: word.endCol, absoluteRow }; | ||
| this.requestRender(); | ||
|
|
||
| if (word) { | ||
| const text = this.getSelection(); | ||
| if (text) { | ||
| this.copyToClipboard(text); | ||
| this.selectionChangedEmitter.fire(); | ||
| } | ||
| } | ||
| } else if (e.detail >= 3) { | ||
| // Triple-click (or more) - select line content (like native Ghostty) | ||
| const cell = this.pixelToCell(e.offsetX, e.offsetY); | ||
| const absoluteRow = this.viewportRowToAbsolute(cell.row); | ||
| this.selectionStart = { col: word.startCol, absoluteRow }; | ||
| this.selectionEnd = { col: word.endCol, absoluteRow }; | ||
| this.requestRender(); | ||
|
|
||
| const text = this.getSelection(); | ||
| if (text) { | ||
| this.copyToClipboard(text); | ||
| this.selectionChangedEmitter.fire(); | ||
| // Find actual line length (exclude trailing empty cells) | ||
| // Use scrollback-aware line retrieval (like getSelection does) | ||
| const scrollbackLength = this.wasmTerm.getScrollbackLength(); | ||
| let line: GhosttyCell[] | null = null; | ||
| if (absoluteRow < scrollbackLength) { | ||
| // Row is in scrollback | ||
| line = this.wasmTerm.getScrollbackLine(absoluteRow); | ||
| } else { | ||
| // Row is in screen buffer | ||
| const screenRow = absoluteRow - scrollbackLength; | ||
| line = this.wasmTerm.getLine(screenRow); | ||
| } | ||
| // Find last non-empty cell (-1 means empty line) | ||
| let endCol = -1; | ||
| if (line) { | ||
| for (let i = line.length - 1; i >= 0; i--) { | ||
| if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) { | ||
| endCol = i; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Only select if line has content (endCol >= 0) | ||
| if (endCol >= 0) { | ||
| // Select line content only (not trailing whitespace) | ||
| this.selectionStart = { col: 0, absoluteRow }; | ||
| this.selectionEnd = { col: endCol, absoluteRow }; | ||
| this.requestRender(); | ||
|
|
||
| const text = this.getSelection(); | ||
| if (text) { | ||
| this.copyToClipboard(text); | ||
| this.selectionChangedEmitter.fire(); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
@@ -828,14 +909,24 @@ export class SelectionManager { | |
| * Get word boundaries at a cell position | ||
| */ | ||
| private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null { | ||
| const line = this.wasmTerm.getLine(row); | ||
| const absoluteRow = this.viewportRowToAbsolute(row); | ||
| const scrollbackLength = this.wasmTerm.getScrollbackLength(); | ||
| let line: GhosttyCell[] | null; | ||
| if (absoluteRow < scrollbackLength) { | ||
| line = this.wasmTerm.getScrollbackLine(absoluteRow); | ||
| } else { | ||
| const screenRow = absoluteRow - scrollbackLength; | ||
| line = this.wasmTerm.getLine(screenRow); | ||
| } | ||
| if (!line) return null; | ||
|
|
||
| // Word characters: letters, numbers, underscore, dash | ||
| // Word characters: letters, numbers, and common path/URL characters | ||
| // Matches native Ghostty behavior where double-click selects entire paths | ||
| // Includes: / (path sep), . (extensions), ~ (home), @ (emails), + (encodings) | ||
| const isWordChar = (cell: GhosttyCell) => { | ||
| if (!cell || cell.codepoint === 0) return false; | ||
| const char = String.fromCodePoint(cell.codepoint); | ||
| return /[\w-]/.test(char); | ||
| return /[\w\-./~@+]/.test(char); | ||
| }; | ||
|
|
||
| // Only return if we're actually on a word character | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting the canvas cursor directly to
textoverrides the inherited cursor from the terminal container, so the link-hover cursor updates inTerminal.handleMouseMove(this.element.style.cursor = link ? 'pointer' : 'text') are no longer visible over the canvas. In practice this regresses hyperlink affordance: links still work, but users no longer get the pointer feedback when hovering them.Useful? React with 👍 / 👎.