test: verify IME, clipboard dialogs, quick terminal and Flatpak paths

Exercises the runtime paths that had never run (details in
docs/rewrite/notes/verification-VR.md):
- IME: replay steps drive the surface's real GtkIMContext handlers with
  a real preedit + attributes; preedit renders under the cursor and
  commits reach the pty, incl. dead-key and compose orderings.
- Paste and OSC 52 read/write confirmation dialogs: allow, deny and
  remember flows.
- Quick terminal on niri via wlr-layer-shell: positions, sizes, toggle
  and autohide. Fixes a real bug: link order could put libwayland-client
  ahead of libgtk4-layer-shell, silently disabling the quick terminal;
  apprt-gtk now loads libwayland-client via wayland-sys dlopen.
- Flatpak host commands against a zbus mock of
  org.freedesktop.Flatpak.Development under dbus-run-session: spawn,
  exit status, cwd/env/fd forwarding and SIGHUP on stop, both directly
  and through termio Exec (flatpak-info path seam added).
- Horizontal-scroll tab switching thresholds and reset.

tests/gtk_replay.rs runs the real binary against the session display
(skips without one).

Test Plan:
- cargo test --test gtk_replay (needs a Wayland/X11 display)
- cargo test -p ghostty-os --test flatpak_mock; -p ghostty-termio
  --test flatpak_exec
This commit is contained in:
ddidderr committed 2026-10-12 06:02:28 +02:00
1 parent a714fd9a65
commit 42cb8796da
7 files changed
+1567 -12

No files matched your search

