From 81d4119d8520bf257b7cd28372d75e21bc3185f4 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 05:02:48 +0200 Subject: [PATCH] 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) --- crates/ghostty-apprt-gtk/src/class/surface.rs | 402 +++++++++++------- crates/ghostty-apprt-gtk/src/debug_input.rs | 250 +++++++++++ docs/rewrite/notes/verification-V3.md | 43 ++ 3 files changed, 537 insertions(+), 158 deletions(-) create mode 100644 docs/rewrite/notes/verification-V3.md diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs index e69ac36..ed3450f 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -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::(); + 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::(); - 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] diff --git a/crates/ghostty-apprt-gtk/src/debug_input.rs b/crates/ghostty-apprt-gtk/src/debug_input.rs index 1880fee..1467d83 100644 --- a/crates/ghostty-apprt-gtk/src/debug_input.rs +++ b/crates/ghostty-apprt-gtk/src/debug_input.rs @@ -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, 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 = w[..n] + .iter() + .map(|x| x.parse()) + .collect::>() + .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 = rest + .split_whitespace() + .map(str::parse) + .collect::>() + .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 = rest + .split_whitespace() + .map(str::parse) + .collect::>() + .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, next: RefCell, + /// 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::(), + 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::() + .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()); + } } diff --git a/docs/rewrite/notes/verification-V3.md b/docs/rewrite/notes/verification-V3.md new file mode 100644 index 0000000..d6a707f --- /dev/null +++ b/docs/rewrite/notes/verification-V3.md @@ -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 [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 ` 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.