Skip to content

Commit 0fbddfb

Browse files
committed
ghostling-rs: add selections
Port of ghostty-org/ghostling#15
1 parent 7c56bbd commit 0fbddfb

1 file changed

Lines changed: 251 additions & 22 deletions

File tree

example/ghostling_rs/src/main.rs

Lines changed: 251 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@
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

1215
use macroquad::{
13-
miniquad::{conf, window::order_quit},
16+
miniquad::{
17+
CursorIcon, conf,
18+
window::{order_quit, set_mouse_cursor},
19+
},
1420
prelude::*,
1521
};
1622
use 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-
621651
impl<'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

Comments
 (0)