77//! `libghostty-vt` is able to achieve behind a simple interface.
88
99#![ deny( unsafe_code) ] // Well, almost.
10- use std:: cell:: Cell ;
10+ use std:: {
11+ cell:: Cell ,
12+ time:: { Duration , Instant } ,
13+ } ;
1114
1215use macroquad:: {
13- miniquad:: { conf, window:: order_quit} ,
16+ miniquad:: {
17+ CursorIcon , conf,
18+ window:: { order_quit, set_mouse_cursor} ,
19+ } ,
1420 prelude:: * ,
1521} ;
1622use nix:: sys:: wait;
@@ -23,10 +29,12 @@ use libghostty_vt::{
2329 kitty:: graphics:: { self , DecodePng , DecodedImage , Graphics , Layer , PlacementIterator } ,
2430 mouse,
2531 render:: { CellIterator , Dirty , RenderState , RowIterator } ,
32+ selection:: gesture:: { DragEvent , Geometry , Gesture , PressEvent , ReleaseEvent } ,
2633 style:: RgbColor ,
2734 terminal:: {
28- ConformanceLevel , DeviceAttributeFeature , DeviceAttributes , DeviceType , Mode ,
29- PrimaryDeviceAttributes , ScrollViewport , SecondaryDeviceAttributes , SizeReportSize ,
35+ ConformanceLevel , DeviceAttributeFeature , DeviceAttributes , DeviceType , Point ,
36+ PointCoordinate , PrimaryDeviceAttributes , ScrollViewport , SecondaryDeviceAttributes ,
37+ SizeReportSize ,
3038 } ,
3139} ;
3240
@@ -212,6 +220,10 @@ async fn main() -> Result<()> {
212220 // (the user shell) is still active or not. We only want to keep
213221 // processing inputs and communicate with the child when it's
214222 // alive, and when it's exited we should handle cleanup properly.
223+ input
224+ . selection
225+ . update_mouse_cursor ( & terminal, dims, matches ! ( child, Child :: Active ( _) ) ) ?;
226+
215227 match child {
216228 Child :: Active ( pid) => {
217229 // Forward keyboard/mouse input only while the child is alive.
@@ -347,13 +359,26 @@ impl<'alloc> Renderer<'alloc> {
347359 let mut cell_it = self . cell_it . update ( row) ?;
348360
349361 while let Some ( cell) = cell_it. next ( ) {
362+ // Selection membership is computed by libghostty from the
363+ // terminal-owned selection. Ghostling only chooses the visual
364+ // policy: selected cells use reverse-video colors, matching the
365+ // common terminal behavior and avoiding a separate theme knob.
366+ let selected = cell. is_selected ( ) ?;
350367 let graphemes = cell. graphemes_len ( ) ?;
351368 let bg = cell. bg_color ( ) ?;
352369
353370 if graphemes == 0 {
354- // The cell has no text, but it might have a background
355- // color (e.g. from an erase with a color set).
356- if let Some ( bg) = bg {
371+ if selected {
372+ draw_rectangle (
373+ x,
374+ y,
375+ dims. cell_width ,
376+ dims. cell_height ,
377+ color ( colors. foreground ) ,
378+ ) ;
379+ } else if let Some ( bg) = bg {
380+ // The cell has no text, but it might have a background
381+ // color (e.g. from an erase with a color set).
357382 draw_rectangle ( x, y, dims. cell_width , dims. cell_height , color ( bg) ) ;
358383 }
359384 } else {
@@ -379,6 +404,11 @@ impl<'alloc> Renderer<'alloc> {
379404 has_bg = true ;
380405 }
381406
407+ if selected {
408+ std:: mem:: swap ( & mut fg, & mut bg) ;
409+ has_bg = true ;
410+ }
411+
382412 // Draw a background rectangle if the cell has a non-default bg
383413 // or if inverse mode forced a swap.
384414 if has_bg {
@@ -616,8 +646,8 @@ struct Input<'alloc> {
616646 mouse_encoder : mouse:: Encoder < ' alloc > ,
617647 mouse_event : mouse:: Event < ' alloc > ,
618648 response : Vec < u8 > ,
649+ selection : SelectionState < ' alloc > ,
619650}
620-
621651impl < ' alloc > Input < ' alloc > {
622652 fn new ( ) -> Result < Self > {
623653 Ok ( Self {
@@ -626,6 +656,7 @@ impl<'alloc> Input<'alloc> {
626656 mouse_encoder : mouse:: Encoder :: new ( ) ?,
627657 mouse_event : mouse:: Event :: new ( ) ?,
628658 response : Vec :: with_capacity ( 64 ) ,
659+ selection : SelectionState :: new ( ) ?,
629660 } )
630661 }
631662
@@ -728,9 +759,11 @@ impl<'alloc> Input<'alloc> {
728759 . into_iter ( )
729760 . any ( |( m, _) | is_mouse_button_down ( m) ) ;
730761
762+ let mods = Self :: keyboard_mods ( ) ;
763+
731764 let ( x, y) = mouse_position ( ) ;
732765 self . mouse_event
733- . set_mods ( Self :: keyboard_mods ( ) )
766+ . set_mods ( mods )
734767 . set_position ( mouse:: Position { x, y } ) ;
735768
736769 self . mouse_encoder
@@ -758,8 +791,16 @@ impl<'alloc> Input<'alloc> {
758791 // Check each mouse button for press/release events.
759792 for ( mb, btn) in Self :: ALL_MOUSE_BUTTONS {
760793 let action = if is_mouse_button_released ( mb) {
794+ if self . selection . is_selecting ( terminal) ? {
795+ self . selection . on_release ( terminal, dims) ?;
796+ continue ;
797+ }
761798 mouse:: Action :: Release
762799 } else if is_mouse_button_pressed ( mb) {
800+ if mb == MouseButton :: Left && Self :: selection_allowed_with_mods ( terminal, mods) ? {
801+ self . selection . on_click ( terminal, x, y, dims) ?;
802+ continue ;
803+ }
763804 mouse:: Action :: Press
764805 } else {
765806 continue ;
@@ -774,6 +815,11 @@ impl<'alloc> Input<'alloc> {
774815 // (or no button for pure motion in any-event tracking mode).
775816 let delta = mouse_delta_position ( ) ;
776817 if delta. x . abs ( ) > 1e-6 || delta. y . abs ( ) > 1e-6 {
818+ if self . selection . is_selecting ( terminal) ? {
819+ self . selection . on_drag ( terminal, x, y, dims, mods) ?;
820+ return Ok ( ( ) ) ;
821+ }
822+
777823 self . mouse_event
778824 . set_action ( mouse:: Action :: Motion )
779825 . set_button (
@@ -792,18 +838,7 @@ impl<'alloc> Input<'alloc> {
792838 // the scrollback buffer so the user can review history.
793839 let ( wheel_x, wheel_y) = mouse_wheel ( ) ;
794840 if wheel_x. abs ( ) > 1e-6 || wheel_y. abs ( ) > 1e-6 {
795- // Check whether any mouse tracking mode is enabled. If so,
796- // the application wants to handle scroll events itself.
797- let is_mouse_tracking = [
798- Mode :: X10_MOUSE ,
799- Mode :: NORMAL_MOUSE ,
800- Mode :: BUTTON_MOUSE ,
801- Mode :: ANY_MOUSE ,
802- ]
803- . into_iter ( )
804- . any ( |mode| matches ! ( terminal. mode( mode) , Ok ( true ) ) ) ;
805-
806- if is_mouse_tracking {
841+ if terminal. is_mouse_tracking ( ) ? {
807842 let scroll_btn = if wheel_y > 0.0 {
808843 mouse:: Button :: Four
809844 } else {
@@ -831,7 +866,7 @@ impl<'alloc> Input<'alloc> {
831866 Ok ( ( ) )
832867 }
833868
834- // Build a Mods bitmask from the current macroquad modifier key state.
869+ /// Build a Mods bitmask from the current macroquad modifier key state.
835870 fn keyboard_mods ( ) -> key:: Mods {
836871 let mut mods = key:: Mods :: empty ( ) ;
837872 if is_key_down ( KeyCode :: LeftShift ) || is_key_down ( KeyCode :: RightShift ) {
@@ -849,6 +884,84 @@ impl<'alloc> Input<'alloc> {
849884 mods
850885 }
851886
887+ // Match Ghostty's rectangular-selection modifier convention so Ghostling feels
888+ // the same across platforms: Option alone on macOS, Ctrl/Super+Alt elsewhere.
889+ fn selection_rectangle_mods ( mods : key:: Mods ) -> bool {
890+ let is_alt = mods. contains ( key:: Mods :: ALT ) ;
891+ if cfg ! ( target_os = "macos" ) {
892+ is_alt
893+ } else {
894+ is_alt && ( mods. contains ( key:: Mods :: CTRL ) || mods. contains ( key:: Mods :: SUPER ) )
895+ }
896+ }
897+
898+ /// Decide whether a mouse gesture should manipulate terminal selection or be
899+ /// forwarded to the running application. Applications that request mouse
900+ /// tracking get normal events, but Shift provides the common terminal-emulator
901+ /// escape hatch for selecting text anyway.
902+ fn selection_allowed_with_mods (
903+ terminal : & Terminal < ' alloc , ' _ > ,
904+ mods : key:: Mods ,
905+ ) -> Result < bool > {
906+ let mouse_tracking = terminal. is_mouse_tracking ( ) ?;
907+
908+ // When an application has enabled mouse tracking, normal mouse events
909+ // belong to the application. Holding Shift overrides that so users can
910+ // still select text in full-screen apps such as vim and tmux.
911+ Ok ( !mouse_tracking || mods. contains ( key:: Mods :: SHIFT ) )
912+ }
913+
914+ /// Convert the current mouse position to a clamped viewport grid point.
915+ /// Clamping lets users start or continue a drag slightly outside the padded
916+ /// terminal area while still extending to the nearest visible cell.
917+ fn mouse_viewport_point ( terminal : & Terminal < ' alloc , ' _ > , dims : Dimensions ) -> Result < Point > {
918+ let cols = terminal. cols ( ) ?;
919+ let rows = terminal. rows ( ) ?;
920+ let ( xpos, ypos) = mouse_position ( ) ;
921+
922+ let x = if xpos > PADDING {
923+ ( ( xpos - PADDING ) / dims. cell_width ) . clamp ( 0.0 , cols as f32 ) as u16
924+ } else {
925+ 0
926+ } ;
927+ let y = if ypos > PADDING {
928+ ( ( ypos - PADDING ) / dims. cell_height ) . clamp ( 0.0 , rows as f32 ) as u32
929+ } else {
930+ 0
931+ } ;
932+
933+ Ok ( Point :: Viewport ( PointCoordinate { x, y } ) )
934+ }
935+
936+ // Decide whether the mouse is over an area where a selection drag can begin.
937+ // Blank cells are included because terminal selections can start in empty grid
938+ // space; the check is about selectable geometry, not text contents.
939+ fn mouse_over_selectable_text (
940+ terminal : & Terminal < ' alloc , ' _ > ,
941+ dims : Dimensions ,
942+ ) -> Result < bool > {
943+ // Test the real terminal grid bounds rather than using the window dimensions:
944+ // padding and any partially visible trailing pixels are not selectable cells,
945+ // and the I-beam cursor should not appear there.
946+ let cols = terminal. cols ( ) ?;
947+ let rows = terminal. rows ( ) ?;
948+
949+ let ( xpos, ypos) = mouse_position ( ) ;
950+ let left = PADDING ;
951+ let top = PADDING ;
952+ let right = left + cols as f32 * dims. cell_width ;
953+ let bottom = top + rows as f32 * dims. cell_height ;
954+
955+ if xpos < left || xpos >= right || ypos < top || ypos >= bottom {
956+ return Ok ( false ) ;
957+ }
958+
959+ if !Self :: selection_allowed_with_mods ( terminal, Self :: keyboard_mods ( ) ) ? {
960+ return Ok ( false ) ;
961+ }
962+ Ok ( true )
963+ }
964+
852965 /// All macroquad mouse buttons we want to check with their libghostty equivalent.
853966 const ALL_MOUSE_BUTTONS : [ ( MouseButton , mouse:: Button ) ; 3 ] = [
854967 ( MouseButton :: Left , mouse:: Button :: Left ) ,
@@ -939,6 +1052,122 @@ impl<'alloc> Input<'alloc> {
9391052 ] ;
9401053}
9411054
1055+ #[ derive( Debug ) ]
1056+ struct SelectionState < ' alloc > {
1057+ gesture : Gesture < ' alloc > ,
1058+ press_event : PressEvent < ' alloc > ,
1059+ release_event : ReleaseEvent < ' alloc > ,
1060+ drag_event : DragEvent < ' alloc > ,
1061+ start_time : Instant ,
1062+ }
1063+
1064+ impl SelectionState < ' _ > {
1065+ fn new ( ) -> Result < Self > {
1066+ let mut press_event = PressEvent :: new ( ) ?;
1067+ press_event. set_repeat_interval ( Duration :: from_millis ( 500 ) ) ?;
1068+
1069+ Ok ( Self {
1070+ gesture : Gesture :: new ( ) ?,
1071+ press_event,
1072+ release_event : ReleaseEvent :: new ( ) ?,
1073+ drag_event : DragEvent :: new ( ) ?,
1074+ start_time : Instant :: now ( ) ,
1075+ } )
1076+ }
1077+
1078+ /// Whether we currently have a selection and the mouse is kept down.
1079+ fn is_selecting ( & self , terminal : & Terminal < ' _ , ' _ > ) -> Result < bool > {
1080+ Ok ( self . gesture . click_count ( terminal) ? > 0 && is_mouse_button_down ( MouseButton :: Left ) )
1081+ }
1082+
1083+ /// Update the selection state upon a left click.
1084+ fn on_click (
1085+ & mut self ,
1086+ terminal : & Terminal < ' _ , ' _ > ,
1087+ x : f32 ,
1088+ y : f32 ,
1089+ dims : Dimensions ,
1090+ ) -> Result < ( ) > {
1091+ let point = Input :: mouse_viewport_point ( terminal, dims) ?;
1092+ let grid_ref = terminal. grid_ref ( point) ?;
1093+
1094+ let selection = self
1095+ . press_event
1096+ . set_repeat_distance ( dims. cell_width . into ( ) ) ?
1097+ . set_time ( self . start_time . elapsed ( ) ) ?
1098+ . set_position ( x. into ( ) , y. into ( ) ) ?
1099+ . apply ( & mut self . gesture , terminal, grid_ref) ?;
1100+
1101+ terminal. set_selection ( selection. as_ref ( ) ) ?;
1102+ Ok ( ( ) )
1103+ }
1104+
1105+ /// Potentially reset the selection state when the mouse is released.
1106+ fn on_release ( & mut self , terminal : & Terminal < ' _ , ' _ > , dims : Dimensions ) -> Result < ( ) > {
1107+ let point = Input :: mouse_viewport_point ( terminal, dims) ?;
1108+ // The point might fall outside the grid, which is completely okay in this case.
1109+ let grid_ref = terminal. grid_ref ( point) . ok ( ) ;
1110+
1111+ self . release_event
1112+ . apply ( & mut self . gesture , terminal, grid_ref) ?;
1113+ Ok ( ( ) )
1114+ }
1115+
1116+ /// Update the selection state during and after a mouse drag.
1117+ fn on_drag (
1118+ & mut self ,
1119+ terminal : & Terminal < ' _ , ' _ > ,
1120+ x : f32 ,
1121+ y : f32 ,
1122+ dims : Dimensions ,
1123+ mods : key:: Mods ,
1124+ ) -> Result < ( ) > {
1125+ let point = Input :: mouse_viewport_point ( terminal, dims) ?;
1126+ let grid_ref = terminal. grid_ref ( point) ?;
1127+
1128+ let selection = self
1129+ . drag_event
1130+ . set_rectangle ( Input :: selection_rectangle_mods ( mods) ) ?
1131+ . set_geometry ( Geometry {
1132+ columns : terminal. cols ( ) ?. into ( ) ,
1133+ cell_width : dims. cell_width as u32 ,
1134+ padding_left : PADDING as u32 ,
1135+ screen_height : dims. window_height as u32 ,
1136+ } ) ?
1137+ . set_position ( x. into ( ) , y. into ( ) ) ?
1138+ . apply ( & mut self . gesture , terminal, grid_ref) ?;
1139+
1140+ terminal. set_selection ( selection. as_ref ( ) ) ?;
1141+ Ok ( ( ) )
1142+ }
1143+
1144+ /// Update the platform cursor to advertise selection affordance.
1145+ ///
1146+ /// This is kept separate from [`Input::handle_mouse`] so the cursor
1147+ /// changes on hover even when no mouse button event is being processed.
1148+ fn update_mouse_cursor (
1149+ & self ,
1150+ terminal : & Terminal < ' _ , ' _ > ,
1151+ dims : Dimensions ,
1152+ input_enabled : bool ,
1153+ ) -> Result < ( ) > {
1154+ let selectable = input_enabled
1155+ && ( self . is_selecting ( terminal) ? || Input :: mouse_over_selectable_text ( terminal, dims) ?) ;
1156+ let rectangle = Input :: selection_rectangle_mods ( Input :: keyboard_mods ( ) ) ;
1157+
1158+ set_mouse_cursor ( if selectable {
1159+ if rectangle {
1160+ CursorIcon :: Crosshair
1161+ } else {
1162+ CursorIcon :: Text
1163+ }
1164+ } else {
1165+ CursorIcon :: Default
1166+ } ) ;
1167+ Ok ( ( ) )
1168+ }
1169+ }
1170+
9421171/// Configuration for macroquad.
9431172///
9441173/// By default the window is resizable and DPI-aware,
0 commit comments