+432 -9
View File
@@ -24,11 +24,25 @@
//! 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)
//! hscroll 130 # horizontal touchpad scroll (surface units); +-120 switches tabs
//! tabs-dump # log the tab count and selected tab of the active window
//! 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
//! ime-preedit ´a # IM: preedit-start (if needed) + preedit-changed with this preedit
//! ime-preedit-end # IM: preedit-end
//! ime-commit á # IM: commit signal (outside any key event)
//! ime-key <mode> <key> [text] # key press+release through the IM filter, mode:
//! # preedit | commit | commit-simple | handled-commit | pass
//! ime-state # log whether the surface is composing
//! animations off # disable GTK/libadwaita animations (so dialogs render at once)
//! quick-focus-out # emit notify::is-active on the quick terminal window (it is not
//! # compositor-focused here), running the autohide handler
//! dialog-info # log the open AdwAlertDialog (heading, body, responses, contents)
//! dialog-remember # turn on the "remember choice" switch of the open dialog
//! dialog-click Allow # click the dialog button with this label (Allow/Deny/OK/Cancel...)
//! quit # quit the application
//! ```
//!
@@ -38,9 +52,14 @@
use std::{cell::RefCell, rc::Rc, time::Duration};
use gtk::{gdk, glib, glib::translate::IntoGlib, prelude::*};
use adw::prelude::*;
use gtk::{gdk, glib, glib::translate::IntoGlib};
use crate::class::{application::Application, surface::Surface, window::Window};
use crate::class::{
application::Application,
surface::{DebugIm, DebugImKey, Surface},
window::Window,
};
/// One parsed command.
#[derive(Debug, Clone, PartialEq)]
@@ -58,11 +77,23 @@ pub enum Step {
Click(u32, String),
Drag(f64, f64, f64, f64, String),
Scroll(f64, f64),
HScroll(f64),
TabsDump,
ClipboardDump,
Resize(i32, i32),
ClipboardSet(String),
WindowsDump,
WindowScreenshot(String),
ImePreedit(String),
ImePreeditEnd,
ImeCommit(String),
ImeKey(String, String, String),
ImeState,
Animations(bool),
DialogInfo,
QuickFocusOut,
DialogRemember,
DialogClick(String),
Quit,
}
@@ -149,6 +180,8 @@ pub fn parse(script: &str) -> Result<Vec<Step>, String> {
};
Step::Scroll(dx, dy)
}
"hscroll" => Step::HScroll(rest.parse().map_err(|_| err("hscroll needs dx"))?),
"tabs-dump" => Step::TabsDump,
"clipboard-dump" => Step::ClipboardDump,
"windows-dump" => Step::WindowsDump,
"window-screenshot" if !rest.is_empty() => Step::WindowScreenshot(rest.to_owned()),
@@ -164,6 +197,38 @@ pub fn parse(script: &str) -> Result<Vec<Step>, String> {
};
Step::Resize(w, h)
}
"ime-preedit" if !rest.is_empty() => Step::ImePreedit(rest.to_owned()),
"ime-preedit-end" => Step::ImePreeditEnd,
"ime-commit" if !rest.is_empty() => Step::ImeCommit(rest.to_owned()),
"ime-state" => Step::ImeState,
"animations" => Step::Animations(match rest {
"on" => true,
"off" => false,
_ => return Err(err("animations needs on|off")),
}),
"dialog-info" => Step::DialogInfo,
"quick-focus-out" => Step::QuickFocusOut,
"dialog-remember" => Step::DialogRemember,
"dialog-click" if !rest.is_empty() => Step::DialogClick(rest.to_owned()),
"ime-key" => {
let mut it = rest.splitn(3, char::is_whitespace);
let (Some(mode), Some(key)) = (it.next(), it.next()) else {
return Err(err("ime-key needs <mode> <key> [text]"));
};
if ![
"preedit",
"commit",
"commit-simple",
"handled-commit",
"pass",
]
.contains(&mode)
{
return Err(err("bad ime-key mode"));
}
let text = it.next().unwrap_or("").trim_start().to_owned();
Step::ImeKey(mode.to_owned(), key.to_owned(), text)
}
"quit" => Step::Quit,
_ => return Err(err(&format!("bad command {line:?}"))),
});
@@ -240,6 +305,7 @@ pub fn start(app: &Application) {
steps,
next: RefCell::new(0),
pos: std::cell::Cell::new((0.0, 0.0)),
mock_im: mock_im::MockIm::default(),
});
// Wait for the first surface to be ready (realized, core initialized).
glib::timeout_add_local(Duration::from_millis(100), move || {
@@ -258,13 +324,15 @@ struct Runner {
next: RefCell<usize>,
/// Last pointer position (logical px).
pos: std::cell::Cell<(f64, f64)>,
/// Stands in for the input method context as the signal emitter.
mock_im: mock_im::MockIm,
}
/// 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: &gtk::Window, path: &str) {
fn window_screenshot(win: &impl IsA<gtk::Widget>, path: &str) {
let (w, h) = (win.width(), win.height());
let paintable = gtk::WidgetPaintable::new(Some(win));
let paintable = gtk::WidgetPaintable::new(Some(win.as_ref()));
let snapshot = gtk::Snapshot::new();
paintable.snapshot(
snapshot.upcast_ref::<gdk::Snapshot>(),
@@ -301,6 +369,64 @@ fn dump_tree(w: &gtk::Widget, depth: usize) {
}
}
/// Log what the compositor-facing state of a window is (layer-shell role for
/// the quick terminal).
fn dump_window_state(win: &Window) {
use gtk4_layer_shell::LayerShell;
let gw: &gtk::Window = win.upcast_ref();
let layer = gw.is_layer_window().then(|| {
let edges = [
gtk4_layer_shell::Edge::Left,
gtk4_layer_shell::Edge::Right,
gtk4_layer_shell::Edge::Top,
gtk4_layer_shell::Edge::Bottom,
]
.map(|e| gw.is_anchor(e));
format!(
"layer={:?} keyboard={:?} namespace={:?} anchors(l,r,t,b)={edges:?} exclusive={}",
gw.layer(),
gw.keyboard_mode(),
gw.namespace(),
gw.exclusive_zone()
)
});
log::warn!(
"debug input: window quick_terminal={} visible={} mapped={} active={} size={}x{} layer_shell={:?}",
win.is_quick_terminal(),
gw.is_visible(),
gw.is_mapped(),
gw.is_active(),
gw.width(),
gw.height(),
layer
);
}
fn collect_descendants<T: IsA<gtk::Widget>>(w: &gtk::Widget, out: &mut Vec<T>) {
let mut c = w.first_child();
while let Some(child) = c {
if let Ok(t) = child.clone().downcast::<T>() {
out.push(t);
}
collect_descendants(&child, out);
c = child.next_sibling();
}
}
fn find_descendant<T: IsA<gtk::Widget>>(w: &gtk::Widget) -> Option<T> {
if let Ok(t) = w.clone().downcast::<T>() {
return Some(t);
}
let mut c = w.first_child();
while let Some(child) = c {
if let Some(t) = find_descendant(&child) {
return Some(t);
}
c = child.next_sibling();
}
None
}
fn parse_mods(spec: &str) -> gdk::ModifierType {
let mut state = gdk::ModifierType::empty();
for m in spec.split('+').filter(|m| !m.is_empty()) {
@@ -325,12 +451,29 @@ fn button_mask(button: u32) -> gdk::ModifierType {
}
impl Runner {
/// The window the replay acts on: the application's active window, or,
/// if that is hidden (e.g. an autohidden quick terminal that GTK still
/// reports as active), the first visible Ghostty window.
fn active_window(&self) -> Option<Window> {
let active = self
.app
.active_window()
.and_then(|w| w.downcast::<Window>().ok());
if active
.as_ref()
.is_some_and(gtk::prelude::WidgetExt::is_visible)
{
return active;
}
gtk::Window::list_toplevels()
.into_iter()
.filter_map(|w| w.downcast::<Window>().ok())
.find(gtk::prelude::WidgetExt::is_visible)
.or(active)
}
fn active_surface(&self) -> Option<Surface> {
self.app
.active_window()?
.downcast::<Window>()
.ok()?
.get_active_surface()
self.active_window()?.get_active_surface()
}
/// Run steps until the next `wait`, which reschedules us.
@@ -386,6 +529,22 @@ impl Runner {
s.debug_scroll(dy);
}
}
Step::HScroll(dx) => {
if let Some(s) = self.active_surface() {
s.debug_hscroll(dx);
}
}
Step::TabsDump => {
if let Some(win) = self
.app
.active_window()
.and_then(|w| w.downcast::<Window>().ok())
{
let tv = win.tab_view();
let selected = tv.selected_page().map(|p| tv.page_position(&p));
log::warn!("debug input: tabs n={} selected={selected:?}", tv.n_pages());
}
}
Step::Resize(w, h) => {
if let Some(win) = self.app.active_window() {
win.set_default_size(w, h);
@@ -406,6 +565,9 @@ impl Runner {
dump_tree(win.upcast_ref(), 0);
}
for w in gtk::Window::list_toplevels() {
if let Some(win) = w.downcast_ref::<Window>() {
dump_window_state(win);
}
log::warn!(
"debug input: toplevel {} title={:?} visible={}",
w.type_().name(),
@@ -420,6 +582,61 @@ impl Runner {
s.debug_clipboard_dump();
}
}
Step::ImePreedit(text) => {
if let Some(s) = self.active_surface() {
self.mock_im.set_preedit(&text);
if !s.debug_im_composing() {
s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditStart);
}
s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditChanged);
}
}
Step::ImePreeditEnd => {
if let Some(s) = self.active_surface() {
self.mock_im.set_preedit("");
s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditEnd);
}
}
Step::ImeCommit(text) => {
if let Some(s) = self.active_surface() {
s.debug_im(self.mock_im.upcast_ref(), DebugIm::Commit(text));
}
}
Step::ImeState => {
if let Some(s) = self.active_surface() {
log::warn!("debug input: composing={}", s.debug_im_composing());
}
}
Step::Animations(on) => {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_enable_animations(on);
}
}
Step::DialogInfo => self.dialog_info(),
Step::QuickFocusOut => {
let quick = gtk::Window::list_toplevels()
.into_iter()
.filter_map(|w| w.downcast::<Window>().ok())
.find(Window::is_quick_terminal);
match quick {
// The window is not active, so this is exactly what
// GTK emits when a quick terminal loses focus.
Some(w) => w.notify("is-active"),
None => log::warn!("debug input: no quick terminal window"),
}
}
Step::DialogRemember => match self.find_dialog() {
Some(d) => {
if let Some(row) = find_descendant::<adw::SwitchRow>(d.upcast_ref()) {
row.set_active(true);
} else {
log::warn!("debug input: the dialog has no switch");
}
}
None => log::warn!("debug input: no dialog is open"),
},
Step::DialogClick(label) => self.dialog_click(&label),
Step::ImeKey(mode, spec, text) => self.ime_key(&mode, &spec, &text),
Step::Text(text) => match self.active_surface() {
Some(s) => s.debug_commit_text(&text),
None => log::warn!("debug input: no active surface"),
@@ -468,6 +685,122 @@ impl Runner {
s.debug_mouse_button(press, button, x, y, m);
}
/// The open alert dialog of the active window, if any.
fn find_dialog(&self) -> Option<adw::AlertDialog> {
find_descendant::<adw::AlertDialog>(self.app.active_window()?.upcast_ref())
}
fn dialog_info(&self) {
let Some(d) = self.find_dialog() else {
log::warn!("debug input: no dialog is open");
return;
};
log::warn!(
"debug input: dialog {} heading={:?} body={:?} remember_switch={}",
d.type_().name(),
d.heading(),
d.body(),
find_descendant::<adw::SwitchRow>(d.upcast_ref()).is_some()
);
let mut chain = Vec::new();
let mut w = Some(d.clone().upcast::<gtk::Widget>());
while let Some(x) = w {
chain.push(format!("{}(mapped={})", x.type_().name(), x.is_mapped()));
w = x.parent();
}
log::warn!(
"debug input: dialog visible={} realized={} chain={}",
d.is_visible(),
d.is_realized(),
chain.join(" < ")
);
for id in ["cancel", "ok", "close", "confirm", "deny"] {
if d.has_response(id) {
log::warn!(
"debug input: dialog response {id} label={:?}",
d.response_label(id)
);
}
}
if let Some(view) = find_descendant::<gtk::TextView>(d.upcast_ref()) {
let buf = view.buffer();
log::warn!(
"debug input: dialog contents={:?}",
buf.text(&buf.start_iter(), &buf.end_iter(), false)
);
}
}
/// Click the dialog's button labelled `label` (the real GtkButton, so the
/// AdwAlertDialog response machinery runs as for a user click).
fn dialog_click(&self, label: &str) {
let Some(d) = self.find_dialog() else {
log::warn!("debug input: no dialog is open");
return;
};
let mut buttons = Vec::new();
collect_descendants::<gtk::Button>(d.upcast_ref(), &mut buttons);
match buttons
.iter()
.find(|b| b.label().is_some_and(|l| l.eq_ignore_ascii_case(label)))
{
Some(b) => b.emit_clicked(),
None => log::warn!(
"debug input: no dialog button {label:?}; have {:?}",
buttons.iter().map(gtk::Button::label).collect::<Vec<_>>()
),
}
}
fn ime_key(&self, mode: &str, spec: &str, text: &str) {
let Some(surface) = self.active_surface() else {
log::warn!("debug input: no active surface");
return;
};
let (key, state) = match parse_key(spec) {
Ok(v) => v,
Err(err) => {
log::error!("debug input: {err}");
return;
}
};
let Some(keycode) = surface
.display()
.map_keyval(key)
.and_then(|keys| keys.first().map(gdk::KeymapKey::keycode))
else {
log::error!("debug input: no keycode for {spec:?}");
return;
};
let sim = match mode {
"preedit" => {
self.mock_im.set_preedit(text);
DebugImKey::Preedit
}
"commit" => {
self.mock_im.set_preedit("");
DebugImKey::Commit(text.to_owned())
}
"commit-simple" => {
self.mock_im.set_preedit("");
DebugImKey::CommitSimple(text.to_owned())
}
"handled-commit" => DebugImKey::HandledCommit(text.to_owned()),
_ => DebugImKey::Pass,
};
let consumed = surface.debug_im_key(
self.mock_im.upcast_ref(),
&sim,
key.into_glib(),
keycode,
state,
);
log::warn!(
"debug input: ime-key consumed={consumed} composing={}",
surface.debug_im_composing()
);
}
fn key(&self, spec: &str) {
let Some(surface) = self.active_surface() else {
log::warn!("debug input: no active surface");
@@ -496,6 +829,64 @@ impl Runner {
}
}
/// A stand-in `GtkIMContext` whose preedit string and attributes the replay
/// controls; the surface's real signal handlers read it through
/// `preedit_string()` exactly as they would from an input method.
pub mod mock_im {
use gtk::{glib, subclass::prelude::*};
mod imp {
use std::cell::RefCell;
use gtk::{glib, pango, subclass::prelude::*};
#[derive(Default)]
pub struct MockIm {
pub preedit: RefCell<String>,
}
#[glib::object_subclass]
impl ObjectSubclass for MockIm {
const NAME: &'static str = "GhosttyDebugMockIm";
type Type = super::MockIm;
type ParentType = gtk::IMContext;
}
impl ObjectImpl for MockIm {}
impl IMContextImpl for MockIm {
fn preedit_string(&self) -> (glib::GString, pango::AttrList, i32) {
let text = self.preedit.borrow().clone();
// What a real input method sends: an underline over the
// whole preedit and the cursor at its end.
let attrs = pango::AttrList::new();
let mut underline = pango::AttrInt::new_underline(pango::Underline::Single);
underline.set_start_index(0);
underline.set_end_index(text.len() as u32);
attrs.insert(underline);
let cursor = text.chars().count() as i32;
(text.as_str().into(), attrs, cursor)
}
}
}
glib::wrapper! {
pub struct MockIm(ObjectSubclass<imp::MockIm>) @extends gtk::IMContext;
}
impl Default for MockIm {
fn default() -> Self {
glib::Object::new()
}
}
impl MockIm {
pub fn set_preedit(&self, text: &str) {
self.imp().preedit.replace(text.to_owned());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -520,6 +911,33 @@ mod tests {
assert!(parse("bogus").is_err());
}
#[test]
fn parse_ime_steps() {
let s = "ime-preedit ´a\nime-preedit-end\nime-commit á\nime-key preedit apostrophe ´\nime-key pass a\nime-state\n";
assert_eq!(
parse(s).expect("valid script"),
vec![
Step::ImePreedit("´a".into()),
Step::ImePreeditEnd,
Step::ImeCommit("á".into()),
Step::ImeKey("preedit".into(), "apostrophe".into(), "´".into()),
Step::ImeKey("pass".into(), "a".into(), String::new()),
Step::ImeState,
]
);
assert_eq!(
parse("dialog-info\ndialog-remember\ndialog-click Allow\n").expect("valid"),
vec![
Step::DialogInfo,
Step::DialogRemember,
Step::DialogClick("Allow".into())
]
);
assert!(parse("dialog-click").is_err());
assert!(parse("ime-key bogus a").is_err());
assert!(parse("ime-key commit").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";
@@ -540,6 +958,11 @@ mod tests {
Step::WindowScreenshot("/tmp/w.png".into()),
]
);
assert_eq!(
parse("hscroll 130\ntabs-dump").expect("valid"),
vec![Step::HScroll(130.0), Step::TabsDump]
);
assert!(parse("hscroll").is_err());
assert!(parse("mouse-move 1").is_err());
assert!(parse("drag 1 2 3").is_err());
assert!(parse("click x").is_err());