@@ -43,6 +43,9 @@ export class SelectionManager {
4343 private selectionStart : { col : number ; absoluteRow : number } | null = null ;
4444 private selectionEnd : { col : number ; absoluteRow : number } | null = null ;
4545 private isSelecting : boolean = false ;
46+ private mouseDownX : number = 0 ;
47+ private mouseDownY : number = 0 ;
48+ private dragThresholdMet : boolean = false ;
4649 private mouseDownTarget : EventTarget | null = null ; // Track where mousedown occurred
4750
4851 // Track rows that need redraw for clearing old selection
@@ -209,11 +212,10 @@ export class SelectionManager {
209212 hasSelection ( ) : boolean {
210213 if ( ! this . selectionStart || ! this . selectionEnd ) return false ;
211214
212- // Check if start and end are the same (single cell, no real selection)
213- return ! (
214- this . selectionStart . col === this . selectionEnd . col &&
215- this . selectionStart . absoluteRow === this . selectionEnd . absoluteRow
216- ) ;
215+ // Don't report selection until drag threshold is met (prevents flash on click)
216+ if ( this . isSelecting && ! this . dragThresholdMet ) return false ;
217+
218+ return true ;
217219 }
218220
219221 /**
@@ -313,9 +315,8 @@ export class SelectionManager {
313315 }
314316
315317 // Convert viewport rows to absolute rows
316- const viewportY = this . getViewportY ( ) ;
317- this . selectionStart = { col : 0 , absoluteRow : viewportY + start } ;
318- this . selectionEnd = { col : dims . cols - 1 , absoluteRow : viewportY + end } ;
318+ this . selectionStart = { col : 0 , absoluteRow : this . viewportRowToAbsolute ( start ) } ;
319+ this . selectionEnd = { col : dims . cols - 1 , absoluteRow : this . viewportRowToAbsolute ( end ) } ;
319320 this . requestRender ( ) ;
320321 this . selectionChangedEmitter . fire ( ) ;
321322 }
@@ -454,12 +455,27 @@ export class SelectionManager {
454455 this . selectionStart = { col : cell . col , absoluteRow } ;
455456 this . selectionEnd = { col : cell . col , absoluteRow } ;
456457 this . isSelecting = true ;
458+ this . mouseDownX = e . offsetX ;
459+ this . mouseDownY = e . offsetY ;
460+ this . dragThresholdMet = false ;
457461 }
458462 } ) ;
459463
460464 // Mouse move on canvas - update selection
461465 canvas . addEventListener ( 'mousemove' , ( e : MouseEvent ) => {
462466 if ( this . isSelecting ) {
467+ // Check if drag threshold has been met
468+ if ( ! this . dragThresholdMet ) {
469+ const dx = e . offsetX - this . mouseDownX ;
470+ const dy = e . offsetY - this . mouseDownY ;
471+ // Use 50% of cell width as threshold to scale with font size
472+ const threshold = this . renderer . getMetrics ( ) . width * 0.5 ;
473+ if ( dx * dx + dy * dy < threshold * threshold ) {
474+ return ; // Below threshold, ignore
475+ }
476+ this . dragThresholdMet = true ;
477+ }
478+
463479 // Mark current selection rows as dirty before updating
464480 this . markCurrentSelectionDirty ( ) ;
465481
@@ -496,6 +512,17 @@ export class SelectionManager {
496512 // Document-level mousemove for tracking mouse position during drag outside canvas
497513 this . boundDocumentMouseMoveHandler = ( e : MouseEvent ) => {
498514 if ( this . isSelecting ) {
515+ // Check drag threshold (same as canvas mousemove)
516+ if ( ! this . dragThresholdMet ) {
517+ const dx = e . clientX - ( canvas . getBoundingClientRect ( ) . left + this . mouseDownX ) ;
518+ const dy = e . clientY - ( canvas . getBoundingClientRect ( ) . top + this . mouseDownY ) ;
519+ const threshold = this . renderer . getMetrics ( ) . width * 0.5 ;
520+ if ( dx * dx + dy * dy < threshold * threshold ) {
521+ return ;
522+ }
523+ this . dragThresholdMet = true ;
524+ }
525+
499526 const rect = canvas . getBoundingClientRect ( ) ;
500527
501528 // Update selection based on clamped position
@@ -550,6 +577,12 @@ export class SelectionManager {
550577 this . isSelecting = false ;
551578 this . stopAutoScroll ( ) ;
552579
580+ // Check if this was a click without drag (threshold never met).
581+ if ( ! this . dragThresholdMet ) {
582+ this . clearSelection ( ) ;
583+ return ;
584+ }
585+
553586 if ( this . hasSelection ( ) ) {
554587 const text = this . getSelection ( ) ;
555588 if ( text ) {
@@ -561,21 +594,67 @@ export class SelectionManager {
561594 } ;
562595 document . addEventListener ( 'mouseup' , this . boundMouseUpHandler ) ;
563596
564- // Double-click - select word
565- canvas . addEventListener ( 'dblclick' , ( e : MouseEvent ) => {
566- const cell = this . pixelToCell ( e . offsetX , e . offsetY ) ;
567- const word = this . getWordAtCell ( cell . col , cell . row ) ;
597+ // Handle click events for double-click (word) and triple-click (line) selection
598+ // Use event.detail which browsers set to click count (1, 2, 3, etc.)
599+ canvas . addEventListener ( 'click' , ( e : MouseEvent ) => {
600+ // event.detail: 1 = single, 2 = double, 3 = triple click
601+ if ( e . detail === 2 ) {
602+ // Double-click - select word
603+ const cell = this . pixelToCell ( e . offsetX , e . offsetY ) ;
604+ const word = this . getWordAtCell ( cell . col , cell . row ) ;
605+
606+ if ( word ) {
607+ const absoluteRow = this . viewportRowToAbsolute ( cell . row ) ;
608+ this . selectionStart = { col : word . startCol , absoluteRow } ;
609+ this . selectionEnd = { col : word . endCol , absoluteRow } ;
610+ this . requestRender ( ) ;
568611
569- if ( word ) {
612+ const text = this . getSelection ( ) ;
613+ if ( text ) {
614+ this . copyToClipboard ( text ) ;
615+ this . selectionChangedEmitter . fire ( ) ;
616+ }
617+ }
618+ } else if ( e . detail >= 3 ) {
619+ // Triple-click (or more) - select line content (like native Ghostty)
620+ const cell = this . pixelToCell ( e . offsetX , e . offsetY ) ;
570621 const absoluteRow = this . viewportRowToAbsolute ( cell . row ) ;
571- this . selectionStart = { col : word . startCol , absoluteRow } ;
572- this . selectionEnd = { col : word . endCol , absoluteRow } ;
573- this . requestRender ( ) ;
574622
575- const text = this . getSelection ( ) ;
576- if ( text ) {
577- this . copyToClipboard ( text ) ;
578- this . selectionChangedEmitter . fire ( ) ;
623+ // Find actual line length (exclude trailing empty cells)
624+ // Use scrollback-aware line retrieval (like getSelection does)
625+ const scrollbackLength = this . wasmTerm . getScrollbackLength ( ) ;
626+ let line : GhosttyCell [ ] | null = null ;
627+ if ( absoluteRow < scrollbackLength ) {
628+ // Row is in scrollback
629+ line = this . wasmTerm . getScrollbackLine ( absoluteRow ) ;
630+ } else {
631+ // Row is in screen buffer
632+ const screenRow = absoluteRow - scrollbackLength ;
633+ line = this . wasmTerm . getLine ( screenRow ) ;
634+ }
635+ // Find last non-empty cell (-1 means empty line)
636+ let endCol = - 1 ;
637+ if ( line ) {
638+ for ( let i = line . length - 1 ; i >= 0 ; i -- ) {
639+ if ( line [ i ] && line [ i ] . codepoint !== 0 && line [ i ] . codepoint !== 32 ) {
640+ endCol = i ;
641+ break ;
642+ }
643+ }
644+ }
645+
646+ // Only select if line has content (endCol >= 0)
647+ if ( endCol >= 0 ) {
648+ // Select line content only (not trailing whitespace)
649+ this . selectionStart = { col : 0 , absoluteRow } ;
650+ this . selectionEnd = { col : endCol , absoluteRow } ;
651+ this . requestRender ( ) ;
652+
653+ const text = this . getSelection ( ) ;
654+ if ( text ) {
655+ this . copyToClipboard ( text ) ;
656+ this . selectionChangedEmitter . fire ( ) ;
657+ }
579658 }
580659 }
581660 } ) ;
@@ -828,14 +907,24 @@ export class SelectionManager {
828907 * Get word boundaries at a cell position
829908 */
830909 private getWordAtCell ( col : number , row : number ) : { startCol : number ; endCol : number } | null {
831- const line = this . wasmTerm . getLine ( row ) ;
910+ const absoluteRow = this . viewportRowToAbsolute ( row ) ;
911+ const scrollbackLength = this . wasmTerm . getScrollbackLength ( ) ;
912+ let line : GhosttyCell [ ] | null ;
913+ if ( absoluteRow < scrollbackLength ) {
914+ line = this . wasmTerm . getScrollbackLine ( absoluteRow ) ;
915+ } else {
916+ const screenRow = absoluteRow - scrollbackLength ;
917+ line = this . wasmTerm . getLine ( screenRow ) ;
918+ }
832919 if ( ! line ) return null ;
833920
834- // Word characters: letters, numbers, underscore, dash
921+ // Word characters: letters, numbers, and common path/URL characters
922+ // Matches native Ghostty behavior where double-click selects entire paths
923+ // Includes: / (path sep), . (extensions), ~ (home), @ (emails), + (encodings)
835924 const isWordChar = ( cell : GhosttyCell ) => {
836925 if ( ! cell || cell . codepoint === 0 ) return false ;
837926 const char = String . fromCodePoint ( cell . codepoint ) ;
838- return / [ \w - ] / . test ( char ) ;
927+ return / [ \w \- . / ~ @ + ] / . test ( char ) ;
839928 } ;
840929
841930 // Only return if we're actually on a word character
0 commit comments