test(apprt-gtk): replay mouse input and verify real-world TUIs
The GTK mouse handlers now delegate to *_impl methods taking button and modifier state, so the real controllers and the debug input replay share one code path. The replay hook gained mouse-move/press/release, click, drag, scroll, resize, clipboard-set/dump, windows-dump and window-screenshot steps (replayed presses bypass the focus-transfer first-click rule since the replay window isn't compositor-focused). Verified in the real app on Wayland (docs/rewrite/notes/verification- V3.md): nvim (colors, status line, cursor shapes, alt-screen restore), htop (rendering, mouse-click column sorting), less (alternate scroll), tmux (splits, status bar), drag/double/triple-click selection into PRIMARY, clipboard copy, context menu, scrollback and reflow on resize, CJK/emoji/ZWJ/nerd-font widths, kitty graphics images and the unsafe paste confirmation dialog. No product bugs were found. Test Plan: - cargo test -p ghostty-apprt-gtk (incl. parse_mouse_steps)
This commit is contained in:
1 parent
55a174882b
commit
81d4119d85
3 files changed
+537
-158
No files matched your search
@@ -772,6 +772,236 @@ impl Surface {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mouse handlers shared by the GTK callbacks and the debug replay ----
|
||||
|
||||
fn mouse_down_impl(
|
||||
&self,
|
||||
button_raw: u32,
|
||||
state: gdk::ModifierType,
|
||||
x: f64,
|
||||
y: f64,
|
||||
assume_focus: bool,
|
||||
) {
|
||||
let this = self;
|
||||
|
||||
// Bell stops ringing if any mouse button is pressed.
|
||||
this.set_bell_ringing(false);
|
||||
|
||||
// Get our surface. If we don't have one, ignore this.
|
||||
let imp = this.imp();
|
||||
let Some(core_surface) = this.core() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If we don't have focus, grab it.
|
||||
let had_focus = assume_focus || imp.gl_area.has_focus();
|
||||
if !had_focus {
|
||||
imp.gl_area.grab_focus();
|
||||
}
|
||||
|
||||
// Report the event
|
||||
let button = translate_mouse_button(button_raw);
|
||||
|
||||
// If this click is only transitioning split focus, suppress it so
|
||||
// it doesn't get forwarded to the terminal as a mouse event.
|
||||
if !had_focus && button == MouseButton::Left {
|
||||
imp.suppress_left_mouse_release.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if button == MouseButton::Middle && !imp.gtk_enable_primary_paste.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let consumed = {
|
||||
let mods = gtk_key::translate_mods(state);
|
||||
match core_surface.mouse_button_callback(MouseButtonState::Press, button, mods) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
log::warn!("error in key callback err={err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If a right click isn't consumed, mouse_button_callback selects
|
||||
// the hovered word and returns false. We can use this to handle
|
||||
// the context menu opening under normal scenarios.
|
||||
if !consumed && button == MouseButton::Right {
|
||||
this.emit_by_name::<()>("menu", &[]);
|
||||
|
||||
let rect = gdk::Rectangle::new(x as i32, y as i32, 1, 1);
|
||||
let popover = imp.context_menu.upcast_ref::<gtk::Popover>();
|
||||
popover.set_pointing_to(Some(&rect));
|
||||
popover.popup();
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_up_impl(&self, button_raw: u32, state: gdk::ModifierType, event: Option<&gdk::Event>) {
|
||||
let this = self;
|
||||
|
||||
let imp = this.imp();
|
||||
let Some(surface) = this.core() else { return };
|
||||
let gtk_mods = state;
|
||||
let button = translate_mouse_button(button_raw);
|
||||
|
||||
if button == MouseButton::Left && imp.suppress_left_mouse_release.get() {
|
||||
imp.suppress_left_mouse_release.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if button == MouseButton::Middle && !imp.gtk_enable_primary_paste.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mods = gtk_key::translate_mods(gtk_mods);
|
||||
let consumed = match surface.mouse_button_callback(MouseButtonState::Release, button, mods)
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
log::warn!("error in key callback err={err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the on-screen keyboard if we have no selection, and
|
||||
// that the mouse event hasn't been intercepted by the callback.
|
||||
//
|
||||
// It's better to do this here rather than within the core
|
||||
// callback since we have direct access to the underlying
|
||||
// gdk.Event here.
|
||||
if !consumed
|
||||
&& button == MouseButton::Left
|
||||
&& !surface.has_selection()
|
||||
&& !this.show_on_screen_keyboard_event(event)
|
||||
{
|
||||
log::warn!("failed to activate the on-screen keyboard");
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_motion_impl(&self, state: gdk::ModifierType, x: f64, y: f64) {
|
||||
let this = self;
|
||||
let imp = this.imp();
|
||||
|
||||
let (sx, sy) = this.scaled_coordinates(x, y);
|
||||
let pos = CursorPos {
|
||||
x: sx as f32,
|
||||
y: sy as f32,
|
||||
};
|
||||
|
||||
// There seem to be at least two cases where GTK issues a mouse
|
||||
// motion event without the cursor actually moving:
|
||||
// 1. GLArea is resized under the mouse. This has the unfortunate
|
||||
// side effect of causing focus to potentially change when
|
||||
// `focus-follows-mouse` is enabled.
|
||||
// 2. The window title is updated. This can cause the mouse to
|
||||
// unhide incorrectly when hide-mouse-when-typing is enabled.
|
||||
// To prevent incorrect behavior, we'll only grab focus and
|
||||
// continue with callback logic if the cursor has actually moved.
|
||||
let cur = imp.cursor_pos.get();
|
||||
let is_cursor_still = (cur.x - pos.x).abs() < 1.0 && (cur.y - pos.y).abs() < 1.0;
|
||||
if is_cursor_still {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we don't have focus, and we want it, grab it.
|
||||
let config = imp.config.borrow().clone();
|
||||
if let Some(config) = config
|
||||
&& !imp.gl_area.has_focus()
|
||||
&& config.get().focus_follows_mouse
|
||||
{
|
||||
imp.gl_area.grab_focus();
|
||||
}
|
||||
|
||||
// Our pos changed, update
|
||||
imp.cursor_pos.set(pos);
|
||||
|
||||
// Notify the callback
|
||||
if let Some(surface) = this.core() {
|
||||
let mods = gtk_key::translate_mods(state);
|
||||
if let Err(err) = surface.cursor_pos_callback(pos, Some(mods)) {
|
||||
log::warn!("error in cursor pos callback err={err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_vertical_impl(&self, x: f64, y: f64) -> bool {
|
||||
let this = self;
|
||||
let imp = this.imp();
|
||||
let Some(surface) = this.core() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Multiply precision scrolls by 10 to get a better response from
|
||||
// touchpad scrolling
|
||||
let precision = imp.precision_scroll.get();
|
||||
let multiplier = if precision { 10.0 } else { 1.0 };
|
||||
let scroll_mods = ghostty_input::ScrollMods {
|
||||
precision,
|
||||
..ghostty_input::ScrollMods::default()
|
||||
};
|
||||
|
||||
let (sx, sy) = this.scaled_coordinates(x, y);
|
||||
// We invert because we apply natural scrolling to the values.
|
||||
// This behavior has existed for years without Linux users
|
||||
// complaining but I suspect we'll have to make this configurable
|
||||
// in the future or read a system setting.
|
||||
match surface.scroll_callback(-sx * multiplier, -sy * multiplier, scroll_mods) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("error in scroll callback err={err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug input replay: pointer motion (logical widget coordinates).
|
||||
pub fn debug_mouse_move(&self, x: f64, y: f64, state: gdk::ModifierType) {
|
||||
self.mouse_motion_impl(state, x, y);
|
||||
}
|
||||
|
||||
/// Debug input replay: button press/release (1 left, 2 middle, 3 right).
|
||||
pub fn debug_mouse_button(
|
||||
&self,
|
||||
press: bool,
|
||||
button: u32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
state: gdk::ModifierType,
|
||||
) {
|
||||
if press {
|
||||
// The replayed window is usually not the compositor-focused one, so
|
||||
// pretend the click did not need to transition focus.
|
||||
self.mouse_down_impl(button, state, x, y, true);
|
||||
} else {
|
||||
self.mouse_up_impl(button, state, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug input replay: wheel scroll (vertical goes through the same
|
||||
/// handler as the scroll controller; horizontal tab switching is skipped).
|
||||
pub fn debug_scroll(&self, dy: f64) {
|
||||
self.scroll_vertical_impl(0.0, dy);
|
||||
}
|
||||
|
||||
/// Debug input replay: text of the GDK clipboard and primary selection.
|
||||
pub fn debug_clipboard_dump(&self) {
|
||||
log::warn!(
|
||||
"debug input: context menu visible = {}",
|
||||
self.imp().context_menu.is_visible()
|
||||
);
|
||||
let display = self.display();
|
||||
for (name, cb) in [
|
||||
("clipboard", display.clipboard()),
|
||||
("primary", display.primary_clipboard()),
|
||||
] {
|
||||
cb.read_text_async(gtk::gio::Cancellable::NONE, move |r| match r {
|
||||
Ok(t) => log::warn!("debug input: {name} = {:?}", t.map(|s| s.to_string())),
|
||||
Err(e) => log::warn!("debug input: {name} read failed: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug input replay: commit text as the input method would (the
|
||||
/// `commit` signal of the IM context), outside of any key event.
|
||||
pub fn debug_commit_text(&self, text: &str) {
|
||||
@@ -3047,58 +3277,13 @@ mod imp {
|
||||
let Some(event) = gesture.current_event() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Bell stops ringing if any mouse button is pressed.
|
||||
this.set_bell_ringing(false);
|
||||
|
||||
// Get our surface. If we don't have one, ignore this.
|
||||
let imp = this.imp();
|
||||
let Some(core_surface) = this.core() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If we don't have focus, grab it.
|
||||
let had_focus = imp.gl_area.has_focus();
|
||||
if !had_focus {
|
||||
imp.gl_area.grab_focus();
|
||||
}
|
||||
|
||||
// Report the event
|
||||
let button = translate_mouse_button(gesture.current_button());
|
||||
|
||||
// If this click is only transitioning split focus, suppress it so
|
||||
// it doesn't get forwarded to the terminal as a mouse event.
|
||||
if !had_focus && button == MouseButton::Left {
|
||||
imp.suppress_left_mouse_release.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if button == MouseButton::Middle && !imp.gtk_enable_primary_paste.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let consumed = {
|
||||
let mods = gtk_key::translate_mods(event.modifier_state());
|
||||
match core_surface.mouse_button_callback(MouseButtonState::Press, button, mods) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
log::warn!("error in key callback err={err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If a right click isn't consumed, mouse_button_callback selects
|
||||
// the hovered word and returns false. We can use this to handle
|
||||
// the context menu opening under normal scenarios.
|
||||
if !consumed && button == MouseButton::Right {
|
||||
this.emit_by_name::<()>("menu", &[]);
|
||||
|
||||
let rect = gdk::Rectangle::new(x as i32, y as i32, 1, 1);
|
||||
let popover = imp.context_menu.upcast_ref::<gtk::Popover>();
|
||||
popover.set_pointing_to(Some(&rect));
|
||||
popover.popup();
|
||||
}
|
||||
this.mouse_down_impl(
|
||||
gesture.current_button(),
|
||||
event.modifier_state(),
|
||||
x,
|
||||
y,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
@@ -3112,44 +3297,11 @@ mod imp {
|
||||
let Some(event) = gesture.current_event() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let imp = this.imp();
|
||||
let Some(surface) = this.core() else { return };
|
||||
let gtk_mods = event.modifier_state();
|
||||
let button = translate_mouse_button(gesture.current_button());
|
||||
|
||||
if button == MouseButton::Left && imp.suppress_left_mouse_release.get() {
|
||||
imp.suppress_left_mouse_release.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if button == MouseButton::Middle && !imp.gtk_enable_primary_paste.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mods = gtk_key::translate_mods(gtk_mods);
|
||||
let consumed =
|
||||
match surface.mouse_button_callback(MouseButtonState::Release, button, mods) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
log::warn!("error in key callback err={err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the on-screen keyboard if we have no selection, and
|
||||
// that the mouse event hasn't been intercepted by the callback.
|
||||
//
|
||||
// It's better to do this here rather than within the core
|
||||
// callback since we have direct access to the underlying
|
||||
// gdk.Event here.
|
||||
if !consumed
|
||||
&& button == MouseButton::Left
|
||||
&& !surface.has_selection()
|
||||
&& !this.show_on_screen_keyboard_event(Some(&event))
|
||||
{
|
||||
log::warn!("failed to activate the on-screen keyboard");
|
||||
}
|
||||
this.mouse_up_impl(
|
||||
gesture.current_button(),
|
||||
event.modifier_state(),
|
||||
Some(&event),
|
||||
);
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
@@ -3157,48 +3309,7 @@ mod imp {
|
||||
let Some(event) = ec.current_event() else {
|
||||
return;
|
||||
};
|
||||
let imp = this.imp();
|
||||
|
||||
let (sx, sy) = this.scaled_coordinates(x, y);
|
||||
let pos = CursorPos {
|
||||
x: sx as f32,
|
||||
y: sy as f32,
|
||||
};
|
||||
|
||||
// There seem to be at least two cases where GTK issues a mouse
|
||||
// motion event without the cursor actually moving:
|
||||
// 1. GLArea is resized under the mouse. This has the unfortunate
|
||||
// side effect of causing focus to potentially change when
|
||||
// `focus-follows-mouse` is enabled.
|
||||
// 2. The window title is updated. This can cause the mouse to
|
||||
// unhide incorrectly when hide-mouse-when-typing is enabled.
|
||||
// To prevent incorrect behavior, we'll only grab focus and
|
||||
// continue with callback logic if the cursor has actually moved.
|
||||
let cur = imp.cursor_pos.get();
|
||||
let is_cursor_still = (cur.x - pos.x).abs() < 1.0 && (cur.y - pos.y).abs() < 1.0;
|
||||
if is_cursor_still {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we don't have focus, and we want it, grab it.
|
||||
let config = imp.config.borrow().clone();
|
||||
if let Some(config) = config
|
||||
&& !imp.gl_area.has_focus()
|
||||
&& config.get().focus_follows_mouse
|
||||
{
|
||||
imp.gl_area.grab_focus();
|
||||
}
|
||||
|
||||
// Our pos changed, update
|
||||
imp.cursor_pos.set(pos);
|
||||
|
||||
// Notify the callback
|
||||
if let Some(surface) = this.core() {
|
||||
let mods = gtk_key::translate_mods(event.modifier_state());
|
||||
if let Err(err) = surface.cursor_pos_callback(pos, Some(mods)) {
|
||||
log::warn!("error in cursor pos callback err={err}");
|
||||
}
|
||||
}
|
||||
this.mouse_motion_impl(event.modifier_state(), x, y);
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
@@ -3238,32 +3349,7 @@ mod imp {
|
||||
y: f64,
|
||||
this: &super::Surface,
|
||||
) -> bool {
|
||||
let imp = this.imp();
|
||||
let Some(surface) = this.core() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Multiply precision scrolls by 10 to get a better response from
|
||||
// touchpad scrolling
|
||||
let precision = imp.precision_scroll.get();
|
||||
let multiplier = if precision { 10.0 } else { 1.0 };
|
||||
let scroll_mods = ghostty_input::ScrollMods {
|
||||
precision,
|
||||
..ghostty_input::ScrollMods::default()
|
||||
};
|
||||
|
||||
let (sx, sy) = this.scaled_coordinates(x, y);
|
||||
// We invert because we apply natural scrolling to the values.
|
||||
// This behavior has existed for years without Linux users
|
||||
// complaining but I suspect we'll have to make this configurable
|
||||
// in the future or read a system setting.
|
||||
match surface.scroll_callback(-sx * multiplier, -sy * multiplier, scroll_mods) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("error in scroll callback err={err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
this.scroll_vertical_impl(x, y)
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
//! # inspector window (PNG)
|
||||
//! inspector-click 100 20 # left click in the inspector (logical px)
|
||||
//! inspector-scroll 100 200 -3 # mouse wheel in the inspector
|
||||
//! mouse-move 100 50 [ctrl+shift] # pointer motion (logical px)
|
||||
//! mouse-press 1 [mods] # button 1/2/3 at the last position
|
||||
//! mouse-release 1 [mods]
|
||||
//! click 1 [mods] # press+release; repeat quickly for multi-click
|
||||
//! drag 10 10 200 10 [mods] # move, press left, move in steps, release
|
||||
//! scroll 0 -3 # wheel (dx dy, in ticks)
|
||||
//! resize 600 400 # set the window default size (logical px)
|
||||
//! clipboard-set a\nb # set the GDK clipboard (\n and \\ escapes)
|
||||
//! window-screenshot /abs/out.png # GTK render of the whole window (dialogs included)
|
||||
//! windows-dump # log every toplevel window / dialog
|
||||
//! clipboard-dump # log the GDK clipboard and primary text
|
||||
//! quit # quit the application
|
||||
//! ```
|
||||
//!
|
||||
@@ -47,6 +58,17 @@ pub enum Step {
|
||||
InspectorScreenshot(String),
|
||||
InspectorClick(f64, f64),
|
||||
InspectorScroll(f64, f64, f64),
|
||||
MouseMove(f64, f64, String),
|
||||
MousePress(u32, String),
|
||||
MouseRelease(u32, String),
|
||||
Click(u32, String),
|
||||
Drag(f64, f64, f64, f64, String),
|
||||
Scroll(f64, f64),
|
||||
ClipboardDump,
|
||||
Resize(i32, i32),
|
||||
ClipboardSet(String),
|
||||
WindowsDump,
|
||||
WindowScreenshot(String),
|
||||
Quit,
|
||||
}
|
||||
|
||||
@@ -91,6 +113,63 @@ pub fn parse(script: &str) -> Result<Vec<Step>, String> {
|
||||
};
|
||||
Step::InspectorScroll(x, y, dy)
|
||||
}
|
||||
"mouse-move" | "drag" => {
|
||||
let n = if cmd == "drag" { 4 } else { 2 };
|
||||
let w: Vec<&str> = rest.split_whitespace().collect();
|
||||
if w.len() < n || w.len() > n + 1 {
|
||||
return Err(err(&format!("{cmd} needs {n} numbers [mods]")));
|
||||
}
|
||||
let v: Vec<f64> = w[..n]
|
||||
.iter()
|
||||
.map(|x| x.parse())
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| err("bad number"))?;
|
||||
let mods = w.get(n).unwrap_or(&"").to_string();
|
||||
if cmd == "drag" {
|
||||
Step::Drag(v[0], v[1], v[2], v[3], mods)
|
||||
} else {
|
||||
Step::MouseMove(v[0], v[1], mods)
|
||||
}
|
||||
}
|
||||
"mouse-press" | "mouse-release" | "click" => {
|
||||
let w: Vec<&str> = rest.split_whitespace().collect();
|
||||
let b = w
|
||||
.first()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.ok_or_else(|| err("needs a button number"))?;
|
||||
let mods = w.get(1).unwrap_or(&"").to_string();
|
||||
match cmd {
|
||||
"mouse-press" => Step::MousePress(b, mods),
|
||||
"mouse-release" => Step::MouseRelease(b, mods),
|
||||
_ => Step::Click(b, mods),
|
||||
}
|
||||
}
|
||||
"scroll" => {
|
||||
let v: Vec<f64> = rest
|
||||
.split_whitespace()
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| err("scroll needs dx dy"))?;
|
||||
let [dx, dy] = v[..] else {
|
||||
return Err(err("scroll needs dx dy"));
|
||||
};
|
||||
Step::Scroll(dx, dy)
|
||||
}
|
||||
"clipboard-dump" => Step::ClipboardDump,
|
||||
"windows-dump" => Step::WindowsDump,
|
||||
"window-screenshot" if !rest.is_empty() => Step::WindowScreenshot(rest.to_owned()),
|
||||
"clipboard-set" => Step::ClipboardSet(rest.replace("\\n", "\n")),
|
||||
"resize" => {
|
||||
let v: Vec<i32> = rest
|
||||
.split_whitespace()
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| err("resize needs w h"))?;
|
||||
let [w, h] = v[..] else {
|
||||
return Err(err("resize needs w h"));
|
||||
};
|
||||
Step::Resize(w, h)
|
||||
}
|
||||
"quit" => Step::Quit,
|
||||
_ => return Err(err(&format!("bad command {line:?}"))),
|
||||
});
|
||||
@@ -166,6 +245,7 @@ pub fn start(app: &Application) {
|
||||
app: app.clone(),
|
||||
steps,
|
||||
next: RefCell::new(0),
|
||||
pos: std::cell::Cell::new((0.0, 0.0)),
|
||||
});
|
||||
// Wait for the first surface to be ready (realized, core initialized).
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||
@@ -182,6 +262,72 @@ struct Runner {
|
||||
app: Application,
|
||||
steps: Vec<Step>,
|
||||
next: RefCell<usize>,
|
||||
/// Last pointer position (logical px).
|
||||
pos: std::cell::Cell<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Render the whole widget tree of `win` (GL area content may be blank, but
|
||||
/// dialogs and popovers are included) to a PNG.
|
||||
fn window_screenshot(win: >k::Window, path: &str) {
|
||||
let (w, h) = (win.width(), win.height());
|
||||
let paintable = gtk::WidgetPaintable::new(Some(win));
|
||||
let snapshot = gtk::Snapshot::new();
|
||||
paintable.snapshot(
|
||||
snapshot.upcast_ref::<gdk::Snapshot>(),
|
||||
f64::from(w),
|
||||
f64::from(h),
|
||||
);
|
||||
let Some(node) = snapshot.to_node() else {
|
||||
log::warn!("debug input: empty window snapshot");
|
||||
return;
|
||||
};
|
||||
let Some(renderer) = win.native().and_then(|n| n.renderer()) else {
|
||||
log::warn!("debug input: no renderer");
|
||||
return;
|
||||
};
|
||||
let texture = renderer.render_texture(node, None);
|
||||
match texture.save_to_png(path) {
|
||||
Ok(()) => log::warn!("debug input: wrote window screenshot {path}"),
|
||||
Err(err) => log::error!("debug input: window screenshot failed: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_tree(w: >k::Widget, depth: usize) {
|
||||
log::warn!(
|
||||
"debug input: tree {:indent$}{} mapped={}",
|
||||
"",
|
||||
w.type_().name(),
|
||||
w.is_mapped(),
|
||||
indent = depth * 2
|
||||
);
|
||||
let mut c = w.first_child();
|
||||
while let Some(child) = c {
|
||||
dump_tree(&child, depth + 1);
|
||||
c = child.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mods(spec: &str) -> gdk::ModifierType {
|
||||
let mut state = gdk::ModifierType::empty();
|
||||
for m in spec.split('+').filter(|m| !m.is_empty()) {
|
||||
state |= match m.to_ascii_lowercase().as_str() {
|
||||
"ctrl" | "control" => gdk::ModifierType::CONTROL_MASK,
|
||||
"shift" => gdk::ModifierType::SHIFT_MASK,
|
||||
"alt" => gdk::ModifierType::ALT_MASK,
|
||||
"super" | "meta" => gdk::ModifierType::SUPER_MASK,
|
||||
_ => gdk::ModifierType::empty(),
|
||||
};
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
/// Modifier state GDK reports while a button is held.
|
||||
fn button_mask(button: u32) -> gdk::ModifierType {
|
||||
match button {
|
||||
1 => gdk::ModifierType::BUTTON1_MASK,
|
||||
2 => gdk::ModifierType::BUTTON2_MASK,
|
||||
_ => gdk::ModifierType::BUTTON3_MASK,
|
||||
}
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
@@ -214,6 +360,72 @@ impl Runner {
|
||||
return;
|
||||
}
|
||||
Step::Key(spec) => self.key(&spec),
|
||||
Step::MouseMove(x, y, m) => {
|
||||
self.pos.set((x, y));
|
||||
if let Some(s) = self.active_surface() {
|
||||
s.debug_mouse_move(x, y, parse_mods(&m));
|
||||
}
|
||||
}
|
||||
Step::MousePress(b, m) => self.button(true, b, &m),
|
||||
Step::MouseRelease(b, m) => self.button(false, b, &m),
|
||||
Step::Click(b, m) => {
|
||||
self.button(true, b, &m);
|
||||
self.button(false, b, &m);
|
||||
}
|
||||
Step::Drag(x1, y1, x2, y2, m) => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
let mods = parse_mods(&m);
|
||||
self.pos.set((x1, y1));
|
||||
s.debug_mouse_move(x1, y1, mods);
|
||||
s.debug_mouse_button(true, 1, x1, y1, mods);
|
||||
for i in 1..=10 {
|
||||
let t = f64::from(i) / 10.0;
|
||||
let (x, y) = (x1 + (x2 - x1) * t, y1 + (y2 - y1) * t);
|
||||
self.pos.set((x, y));
|
||||
s.debug_mouse_move(x, y, mods | gdk::ModifierType::BUTTON1_MASK);
|
||||
}
|
||||
s.debug_mouse_button(false, 1, x2, y2, mods);
|
||||
}
|
||||
}
|
||||
Step::Scroll(_dx, dy) => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
s.debug_scroll(dy);
|
||||
}
|
||||
}
|
||||
Step::Resize(w, h) => {
|
||||
if let Some(win) = self.app.active_window() {
|
||||
win.set_default_size(w, h);
|
||||
}
|
||||
}
|
||||
Step::ClipboardSet(text) => {
|
||||
if let Some(d) = gdk::Display::default() {
|
||||
d.clipboard().set_text(&text);
|
||||
}
|
||||
}
|
||||
Step::WindowScreenshot(path) => {
|
||||
if let Some(win) = self.app.active_window() {
|
||||
window_screenshot(&win, &path);
|
||||
}
|
||||
}
|
||||
Step::WindowsDump => {
|
||||
if let Some(win) = self.app.active_window() {
|
||||
dump_tree(win.upcast_ref(), 0);
|
||||
}
|
||||
for w in gtk::Window::list_toplevels() {
|
||||
log::warn!(
|
||||
"debug input: toplevel {} title={:?} visible={}",
|
||||
w.type_().name(),
|
||||
w.downcast_ref::<gtk::Window>()
|
||||
.and_then(gtk::prelude::GtkWindowExt::title),
|
||||
w.is_visible()
|
||||
);
|
||||
}
|
||||
}
|
||||
Step::ClipboardDump => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
s.debug_clipboard_dump();
|
||||
}
|
||||
}
|
||||
Step::Text(text) => match self.active_surface() {
|
||||
Some(s) => s.debug_commit_text(&text),
|
||||
None => log::warn!("debug input: no active surface"),
|
||||
@@ -250,6 +462,18 @@ impl Runner {
|
||||
}
|
||||
}
|
||||
|
||||
fn button(&self, press: bool, button: u32, mods: &str) {
|
||||
let Some(s) = self.active_surface() else {
|
||||
return;
|
||||
};
|
||||
let (x, y) = self.pos.get();
|
||||
let mut m = parse_mods(mods);
|
||||
if !press {
|
||||
m |= button_mask(button);
|
||||
}
|
||||
s.debug_mouse_button(press, button, x, y, m);
|
||||
}
|
||||
|
||||
fn key(&self, spec: &str) {
|
||||
let Some(surface) = self.active_surface() else {
|
||||
log::warn!("debug input: no active surface");
|
||||
@@ -301,4 +525,30 @@ mod tests {
|
||||
assert!(parse("wait x").is_err());
|
||||
assert!(parse("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mouse_steps() {
|
||||
let s = "mouse-move 1 2\nmouse-move 1.5 2 ctrl+shift\nmouse-press 1\nmouse-release 3 alt\nclick 2\ndrag 1 2 3 4\nscroll 0 -3\nresize 600 400\nclipboard-set a\\nb\nclipboard-dump\nwindows-dump\nwindow-screenshot /tmp/w.png\n";
|
||||
assert_eq!(
|
||||
parse(s).expect("valid script"),
|
||||
vec![
|
||||
Step::MouseMove(1.0, 2.0, String::new()),
|
||||
Step::MouseMove(1.5, 2.0, "ctrl+shift".into()),
|
||||
Step::MousePress(1, String::new()),
|
||||
Step::MouseRelease(3, "alt".into()),
|
||||
Step::Click(2, String::new()),
|
||||
Step::Drag(1.0, 2.0, 3.0, 4.0, String::new()),
|
||||
Step::Scroll(0.0, -3.0),
|
||||
Step::Resize(600, 400),
|
||||
Step::ClipboardSet("a\nb".into()),
|
||||
Step::ClipboardDump,
|
||||
Step::WindowsDump,
|
||||
Step::WindowScreenshot("/tmp/w.png".into()),
|
||||
]
|
||||
);
|
||||
assert!(parse("mouse-move 1").is_err());
|
||||
assert!(parse("drag 1 2 3").is_err());
|
||||
assert!(parse("click x").is_err());
|
||||
assert!(parse("scroll 1").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Verification V3 (real GTK app, real programs, mouse input)
|
||||
|
||||
## Replay hook extensions (`crates/ghostty-apprt-gtk/src/debug_input.rs`, `class/surface.rs`)
|
||||
New steps: `mouse-move x y [mods]`, `mouse-press|mouse-release|click <btn> [mods]`, `drag x1 y1 x2 y2 [mods]`,
|
||||
`scroll dx dy`, `resize w h`, `clipboard-set`, `clipboard-dump` (also logs context-menu visibility),
|
||||
`windows-dump` (toplevels + widget tree, shows AdwDialogs), `window-screenshot` (GTK render of the whole
|
||||
window incl. headerbar/dialogs; the GL area content may differ from `screenshot`).
|
||||
The GTK handlers `mouse_down/mouse_up/mouse_motion/scroll_vertical` were split into `*_impl` methods
|
||||
(taking button/modifier state instead of a `GdkEvent`) so the replay runs the exact same code as the
|
||||
gesture controllers. The replay passes `assume_focus=true` for presses: the replayed window is not
|
||||
compositor-focused, and the "first click only transfers focus" suppression would otherwise eat every click.
|
||||
Parser unit test `parse_mouse_steps` added.
|
||||
|
||||
## Scenarios (GTK, Wayland, release profile)
|
||||
| Scenario | Result |
|
||||
|---|---|
|
||||
| nvim (-e nvim file.rs): syntax colors, ligatures, status line, `:q` | PASS. Insert mode bar cursor OK. |
|
||||
| nvim from shell, `:q`: alternate screen restore | PASS (prompt + prior output intact). |
|
||||
| htop: meters, colors, box/bars, F10 | PASS. Mouse click on MEM% header sorts (mouse reporting; clicking twice toggles asc/desc). F10 quits. |
|
||||
| less on 300 lines: wheel in alt screen, `q` | PASS. 5 wheel ticks -> 15 lines (3 arrow keys per tick, DECSET 1007), `q` restores. |
|
||||
| tmux: start, ctrl+b %, status bar, exit | PASS. |
|
||||
| shell drag selection | PASS (highlight; text lands in PRIMARY via copy-on-select). |
|
||||
| double-click word / triple-click line | PASS ("beta"; whole line). |
|
||||
| ctrl+shift+c | PASS (CLIPBOARD gets the selection). |
|
||||
| right-click | PASS: context popover becomes visible (`context menu visible = true`). |
|
||||
| wheel scrollback (10 ticks = 30 lines) | PASS. |
|
||||
| long wrapped line + window resize (shrink, grow) | PASS: reflow correct both ways, viewport preserved. |
|
||||
| unicode: CJK, emoji, ZWJ family, combining, fullwidth, powerline/nerd glyphs, box drawing | PASS: widths/alignment correct. |
|
||||
| kitty graphics (PNG, a=T, c=8,r=4) | PASS: image rendered at the right cell size, text flows after it. |
|
||||
| multi-line paste into `cat` (no bracketed paste) | PASS: core rejects as unsafe, `GhosttyClipboardConfirmationDialog` is created and mapped (widget tree dump). The dialog isn't visible in the render-texture screenshot, only verified structurally. |
|
||||
| Raw mouse bytes (SGR 1006, button 1/3, wheel) | PASS: `ESC[<0;10;5M/m`, `ESC[<2;..`, `ESC[<64;..` x3 per tick. |
|
||||
| Kitty keyboard protocol (flags 1/31) from `key` steps | PASS (CSI u with text/release events). |
|
||||
|
||||
## Bugs
|
||||
No product bugs found in this pass. Observations:
|
||||
- Release profile keeps `debug-assertions = true` (workspace Cargo.toml), so the "debug build" banner shows; use `--profile production` to get the real release look.
|
||||
- `screenshot` returns the renderer's last presented frame; right after input it can be one frame stale. Use >= 1 s waits.
|
||||
- Replay limits: `key <printable>` bypasses the IM; `text` commits bypass key events (plain bytes even under kitty flags "report all keys"); window is not compositor-focused so cursor is hollow and clicks need `assume_focus`.
|
||||
- nvim leaves swap files when the replay is `quit` mid-edit; a later nvim of the same file prompts (E325) and swallows keystrokes. This looked like a bug at first but is nvim behaviour (use `nvim -n`).
|
||||
|
||||
## Remaining
|
||||
- Confirm/deny of the paste dialog by interacting with it not exercised; IME via real input method not exercised.
|
||||
- Horizontal scroll (tab switching) not replayed.
|
||||
Reference in new issue
Block a user