diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs index aac6397..327bd57 100644 --- a/crates/ghostty-apprt-gtk/src/class/application.rs +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -192,16 +192,63 @@ fn set_gtk_env(config: &CoreConfig) { set_env("GDK_DISABLE", &gdk_disable); } -/// Call `setlocale(LC_ALL, "")` like Ghostty's `global.zig` does at startup -/// (see docs/rewrite/INTEGRATION_TODO.md). Idempotent. +/// Port of Ghostty's `os/locale.zig` `ensureLocale` (called from +/// `global.zig` at startup): set the process locale from the environment and, +/// if that fails (usually an invalid `LANG`), retry with `LANG` unset, then fall +/// back to `en_US.UTF-8` (updating `LANG` on success). Idempotent. This must +/// run before GTK initializes and before the child environment is captured. #[allow(unsafe_code)] pub fn init_locale() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { - // SAFETY: called with a valid NUL-terminated empty string at startup - // before other threads depend on the locale. + // SAFETY: all strings are valid NUL-terminated C strings; this runs at + // startup before other threads read the environment or locale. unsafe { - libc::setlocale(libc::LC_ALL, c"".as_ptr()); + fn show(p: *const libc::c_char) -> String { + // SAFETY: setlocale returns a valid C string when non-null. + unsafe { std::ffi::CStr::from_ptr(p) } + .to_string_lossy() + .into_owned() + } + + // Set the locale to whatever is set in env vars. + let r = libc::setlocale(libc::LC_ALL, c"".as_ptr()); + if !r.is_null() { + log::info!("setlocale from env result={}", show(r)); + return; + } + + // setlocale failed, probably because LANG is invalid. Try again + // without LANG to use the system default. + if std::env::var_os("LANG").is_some_and(|v| !v.is_empty()) { + // Both so that LANG is either empty or unset completely. + libc::setenv(c"LANG".as_ptr(), c"".as_ptr(), 1); + libc::unsetenv(c"LANG".as_ptr()); + + let r = libc::setlocale(libc::LC_ALL, c"".as_ptr()); + if !r.is_null() { + let v = show(r); + log::info!("setlocale after unset lang result={v}"); + + // An unsupported locale yields the "C" fallback; we + // prefer moving on to en_US.UTF-8 in that case. + if v != "C" { + return; + } + } + } + + // Failure again... fallback to en_US.UTF-8 + log::warn!( + "setlocale failed with LANG and system default. Falling back to en_US.UTF-8" + ); + let r = libc::setlocale(libc::LC_ALL, c"en_US.UTF-8".as_ptr()); + if r.is_null() { + log::warn!("setlocale failed even with the fallback, uncertain results"); + } else { + libc::setenv(c"LANG".as_ptr(), c"en_US.UTF-8".as_ptr(), 1); + log::info!("setlocale default result={}", show(r)); + } } }); } @@ -605,6 +652,9 @@ impl Application { // Tell systemd that we are ready. ghostty_os::systemd::notify::ready(); + // Developer aid: replay synthesized input (GHOSTTY_DEBUG_INPUT_SCRIPT). + crate::debug_input::start(self); + log::debug!("entering runloop"); let imp = self.imp(); imp.running.set(true); diff --git a/crates/ghostty-apprt-gtk/src/class/clipboard_confirmation_dialog.rs b/crates/ghostty-apprt-gtk/src/class/clipboard_confirmation_dialog.rs index 7f65613..d090056 100644 --- a/crates/ghostty-apprt-gtk/src/class/clipboard_confirmation_dialog.rs +++ b/crates/ghostty-apprt-gtk/src/class/clipboard_confirmation_dialog.rs @@ -128,13 +128,16 @@ mod imp { } #[template_callback] - fn reveal_clicked(&self, _button: >k::Button) { - self.set_blurred(false, false, true); + fn reveal_clicked(_button: >k::Button, this: &super::ClipboardConfirmationDialog) { + // The signal emitter is the button, so the dialog arrives as the + // trailing user-data argument (a `&self` receiver would be + // matched against the button and panic). + this.imp().set_blurred(false, false, true); } #[template_callback] - fn hide_clicked(&self, _button: >k::Button) { - self.set_blurred(true, true, false); + fn hide_clicked(_button: >k::Button, this: &super::ClipboardConfirmationDialog) { + this.imp().set_blurred(true, true, false); } } diff --git a/crates/ghostty-apprt-gtk/src/class/split_tree.rs b/crates/ghostty-apprt-gtk/src/class/split_tree.rs index 31f74ab..ac53e2f 100644 --- a/crates/ghostty-apprt-gtk/src/class/split_tree.rs +++ b/crates/ghostty-apprt-gtk/src/class/split_tree.rs @@ -940,18 +940,36 @@ mod split_imp { #[gtk::template_callbacks] impl SplitTreeSplit { #[template_callback] - fn notify_max_position(&self, _pspec: glib::ParamSpec) { - self.obj().refresh(); + fn notify_max_position( + _paned: >k::Paned, + _pspec: glib::ParamSpec, + this: &super::SplitTreeSplit, + ) { + // The emitter is the child `Paned`; the template instance is the + // trailing user-data argument. + this.refresh(); } #[template_callback] - fn notify_min_position(&self, _pspec: glib::ParamSpec) { - self.obj().refresh(); + fn notify_min_position( + _paned: >k::Paned, + _pspec: glib::ParamSpec, + this: &super::SplitTreeSplit, + ) { + // The emitter is the child `Paned`; the template instance is the + // trailing user-data argument. + this.refresh(); } #[template_callback] - fn notify_position(&self, _pspec: glib::ParamSpec) { - self.obj().refresh(); + fn notify_position( + _paned: >k::Paned, + _pspec: glib::ParamSpec, + this: &super::SplitTreeSplit, + ) { + // The emitter is the child `Paned`; the template instance is the + // trailing user-data argument. + this.refresh(); } } diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs index 7042d11..f79101a 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -32,6 +32,7 @@ use ghostty_input::{Action as KeyAction, Key, KeyEvent, Mods, MouseButton, Mouse use ghostty_vt::mouse_shape::MouseShape; use ghostty_vt::osc::{ProgressReport, ProgressState}; use gtk::glib::subclass::Signal; +use gtk::glib::translate::{FromGlib, IntoGlib}; use gtk::{gdk, gio, glib}; use crate::class::application::Application; @@ -650,16 +651,88 @@ impl Surface { // scenario we reach this point with im_len > 0 and that's if we // received a commit event from the input method. We don't want to // keep that state around since we've handled it here. - let result = self.key_event_encode(action, &event, &key_event, keyval, keycode, gtk_mods); + let result = self.key_event_encode( + action, + &KeySource::Event { + event: &event, + key_event: &key_event, + }, + keyval, + keycode, + gtk_mods, + ); imp.im_buf.borrow_mut().clear(); result } + /// Debug input replay (`GHOSTTY_DEBUG_INPUT_SCRIPT`): feed a synthesized + /// key event through the same encoding path as [`Surface::key_event`] + /// (physical key lookup, keyval translation, modifier translation, then + /// `key_callback`). The only part skipped is the input method filter + /// (it needs a real `GdkEvent`); text is committed through + /// [`Surface::debug_commit_text`] instead. + /// + /// `keyval`/`keycode`/`state` are what GTK's key controller would pass. + pub fn debug_key_event( + &self, + action: KeyAction, + keyval: u32, + keycode: u32, + state: gdk::ModifierType, + ) -> bool { + let display = self.imp().gl_area.display(); + // What `GdkKeyEvent::keyval()/consumed_modifiers()/layout()` would + // report for this keycode+state (group 0). + let (translated, consumed) = display.translate_key(keycode, state, 0).map_or( + ( + { + // SAFETY: GdkKey wraps a plain keyval integer. + #[allow(unsafe_code)] + unsafe { + gdk::Key::from_glib(keyval) + } + }, + gdk::ModifierType::empty(), + ), + |t| (t.0, t.3), + ); + let result = self.key_event_encode( + action, + &KeySource::Synthetic { + translated, + layout: 0, + consumed, + }, + translated.into_glib(), + keycode, + state, + ); + self.imp().im_buf.borrow_mut().clear(); + result + } + + /// Debug input replay: write the renderer's last frame to `path` (PNG). + pub fn debug_screenshot(&self, path: &str) { + let Some(core) = self.core() else { return }; + // The renderer reads back from the GL context. + self.imp().gl_area.make_current(); + self.imp().gl_area.queue_render(); + match core.write_screenshot(path) { + Ok(()) => log::warn!("debug input: wrote screenshot {path}"), + Err(err) => log::error!("debug input: screenshot failed: {err}"), + } + } + + /// 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) { + self.imp().im_context.emit_by_name::<()>("commit", &[&text]); + } + fn key_event_encode( &self, action: KeyAction, - event: &gdk::Event, - key_event: &gdk::KeyEvent, + src: &KeySource<'_>, keyval: u32, keycode: u32, gtk_mods: gdk::ModifierType, @@ -667,9 +740,18 @@ impl Surface { let imp = self.imp(); // Get the keyvals for this event. - let keyval_unicode: Option = key_event.keyval().to_unicode(); - let keyval_unicode_unshifted = - gtk_key::keyval_unicode_unshifted(&*imp.gl_area, key_event, keycode); + let keyval_unicode: Option = match src { + KeySource::Event { key_event, .. } => key_event.keyval().to_unicode(), + KeySource::Synthetic { translated, .. } => translated.to_unicode(), + }; + let keyval_unicode_unshifted = match src { + KeySource::Event { key_event, .. } => { + gtk_key::keyval_unicode_unshifted(&*imp.gl_area, key_event, keycode) + } + KeySource::Synthetic { layout, .. } => { + gtk_key::keyval_unicode_unshifted_for_layout(&*imp.gl_area, *layout, keycode) + } + }; // We want to get the physical unmapped key to process physical // keybinds. (These are keybinds explicitly marked as requesting @@ -699,13 +781,28 @@ impl Surface { // Get our modifier for the event let application = app(); - let mods: Mods = gtk_key::event_mods(event, physical_key, gtk_mods, action, |dev, m| { - application.winproto().event_mods(dev, m) - }); + let device = match src { + KeySource::Event { event, .. } => event.device(), + KeySource::Synthetic { .. } => imp + .gl_area + .display() + .default_seat() + .and_then(|s| s.keyboard()), + }; + let mods: Mods = gtk_key::event_mods_for_device( + device.as_ref(), + physical_key, + gtk_mods, + action, + |dev, m| application.winproto().event_mods(dev, m), + ); // Get our consumed modifiers - let consumed_mods: Mods = - gtk_key::translate_mods(key_event.consumed_modifiers() & gdk::MODIFIER_MASK); + let consumed = match src { + KeySource::Event { key_event, .. } => key_event.consumed_modifiers(), + KeySource::Synthetic { consumed, .. } => *consumed, + }; + let consumed_mods: Mods = gtk_key::translate_mods(consumed & gdk::MODIFIER_MASK); // If we have no UTF-8 text, we try to convert our keyval to // a text value. We have to do this because GTK will not process @@ -1758,6 +1855,20 @@ fn compute_fraction(progress: u8) -> f64 { f64::from(progress.min(100)) / 100.0 } +/// Where the data for the key encoding path comes from: a real GDK key event +/// or a synthesized one (debug input replay). +enum KeySource<'a> { + Event { + event: &'a gdk::Event, + key_event: &'a gdk::KeyEvent, + }, + Synthetic { + translated: gdk::Key, + layout: u32, + consumed: gdk::ModifierType, + }, +} + // --------------------------------------------------------------------- // Apprt Surface (Surface.zig) diff --git a/crates/ghostty-apprt-gtk/src/debug_input.rs b/crates/ghostty-apprt-gtk/src/debug_input.rs new file mode 100644 index 0000000..494d512 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/debug_input.rs @@ -0,0 +1,246 @@ +//! Debug-only input replay (`GHOSTTY_DEBUG_INPUT_SCRIPT=`). +//! +//! Compositors under test (niri) do not let us inject keys into a window, so +//! this hook synthesizes the events inside the process, at the same entry +//! points the GTK handlers use: key events go through +//! `Surface::debug_key_event` (the keyval/keycode/state translation of +//! `Surface::key_event`, i.e. `gtk/key.rs`, then `key_callback`) and text goes +//! through the IM context's `commit` signal (`im_commit`). +//! +//! Script format, one command per line (`#` starts a comment): +//! +//! ```text +//! key ctrl+shift+t # press+release; mods: ctrl shift alt super +//! text hello world # IM commit of the rest of the line +//! wait 200 # milliseconds +//! screenshot /abs/out.png # dump the renderer's last frame (PNG) +//! quit # quit the application +//! ``` +//! +//! Key names are GDK keysym names (`t`, `Return`, `Tab`, `equal`, `Up`, `F5`) +//! with a few lowercase aliases (`enter`, `esc`, `up`, ...). Commands act on +//! the active surface of the active window at the time they run. + +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk::glib::translate::IntoGlib; +use gtk::prelude::*; +use gtk::{gdk, glib}; + +use crate::class::application::Application; +use crate::class::surface::Surface; +use crate::class::window::Window; + +/// One parsed command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Step { + Key(String), + Text(String), + Wait(u64), + Screenshot(String), + Quit, +} + +/// Parse a script. Errors carry the 1-based line number. +pub fn parse(script: &str) -> Result, String> { + let mut steps = Vec::new(); + for (i, raw) in script.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (cmd, rest) = line.split_once(char::is_whitespace).unwrap_or((line, "")); + let rest = rest.trim_start(); + let err = |m: &str| format!("line {}: {m}", i + 1); + steps.push(match cmd { + "key" if !rest.is_empty() => Step::Key(rest.to_owned()), + "text" if !rest.is_empty() => Step::Text(rest.to_owned()), + "wait" => Step::Wait(rest.parse().map_err(|_| err("wait needs milliseconds"))?), + "screenshot" if !rest.is_empty() => Step::Screenshot(rest.to_owned()), + "quit" => Step::Quit, + _ => return Err(err(&format!("bad command {line:?}"))), + }); + } + Ok(steps) +} + +/// Parse `ctrl+shift+t` into (keyval, state). +pub fn parse_key(spec: &str) -> Result<(gdk::Key, gdk::ModifierType), String> { + let mut state = gdk::ModifierType::empty(); + let parts: Vec<&str> = spec.split('+').collect(); + let (name, mods) = parts.split_last().ok_or("empty key")?; + // "ctrl++" style: the key itself is "+" + let (name, mods) = if name.is_empty() { + ("plus", &mods[..mods.len().saturating_sub(1)]) + } else { + (*name, mods) + }; + for m in mods { + 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, + other => return Err(format!("unknown modifier {other:?}")), + }; + } + let alias = match name.to_ascii_lowercase().as_str() { + "enter" | "return" => "Return", + "esc" | "escape" => "Escape", + "tab" => "Tab", + "space" => "space", + "backspace" => "BackSpace", + "delete" => "Delete", + "up" => "Up", + "down" => "Down", + "left" => "Left", + "right" => "Right", + "home" => "Home", + "end" => "End", + "pageup" | "page_up" => "Page_Up", + "pagedown" | "page_down" => "Page_Down", + "insert" => "Insert", + _ => name, + }; + let key = gdk::Key::from_name(alias) + .or_else(|| gdk::Key::from_name(name)) + .ok_or_else(|| format!("unknown key {name:?}"))?; + Ok((key, state)) +} + +/// Start the replay if `GHOSTTY_DEBUG_INPUT_SCRIPT` is set. +pub fn start(app: &Application) { + let Some(path) = std::env::var_os("GHOSTTY_DEBUG_INPUT_SCRIPT") else { + return; + }; + let script = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(err) => { + log::error!("debug input: cannot read {}: {err}", path.to_string_lossy()); + return; + } + }; + let steps = match parse(&script) { + Ok(s) => s, + Err(err) => { + log::error!("debug input: {err}"); + return; + } + }; + log::warn!("debug input: replaying {} steps", steps.len()); + let runner = Rc::new(Runner { + app: app.clone(), + steps, + next: RefCell::new(0), + }); + // Wait for the first surface to be ready (realized, core initialized). + glib::timeout_add_local(Duration::from_millis(100), move || { + if runner.active_surface().is_some_and(|s| s.core().is_some()) { + runner.run(); + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); +} + +struct Runner { + app: Application, + steps: Vec, + next: RefCell, +} + +impl Runner { + fn active_surface(&self) -> Option { + self.app + .active_window()? + .downcast::() + .ok()? + .get_active_surface() + } + + /// Run steps until the next `wait`, which reschedules us. + fn run(self: &Rc) { + loop { + let idx = *self.next.borrow(); + let Some(step) = self.steps.get(idx).cloned() else { + log::warn!("debug input: script finished"); + return; + }; + *self.next.borrow_mut() += 1; + log::warn!("debug input: {step:?}"); + match step { + Step::Wait(ms) => { + let this = Rc::clone(self); + glib::timeout_add_local_once(Duration::from_millis(ms), move || this.run()); + return; + } + Step::Quit => { + self.app.quit(); + return; + } + Step::Key(spec) => self.key(&spec), + Step::Text(text) => match self.active_surface() { + Some(s) => s.debug_commit_text(&text), + None => log::warn!("debug input: no active surface"), + }, + Step::Screenshot(path) => match self.active_surface() { + Some(s) => s.debug_screenshot(&path), + None => log::warn!("debug input: no active surface"), + }, + } + } + } + + fn key(&self, spec: &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 display = surface.display(); + let Some(keycode) = display + .map_keyval(key) + .and_then(|keys| keys.first().map(gdk::KeymapKey::keycode)) + else { + log::error!("debug input: no keycode for {spec:?}"); + return; + }; + // What GTK reports as `keyval` to the key controller is the + // translated keyval; the encode path translates again from keycode. + let keyval = key.into_glib(); + surface.debug_key_event(ghostty_input::key::Action::Press, keyval, keycode, state); + surface.debug_key_event(ghostty_input::key::Action::Release, keyval, keycode, state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_script() { + let s = + "# c\n\nkey ctrl+shift+t\ntext hello world\nwait 200\nscreenshot /tmp/a.png\nquit\n"; + assert_eq!( + parse(s).expect("valid script"), + vec![ + Step::Key("ctrl+shift+t".into()), + Step::Text("hello world".into()), + Step::Wait(200), + Step::Screenshot("/tmp/a.png".into()), + Step::Quit + ] + ); + assert!(parse("wait x").is_err()); + assert!(parse("bogus").is_err()); + } +} diff --git a/crates/ghostty-apprt-gtk/src/key.rs b/crates/ghostty-apprt-gtk/src/key.rs index 7870cd7..d07f393 100644 --- a/crates/ghostty-apprt-gtk/src/key.rs +++ b/crates/ghostty-apprt-gtk/src/key.rs @@ -101,11 +101,19 @@ pub fn keyval_unicode_unshifted( event: &gdk::KeyEvent, keycode: u32, ) -> u32 { - let display = widget.as_ref().display(); - // We need the currently active keyboard layout so we know what group to // look at. - let layout = event.layout(); + keyval_unicode_unshifted_for_layout(widget, event.layout(), keycode) +} + +/// Like [`keyval_unicode_unshifted`] with an explicit keyboard layout (group) +/// instead of a `GdkKeyEvent`; used by the debug input replay. +pub fn keyval_unicode_unshifted_for_layout( + widget: &impl IsA, + layout: u32, + keycode: u32, +) -> u32 { + let display = widget.as_ref().display(); // All possible keyboard mappings for this keycode (the physical key). let Some(entries) = display.map_keycode(keycode) else { @@ -132,10 +140,25 @@ pub fn event_mods( action: Action, winproto_event_mods: impl FnOnce(Option<&gdk::Device>, gdk::ModifierType) -> Mods, ) -> Mods { - let device = event.device(); + event_mods_for_device( + event.device().as_ref(), + physical_key, + gtk_mods, + action, + winproto_event_mods, + ) +} - let mut mods = winproto_event_mods(device.as_ref(), gtk_mods); - mods.num_lock = device.as_ref().is_some_and(DeviceExt::is_num_locked); +/// Like [`event_mods`] but with the (optional) device instead of the event. +pub fn event_mods_for_device( + device: Option<&gdk::Device>, + physical_key: K, + gtk_mods: gdk::ModifierType, + action: Action, + winproto_event_mods: impl FnOnce(Option<&gdk::Device>, gdk::ModifierType) -> Mods, +) -> Mods { + let mut mods = winproto_event_mods(device, gtk_mods); + mods.num_lock = device.is_some_and(DeviceExt::is_num_locked); // We use the physical key to determine sided modifiers. As far as I can // tell there's no other way to reliably determine this. diff --git a/crates/ghostty-apprt-gtk/src/lib.rs b/crates/ghostty-apprt-gtk/src/lib.rs index cb094c0..c4f3401 100644 --- a/crates/ghostty-apprt-gtk/src/lib.rs +++ b/crates/ghostty-apprt-gtk/src/lib.rs @@ -6,6 +6,7 @@ pub mod app; pub mod cgroup; pub mod class; pub mod css; +pub mod debug_input; pub mod ext; pub mod flatpak; pub mod gl; diff --git a/crates/ghostty-apprt-gtk/src/post_fork.rs b/crates/ghostty-apprt-gtk/src/post_fork.rs index 2c23727..017d5e0 100644 --- a/crates/ghostty-apprt-gtk/src/post_fork.rs +++ b/crates/ghostty-apprt-gtk/src/post_fork.rs @@ -1,15 +1,12 @@ //! Parent post-fork hook creating the transient scope (port of //! `apprt/gtk/post_fork.zig`). -use std::time::{Duration, Instant}; - use ghostty_config::config::Config; use ghostty_config::types_b::{GtkSingleInstance, LinuxCgroup}; use gtk::gio; use gtk::prelude::*; use crate::cgroup; -use crate::pre_exec::current_scope_matches; /// Allowable error from a post-fork hook; fails surface creation. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] @@ -87,25 +84,16 @@ pub fn post_fork(info: &PostForkInfo, pid: Option) -> Result<(), PostForkEr return Ok(()); } - let start = Instant::now(); - loop { - if start.elapsed() > Duration::from_millis(250) { - // Zig checks `rt_pre_exec_info.linux_cgroup_hard_fail` here; - // both infos come from the same config so they're identical. - if info.linux_cgroup_hard_fail { - log::error!("transition to new transient systemd scope {expected} took too long"); - return Err(PostForkError); - } - log::warn!("transition to transient systemd scope {expected} took too long"); - break; + if ghostty_os::cgroup::wait_for_scope(pid, &expected) { + log::debug!("transition to transient systemd scope {expected} complete"); + } else { + // Zig checks `rt_pre_exec_info.linux_cgroup_hard_fail` here; + // both infos come from the same config so they're identical. + if info.linux_cgroup_hard_fail { + log::error!("transition to new transient systemd scope {expected} took too long"); + return Err(PostForkError); } - - if current_scope_matches(pid, &expected) { - log::debug!("transition to transient systemd scope {expected} complete"); - break; - } - - std::thread::sleep(Duration::from_millis(25)); + log::warn!("transition to transient systemd scope {expected} took too long"); } Ok(()) diff --git a/crates/ghostty-apprt-gtk/src/pre_exec.rs b/crates/ghostty-apprt-gtk/src/pre_exec.rs index fd2f046..64c19ee 100644 --- a/crates/ghostty-apprt-gtk/src/pre_exec.rs +++ b/crates/ghostty-apprt-gtk/src/pre_exec.rs @@ -1,9 +1,8 @@ //! Child pre-exec hook for cgroup transitions (port of `apprt/gtk/pre_exec.zig`). //! //! Runs in the child between fork and exec, so it only reads procfs and -//! sleeps. The logic is also mirrored by `ghostty-termio`'s `Command`. - -use std::time::{Duration, Instant}; +//! sleeps. The wait loop is shared with `ghostty-termio` via +//! `ghostty_os::cgroup::wait_for_scope`. use ghostty_config::config::Config; use ghostty_config::types_b::{GtkSingleInstance, LinuxCgroup}; @@ -46,36 +45,13 @@ pub fn pre_exec(info: &PreExecInfo) -> Option { let pid = std::process::id(); let expected = cgroup::fmt_scope(pid); - let start = Instant::now(); - - loop { - if start.elapsed() > Duration::from_millis(250) { - if info.linux_cgroup_hard_fail { - log::error!("transition to new transient systemd scope took too long"); - return Some(127); - } - break; - } - - if current_scope_matches(pid, &expected) { - return None; - } - - std::thread::sleep(Duration::from_millis(25)); + if !ghostty_os::cgroup::wait_for_scope(pid, &expected) && info.linux_cgroup_hard_fail { + log::error!("transition to new transient systemd scope took too long"); + return Some(127); } - None } -/// True if the final path component of `pid`'s cgroup equals `expected`. -pub(crate) fn current_scope_matches(pid: u32, expected: &str) -> bool { - let Some(raw) = ghostty_os::cgroup::current(pid) else { - return false; - }; - raw.rsplit_once('/') - .is_some_and(|(_, last)| last == expected) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ghostty-apprt/src/runtime_api.rs b/crates/ghostty-apprt/src/runtime_api.rs index 6475113..929cae7 100644 --- a/crates/ghostty-apprt/src/runtime_api.rs +++ b/crates/ghostty-apprt/src/runtime_api.rs @@ -235,6 +235,13 @@ pub trait CoreSurface { } } + /// Debug aid: write the last presented frame as a PNG (GL context must be + /// current). Used by the apprt's `GHOSTTY_DEBUG_INPUT_SCRIPT` replay. + fn write_screenshot(&self, path: &str) -> Result<(), CoreError> { + let _ = path; + Err("screenshots are not supported".into()) + } + // ---- inspector (deferred) ------------------------------------------- fn activate_inspector(&self) -> Result<(), CoreError> { Ok(()) diff --git a/crates/ghostty-core/src/global.rs b/crates/ghostty-core/src/global.rs index 71d24fc..822cb3a 100644 --- a/crates/ghostty-core/src/global.rs +++ b/crates/ghostty-core/src/global.rs @@ -26,21 +26,29 @@ impl Default for Logging { } impl Logging { - /// Parse the `GHOSTTY_LOG` value (comma separated field names, an - /// optional `no-` prefix disables; Zig: `cli.args.parsePackedStruct`). - /// Invalid values yield the default. + /// Parse the `GHOSTTY_LOG` value (Zig: `cli.args.parsePackedStruct`): + /// a standalone boolean (`1/t/T/true/0/f/F/false`) sets every field, + /// otherwise a comma separated list of field names where a `no-` prefix + /// disables. Fields not mentioned keep their default (stderr on). Any + /// invalid value (including an empty part) yields the default. #[must_use] pub fn parse(v: &str) -> Logging { - let mut out = Logging { stderr: false }; + match v { + "1" | "t" | "T" | "true" => return Logging { stderr: true }, + "0" | "f" | "F" | "false" => return Logging { stderr: false }, + _ => {} + } + let mut out = Logging::default(); for part in v.split(',') { - let part = part.trim(); + let part = part.trim_matches([' ', '\t']); let (name, value) = match part.strip_prefix("no-") { Some(n) => (n, false), None => (part, true), }; match name { "stderr" => out.stderr = value, - "macos" | "" => {} + // The only other field (macOS unified logging) is a no-op. + "macos" => {} _ => return Logging::default(), } } @@ -87,11 +95,16 @@ pub struct GlobalState { static STATE: OnceLock = OnceLock::new(); /// Options for [`init`]. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Clone, Copy, Default)] pub struct InitOptions { /// A CLI action is being executed: disable stderr logging by default /// since the action writes to stderr/stdout itself. pub action: bool, + + /// The runtime-specific resources directory discovery (Zig: + /// `apprt.runtime.resourcesDir`, e.g. GTK's Flatpak host path lookup). + /// `None` uses the generic `ghostty_os::resourcesdir::resources_dir`. + pub resources_dir: Option std::io::Result>, } /// The global state. If [`init`] was never called, a default state (no @@ -132,7 +145,10 @@ pub fn init(opts: InitOptions) -> &'static GlobalState { // Find our resources directory once for the app so every launch // hereafter can use this cached value. - let resources_dir = match ghostty_os::resourcesdir::resources_dir() { + let discover = opts + .resources_dir + .unwrap_or(ghostty_os::resourcesdir::resources_dir); + let resources_dir = match discover() { Ok(d) => d, Err(err) => { log::warn!("failed to find resources dir err={err}"); @@ -224,7 +240,14 @@ mod tests { fn logging_parse() { assert!(Logging::parse("stderr").stderr); assert!(!Logging::parse("no-stderr").stderr); - assert!(!Logging::parse("macos").stderr); + // Unmentioned fields keep their default (stderr on). + assert!(Logging::parse("macos").stderr); + assert!(!Logging::parse("false").stderr); + assert!(!Logging::parse("0").stderr); + assert!(Logging::parse("true").stderr); + // Empty / invalid values yield the default. + assert!(Logging::parse("").stderr); + assert!(Logging::parse("stderr,").stderr); assert_eq!(Logging::parse("garbage"), Logging::default()); } } diff --git a/crates/ghostty-core/src/surface/binding.rs b/crates/ghostty-core/src/surface/binding.rs index 751cdee..12a6491 100644 --- a/crates/ghostty-core/src/surface/binding.rs +++ b/crates/ghostty-core/src/surface/binding.rs @@ -570,30 +570,6 @@ impl Surface { Ok(Text { text, viewport }) } - /// Open a URL through the runtime, falling back to the default opener - /// (Zig: `openUrl`). - pub(crate) fn open_url_with_fallback(&self, action: OpenUrl) { - // If the apprt handles it then we're done. - let kind = action.kind; - let url = action.url.clone(); - if self.perform(RtAction::OpenUrl(action)) { - return; - } - - // apprt didn't handle it, fallback to our simple cross-platform URL - // opener. We log a warning because we want well-behaved apprts to - // handle this themselves. - log::warn!("apprt did not handle open URL action, falling back to default opener"); - let kind = match kind { - OpenUrlKind::Unknown => ghostty_os::open::OpenUrlKind::Unknown, - OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text, - OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html, - }; - if let Err(err) = ghostty_os::open::open(kind, &url) { - log::warn!("failed to open url err={err}"); - } - } - /// Write the screen/history/selection to a temp file and paste/copy/open /// the path (Zig: `writeScreenFile`). fn write_screen_file( @@ -620,13 +596,13 @@ impl Surface { false, ); } - WriteScreenAction::Open => self.open_url_with_fallback(OpenUrl { + WriteScreenAction::Open => self.open_url(OpenUrl { kind: match write_screen.emit { WriteScreenFormat::Plain | WriteScreenFormat::Vt => OpenUrlKind::Text, WriteScreenFormat::Html => OpenUrlKind::Html, }, url: path, - }), + })?, WriteScreenAction::Paste => self.queue_io(IoMessage::write_req(path.as_bytes())), } Ok(()) @@ -700,6 +676,10 @@ impl Surface { } else { format!("\x1b{data}") }; + // Zig formats into a fixed 128 byte buffer (`NoSpaceLeft`). + if full.len() > 128 { + return Err("csi/esc binding sequence too long (max 128 bytes)".into()); + } self.queue_io(IoMessage::write_req(full.as_bytes())); // CSI/ESC triggers a scroll. diff --git a/crates/ghostty-core/src/surface/clipboard.rs b/crates/ghostty-core/src/surface/clipboard.rs index dca021f..95127f5 100644 --- a/crates/ghostty-core/src/surface/clipboard.rs +++ b/crates/ghostty-core/src/surface/clipboard.rs @@ -43,13 +43,19 @@ impl Surface { return; } - // Decode. Zig tolerates a missing-padding error ("It is possible to - // actually have valid data and get this error, so we allow it"), so - // decode leniently without requiring padding. + // Zig validates the size first (`calcSizeForSlice` on the padded + // standard alphabet rejects lengths that aren't a multiple of 4), + // then decodes ignoring only the `InvalidPadding` error (e.g. nonzero + // trailing bits as in "YR==", which still decodes to "a"). + if !data.len().is_multiple_of(4) { + log::info!("application sent invalid base64 data for OSC 52"); + return; + } let lenient = base64::engine::GeneralPurpose::new( &base64::alphabet::STANDARD, base64::engine::GeneralPurposeConfig::new() - .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), + .with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireCanonical) + .with_decode_allow_trailing_bits(true), ); let Ok(decoded) = lenient.decode(data) else { log::info!("application sent invalid base64 data for OSC 52"); @@ -78,23 +84,8 @@ impl Surface { // The options we'll use for all formatting. We'll just override the // emit format. - let codepoint_map: Vec = cfg - .clipboard_codepoint_map - .map - .list - .iter() - .map(|e| formatter::CodepointMap { - range: e.range, - replacement: match &e.replacement { - ghostty_config::clipboard_codepoint_map::Replacement::Codepoint(c) => { - formatter::Replacement::Codepoint(*c) - } - ghostty_config::clipboard_codepoint_map::Replacement::String(s) => { - formatter::Replacement::String(s.clone()) - } - }, - }) - .collect(); + let codepoint_map: Vec = + cfg.clipboard_codepoint_map.map.list.clone(); let opts = formatter::Options { emit: Format::Plain, // We'll override this below unwrap: true, diff --git a/crates/ghostty-core/src/surface/keyboard.rs b/crates/ghostty-core/src/surface/keyboard.rs index 0fb0639..56874ff 100644 --- a/crates/ghostty-core/src/surface/keyboard.rs +++ b/crates/ghostty-core/src/surface/keyboard.rs @@ -731,12 +731,9 @@ impl Surface { BindingAction::Redo => RtAction::Redo, _ => return, }; - let target = if matches!(rt, RtAction::NewWindow) { - Target::Surface(self.id) - } else { - Target::App - }; - self.rt_app.perform_action(target, rt); + // Zig `App.performAction`: new_window is created with parent = null, + // so it never inherits from the invoking surface. + self.rt_app.perform_action(Target::App, rt); } /// Deactivate every key table. Returns whether any was active. diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs index 3e9a776..4623f88 100644 --- a/crates/ghostty-core/src/surface/mod.rs +++ b/crates/ghostty-core/src/surface/mod.rs @@ -1334,7 +1334,14 @@ impl Surface { t: &mut ghostty_terminal::terminal::Terminal, sel: Option, ) -> Result<(), ghostty_terminal::page::Error> { - let prev = t.screen().selection; + // Compare against the previous selection BEFORE replacing it: + // `select` releases the previous selection's tracked pins, after + // which they can no longer be resolved (Zig compares through raw + // pointers that happen to stay readable). + let unchanged = match (sel, t.screen().selection) { + (Some(sel), Some(prev)) => sel.eql(&t.screen().pages, &prev), + _ => false, + }; t.screen_mut().select(sel)?; let cfg = self.cfg(); @@ -1352,9 +1359,7 @@ impl Surface { let Some(sel) = sel else { return Ok(()); }; - if let Some(prev) = prev - && sel.eql(&t.screen().pages, &prev) - { + if unchanged { return Ok(()); } @@ -1708,28 +1713,40 @@ impl Surface { if self.debug_shot_done.get() || self.created.elapsed().as_millis() < u128::from(delay) { return; } - let Some((w, h, mut px)) = self.renderer.last_frame_pixels() else { - return; + if self + .write_screenshot_png(std::path::Path::new(&path)) + .is_ok() + { + self.debug_shot_done.set(true); + } + } + + /// Write the last presented frame as a PNG. Requires the GL context to be + /// current. + pub fn write_screenshot_png(&self, path: &std::path::Path) -> Result<(), CoreError> { + let Some((w, h, px)) = self.renderer.last_frame_pixels() else { + return Err("no frame has been presented yet".into()); }; - self.debug_shot_done.set(true); // Bottom-up RGBA -> top-down. let stride = w * 4; let mut flipped = Vec::with_capacity(px.len()); - for row in px.chunks_exact_mut(stride).rev() { + for row in px.chunks_exact(stride).rev() { flipped.extend_from_slice(row); } - let write = || -> Result<(), Box> { - let file = std::io::BufWriter::new(std::fs::File::create(&path)?); - let mut enc = png::Encoder::new(file, w as u32, h as u32); - enc.set_color(png::ColorType::Rgba); - enc.set_depth(png::BitDepth::Eight); - enc.write_header()?.write_image_data(&flipped)?; - Ok(()) - }; - match write() { - Ok(()) => log::info!("wrote debug screenshot to {}", path.to_string_lossy()), - Err(err) => log::warn!("debug screenshot failed: {err}"), - } + let file = std::io::BufWriter::new(std::fs::File::create(path)?); + let mut enc = png::Encoder::new(file, w as u32, h as u32); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + enc.write_header()?.write_image_data(&flipped)?; + log::info!("wrote screenshot to {}", path.display()); + Ok(()) + } + + /// The pixels of the last presented frame (`width`, `height`, RGBA8, + /// bottom-up). Test/debug hook for headless integration tests; requires + /// the GL context to be current on the calling thread. + pub fn frame_pixels(&self) -> Option<(usize, usize, Vec)> { + self.renderer.last_frame_pixels() } /// The GLArea was (re)realized; the GL context is current. @@ -1777,15 +1794,16 @@ impl Drop for Surface { } } -/// The environment for the child: whatever the runtime wants plus the -/// process environment (Zig: `rt_surface.defaultTermioEnv()` falling back to -/// `getEnvMap`). +/// The environment for the child: exactly what the runtime provides +/// (`rt_surface.defaultTermioEnv()`, which already removed the variables it +/// does not want to leak); only if it provides nothing (the Zig error path) +/// do we fall back to the process environment (`getEnvMap`). fn rt_surface_env(rt: &dyn RtSurface) -> std::collections::HashMap { - let mut env = ghostty_os::env::get_env_map(); - for (k, v) in rt.default_termio_env() { - env.insert(k, v); + let rt_env = rt.default_termio_env(); + if rt_env.is_empty() { + return ghostty_os::env::get_env_map(); } - env + rt_env.into_iter().collect() } // ---------------------------------------------------------------------- @@ -1917,6 +1935,10 @@ impl CoreSurface for Surface { Ok(()) } + fn write_screenshot(&self, path: &str) -> Result<(), CoreError> { + self.write_screenshot_png(std::path::Path::new(path)) + } + fn id(&self) -> Option { Some(self.id) } diff --git a/crates/ghostty-core/src/surface/mouse.rs b/crates/ghostty-core/src/surface/mouse.rs index cfaf515..8fcb158 100644 --- a/crates/ghostty-core/src/surface/mouse.rs +++ b/crates/ghostty-core/src/surface/mouse.rs @@ -1663,7 +1663,7 @@ impl Surface { Ok(true) } - fn open_url(&self, action: OpenUrl) -> Result<(), CoreError> { + pub(crate) fn open_url(&self, action: OpenUrl) -> Result<(), CoreError> { // If the apprt handles it then we're done. let kind = action.kind; let url = action.url.clone(); diff --git a/crates/ghostty-core/tests/common/mod.rs b/crates/ghostty-core/tests/common/mod.rs new file mode 100644 index 0000000..ad16441 --- /dev/null +++ b/crates/ghostty-core/tests/common/mod.rs @@ -0,0 +1,673 @@ +//! End-to-end test harness: the REAL core `Surface` (real termio with a real +//! pty + child process, real `Terminal`, real renderer on a headless EGL +//! context) driven through a fake apprt (`FakeRt`) that records the performed +//! actions and implements the clipboard in memory. +//! +//! The harness is single-threaded like the GTK main loop: the test thread is +//! the "main thread" and calls [`Harness::pump`] (which is `App::tick` plus +//! answering pending clipboard reads) while polling for conditions. +#![allow(dead_code, clippy::unwrap_used, clippy::expect_used)] + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use ghostty_apprt::action::{Action, Target}; +use ghostty_apprt::runtime_api::{ + ClipboardError, CoreApp, InputEffect, RtApp, RtSurface, SurfaceId, Waker, +}; +use ghostty_apprt::structs::{Clipboard, ClipboardRequest, ContentScale, CursorPos, SurfaceSize}; +use ghostty_config::command::Command; +use ghostty_config::config::Config; +use ghostty_core::{App, Surface}; +use ghostty_input::{ + Key, KeyEvent, Mods, MouseButton, MouseButtonState, ScrollMods, key::Action as KeyAction, +}; +use ghostty_renderer::headless::Headless; + +/// Generous upper bound for every polled condition. +pub const TIMEOUT: Duration = Duration::from_secs(30); + +struct NopWaker; +impl Waker for NopWaker { + fn wake(&self) {} +} + +/// A recorded `set_clipboard` call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardWrite { + pub clipboard: Clipboard, + pub contents: Vec<(String, String)>, + pub confirm: bool, +} + +/// A confirmation dialog the GTK apprt would show. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Confirmation { + pub request: ClipboardRequest, + pub text: String, +} + +/// The fake apprt (both `RtApp` and `RtSurface`). +pub struct FakeRt { + pub actions: RefCell, Action)>>, + /// In-memory clipboards (plain text). + pub clipboards: RefCell>, + pub writes: RefCell>, + pending: RefCell>, + pub confirmations: RefCell>, + pub closes: RefCell>, + pub size: Cell, + pub scale: Cell, + pub cursor: Cell, + /// What `perform_action(ShowChildExited)` returns (GTK: true). + pub show_child_exited: Cell, + pub title: RefCell>, + gl: Rc, +} + +fn cb_key(c: Clipboard) -> u8 { + match c { + Clipboard::Standard => 0, + Clipboard::Selection => 1, + Clipboard::Primary => 2, + } +} + +impl RtApp for FakeRt { + fn perform_action(&self, target: Target, action: Action) -> bool { + let ret = match &action { + Action::ShowChildExited(_) => self.show_child_exited.get(), + Action::SetTitle(t) => { + *self.title.borrow_mut() = Some(t.title.clone()); + true + } + _ => true, + }; + self.actions.borrow_mut().push((target, action)); + ret + } +} + +impl RtSurface for FakeRt { + fn close(&self, process_active: bool) { + self.closes.borrow_mut().push(process_active); + } + fn cgroup(&self) -> Option { + None + } + fn get_title(&self) -> Option { + self.title.borrow().clone() + } + fn get_content_scale(&self) -> ContentScale { + self.scale.get() + } + fn get_size(&self) -> SurfaceSize { + self.size.get() + } + fn get_cursor_pos(&self) -> CursorPos { + self.cursor.get() + } + fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool { + // Like GTK: paste requests need text on the clipboard. + if state == ClipboardRequest::Paste + && !self.clipboards.borrow().contains_key(&cb_key(clipboard)) + { + return false; + } + self.pending.borrow_mut().push_back((clipboard, state)); + true + } + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) { + self.writes.borrow_mut().push(ClipboardWrite { + clipboard, + contents: contents.to_vec(), + confirm, + }); + if let Some((_, text)) = contents.iter().find(|(m, _)| m == "text/plain") { + // Like GTK, a confirm=true write shows a dialog first; the + // harness only stores unconfirmed writes. + if confirm { + self.confirmations.borrow_mut().push(Confirmation { + request: ClipboardRequest::Osc52Write(clipboard), + text: text.clone(), + }); + } else { + self.clipboards + .borrow_mut() + .insert(cb_key(clipboard), text.clone()); + } + } + } + fn default_termio_env(&self) -> Vec<(String, String)> { + // Like GTK: the process environment minus what the runtime does not + // want to leak (here HOME stands in for GDK_DISABLE & co), plus extras. + let mut env: Vec<(String, String)> = + std::env::vars().filter(|(k, _)| k != "HOME").collect(); + env.push(("TERM_PROGRAM_TEST".to_owned(), "1".to_owned())); + env + } + fn gl_proc_address(&self, name: &str) -> *const std::ffi::c_void { + self.gl.proc_address(name) + } +} + +static COUNTER: AtomicU64 = AtomicU64::new(1); + +/// Options for building a harness. +#[derive(Default, Clone)] +pub struct Opts { + /// Extra config args (`--key=value`). + pub args: Vec, + /// `sh -c` script; `{out}` is replaced by the recording file path. + pub script: Option, + /// Surface size in pixels (default 800x600). + pub size: Option<(u32, u32)>, + pub scale: Option, + pub show_child_exited: Option, +} + +impl Opts { + pub fn args(mut self, a: &[&str]) -> Opts { + self.args.extend(a.iter().map(|s| (*s).to_owned())); + self + } + pub fn script(mut self, s: &str) -> Opts { + self.script = Some(s.to_owned()); + self + } +} + +pub struct Harness { + pub app: App, + pub rt: Rc, + pub surface: Rc, + pub out: PathBuf, + pub id: SurfaceId, + base: Cell, + // Dropped last: the GL context must outlive the surface. + _gl: Rc, +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.out); + } +} + +fn tmp_path(tag: &str) -> PathBuf { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + dir.join(format!("e2e-{}-{}-{tag}", std::process::id(), n)) +} + +impl Harness { + /// Try to build; `None` when no headless GL is available. + pub fn try_new(opts: &Opts) -> Option { + let gl = match Headless::new() { + Ok(g) => Rc::new(g), + Err(e) => { + eprintln!("SKIP: headless EGL unavailable: {e}"); + return None; + } + }; + let (w, h) = opts.size.unwrap_or((800, 600)); + gl.set_viewport(w as i32, h as i32); + let s = opts.scale.unwrap_or(1.0); + let rt = Rc::new(FakeRt { + actions: RefCell::default(), + clipboards: RefCell::default(), + writes: RefCell::default(), + pending: RefCell::default(), + confirmations: RefCell::default(), + closes: RefCell::default(), + size: Cell::new(SurfaceSize { + width: w, + height: h, + }), + scale: Cell::new(ContentScale { x: s, y: s }), + cursor: Cell::new(CursorPos { x: 0.0, y: 0.0 }), + show_child_exited: Cell::new(opts.show_child_exited.unwrap_or(false)), + title: RefCell::default(), + gl: Rc::clone(&gl), + }); + let out = tmp_path("rec"); + let _ = std::fs::remove_file(&out); + + let mut config = Config::new_default(); + let mut args: Vec = vec![ + "--linux-cgroup=never".into(), + "--shell-integration=none".into(), + "--config-default-files=false".into(), + // Keep tests quiet and deterministic. + "--desktop-notifications=true".into(), + ]; + args.extend(opts.args.iter().cloned()); + let mut it = ghostty_config::cli_args::VecIterator::new(args); + config.load_iter(&mut it).expect("config args"); + if let Some(script) = &opts.script { + let script = script.replace("{out}", out.to_str().unwrap()); + config.command = Some(Command::Direct(vec!["sh".into(), "-c".into(), script])); + } + config.finalize().expect("finalize"); + + let app = App::new(Arc::new(NopWaker)).expect("app"); + let id: SurfaceId = 1; + let rt_app: Rc = rt.clone(); + let rt_surface: Rc = rt.clone(); + let core = app + .new_surface(id, config, rt_app, rt_surface) + .expect("new_surface"); + let surface = app.state().surface(id).expect("surface registered"); + drop(core); + Some(Harness { + app, + rt, + surface, + out, + id, + base: Cell::new(0), + _gl: gl, + }) + } + + #[allow(clippy::needless_pass_by_value)] + pub fn new(opts: Opts) -> Option { + Harness::try_new(&opts) + } + + /// A recorder child: raw tty, no echo, optional setup escape output, then + /// prints READY and records every byte it receives into the out file. + pub fn recorder(setup_printf: &str, args: &[&str]) -> Option { + let script = + format!("stty raw -echo; printf '{setup_printf}'; printf READY; exec cat >'{{out}}'"); + let h = Harness::new(Opts::default().args(args).script(&script))?; + h.wait_screen("READY"); + Some(h) + } + + /// Like [`Harness::recorder`] but runs the shell snippet `pre` (cooked + /// tty) first, e.g. to print lots of output. + pub fn with_pre(pre: &str, args: &[&str]) -> Option { + let script = format!("{pre}; stty raw -echo; printf READY; exec cat >'{{out}}'"); + let h = Harness::new(Opts::default().args(args).script(&script))?; + h.wait_screen("READY"); + Some(h) + } + + // ------------------------------------------------------------------ + // Event loop + + /// `App::tick` plus answering pending clipboard reads (async in GTK). + pub fn pump(&self) { + self.app.tick(self.rt.as_ref()).unwrap(); + loop { + let next = self.rt.pending.borrow_mut().pop_front(); + let Some((clipboard, req)) = next else { break }; + let text = self + .rt + .clipboards + .borrow() + .get(&cb_key(clipboard)) + .cloned() + .unwrap_or_default(); + match self.surface.complete_clipboard_request(req, &text, false) { + Ok(()) => {} + Err(ClipboardError::UnsafePaste | ClipboardError::UnauthorizedPaste) => { + self.rt + .confirmations + .borrow_mut() + .push(Confirmation { request: req, text }); + } + Err(e) => panic!("clipboard request failed: {e}"), + } + } + } + + /// Poll `cond` (pumping the app) until true or timeout. + pub fn wait(&self, what: &str, mut cond: impl FnMut(&Harness) -> bool) { + let start = Instant::now(); + loop { + self.pump(); + if cond(self) { + return; + } + assert!( + start.elapsed() < TIMEOUT, + "timeout waiting for {what}\nscreen:\n{}\nrecorded: {:?}\nactions: {:#?}", + self.screen(), + self.recorded(), + self.action_names(), + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// Assert `cond` stays false for a short period (negative checks). The + /// duration is a deliberate settle time, used sparingly. + pub fn settle(&self, ms: u64) { + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(ms) { + self.pump(); + std::thread::sleep(Duration::from_millis(5)); + } + } + + // ------------------------------------------------------------------ + // Observation + + /// The visible viewport as plain text. + pub fn screen(&self) -> String { + self.surface.lock_terminal().plain_string().unwrap() + } + + pub fn wait_screen(&self, needle: &str) { + self.wait(&format!("screen to contain {needle:?}"), |h| { + h.screen().contains(needle) + }); + } + + /// Bytes the recorder child has received so far. + pub fn recorded(&self) -> Vec { + let all = std::fs::read(&self.out).unwrap_or_default(); + all.get(self.base.get()..).unwrap_or_default().to_vec() + } + + /// Wait for exactly `expected` (as a prefix of what was recorded, then + /// the full length) and assert equality. + pub fn expect_input(&self, expected: &[u8]) { + self.wait(&format!("child input {expected:?}"), |h| { + h.recorded().len() >= expected.len() + }); + assert_eq!( + String::from_utf8_lossy(&self.recorded()), + String::from_utf8_lossy(expected) + ); + assert_eq!(self.recorded(), expected); + } + + /// Start a fresh recording window: later `recorded()`/`expect_input` + /// only see bytes after this point. Call after an `expect_input`. + pub fn mark(&self) { + let len = std::fs::metadata(&self.out).map_or(0, |m| m.len() as usize); + self.base.set(len); + } + + pub fn actions(&self) -> Vec { + self.rt + .actions + .borrow() + .iter() + .map(|(_, a)| a.clone()) + .collect() + } + + pub fn action_names(&self) -> Vec { + self.rt + .actions + .borrow() + .iter() + .filter(|(_, a)| !matches!(a, Action::Render | Action::Scrollbar(_))) + .map(|(t, a)| format!("{t:?} {a:?}")) + .collect() + } + + /// Wait for an action matching `f` and return it. + pub fn wait_action(&self, what: &str, f: impl Fn(&Action) -> Option) -> T { + let mut found = None; + self.wait(what, |h| { + found = h.rt.actions.borrow().iter().find_map(|(_, a)| f(a)); + found.is_some() + }); + found.unwrap() + } + + pub fn has_action(&self, f: impl Fn(&Action) -> bool) -> bool { + self.rt.actions.borrow().iter().any(|(_, a)| f(a)) + } + + pub fn count_actions(&self, f: impl Fn(&Action) -> bool) -> usize { + self.rt + .actions + .borrow() + .iter() + .filter(|(_, a)| f(a)) + .count() + } + + pub fn clear_actions(&self) { + self.rt.actions.borrow_mut().clear(); + } + + // ------------------------------------------------------------------ + // Input + + pub fn key_event(&self, ev: KeyEvent<'_>) -> InputEffect { + self.surface.key_callback(ev).unwrap() + } + + /// Press (and release) a key with the given mods and text. + pub fn key(&self, key: Key, mods: Mods, utf8: &str) -> InputEffect { + let unshifted = utf8.chars().next().map_or(0, |c| { + if c.is_ascii_uppercase() { + c.to_ascii_lowercase() as u32 + } else { + c as u32 + } + }); + let eff = self.key_event(KeyEvent { + action: KeyAction::Press, + key, + mods, + consumed_mods: if mods.shift && !utf8.is_empty() { + Mods { + shift: true, + ..Mods::default() + } + } else { + Mods::default() + }, + composing: false, + utf8, + unshifted_codepoint: unshifted, + }); + // GTK reports the same text/codepoint on release. + self.key_event(KeyEvent { + action: KeyAction::Release, + key, + mods, + consumed_mods: Mods::default(), + composing: false, + utf8, + unshifted_codepoint: unshifted, + }); + eff + } + + /// A key without text (arrows, F-keys, ...). + pub fn special(&self, key: Key, mods: Mods) -> InputEffect { + self.key(key, mods, "") + } + + /// Type printable ASCII text char by char. + pub fn type_text(&self, s: &str) { + for c in s.chars() { + let (key, shift) = key_for_char(c); + let mods = Mods { + shift, + ..Mods::default() + }; + let mut buf = [0u8; 4]; + self.key(key, mods, c.encode_utf8(&mut buf)); + } + } + + /// Write bytes to the child by pasting-unsafe means: straight into the + /// pty through the IO mailbox, bypassing paste protection. + pub fn paste_raw(&self, s: &str) { + self.surface.text_callback(s).unwrap(); + } + + pub fn press_mouse(&self, button: MouseButton, mods: Mods) -> bool { + self.surface + .mouse_button_callback(MouseButtonState::Press, button, mods) + .unwrap() + } + + pub fn release_mouse(&self, button: MouseButton, mods: Mods) -> bool { + self.surface + .mouse_button_callback(MouseButtonState::Release, button, mods) + .unwrap() + } + + /// Pixel position of the center of a cell. + pub fn cell_pos(&self, col: u32, row: u32) -> CursorPos { + let s = self.surface.size(); + CursorPos { + x: (s.padding.left + col * s.cell.width + s.cell.width / 2) as f32, + y: (s.padding.top + row * s.cell.height + s.cell.height / 2) as f32, + } + } + + /// Position inside a cell; `fx`/`fy` in [0,1). + pub fn cell_pos_frac(&self, col: u32, row: u32, fx: f32, fy: f32) -> CursorPos { + let s = self.surface.size(); + CursorPos { + x: s.padding.left as f32 + (col as f32 + fx) * s.cell.width as f32, + y: s.padding.top as f32 + (row as f32 + fy) * s.cell.height as f32, + } + } + + pub fn move_to_pos(&self, p: CursorPos, mods: Option) { + self.rt.cursor.set(p); + self.surface.cursor_pos_callback(p, mods).unwrap(); + } + + pub fn move_to(&self, col: u32, row: u32, mods: Option) { + let p = self.cell_pos(col, row); + self.rt.cursor.set(p); + self.surface.cursor_pos_callback(p, mods).unwrap(); + } + + pub fn click(&self, col: u32, row: u32, button: MouseButton, mods: Mods) { + self.move_to(col, row, Some(mods)); + self.press_mouse(button, mods); + self.release_mouse(button, mods); + } + + pub fn scroll(&self, x: f64, y: f64) { + self.surface + .scroll_callback(x, y, ScrollMods::default()) + .unwrap(); + } + + pub fn bind(&self, action: &str) -> bool { + let a = ghostty_input::binding::Action::parse(action).expect("parse action"); + self.surface.perform_binding_action(&a).unwrap() + } + + /// Clipboard text. + pub fn clipboard(&self, c: Clipboard) -> Option { + self.rt.clipboards.borrow().get(&cb_key(c)).cloned() + } + + pub fn set_clipboard_text(&self, c: Clipboard, text: &str) { + self.rt + .clipboards + .borrow_mut() + .insert(cb_key(c), text.to_owned()); + } + + pub fn grid(&self) -> (u16, u16) { + let g = self.surface.size().grid(); + (g.columns, g.rows) + } +} + +/// Physical key for an ASCII char (US layout) and whether shift is needed. +pub fn key_for_char(c: char) -> (Key, bool) { + use Key::*; + let letters = [ + KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, KeyN, KeyO, + KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ, + ]; + let digits = [ + Digit0, Digit1, Digit2, Digit3, Digit4, Digit5, Digit6, Digit7, Digit8, Digit9, + ]; + match c { + 'a'..='z' => (letters[(c as u8 - b'a') as usize], false), + 'A'..='Z' => (letters[(c as u8 - b'A') as usize], true), + '0'..='9' => (digits[(c as u8 - b'0') as usize], false), + ')' => (Digit0, true), + '!' => (Digit1, true), + '@' => (Digit2, true), + '#' => (Digit3, true), + '$' => (Digit4, true), + '%' => (Digit5, true), + '^' => (Digit6, true), + '&' => (Digit7, true), + '*' => (Digit8, true), + '(' => (Digit9, true), + ' ' => (Space, false), + '-' => (Minus, false), + '_' => (Minus, true), + '=' => (Equal, false), + '+' => (Equal, true), + '.' => (Period, false), + ',' => (Comma, false), + '/' => (Slash, false), + ';' => (Semicolon, false), + ':' => (Semicolon, true), + '\'' => (Quote, false), + '"' => (Quote, true), + '[' => (BracketLeft, false), + ']' => (BracketRight, false), + '\\' => (Backslash, false), + '`' => (Backquote, false), + _ => (Unidentified, false), + } +} + +pub fn ctrl() -> Mods { + Mods { + ctrl: true, + ..Mods::default() + } +} +pub fn alt() -> Mods { + Mods { + alt: true, + ..Mods::default() + } +} +pub fn shift() -> Mods { + Mods { + shift: true, + ..Mods::default() + } +} +pub fn ctrl_shift() -> Mods { + Mods { + ctrl: true, + shift: true, + ..Mods::default() + } +} +pub fn none() -> Mods { + Mods::default() +} + +/// Skip the test when headless GL isn't available. +#[macro_export] +macro_rules! harness { + ($e:expr) => { + match $e { + Some(h) => h, + None => return, + } + }; +} diff --git a/crates/ghostty-core/tests/e2e_bindings.rs b/crates/ghostty-core/tests/e2e_bindings.rs new file mode 100644 index 0000000..348628a --- /dev/null +++ b/crates/ghostty-core/tests/e2e_bindings.rs @@ -0,0 +1,422 @@ +//! Keybinding actions end to end (default and configured bindings). + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::fmt::Write as _; + +mod common; +use common::*; +use ghostty_apprt::action::{Action, GotoSplit, SplitDirection}; +use ghostty_apprt::structs::Clipboard; +use ghostty_input::Key; + +#[test] +fn select_all_copy_paste_roundtrip() { + let h = harness!(Harness::with_pre("printf 'hello world\\n'", &[])); + assert!(h.bind("select_all")); + assert!(h.surface.has_selection()); + assert!(h.bind("copy_to_clipboard")); + let c = h.clipboard(Clipboard::Standard).expect("clipboard set"); + assert!(c.starts_with("hello world"), "{c:?}"); + // Mixed format also writes html + assert!( + h.rt.writes + .borrow() + .iter() + .any(|w| w.contents.iter().any(|(m, _)| m == "text/html")) + ); + // paste back: goes to the child + h.set_clipboard_text(Clipboard::Standard, "pasted"); + assert!(h.bind("paste_from_clipboard")); + h.expect_input(b"pasted"); +} + +#[test] +fn select_all_twice_with_copy_on_select_does_not_panic() { + // Regression: replacing a selection compared against released pins. + let h = harness!(Harness::with_pre( + "printf 'hello\\n'", + &["--copy-on-select=clipboard"] + )); + assert!(h.bind("select_all")); + assert!(h.bind("select_all")); + assert!(h.surface.has_selection()); + // copy-on-select=clipboard wrote both clipboards. + assert_eq!( + h.clipboard(Clipboard::Standard), + h.clipboard(Clipboard::Selection) + ); + assert!( + h.clipboard(Clipboard::Standard) + .unwrap() + .starts_with("hello") + ); +} + +#[test] +fn copy_without_selection_is_not_performed() { + let h = harness!(Harness::recorder("", &[])); + assert!(!h.bind("copy_to_clipboard")); + assert!(h.clipboard(Clipboard::Standard).is_none()); +} + +#[test] +fn default_ctrl_shift_c_and_v() { + let h = harness!(Harness::with_pre("printf 'copyme\\n'", &[])); + h.bind("select_all"); + h.key(Key::KeyC, ctrl_shift(), "C"); + assert!( + h.clipboard(Clipboard::Standard) + .unwrap() + .starts_with("copyme") + ); + h.set_clipboard_text(Clipboard::Standard, "xyz"); + h.key(Key::KeyV, ctrl_shift(), "V"); + h.expect_input(b"xyz"); +} + +#[test] +fn bracketed_paste() { + let h = harness!(Harness::recorder(r"\033[?2004h", &[])); + h.wait("bracketed", |h| { + h.surface + .lock_terminal() + .modes + .get(ghostty_vt::modes::Mode::BracketedPaste) + }); + h.set_clipboard_text(Clipboard::Standard, "line1\nline2"); + assert!(h.bind("paste_from_clipboard")); + h.expect_input(b"\x1b[200~line1\nline2\x1b[201~"); +} + +#[test] +fn unbracketed_paste_converts_newlines_and_requires_confirmation_when_unsafe() { + let h = harness!(Harness::recorder("", &[])); + h.set_clipboard_text(Clipboard::Standard, "safe text"); + assert!(h.bind("paste_from_clipboard")); + h.expect_input(b"safe text"); + h.mark(); + + // A newline is unsafe without bracketed paste: confirmation required. + h.set_clipboard_text(Clipboard::Standard, "rm -rf x\nls"); + assert!(h.bind("paste_from_clipboard")); + h.wait("confirmation", |h| !h.rt.confirmations.borrow().is_empty()); + h.settle(100); + assert!(h.recorded().is_empty(), "paste must wait for confirmation"); + // Confirm it. + let c = h.rt.confirmations.borrow()[0].clone(); + h.surface + .complete_clipboard_request(c.request, &c.text, true) + .unwrap(); + h.expect_input(b"rm -rf x\rls"); +} + +#[test] +fn paste_without_text_on_clipboard_is_not_performed() { + let h = harness!(Harness::recorder("", &[])); + assert!(!h.bind("paste_from_clipboard")); +} + +#[test] +fn paste_protection_can_be_disabled() { + let h = harness!(Harness::recorder( + "", + &["--clipboard-paste-protection=false"] + )); + h.set_clipboard_text(Clipboard::Standard, "a\nb"); + assert!(h.bind("paste_from_clipboard")); + h.expect_input(b"a\rb"); +} + +#[test] +fn font_size_actions() { + let h = harness!(Harness::recorder("", &[])); + let (c0, r0) = h.grid(); + let cell0 = h.surface.size().cell; + h.clear_actions(); + + assert!(h.bind("increase_font_size:4")); + let cell1 = h.surface.size().cell; + assert!(cell1.height > cell0.height && cell1.width > cell0.width); + let (c1, r1) = h.grid(); + assert!( + c1 < c0 && r1 < r0, + "grid must shrink: {c0}x{r0} -> {c1}x{r1}" + ); + assert!(h.has_action(|a| matches!(a, Action::CellSize(c) if c.width == cell1.width))); + // The pty sees the new size eventually (TIOCSWINSZ via stty in other test). + + assert!(h.bind("decrease_font_size:8")); + let cell2 = h.surface.size().cell; + assert!(cell2.height < cell0.height); + + assert!(h.bind("reset_font_size")); + assert_eq!(h.surface.size().cell, cell0); + assert_eq!(h.grid(), (c0, r0)); +} + +#[test] +fn default_ctrl_equal_and_minus_change_font() { + let h = harness!(Harness::recorder("", &[])); + let cell0 = h.surface.size().cell; + h.key(Key::Equal, ctrl(), "="); + assert!(h.surface.size().cell.height > cell0.height); + h.key(Key::Digit0, ctrl(), "0"); + assert_eq!(h.surface.size().cell, cell0); + h.key(Key::Minus, ctrl(), "-"); + assert!(h.surface.size().cell.height < cell0.height); + // none of these keys reach the child + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); +} + +#[test] +fn tab_split_close_actions() { + let h = harness!(Harness::recorder("", &[])); + h.clear_actions(); + assert!(h.bind("new_tab")); + assert!(h.has_action(|a| matches!(a, Action::NewTab))); + assert!(h.bind("new_split:right")); + assert!(h.has_action(|a| matches!(a, Action::NewSplit(SplitDirection::Right)))); + assert!(h.bind("new_split:down")); + assert!(h.has_action(|a| matches!(a, Action::NewSplit(SplitDirection::Down)))); + assert!(h.bind("goto_split:left")); + assert!(h.has_action(|a| matches!(a, Action::GotoSplit(GotoSplit::Left)))); + assert!(h.bind("goto_split:next")); + assert!(h.has_action(|a| matches!(a, Action::GotoSplit(GotoSplit::Next)))); + assert!(h.bind("new_window")); + assert!(h.has_action(|a| matches!(a, Action::NewWindow))); + assert!(h.bind("toggle_split_zoom")); + assert!(h.has_action(|a| matches!(a, Action::ToggleSplitZoom))); + assert!(h.bind("equalize_splits")); + assert!(h.has_action(|a| matches!(a, Action::EqualizeSplits))); + assert!(h.bind("toggle_fullscreen")); + assert!(h.has_action(|a| matches!(a, Action::ToggleFullscreen(_)))); + + assert!(h.rt.closes.borrow().is_empty()); + assert!(h.bind("close_surface")); + assert_eq!(h.rt.closes.borrow().len(), 1); +} + +#[test] +fn default_keybinds_for_tabs_and_splits() { + let h = harness!(Harness::recorder("", &[])); + h.clear_actions(); + h.key(Key::KeyT, ctrl_shift(), "T"); + h.key(Key::KeyO, ctrl_shift(), "O"); + h.key(Key::KeyE, ctrl_shift(), "E"); + h.key(Key::KeyN, ctrl_shift(), "N"); + h.key(Key::KeyW, ctrl_shift(), "W"); + assert!(h.has_action(|a| matches!(a, Action::NewTab))); + assert!(h.has_action(|a| matches!(a, Action::NewSplit(SplitDirection::Right)))); + assert!(h.has_action(|a| matches!(a, Action::NewSplit(SplitDirection::Down)))); + assert!(h.has_action(|a| matches!(a, Action::NewWindow))); + assert!(h.has_action(|a| matches!(a, Action::CloseTab(_)))); + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); +} + +#[test] +fn scroll_page_up_down_and_clear_screen() { + // Viewport scrolling goes through the IO thread, so wait for each change. + let h = harness!(Harness::with_pre("seq 1 300", &[])); + let before = h.screen(); + assert!(before.contains("300"), "{before}"); + assert!(h.bind("scroll_page_up")); + h.wait("scrolled up", |h| h.screen() != before); + assert!(!h.screen().contains("READY")); + assert!(h.bind("scroll_page_down")); + h.wait("scrolled down", |h| h.screen() == before); + assert!(h.bind("scroll_to_top")); + h.wait("top", |h| h.screen().starts_with("1\n")); + assert!(h.bind("scroll_to_bottom")); + h.wait("bottom", |h| h.screen() == before); + + // Keyboard: shift+page_up / shift+end are bound by default. + h.key(Key::PageUp, shift(), ""); + h.wait("shift+pageup", |h| h.screen() != before); + h.key(Key::End, shift(), ""); + h.wait("shift+end", |h| h.screen() == before); + h.settle(30); + assert_eq!(h.recorded(), Vec::::new()); + + // clear_screen: viewport and history cleared. + assert!(h.bind("clear_screen")); + h.wait("screen cleared", |h| !h.screen().contains("300")); + assert!(h.bind("scroll_to_top")); + h.settle(50); + assert!( + !h.screen().contains("150"), + "history cleared: {}", + h.screen() + ); +} + +#[test] +fn jump_to_prompt_with_osc133() { + let mut pre = String::new(); + for i in 1..=5 { + let _ = write!(pre, "printf '\\033]133;A\\033\\\\prompt{i}\\n'; seq 1 40; "); + } + pre.push_str("true"); + let h = harness!(Harness::with_pre(&pre, &[])); + let bottom = h.screen(); + assert!(bottom.contains("40")); + assert!(h.bind("jump_to_prompt:-1")); + h.wait("viewport moved to a prompt", |h| h.screen() != bottom); + let s = h.screen(); + assert!(s.contains("prompt"), "{s}"); + let first = s.lines().next().unwrap().to_owned(); + assert!(first.starts_with("prompt"), "prompt at the top row: {s}"); + assert!(h.bind("jump_to_prompt:-1")); + h.wait("moved again", |h| { + h.screen().lines().next() != Some(first.as_str()) + }); + let second = h.screen().lines().next().unwrap().to_owned(); + assert!(second.starts_with("prompt") && second != first); + assert!(h.bind("jump_to_prompt:1")); + h.wait("moved forward", |h| { + h.screen().lines().next() == Some(first.as_str()) + }); +} + +#[test] +fn key_sequence_binding() { + let h = harness!(Harness::recorder("", &["--keybind=ctrl+a>n=new_tab"])); + h.clear_actions(); + let e = h.key(Key::KeyA, ctrl(), "a"); + assert_eq!(e, ghostty_apprt::runtime_api::InputEffect::Consumed); + assert!(h.has_action(|a| matches!(a, Action::KeySequence(_)))); + assert!(!h.has_action(|a| matches!(a, Action::NewTab))); + h.key(Key::KeyN, none(), "n"); + assert!(h.has_action(|a| matches!(a, Action::NewTab))); + assert!(h.has_action(|a| matches!( + a, + Action::KeySequence(ghostty_apprt::action::KeySequence::End) + ))); + h.settle(50); + assert!( + h.recorded().is_empty(), + "sequence keys are not sent to the app" + ); +} + +#[test] +fn key_sequence_mismatch_flushes_keys_to_child() { + let h = harness!(Harness::recorder("", &["--keybind=ctrl+a>n=new_tab"])); + h.key(Key::KeyA, ctrl(), "a"); + h.key(Key::KeyX, none(), "x"); + h.expect_input(b"\x01x"); + assert!(!h.has_action(|a| matches!(a, Action::NewTab))); +} + +#[test] +fn unconsumed_binding_also_sends_key() { + let h = harness!(Harness::recorder( + "", + &[ + "--keybind=unconsumed:ctrl+b=new_tab", + "--keybind=ctrl+k=new_split:right" + ] + )); + h.clear_actions(); + h.key(Key::KeyB, ctrl(), "b"); + h.key(Key::KeyK, ctrl(), "k"); + assert!(h.has_action(|a| matches!(a, Action::NewTab))); + assert!(h.has_action(|a| matches!(a, Action::NewSplit(_)))); + h.expect_input(b"\x02"); // only the unconsumed one reaches the child +} + +#[test] +fn performable_binding_passes_through_when_not_performed() { + let h = harness!(Harness::with_pre( + "printf 'sel\\n'", + &["--keybind=performable:ctrl+x=copy_to_clipboard"] + )); + // no selection: copy cannot be performed so ctrl+x goes to the child + h.key(Key::KeyX, ctrl(), "x"); + h.expect_input(b"\x18"); + h.mark(); + h.bind("select_all"); + h.key(Key::KeyX, ctrl(), "x"); + assert!(h.clipboard(Clipboard::Standard).unwrap().starts_with("sel")); + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); +} + +#[test] +fn text_csi_esc_and_ignore_bindings() { + let h = harness!(Harness::recorder( + "", + &[ + "--keybind=ctrl+1=text:hi\\x21", + "--keybind=ctrl+2=csi:1;5H", + "--keybind=ctrl+3=esc:x", + "--keybind=ctrl+4=ignore", + ] + )); + h.key(Key::Digit1, ctrl(), "1"); + h.key(Key::Digit2, ctrl(), "2"); + h.key(Key::Digit3, ctrl(), "3"); + h.key(Key::Digit4, ctrl(), "4"); + h.expect_input(b"hi!\x1b[1;5H\x1bx"); +} + +#[test] +fn csi_binding_longer_than_the_zig_buffer_is_rejected() { + let h = harness!(Harness::recorder("", &[])); + let long = "1".repeat(127); + let a = ghostty_input::binding::Action::parse(&format!("csi:{long}")).unwrap(); + assert!(h.surface.perform_binding_action(&a).is_err()); + let ok = "1".repeat(126); + let a = ghostty_input::binding::Action::parse(&format!("csi:{ok}")).unwrap(); + assert!(h.surface.perform_binding_action(&a).is_ok()); + let a = ghostty_input::binding::Action::parse(&format!("esc:{}", "2".repeat(128))).unwrap(); + assert!(h.surface.perform_binding_action(&a).is_err()); + let a = ghostty_input::binding::Action::parse(&format!("esc:{}", "2".repeat(127))).unwrap(); + assert!(h.surface.perform_binding_action(&a).is_ok()); +} + +#[test] +fn global_new_window_binding_has_no_parent() { + let h = harness!(Harness::recorder( + "", + &["--keybind=global:ctrl+n=new_window"] + )); + h.clear_actions(); + h.key(Key::KeyN, ctrl(), "n"); + let found = + h.rt.actions.borrow().iter().any(|(t, a)| { + matches!(a, Action::NewWindow) && *t == ghostty_apprt::action::Target::App + }); + assert!(found, "{:?}", h.action_names()); +} + +#[test] +fn child_environment_comes_from_the_runtime() { + // FakeRt removes HOME like GTK removes GDK_DISABLE & co; the core must not + // restore it from the process environment. + let h = harness!(Harness::new(Opts::default().script( + "env | grep -c '^HOME=' >'{out}'; env | grep -c '^TERM_PROGRAM_TEST=1' >>'{out}'; sleep 5" + ))); + h.wait("env output", |h| h.recorded().len() >= 4); + assert_eq!(String::from_utf8_lossy(&h.recorded()), "0\n1\n"); +} + +#[test] +fn write_screen_file_open_failure_is_an_error() { + // The fake apprt declines open_url and xdg-open may not exist; when the + // fallback fails the binding must report failure (so performable + // bindings fall through). + let h = harness!(Harness::with_pre("printf 'x\\n'", &[])); + let a = ghostty_input::binding::Action::parse("write_screen_file:open").unwrap(); + let res = h.surface.perform_binding_action(&a); + let xdg = std::process::Command::new("sh") + .arg("-c") + .arg("command -v xdg-open") + .output() + .is_ok_and(|o| o.status.success()); + if !xdg { + assert!(res.is_err(), "{res:?}"); + } +} diff --git a/crates/ghostty-core/tests/e2e_ime.rs b/crates/ghostty-core/tests/e2e_ime.rs new file mode 100644 index 0000000..de82290 --- /dev/null +++ b/crates/ghostty-core/tests/e2e_ime.rs @@ -0,0 +1,119 @@ +//! IME: preedit state and rendering, commit, composing key events. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; +use common::*; +use ghostty_input::{Key, KeyEvent}; + +/// RGBA pixels of the cells `cols` on `row` of the last drawn frame. +fn region(h: &Harness, cols: std::ops::Range, row: u32) -> Vec { + h.surface.draw().expect("draw"); + let Some((w, hgt, px)) = h.surface.frame_pixels() else { + return Vec::new(); + }; + let s = h.surface.size(); + let x0 = (s.padding.left + cols.start * s.cell.width) as usize; + let x1 = (s.padding.left + cols.end * s.cell.width) as usize; + let y0 = (s.padding.top + row * s.cell.height) as usize; + let y1 = y0 + s.cell.height as usize; + let mut out = Vec::new(); + for y in y0..y1 { + // frame is bottom-up + let fy = hgt - 1 - y; + let start = (fy * w + x0) * 4; + let end = (fy * w + x1) * 4; + out.extend_from_slice(&px[start..end]); + } + out +} + +#[test] +fn preedit_state_and_ime_point() { + let h = harness!(Harness::recorder("", &[])); + let before = h.surface.ime_point(); + h.surface + .preedit_callback(Some("\u{3042}\u{3044}")) + .unwrap(); + { + let st = h.surface.lock_state(); + let p = st.preedit.as_ref().expect("preedit set"); + assert_eq!(p.codepoints.len(), 2); + assert_eq!(p.codepoints[0].codepoint, 0x3042); + assert!(p.codepoints[0].wide); + } + // The candidate window sits below the cursor and spans the preedit. + let after = h.surface.ime_point(); + assert_eq!(after.x, before.x); + assert_eq!(after.y, before.y); + assert!(after.width >= f64::from(2 * 2 * h.surface.size().cell.width) - 1.0); + h.surface.preedit_callback(None).unwrap(); + assert!(h.surface.lock_state().preedit.is_none()); + // Nothing is sent to the child by preedit alone. + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); +} + +#[test] +fn preedit_is_rendered_and_cleared() { + let h = harness!(Harness::recorder("", &[])); + // Cursor sits after "READY" at column 5, row 0. Disable blink noise by + // comparing the cells after the cursor cell. + let cols = 7..10; + let baseline = { + let mut b = Vec::new(); + h.wait("first frame", |h| { + b = region(h, cols.clone(), 0); + !b.is_empty() + }); + b + }; + h.surface.preedit_callback(Some("abcd")).unwrap(); + h.wait("preedit visible", |h| { + let r = region(h, cols.clone(), 0); + !r.is_empty() && r != baseline + }); + h.surface.preedit_callback(None).unwrap(); + h.wait("preedit cleared", |h| { + region(h, cols.clone(), 0) == baseline + }); +} + +#[test] +fn commit_sends_text_and_composing_keys_do_not() { + let h = harness!(Harness::recorder("", &[])); + // A key event that is part of a composition is not encoded. + h.key_event(KeyEvent { + key: Key::KeyK, + composing: true, + utf8: "k", + unshifted_codepoint: 'k' as u32, + ..KeyEvent::default() + }); + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); + + // Commit: preedit ends, committed text is sent with no key. + h.surface + .preedit_callback(Some("\u{3053}\u{3093}")) + .unwrap(); + h.surface.preedit_callback(None).unwrap(); + h.key_event(KeyEvent { + key: Key::Unidentified, + utf8: "\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}", + ..KeyEvent::default() + }); + h.expect_input("\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}".as_bytes()); +} + +#[test] +fn enter_with_committed_text_sends_text_not_cr() { + // IME confirmation sends enter with utf8: the text is sent, not "\r". + let h = harness!(Harness::recorder("", &[])); + h.key_event(KeyEvent { + key: Key::Enter, + utf8: "\u{6f22}", + ..KeyEvent::default() + }); + h.expect_input("\u{6f22}".as_bytes()); +} diff --git a/crates/ghostty-core/tests/e2e_keys.rs b/crates/ghostty-core/tests/e2e_keys.rs new file mode 100644 index 0000000..a103340 --- /dev/null +++ b/crates/ghostty-core/tests/e2e_keys.rs @@ -0,0 +1,255 @@ +//! Keyboard input end to end: key events -> core Surface -> encoder -> pty -> +//! a real `cat` child that records the exact bytes it received. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; +use common::*; +use ghostty_input::Key; + +fn rec_args(setup: &str, args: &[&str]) -> Option { + Harness::recorder(setup, args) +} + +fn rec(setup: &str) -> Option { + Harness::recorder(setup, &[]) +} + +#[test] +fn printable_text_enter_backspace_tab() { + let h = harness!(rec("")); + h.type_text("Hello, World! 123"); + h.key(Key::Enter, none(), ""); + h.key(Key::Backspace, none(), ""); + h.key(Key::Tab, none(), ""); + h.key(Key::Tab, shift(), ""); + h.key(Key::Escape, none(), ""); + h.key(Key::Space, none(), " "); + h.expect_input(b"Hello, World! 123\r\x7f\t\x1b[Z\x1b "); +} + +#[test] +fn utf8_text_is_passed_through() { + let h = harness!(rec("")); + h.key(Key::KeyE, none(), "\u{e9}"); + h.key(Key::Unidentified, none(), "\u{1f600}"); + h.expect_input("\u{e9}\u{1f600}".as_bytes()); +} + +#[test] +fn ctrl_keys() { + let h = harness!(rec("")); + h.key(Key::KeyC, ctrl(), "c"); + h.key(Key::KeyD, ctrl(), "d"); + h.key(Key::KeyA, ctrl(), "a"); + h.key(Key::KeyZ, ctrl(), "z"); + // ctrl+[ is deliberately not in Ghostty's ctrl table (kitty behaviour): CSI u. + h.key(Key::BracketLeft, ctrl(), "["); + h.key(Key::Space, ctrl(), " "); + h.expect_input(b"\x03\x04\x01\x1a\x1b[91;5u\x00"); +} + +#[test] +fn alt_prefix() { + let h = harness!(rec("")); + h.key(Key::KeyX, alt(), "x"); + h.key(Key::KeyB, alt(), "b"); + h.key(Key::Enter, alt(), ""); + h.key(Key::Backspace, alt(), ""); + h.expect_input(b"\x1bx\x1bb\x1b\r\x1b\x7f"); +} + +#[test] +fn ctrl_alt() { + let h = harness!(rec("")); + h.key( + Key::KeyC, + ghostty_input::Mods { + ctrl: true, + alt: true, + ..Default::default() + }, + "c", + ); + h.expect_input(b"\x1b\x03"); +} + +#[test] +fn arrows_normal_mode() { + let h = harness!(rec("")); + h.special(Key::ArrowUp, none()); + h.special(Key::ArrowDown, none()); + h.special(Key::ArrowRight, none()); + h.special(Key::ArrowLeft, none()); + h.special(Key::Home, none()); + h.special(Key::End, none()); + h.special(Key::PageUp, none()); + h.special(Key::PageDown, none()); + h.special(Key::Insert, none()); + h.special(Key::Delete, none()); + h.expect_input(b"\x1b[A\x1b[B\x1b[C\x1b[D\x1b[H\x1b[F\x1b[5~\x1b[6~\x1b[2~\x1b[3~"); +} + +#[test] +fn arrows_decckm_application_mode() { + let h = harness!(rec(r"\033[?1h")); + h.special(Key::ArrowUp, none()); + h.special(Key::ArrowDown, none()); + h.special(Key::ArrowRight, none()); + h.special(Key::ArrowLeft, none()); + h.special(Key::Home, none()); + h.special(Key::End, none()); + // Modified arrows always use CSI form. + h.special(Key::ArrowUp, shift()); + h.special(Key::ArrowLeft, ctrl()); + h.expect_input(b"\x1bOA\x1bOB\x1bOC\x1bOD\x1bOH\x1bOF\x1b[1;2A\x1b[1;5D"); +} + +#[test] +fn function_keys() { + let h = harness!(rec("")); + for k in [ + Key::F1, + Key::F2, + Key::F3, + Key::F4, + Key::F5, + Key::F6, + Key::F7, + Key::F8, + Key::F9, + Key::F10, + Key::F11, + Key::F12, + ] { + h.special(k, none()); + } + h.special(Key::F1, shift()); + h.special(Key::F5, ctrl()); + h.expect_input( + b"\x1bOP\x1bOQ\x1bOR\x1bOS\x1b[15~\x1b[17~\x1b[18~\x1b[19~\x1b[20~\x1b[21~\x1b[23~\x1b[24~\x1b[1;2P\x1b[15;5~", + ); +} + +#[test] +fn keypad_normal_and_application() { + // numlock on, normal keypad mode: plain digits + let h = harness!(rec("")); + h.key(Key::Numpad1, none(), "1"); + h.key(Key::NumpadAdd, none(), "+"); + h.key(Key::NumpadEnter, none(), ""); + h.expect_input(b"1+\r"); + + // DECKPAM alone is ignored (mode 1035 ignore_keypad_with_numlock defaults on). + let h = harness!(rec(r"\033=")); + h.key(Key::Numpad1, none(), "1"); + h.expect_input(b"1"); + + // DECKPAM + mode 1035 off: application keypad sequences + let h = harness!(rec(r"\033=\033[?1035l")); + h.key(Key::Numpad1, none(), "1"); + h.key(Key::NumpadAdd, none(), "+"); + h.key(Key::NumpadEnter, none(), ""); + h.expect_input(b"\x1bOq\x1bOk\x1bOM"); +} + +#[test] +fn kitty_keyboard_disambiguate() { + let h = harness!(rec(r"\033[>1u")); + h.wait("kitty flags", |h| { + h.surface + .lock_terminal() + .screen() + .kitty_keyboard + .current() + .int() + == 1 + }); + h.key(Key::KeyC, ctrl(), "c"); + h.key(Key::Escape, none(), ""); + h.key(Key::KeyX, alt(), "x"); + h.key(Key::KeyA, none(), "a"); + h.key(Key::Enter, none(), ""); + h.key(Key::Enter, shift(), ""); + h.special(Key::ArrowUp, none()); + h.special(Key::F1, none()); + h.expect_input(b"\x1b[99;5u\x1b[27u\x1b[120;3ua\r\x1b[13;2u\x1b[A\x1b[P"); +} + +#[test] +fn kitty_keyboard_report_events_sends_release() { + // flags 1|2: disambiguate + report event types + let h = harness!(rec(r"\033[>3u")); + h.wait("kitty flags", |h| { + h.surface + .lock_terminal() + .screen() + .kitty_keyboard + .current() + .int() + == 3 + }); + h.key(Key::KeyA, none(), "a"); + h.key(Key::KeyC, ctrl(), "c"); + // press "a" is plain text, release "a" is reported + h.expect_input(b"a\x1b[97;1:3u\x1b[99;5u\x1b[99;5:3u"); +} + +#[test] +fn kitty_keyboard_pop_restores_legacy() { + let h = harness!(rec(r"\033[>1u\033[<1u")); + h.wait("kitty popped", |h| { + h.surface + .lock_terminal() + .screen() + .kitty_keyboard + .current() + .int() + == 0 + }); + h.key(Key::KeyC, ctrl(), "c"); + h.expect_input(b"\x03"); +} + +#[test] +fn modify_other_keys_state_2() { + let h = harness!(rec(r"\033[>4;2m")); + h.wait("mok2", |h| { + h.surface.lock_terminal().flags.modify_other_keys_2 + }); + // Plain ctrl+letter stays legacy, other ctrl combos use CSI 27. + h.key(Key::KeyA, ctrl(), "a"); + h.key(Key::Semicolon, ctrl(), ";"); + h.key(Key::Digit1, ctrl(), "1"); + h.key(Key::KeyA, none(), "a"); + h.expect_input(b"\x01\x1b[27;5;59~1a"); +} + +#[test] +fn no_input_while_disable_keyboard_mode() { + // KAM (CSI 2 h) disables keyboard input. + let h = harness!(rec_args(r"\033[2h", &["--vt-kam-allowed=true"])); + h.wait("kam", |h| { + h.surface + .lock_terminal() + .modes + .get(ghostty_vt::modes::Mode::DisableKeyboard) + }); + h.type_text("zz"); + h.settle(100); + assert_eq!(h.recorded(), Vec::::new()); +} + +#[test] +fn key_release_without_press_is_ignored_in_legacy() { + let h = harness!(rec("")); + // Only a release event: nothing should be sent. + h.key_event(ghostty_input::KeyEvent { + action: ghostty_input::key::Action::Release, + key: Key::KeyA, + utf8: "a", + ..Default::default() + }); + h.type_text("b"); + h.expect_input(b"b"); +} diff --git a/crates/ghostty-core/tests/e2e_mouse.rs b/crates/ghostty-core/tests/e2e_mouse.rs new file mode 100644 index 0000000..dd610fd --- /dev/null +++ b/crates/ghostty-core/tests/e2e_mouse.rs @@ -0,0 +1,358 @@ +//! Mouse end to end: selection, copy-on-select, multi-click, mouse reporting +//! formats, alternate scroll, shift bypass and links. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; +use common::*; +use ghostty_apprt::action::Action; +use ghostty_apprt::structs::Clipboard; +use ghostty_input::{Key, MouseButton}; + +const L: MouseButton = MouseButton::Left; + +/// Drag-select from the left edge of `c0` to the right edge of `c1` on `row`. +fn drag(h: &Harness, c0: u32, c1: u32, row: u32, mods: ghostty_input::Mods) { + h.move_to_pos(h.cell_pos_frac(c0, row, 0.1, 0.5), Some(mods)); + h.press_mouse(L, mods); + // a couple of intermediate moves like a real drag + for c in c0..=c1 { + h.move_to_pos(h.cell_pos_frac(c, row, 0.9, 0.5), Some(mods)); + } + h.release_mouse(L, mods); +} + +fn text_harness(args: &[&str]) -> Option { + Harness::with_pre("printf 'hello world foo\\nsecond line\\n'", args) +} + +#[test] +fn drag_selects_and_copy_on_select_fills_selection_clipboard() { + let h = harness!(text_harness(&[])); + drag(&h, 0, 4, 0, none()); + assert_eq!(h.surface.selection_string().as_deref(), Some("hello")); + // copy-on-select = true: the selection clipboard (not the standard one) + assert_eq!(h.clipboard(Clipboard::Selection).as_deref(), Some("hello")); + assert_eq!(h.clipboard(Clipboard::Standard), None); + + assert!(h.bind("copy_to_clipboard")); + assert_eq!(h.clipboard(Clipboard::Standard).as_deref(), Some("hello")); +} + +#[test] +fn copy_on_select_clipboard_sets_both_and_false_sets_none() { + let h = harness!(text_harness(&["--copy-on-select=clipboard"])); + drag(&h, 6, 10, 0, none()); + assert_eq!(h.clipboard(Clipboard::Standard).as_deref(), Some("world")); + assert_eq!(h.clipboard(Clipboard::Selection).as_deref(), Some("world")); + + let h = harness!(text_harness(&["--copy-on-select=false"])); + drag(&h, 6, 10, 0, none()); + assert_eq!(h.surface.selection_string().as_deref(), Some("world")); + assert_eq!(h.clipboard(Clipboard::Selection), None); + assert_eq!(h.clipboard(Clipboard::Standard), None); +} + +#[test] +fn click_clears_selection() { + let h = harness!(text_harness(&[])); + drag(&h, 0, 4, 0, none()); + assert!(h.surface.has_selection()); + h.click(2, 1, L, none()); + assert!(!h.surface.has_selection()); +} + +#[test] +fn double_click_selects_word_and_triple_click_line() { + let h = harness!(text_harness(&[])); + h.move_to(7, 0, None); + h.press_mouse(L, none()); + h.release_mouse(L, none()); + h.press_mouse(L, none()); + h.release_mouse(L, none()); + assert_eq!(h.surface.selection_string().as_deref(), Some("world")); + h.press_mouse(L, none()); + h.release_mouse(L, none()); + assert_eq!( + h.surface.selection_string().as_deref(), + Some("hello world foo") + ); +} + +#[test] +fn reporting_normal_sgr() { + // 1000 normal + 1006 SGR + let h = harness!(Harness::recorder(r"\033[?1000h\033[?1006h", &[])); + h.wait("mouse mode", |h| { + h.surface.lock_terminal().flags.mouse_event != ghostty_terminal::terminal::MouseEvents::None + }); + h.move_to(5, 3, None); // motion is not reported in normal mode + h.press_mouse(L, none()); + h.release_mouse(L, none()); + h.press_mouse(MouseButton::Right, ctrl()); + h.release_mouse(MouseButton::Right, ctrl()); + h.expect_input(b"\x1b[<0;6;4M\x1b[<0;6;4m\x1b[<18;6;4M\x1b[<18;6;4m"); + h.mark(); + // wheel: buttons 4/5 + h.move_to(5, 3, Some(none())); // ctrl is released + h.scroll(0.0, 1.0); + h.scroll(0.0, -1.0); + // One discrete tick is `mouse-scroll-multiplier` (3) rows -> 3 reports per tick. + h.wait("wheel", |h| h.recorded().len() >= 6 * 9); + let r = String::from_utf8(h.recorded()).unwrap(); + assert!(r.starts_with("\x1b[<64;6;4M"), "{r:?}"); + assert!(r.contains("\x1b[<65;6;4M"), "{r:?}"); +} + +#[test] +fn reporting_x10_mode_only_presses() { + let h = harness!(Harness::recorder(r"\033[?9h", &[])); + h.wait("x10", |h| { + h.surface.lock_terminal().flags.mouse_event == ghostty_terminal::terminal::MouseEvents::X10 + }); + h.click(5, 3, L, none()); + // ESC [ M, 32+0, 32+6, 32+4 + h.expect_input(b"\x1b[M &$"); +} + +#[test] +fn reporting_normal_default_format() { + let h = harness!(Harness::recorder(r"\033[?1000h", &[])); + h.wait("mode", |h| { + h.surface.lock_terminal().flags.mouse_event + == ghostty_terminal::terminal::MouseEvents::Normal + }); + h.click(5, 3, L, shift_free()); + // press: button 0, release: button 3 in non-SGR formats + h.expect_input(b"\x1b[M &$\x1b[M#&$"); +} + +fn shift_free() -> ghostty_input::Mods { + none() +} + +#[test] +fn reporting_button_and_any_motion() { + // 1002 button-event tracking + SGR + let h = harness!(Harness::recorder(r"\033[?1002h\033[?1006h", &[])); + h.wait("mode", |h| { + h.surface.lock_terminal().flags.mouse_event + == ghostty_terminal::terminal::MouseEvents::Button + }); + h.move_to(1, 1, None); // no button: not reported + h.press_mouse(L, none()); + h.move_to(2, 1, Some(none())); + h.move_to(2, 1, Some(none())); // same cell: deduplicated + h.move_to(3, 1, Some(none())); + h.release_mouse(L, none()); + h.expect_input(b"\x1b[<0;2;2M\x1b[<32;3;2M\x1b[<32;4;2M\x1b[<0;4;2m"); + + // 1003 any-event tracking reports motion without buttons (code 35) + let h = harness!(Harness::recorder(r"\033[?1003h\033[?1006h", &[])); + h.wait("mode", |h| { + h.surface.lock_terminal().flags.mouse_event == ghostty_terminal::terminal::MouseEvents::Any + }); + h.move_to(1, 1, Some(none())); + h.move_to(2, 1, Some(none())); + h.expect_input(b"\x1b[<35;2;2M\x1b[<35;3;2M"); +} + +#[test] +fn reporting_urxvt_and_utf8_formats() { + let h = harness!(Harness::recorder(r"\033[?1000h\033[?1015h", &[])); + h.wait("urxvt", |h| { + h.surface.lock_terminal().flags.mouse_format + == ghostty_terminal::terminal::MouseFormat::Urxvt + }); + h.click(5, 3, L, none()); + h.expect_input(b"\x1b[32;6;4M\x1b[35;6;4M"); + + // UTF-8 format can encode columns > 95 as 2-byte sequences. + let h = harness!(Harness::new( + Opts { + size: Some((1600, 600)), + ..Opts::default() + } + .script( + "stty raw -echo; printf '\\033[?1000h\\033[?1005h'; printf READY; exec cat >'{out}'" + ) + )); + h.wait_screen("READY"); + h.wait("utf8", |h| { + h.surface.lock_terminal().flags.mouse_format + == ghostty_terminal::terminal::MouseFormat::Utf8 + }); + assert!(h.grid().0 > 130, "grid {:?}", h.grid()); + h.move_to(120, 2, None); + h.press_mouse(L, none()); + let mut want = b"\x1b[M ".to_vec(); + want.extend("\u{20}".chars().map(|_| 0u8).take(0)); + let col = char::from_u32(32 + 120 + 1).unwrap(); + want.extend(col.to_string().as_bytes()); + want.push(32 + 2 + 1); + h.expect_input(&want); +} + +#[test] +fn reporting_sgr_pixels() { + let h = harness!(Harness::recorder(r"\033[?1000h\033[?1016h", &[])); + h.wait("sgr-pixels", |h| { + h.surface.lock_terminal().flags.mouse_format + == ghostty_terminal::terminal::MouseFormat::SgrPixels + }); + let p = h.cell_pos_frac(5, 3, 0.5, 0.5); + h.move_to_pos(p, None); + h.press_mouse(L, none()); + // terminal-space coordinates are surface minus padding, rounded + let pad = h.surface.size().padding; + let x = (p.x - pad.left as f32).round() as i32; + let y = (p.y - pad.top as f32).round() as i32; + h.expect_input(format!("\x1b[<0;{x};{y}M").as_bytes()); +} + +#[test] +fn shift_bypasses_mouse_reporting_and_selects() { + let h = harness!(Harness::with_pre( + "printf 'hello world foo\\n'; printf '\\033[?1000h\\033[?1006h'", + &[] + )); + h.wait("mode", |h| { + h.surface.lock_terminal().flags.mouse_event != ghostty_terminal::terminal::MouseEvents::None + }); + // Without shift the app gets the click and nothing is selected. + drag(&h, 0, 4, 0, none()); + assert!(!h.surface.has_selection()); + h.wait("reports", |h| !h.recorded().is_empty()); + h.mark(); + // With shift the click is ours. + drag(&h, 0, 4, 0, shift()); + assert_eq!(h.surface.selection_string().as_deref(), Some("hello")); + h.settle(50); + assert!(h.recorded().is_empty(), "{:?}", h.recorded()); +} + +#[test] +fn mouse_reporting_can_be_disabled_by_config() { + let h = harness!(Harness::recorder( + r"\033[?1000h\033[?1006h", + &["--mouse-reporting=false"] + )); + h.click(5, 3, L, none()); + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); +} + +#[test] +fn alternate_scroll_sends_cursor_keys() { + let h = harness!(Harness::recorder(r"\033[?1049h", &[])); + h.wait("alt screen", |h| { + h.surface.lock_terminal().screens.active_key == ghostty_terminal::screen_set::Key::Alternate + }); + h.scroll(0.0, 1.0); + let n_up = { + h.wait("up", |h| !h.recorded().is_empty()); + h.settle(50); + h.recorded() + }; + assert!(n_up.len() >= 3 && n_up.len() % 3 == 0, "{n_up:?}"); + assert!(n_up.chunks(3).all(|c| c == b"\x1b[A"), "{n_up:?}"); + h.mark(); + h.scroll(0.0, -1.0); + h.wait("down", |h| !h.recorded().is_empty()); + h.settle(50); + let down = h.recorded(); + assert!(down.chunks(3).all(|c| c == b"\x1b[B"), "{down:?}"); + assert_eq!(down.len(), n_up.len()); +} + +#[test] +fn alternate_scroll_application_cursor_keys() { + let h = harness!(Harness::recorder(r"\033[?1049h\033[?1h", &[])); + h.wait("alt screen", |h| { + h.surface.lock_terminal().screens.active_key == ghostty_terminal::screen_set::Key::Alternate + }); + h.scroll(0.0, -1.0); + h.wait("down", |h| !h.recorded().is_empty()); + h.settle(50); + assert!(h.recorded().chunks(3).all(|c| c == b"\x1bOB")); +} + +#[test] +fn primary_screen_wheel_scrolls_viewport_not_app() { + let h = harness!(Harness::with_pre("seq 1 300", &[])); + let before = h.screen(); + h.scroll(0.0, 3.0); + h.wait("viewport scrolled", |h| h.screen() != before); + h.settle(30); + assert_eq!(h.recorded(), Vec::::new()); +} + +fn osc8_child() -> Option { + Harness::with_pre( + "printf 'go \\033]8;;http://osc8.example/x\\033\\\\here\\033]8;;\\033\\\\ and https://ghostty.org/docs/config ok\\n'", + &[], + ) +} + +#[test] +fn osc8_link_hover_and_ctrl_click_opens() { + let h = harness!(osc8_child()); + // hover with ctrl over "here" (cols 3..6) + h.move_to(4, 0, Some(ctrl())); + let url = h.wait_action("mouse over link", |a| match a { + Action::MouseOverLink(l) if !l.url.is_empty() => Some(l.url.clone()), + _ => None, + }); + assert_eq!(url, "http://osc8.example/x"); + h.click(4, 0, L, ctrl()); + let opened = h.wait_action("open url", |a| match a { + Action::OpenUrl(u) => Some(u.url.clone()), + _ => None, + }); + assert_eq!(opened, "http://osc8.example/x"); +} + +#[test] +fn regex_url_ctrl_click_opens_and_plain_click_does_not() { + let h = harness!(osc8_child()); + // "go here and https://ghostty.org/docs/config ok": URL starts at col 12 + h.click(20, 0, L, none()); + h.settle(50); + assert!(!h.has_action(|a| matches!(a, Action::OpenUrl(_)))); + h.move_to(30, 2, Some(none())); + h.move_to(20, 0, Some(ctrl())); + h.wait_action("hover", |a| match a { + Action::MouseOverLink(l) if l.url.contains("ghostty.org") => Some(()), + _ => None, + }); + h.click(20, 0, L, ctrl()); + let opened = h.wait_action("open url", |a| match a { + Action::OpenUrl(u) => Some(u.url.clone()), + _ => None, + }); + assert_eq!(opened, "https://ghostty.org/docs/config"); + // moving away clears the hover + h.clear_actions(); + h.move_to(2, 5, Some(none())); + h.wait_action("hover cleared", |a| match a { + Action::MouseOverLink(l) if l.url.is_empty() => Some(()), + _ => None, + }); +} + +#[test] +fn middle_click_pastes_selection_clipboard() { + let h = harness!(Harness::recorder("", &[])); + h.set_clipboard_text(Clipboard::Selection, "sel-text"); + h.click(3, 3, MouseButton::Middle, none()); + h.expect_input(b"sel-text"); +} + +#[test] +fn right_click_shows_context_menu_action_or_nothing_to_child() { + let h = harness!(Harness::recorder("", &[])); + h.click(3, 3, MouseButton::Right, none()); + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); + let _ = Key::Enter; +} diff --git a/crates/ghostty-core/tests/e2e_term.rs b/crates/ghostty-core/tests/e2e_term.rs new file mode 100644 index 0000000..eba0caf --- /dev/null +++ b/crates/ghostty-core/tests/e2e_term.rs @@ -0,0 +1,320 @@ +//! Terminal <-> runtime events: resize, focus, title, pwd, bell, OSC 52, +//! notifications, progress, child exit. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; +use common::*; +use ghostty_apprt::action::Action; +use ghostty_apprt::structs::{Clipboard, SurfaceSize}; +use ghostty_input::Key; +use ghostty_vt::osc::ProgressState; + +#[test] +fn resize_reaches_child_via_tiocswinsz() { + // The child prints `stty size` on every SIGWINCH. + let h = harness!(Harness::new(Opts::default().script( + "stty size >'{out}'; trap 'stty size >>\"{out}\"' WINCH; printf READY; while :; do sleep 0.02; done" + ))); + h.wait_screen("READY"); + let (c0, r0) = h.grid(); + h.wait("initial size", |h| { + String::from_utf8_lossy(&h.recorded()).starts_with(&format!("{r0} {c0}\n")) + }); + + h.surface + .size_callback(SurfaceSize { + width: 1000, + height: 500, + }) + .unwrap(); + let (c1, r1) = h.grid(); + assert_ne!((c0, r0), (c1, r1)); + h.wait("new size seen by child", |h| { + String::from_utf8_lossy(&h.recorded()).contains(&format!("{r1} {c1}\n")) + }); + // The terminal itself was resized too. + let t = h.surface.lock_terminal(); + assert_eq!((t.cols, t.rows), (c1, r1)); +} + +#[test] +fn font_size_change_resizes_pty() { + let h = harness!(Harness::new(Opts::default().script( + "trap 'stty size >>\"{out}\"' WINCH; printf READY; while :; do sleep 0.02; done" + ))); + h.wait_screen("READY"); + h.bind("increase_font_size:6"); + let (c, r) = h.grid(); + h.wait("child saw new grid", |h| { + String::from_utf8_lossy(&h.recorded()).contains(&format!("{r} {c}\n")) + }); +} + +#[test] +fn focus_events_mode_1004() { + let h = harness!(Harness::recorder(r"\033[?1004h", &[])); + h.wait("mode 1004", |h| { + h.surface + .lock_terminal() + .modes + .get(ghostty_vt::modes::Mode::FocusEvent) + }); + h.surface.focus_callback(false).unwrap(); + h.surface.focus_callback(true).unwrap(); + // (enabling mode 1004 reports the current focus state first) + h.expect_input(b"\x1b[I\x1b[O\x1b[I"); +} + +#[test] +fn no_focus_events_without_mode() { + let h = harness!(Harness::recorder("", &[])); + h.surface.focus_callback(false).unwrap(); + h.surface.focus_callback(true).unwrap(); + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); +} + +#[test] +fn title_osc0_osc2_set_title_action() { + let h = harness!(Harness::recorder( + r"\033]0;first title\007\033]2;second\033\\", + &[] + )); + h.wait("second title", |h| { + h.has_action(|a| matches!(a, Action::SetTitle(t) if t.title == "second")) + }); + assert!(h.has_action(|a| matches!(a, Action::SetTitle(t) if t.title == "first title"))); +} + +#[test] +fn title_from_config_ignores_osc() { + let h = harness!(Harness::recorder(r"\033]2;osc\007", &["--title=fixed"])); + h.settle(100); + assert!(h.has_action(|a| matches!(a, Action::SetTitle(t) if t.title == "fixed"))); + assert!(!h.has_action(|a| matches!(a, Action::SetTitle(t) if t.title == "osc"))); +} + +#[test] +fn pwd_osc7() { + let h = harness!(Harness::recorder( + r"\033]7;file://localhost/tmp/a%%20b\033\\", + &[] + )); + let pwd = h.wait_action("pwd", |a| match a { + Action::Pwd(p) if !p.pwd.is_empty() => Some(p.pwd.clone()), + _ => None, + }); + assert_eq!(pwd, "/tmp/a b"); + assert_eq!(h.surface.pwd().as_deref(), Some("/tmp/a b")); +} + +#[test] +fn bell_rings_once_per_burst() { + let h = harness!(Harness::recorder(r"\007", &[])); + h.wait_action("bell", |a| matches!(a, Action::RingBell).then_some(())); + // Rate limited to one per 100ms: a burst of bells is collapsed. + let h = harness!(Harness::recorder(r"\007\007\007\007", &[])); + h.wait_action("bell", |a| matches!(a, Action::RingBell).then_some(())); + h.settle(60); + assert!(h.count_actions(|a| matches!(a, Action::RingBell)) <= 2); +} + +#[test] +fn osc52_write_allowed_by_default() { + let h = harness!(Harness::recorder(r"\033]52;c;aGVsbG8=\007", &[])); + h.wait("clipboard written", |h| { + h.clipboard(Clipboard::Standard).is_some() + }); + assert_eq!(h.clipboard(Clipboard::Standard).as_deref(), Some("hello")); + assert!(!h.rt.writes.borrow()[0].confirm); +} + +#[test] +fn osc52_write_ask_requires_confirmation_and_deny_ignores() { + let h = harness!(Harness::recorder( + r"\033]52;c;aGVsbG8=\007", + &["--clipboard-write=ask"] + )); + h.wait("write requested", |h| !h.rt.writes.borrow().is_empty()); + assert!(h.rt.writes.borrow()[0].confirm); + assert_eq!(h.clipboard(Clipboard::Standard), None); + // The user confirms: the apprt calls complete_clipboard_request. + let c = h.rt.confirmations.borrow()[0].clone(); + h.surface + .complete_clipboard_request(c.request, &c.text, true) + .unwrap(); + assert_eq!(h.rt.writes.borrow().len(), 2); + + let h = harness!(Harness::recorder( + r"\033]52;c;aGVsbG8=\007", + &["--clipboard-write=deny"] + )); + h.settle(100); + assert!(h.rt.writes.borrow().is_empty()); +} + +#[test] +fn osc52_write_base64_acceptance_matches_zig() { + // "YQ" (unpadded) is rejected, "YR==" (nonzero trailing bits) decodes. + let h = harness!(Harness::recorder(r"\033]52;c;YQ\007", &[])); + h.settle(100); + assert!(h.rt.writes.borrow().is_empty()); + let h = harness!(Harness::recorder(r"\033]52;c;YR==\007", &[])); + h.wait("clipboard written", |h| { + h.clipboard(Clipboard::Standard).is_some() + }); + assert_eq!(h.clipboard(Clipboard::Standard).as_deref(), Some("a")); +} + +/// A child that sends an OSC 52 read query after it received one byte. +fn osc52_query_child(args: &[&str]) -> Option { + let h = Harness::new(Opts::default().args(args).script( + "stty raw -echo; printf READY; head -c1 >/dev/null; printf '\\033]52;c;?\\007'; exec cat >'{out}'", + ))?; + h.wait_screen("READY"); + Some(h) +} + +#[test] +fn osc52_read_requires_confirmation_by_default() { + let h = harness!(osc52_query_child(&[])); + h.set_clipboard_text(Clipboard::Standard, "secret"); + h.type_text("x"); + h.wait("confirmation asked", |h| { + !h.rt.confirmations.borrow().is_empty() + }); + h.settle(50); + assert_eq!(h.recorded(), Vec::::new()); + let c = h.rt.confirmations.borrow()[0].clone(); + h.surface + .complete_clipboard_request(c.request, &c.text, true) + .unwrap(); + // base64("secret") = c2VjcmV0 + h.expect_input(b"\x1b]52;c;c2VjcmV0\x1b\\"); +} + +#[test] +fn osc52_read_allow_and_deny() { + let h = harness!(osc52_query_child(&["--clipboard-read=allow"])); + h.set_clipboard_text(Clipboard::Standard, "hi"); + h.type_text("x"); + h.expect_input(b"\x1b]52;c;aGk=\x1b\\"); + + let h = harness!(osc52_query_child(&["--clipboard-read=deny"])); + h.set_clipboard_text(Clipboard::Standard, "hi"); + h.type_text("x"); + h.settle(100); + assert_eq!(h.recorded(), Vec::::new()); + assert!(h.rt.confirmations.borrow().is_empty()); +} + +#[test] +fn desktop_notifications() { + let h = harness!(Harness::recorder(r"\033]9;just a body\007", &[])); + let (t, b) = h.wait_action("osc9", |a| match a { + Action::DesktopNotification(n) => Some((n.title.clone(), n.body.clone())), + _ => None, + }); + assert_eq!(b, "just a body"); + let _ = t; + + let h = harness!(Harness::recorder( + r"\033]777;notify;My Title;My Body\007", + &[] + )); + let (t, b) = h.wait_action("osc777", |a| match a { + Action::DesktopNotification(n) => Some((n.title.clone(), n.body.clone())), + _ => None, + }); + assert_eq!((t.as_str(), b.as_str()), ("My Title", "My Body")); + + let h = harness!(Harness::recorder( + r"\033]9;x\007", + &["--desktop-notifications=false"] + )); + h.settle(100); + assert!(!h.has_action(|a| matches!(a, Action::DesktopNotification(_)))); +} + +#[test] +fn progress_report_osc_9_4() { + let h = harness!(Harness::recorder(r"\033]9;4;1;50\007", &[])); + let p = h.wait_action("progress", |a| match a { + Action::ProgressReport(p) => Some(*p), + _ => None, + }); + assert_eq!(p.state, ProgressState::Set); + assert_eq!(p.progress, Some(50)); +} + +#[test] +fn child_exit_zero_closes_surface_by_default() { + let h = harness!(Harness::new(Opts::default().script("sleep 0.4; exit 0"))); + h.wait("surface closed", |h| !h.rt.closes.borrow().is_empty()); + // normal exit after > abnormal runtime: the notice is shown in the terminal + // when the apprt has no native UI. + assert!(h.screen().contains("Process exited"), "{}", h.screen()); + assert!(ghostty_apprt::runtime_api::CoreSurface::child_exited( + &*h.surface + )); +} + +#[test] +fn child_exit_with_wait_after_command_keeps_surface() { + let h = harness!(Harness::new( + Opts::default() + .args(&["--wait-after-command=true"]) + .script("sleep 0.4; exit 0") + )); + h.wait("process exited text", |h| { + h.screen().contains("Process exited") + }); + h.settle(100); + assert!(h.rt.closes.borrow().is_empty()); + // "Press any key to close the terminal": a key press closes it. + h.key(Key::Enter, none(), ""); + assert!(!h.rt.closes.borrow().is_empty()); +} + +#[test] +fn child_exit_native_ui_gets_show_child_exited() { + let h = harness!(Harness::new(Opts { + show_child_exited: Some(true), + ..Opts::default().script("sleep 0.4; exit 3") + })); + let info = h.wait_action("show child exited", |a| match a { + Action::ShowChildExited(i) => Some(*i), + _ => None, + }); + assert_eq!(info.exit_code, 3); + assert!(!h.screen().contains("Process exited")); +} + +#[test] +fn abnormal_exit_shows_error_in_terminal_and_waits() { + let h = harness!(Harness::new(Opts::default().script("exit 7"))); + h.wait("abnormal message", |h| { + h.screen() + .contains("failed to launch the requested command") + }); + let s = h.screen(); + assert!(s.contains("Exit Code: 7"), "{s}"); + assert!(s.contains("Press any key to close the window"), "{s}"); + // waits for a key, doesn't close by itself + h.settle(100); + assert!(h.rt.closes.borrow().is_empty()); + h.key(Key::KeyA, none(), "a"); + assert!(!h.rt.closes.borrow().is_empty()); +} + +#[test] +fn ctrl_c_interrupts_cooked_child() { + // A cooked tty turns ctrl+c (0x03) into SIGINT for the foreground group. + let h = harness!(Harness::new(Opts::default().script( + "trap 'printf GOT >\"{out}\"' INT; printf READY; while :; do sleep 0.05; done" + ))); + h.wait_screen("READY"); + h.key(Key::KeyC, ctrl(), "c"); + h.wait("SIGINT delivered", |h| h.recorded() == b"GOT"); +} diff --git a/crates/ghostty-renderer/src/headless.rs b/crates/ghostty-renderer/src/headless.rs new file mode 100644 index 0000000..d398142 --- /dev/null +++ b/crates/ghostty-renderer/src/headless.rs @@ -0,0 +1,115 @@ +//! Headless GL context (surfaceless EGL, Mesa) for tests and tools that need a +//! current OpenGL 4.3 core context without a window system. +//! +//! This is a test-support API (not used by the application): integration tests +//! of other crates (e.g. `ghostty-core/tests/e2e_*.rs`) use it to build a real +//! [`Renderer`](crate::Renderer) without GTK. The context is made current on +//! the thread that calls [`Headless::new`] and must be dropped on that thread. + +#![allow(unsafe_code)] + +use std::ffi::c_void; + +use khronos_egl as egl; + +// EGL_PLATFORM_SURFACELESS_MESA, falling back to the default display. +const PLATFORM_SURFACELESS_MESA: egl::Enum = 0x31DD; + +/// A surfaceless EGL context, current on the creating thread. +pub struct Headless { + egl: egl::DynamicInstance, + display: egl::Display, + context: egl::Context, +} + +impl Headless { + /// Create a GL 4.3 core context and make it current on this thread. + pub fn new() -> Result { + // SAFETY: loads the system libEGL. + let egl = unsafe { egl::DynamicInstance::::load_required() } + .map_err(|e| format!("cannot load libEGL: {e}"))?; + + // SAFETY: EGL_DEFAULT_DISPLAY with an empty (terminated) attribute list. + let display = unsafe { + egl.get_platform_display( + PLATFORM_SURFACELESS_MESA, + egl::DEFAULT_DISPLAY, + &[egl::ATTRIB_NONE], + ) + } + .or_else(|_| { + // SAFETY: default display handle. + unsafe { egl.get_display(egl::DEFAULT_DISPLAY) }.ok_or(egl::Error::BadDisplay) + }) + .map_err(|e| format!("no EGL display: {e}"))?; + egl.initialize(display) + .map_err(|e| format!("eglInitialize: {e}"))?; + egl.bind_api(egl::OPENGL_API) + .map_err(|e| format!("eglBindAPI: {e}"))?; + + let attribs = [ + egl::RENDERABLE_TYPE, + egl::OPENGL_BIT, + egl::SURFACE_TYPE, + egl::PBUFFER_BIT, + egl::NONE, + ]; + let config = egl + .choose_first_config(display, &attribs) + .map_err(|e| format!("eglChooseConfig: {e}"))? + .ok_or("no EGL config")?; + let ctx_attribs = [ + egl::CONTEXT_MAJOR_VERSION, + 4, + egl::CONTEXT_MINOR_VERSION, + 3, + egl::CONTEXT_OPENGL_PROFILE_MASK, + egl::CONTEXT_OPENGL_CORE_PROFILE_BIT, + egl::NONE, + ]; + let context = egl + .create_context(display, config, None, &ctx_attribs) + .map_err(|e| format!("eglCreateContext (GL 4.3 core): {e}"))?; + // Surfaceless: EGL_KHR_surfaceless_context. + egl.make_current(display, None, None, Some(context)) + .map_err(|e| format!("eglMakeCurrent: {e}"))?; + Ok(Headless { + egl, + display, + context, + }) + } + + /// `glViewport(0, 0, width, height)`. The renderer reads the viewport to + /// learn the surface size (GTK's `GLArea` sets it before every render), so + /// headless callers must set it to the surface size. + pub fn set_viewport(&self, width: i32, height: i32) { + let p = self.proc_address("glViewport"); + if p.is_null() { + return; + } + // SAFETY: `glViewport` has the C signature `void(GLint, GLint, GLsizei, + // GLsizei)`; the context is current on this thread. + unsafe { + let f: extern "C" fn(i32, i32, i32, i32) = std::mem::transmute(p); + f(0, 0, width, height); + } + } + + /// Resolve a GL function pointer (null if unknown). + #[must_use] + pub fn proc_address(&self, name: &str) -> *const c_void { + self.egl + .get_proc_address(name) + .map_or(std::ptr::null(), |f| f as *const c_void) + } +} + +impl Drop for Headless { + fn drop(&mut self) { + let _ = self.egl.make_current(self.display, None, None, None); + let _ = self.egl.destroy_context(self.display, self.context); + // The display is shared by every context of the process (tests run in + // parallel threads), so it is deliberately not terminated. + } +} diff --git a/crates/ghostty-renderer/src/lib.rs b/crates/ghostty-renderer/src/lib.rs index b249ca8..9201892 100644 --- a/crates/ghostty-renderer/src/lib.rs +++ b/crates/ghostty-renderer/src/lib.rs @@ -15,6 +15,7 @@ pub mod backend; pub mod cell; pub mod cursor; pub mod generic; +pub mod headless; pub mod image; pub mod link; pub mod math; diff --git a/crates/ghostty-termio/src/command.rs b/crates/ghostty-termio/src/command.rs index 5e0d03f..91b68a0 100644 --- a/crates/ghostty-termio/src/command.rs +++ b/crates/ghostty-termio/src/command.rs @@ -17,7 +17,6 @@ use std::{ ffi::{CString, NulError, c_char}, os::fd::OwnedFd, panic::{AssertUnwindSafe, catch_unwind}, - time::{Duration, Instant}, }; use ghostty_os::file::Rlimit; @@ -497,23 +496,7 @@ pub fn fmt_scope(pid: u32) -> String { format!("app-ghostty-surface-transient-{pid}.scope") } -/// Wait for the current process' cgroup to end in `expected`. Returns true if -/// seen, false on timeout (250ms). -fn wait_for_cgroup(pid: u32, expected: &str) -> bool { - let start = Instant::now(); - loop { - if start.elapsed() > Duration::from_millis(250) { - return false; - } - if let Some(raw) = ghostty_os::cgroup::current(pid) - && let Some(idx) = raw.rfind('/') - && &raw[idx + 1..] == expected - { - return true; - } - std::thread::sleep(Duration::from_millis(25)); - } -} +use ghostty_os::cgroup::wait_for_scope as wait_for_cgroup; /// If we are expecting to be moved to a transient systemd scope, wait to see /// if that happens by checking for the correct name of the current cgroup. diff --git a/crates/ghostty-termio/src/exec.rs b/crates/ghostty-termio/src/exec.rs index c2e469e..6f1d9ba 100644 --- a/crates/ghostty-termio/src/exec.rs +++ b/crates/ghostty-termio/src/exec.rs @@ -771,10 +771,20 @@ impl Subprocess { // Assume that the resources directory is adjacent to the terminfo // database - let parent = std::path::Path::new(base) - .parent() - .map_or_else(String::new, |p| p.to_string_lossy().into_owned()); - env.insert("TERMINFO".into(), format!("{parent}/terminfo")); + // + // Rust-specific: the in-repo dev resources dir (`/resources`) + // carries its compiled terminfo inside itself, so prefer that + // when present. + let own = format!("{base}/terminfo"); + let terminfo = if std::path::Path::new(&format!("{own}/x/xterm-ghostty")).exists() { + own + } else { + let parent = std::path::Path::new(base) + .parent() + .map_or_else(String::new, |p| p.to_string_lossy().into_owned()); + format!("{parent}/terminfo") + }; + env.insert("TERMINFO".into(), terminfo); } else { env.insert("TERM".into(), "xterm-256color".into()); env.insert("COLORTERM".into(), "truecolor".into()); diff --git a/crates/ghostty-termio/src/pty.rs b/crates/ghostty-termio/src/pty.rs index 848c8ad..d14178a 100644 --- a/crates/ghostty-termio/src/pty.rs +++ b/crates/ghostty-termio/src/pty.rs @@ -92,8 +92,13 @@ pub enum Error { /// A pty master/slave pair. /// /// Deviation from Zig: both fds are `OwnedFd` and are closed on drop (the Zig -/// struct never closes the slave automatically). To close the slave early -/// (as Exec does after the fork) use [`Pty::close_slave`] or destructure. +/// struct never closes the slave automatically). This is behaviour-preserving: +/// Zig's `Exec.Subprocess.start` closes the slave explicitly in exactly two +/// places (in the parent right after the child was started successfully, and +/// in the start error path before `pty.deinit()`), and `Pty.deinit` is only +/// reached afterwards. The Rust `Exec` calls [`Pty::close_slave`] after a +/// successful start and drops the whole `Pty` on error, so the slave fd lives +/// exactly as long as in Zig. #[derive(Debug)] pub struct Pty { pub master: OwnedFd, diff --git a/crates/ghostty-termio/src/stream_handler.rs b/crates/ghostty-termio/src/stream_handler.rs index 28ad4bf..4cddbf9 100644 --- a/crates/ghostty-termio/src/stream_handler.rs +++ b/crates/ghostty-termio/src/stream_handler.rs @@ -31,7 +31,6 @@ use ghostty_apprt::structs::Clipboard; use ghostty_apprt::surface::{ DesktopNotification, Message as SurfaceMessage, ReportTitleStyle, SET_TITLE_LEN, WriteReq, }; -use ghostty_config::types_a::CursorStyle as ConfigCursorStyle; use ghostty_config::types_b::{ClipboardAccess, OSCColorReportFormat}; use ghostty_datastruct::blocking_queue::Timeout; use ghostty_renderer::size::Size; @@ -66,16 +65,6 @@ use crate::wakeup::Wakeup; /// `build_config.version_string`. pub const VERSION_STRING: &str = "1.3.1"; -/// The DECSCUSR style the config/default maps to. -pub fn cursor_style_from_config(style: ConfigCursorStyle) -> CursorStyle { - match style { - ConfigCursorStyle::Bar => CursorStyle::Bar, - ConfigCursorStyle::Block => CursorStyle::Block, - ConfigCursorStyle::Underline => CursorStyle::Underline, - ConfigCursorStyle::BlockHollow => CursorStyle::BlockHollow, - } -} - /// The long-lived state of the handler (everything but the terminal). pub struct HandlerState { /// The cached size info (`size: *renderer.Size`). Kept up to date by @@ -120,6 +109,10 @@ pub struct HandlerState { /// such as XTGETTCAP. pub dcs: dcs::Handler, + /// The tmux control mode viewer state (`tmux_viewer`). Dropping it is + /// the equivalent of Zig's `viewer.deinit()` + `destroy`. + pub tmux_viewer: Option>, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -189,6 +182,7 @@ impl HandlerState { clipboard_write: opts.clipboard_write, apc: apc::Handler::default(), dcs: dcs::Handler::default(), + tmux_viewer: None, termio_messaged: false, seen_title: false, outbox_termio: VecDeque::new(), @@ -332,7 +326,7 @@ impl TerminalStream { st.osc_color_report_format = config.osc_color_report_format; st.clipboard_write = config.clipboard_write; st.enquiry_response = config.enquiry_response.clone().into_bytes(); - st.default_cursor_style = cursor_style_from_config(config.cursor_style); + st.default_cursor_style = config.cursor_style; st.default_cursor_blink = config.cursor_blink; let mut h = StreamHandler { terminal, st }; @@ -653,11 +647,56 @@ impl StreamHandler<'_> { fn dcs_command(&mut self, cmd: dcs::Command) { match cmd { - // TODO(integration): tmux control mode viewer - // (`terminal.tmux.Viewer`) is not ported in ghostty-terminal - // yet; the events are ignored. dcs::Command::Tmux(tmux) => { - log::info!("tmux control mode event ignored (viewer not ported): {tmux:?}"); + use ghostty_terminal::tmux::control::Notification; + use ghostty_terminal::tmux::viewer::{Action as ViewerAction, Input}; + + log::info!("tmux control mode event cmd={tmux:?}"); + + match tmux { + Notification::Enter => { + // Setup our viewer state + assert!(self.st.tmux_viewer.is_none()); + self.st.tmux_viewer = Some(Box::new(ghostty_terminal::tmux::Viewer::new())); + return; + } + + Notification::Exit => { + // Free our viewer state if we have one + self.st.tmux_viewer = None; + return; + } + + _ => {} + } + + let Some(viewer) = self.st.tmux_viewer.as_mut() else { + // This can only really happen if we failed to + // initialize the viewer on enter. + log::info!("received tmux control mode command without viewer: {tmux:?}"); + return; + }; + + let actions = viewer.next(Input::Tmux(tmux)); + for action in actions { + log::info!("tmux viewer action={action:?}"); + match action { + // We ignore this because we will fully exit when + // our DCS connection ends. We may want to handle + // this in the future to notify our GUI we're + // disconnected though. + ViewerAction::Exit => {} + + ViewerAction::Command(command) => { + assert_eq!(command.last(), Some(&b'\n')); + self.write_req(&command); + } + + // TODO (Zig has the same TODO): windows are not + // surfaced to the GUI yet. + ViewerAction::Windows(_) => {} + } + } } dcs::Command::Xtgettcap(mut gettcap) => { @@ -2127,7 +2166,7 @@ mod tests { fn change_config_updates_default_cursor_and_requests_scheme_report() { let mut h = Harness::new(); let mut cfg = crate::termio::DerivedConfig::new(&ghostty_config::config::Config::default()); - cfg.cursor_style = ConfigCursorStyle::Underline; + cfg.cursor_style = CursorStyle::Underline; cfg.cursor_blink = Some(false); cfg.enquiry_response = "hi".into(); h.stream.change_config(&mut h.term, &cfg); @@ -2170,6 +2209,16 @@ mod tests { assert_eq!("", h.term.plain_string().unwrap()); } + #[test] + fn dcs_tmux_enter_creates_viewer_and_exit_drops_it() { + let mut h = Harness::new(); + h.feed(b"\x1bP1000p%begin 1 2 3\n%end 1 2 3\n"); + assert!(h.stream.state.tmux_viewer.is_some()); + h.feed(b"%exit\n\x1b\\after"); + assert!(h.stream.state.tmux_viewer.is_none()); + assert!(h.term.plain_string().unwrap().contains("after")); + } + #[test] fn dcs_tmux_is_ignored_without_panicking() { let mut h = Harness::new(); diff --git a/crates/ghostty-termio/src/termio.rs b/crates/ghostty-termio/src/termio.rs index 2f3ee2c..2a566d9 100644 --- a/crates/ghostty-termio/src/termio.rs +++ b/crates/ghostty-termio/src/termio.rs @@ -29,7 +29,7 @@ use std::time::{Duration, Instant}; use ghostty_config::conditional; use ghostty_config::config::Config; use ghostty_config::io::ReadableIO; -use ghostty_config::types_a::{Color, CursorStyle as ConfigCursorStyle, TerminalColor}; +use ghostty_config::types_a::{Color, CursorStyle, TerminalColor}; use ghostty_config::types_b::{ClipboardAccess, GraphemeWidthMethod, OSCColorReportFormat}; use ghostty_datastruct::blocking_queue::Timeout; use ghostty_renderer::message::Message as RendererMessage; @@ -52,9 +52,7 @@ use crate::message::{Message, SizeReport}; use crate::options::Options; use crate::sinks::{RendererMailbox, SurfaceMailbox}; use crate::state::{SharedState, SharedStateExt, State}; -use crate::stream_handler::{ - HandlerOptions, HandlerState, TerminalStream, cursor_style_from_config, -}; +use crate::stream_handler::{HandlerOptions, HandlerState, TerminalStream}; use crate::wakeup::Wakeup; /// Errors from termio initialization and thread setup. @@ -95,7 +93,7 @@ pub enum MutexState { pub struct DerivedConfig { pub palette: Palette, pub image_storage_limit: usize, - pub cursor_style: ConfigCursorStyle, + pub cursor_style: CursorStyle, pub cursor_blink: Option, pub cursor_color: Option, pub foreground: Color, @@ -376,7 +374,7 @@ impl Termio { set_image_limits(&mut term, config.image_storage_limit); // Set our default cursor style - term.screen_mut().cursor.cursor_style = cursor_style_from_config(config.cursor_style); + term.screen_mut().cursor.cursor_style = config.cursor_style; // Setup our terminal size in pixels for certain requests. term.width_px = u32::from(term.cols) * size.cell.width; @@ -391,7 +389,7 @@ impl Termio { termio_mailbox: mailbox.clone(), surface_mailbox: Arc::clone(&surface_mailbox), renderer_wakeup: renderer_wakeup.clone(), - default_cursor_style: cursor_style_from_config(config.cursor_style), + default_cursor_style: config.cursor_style, default_cursor_blink: config.cursor_blink, enquiry_response: config.enquiry_response.clone().into_bytes(), osc_color_report_format: config.osc_color_report_format, diff --git a/docs/rewrite/STATUS.md b/docs/rewrite/STATUS.md index d0899ea..cef48c2 100644 --- a/docs/rewrite/STATUS.md +++ b/docs/rewrite/STATUS.md @@ -35,7 +35,7 @@ Legend: โฌœ todo ยท ๐ŸŸจ in progress ยท โœ… done (gate passed, committed) ยท โš  | RV1 | Codex read-only audit core Surface vs Zig | codex astra | โœ… | 9 findings (notes/review-core-codex.md) โ†’ handed to V1 | | P1 | ghostty-bench port, Zig baseline, perf tuning | sonnet | โœ… | stream at Zig parity; screen-clone 2.6x | | V1b | finish V1: clippy, audit fixes, GTK replay check | sonnet | ๐ŸŸจ | | -| P2 | perf: screen-clone, osc-parser | sonnet | ๐ŸŸจ | | +| P2 | perf: screen-clone, osc-parser | sonnet | โœ… | clone at parity; render 2.4x | ## Log - 2026-10-11: Phase 0 complete; Phase 1 launched. diff --git a/docs/rewrite/notes/ghostty-core.md b/docs/rewrite/notes/ghostty-core.md index 084eff1..421e41b 100644 --- a/docs/rewrite/notes/ghostty-core.md +++ b/docs/rewrite/notes/ghostty-core.md @@ -40,7 +40,7 @@ Wiring details: renderer->app via `Host` (scrollbar/health/redraw as app mailbox apprt-gtk got `RtSurface::gl_proc_address` (libepoxy `epoxy_*` pointer variables) and `create_scope_fn` (cgroup scope over D-Bus from the IO thread). Deviations/stubs: inspector no-op; locale only via GTK's setlocale (no unsafe in core/root); crash reporting (sentry) not ported; `Rust version` row of `+version` empty; default DPI 96 (Zig linux). -How to run: `GSETTINGS_BACKEND=memory GHOSTTY_RESOURCES_DIR=/usr/share/ghostty cargo run -- -e sh -c '...'` (debug builds only find resources via the env var here). +How to run (resources are found automatically via `/resources`, see notes/cleanup-V2.md): `GSETTINGS_BACKEND=memory cargo run -- -e sh -c '...'`. Dev screenshot: `GHOSTTY_DEBUG_SCREENSHOT=/abs/out.png [GHOSTTY_DEBUG_SCREENSHOT_MS=2500]` dumps the renderer's last frame (compositor screenshots under niri come out black / are blocked). Verified manually: window opens on Wayland and X11, shell prompt and SGR colored output render (target/screenshots/frame.png, shell.png), child exit closes window, clean shutdown. NOT verified: keyboard input end-to-end (no way to inject keys into the window here; xdotool/niri did not deliver), mouse, clipboard, IME, splits/tabs. diff --git a/docs/rewrite/notes/verification-V1.md b/docs/rewrite/notes/verification-V1.md new file mode 100644 index 0000000..7b0a79d --- /dev/null +++ b/docs/rewrite/notes/verification-V1.md @@ -0,0 +1,47 @@ +# Verification V1 / V1b (core end-to-end + GTK runtime) + +## E2E tests (`crates/ghostty-core/tests/`, 81 tests, all headless; real core Surface + termio + pty + headless EGL renderer, fake apprt) +- `common/mod.rs`: harness (FakeRt recording actions/clipboard, recorder child `cat`-like process, pump/wait/settle helpers). +- `e2e_bindings.rs` (23): default and configured binding actions, select-all/copy/paste, csi/esc/text, screen-file open, child env. +- `e2e_ime.rs` (4): preedit state/rendering, commit, composing key events. +- `e2e_keys.rs` (15): key events -> encoder -> pty bytes (legacy, kitty, modifiers, sequences). +- `e2e_mouse.rs` (19): selection, copy-on-select, multi-click, mouse reporting (x10/normal/sgr), wheel, alternate scroll. +- `e2e_term.rs` (20): resize/TIOCSWINSZ, focus, title, pwd, bell, OSC 52 read/write, notifications, progress, child exit. +- Gate: workspace build, `cargo test --workspace` (~480 tests), `cargo clippy --workspace --all-targets -D warnings`, `cargo fmt --all` all pass. + +## Bugs found and fixed +- core `surface/mod.rs`: set_selection compared against the previous selection after replacing it (tracked-pin panic, e.g. select-all twice with copy-on-select). +- core `surface/mod.rs`: child env overlaid runtime env on the process env; now the runtime env wholesale (fallback only if empty). +- core `surface/keyboard.rs`: global/all `new_window` targeted the surface (parent inheritance); now `Target::App`. +- core `global.rs` + apprt-gtk `flatpak.rs`: runtime-specific resources-dir hook (host_path), wired from `src/main.rs`. +- core `surface/binding.rs`: failed screen-file open returned success; now an error (performable bindings fall through). CSI/ESC > 128 bytes rejected. +- core `surface/clipboard.rs`: OSC 52 base64 acceptance matches Zig (size check, ignore trailing-bit errors). +- core `global.rs`: `GHOSTTY_LOG` boolean aliases/empty value/invalid-field behaviour. +- apprt-gtk `class/application.rs` + `src/main.rs`: `init_locale` (ensureLocale) before GTK init and for CLI actions. +- renderer `headless.rs`, termio, apprt `runtime_api.rs`: fixes needed by the headless harness; apprt-gtk `debug_input.rs`: new input replay hook. +- tests: wheel e2e test raced under load (one discrete tick = 3 reports); fixed to wait for all 6. +- `dist/` (desktop file, metainfo, dbus/systemd units, icons) is legitimate install data; kept. + +## Codex audit (review-core-codex.md), 9 items +1. selection-replacement panic: FIXED, test `select_all_twice_with_copy_on_select_does_not_panic`. +2. child env filtering: FIXED, test `child_environment_comes_from_the_runtime`. +3. new_window parent: FIXED for global/all (keyboard.rs). Surface-local `new_window` binding (binding.rs) still passes `Target::Surface` deliberately: Zig Surface.zig:5122 calls `app.newWindow` with the surface as parent there. +4. flatpak host path: FIXED (hook, no unit test: needs /.flatpak-info). +5. screen-file open failure: FIXED, test `write_screen_file_open_failure_is_an_error`. +6. ensureLocale: FIXED (unsafe setlocale/setenv in apprt-gtk, SAFETY comments); not unit-testable (process-global locale). +7. OSC 52 base64: FIXED, test `osc52_write_base64_acceptance_matches_zig`. +8. CSI/ESC length limit: FIXED, test `csi_binding_longer_than_the_zig_buffer_is_rejected`. +9. GHOSTTY_LOG parsing: FIXED, unit test `logging_parse`. + +## GTK runtime check (real app, Wayland, `GHOSTTY_DEBUG_INPUT_SCRIPT` + screenshots) +- `echo hi` + Enter: output `hi` shown. PASS. +- ctrl+shift+t: new tab, typing works in it. PASS. +- ctrl+shift+o / ctrl+shift+e: right and down splits, each pane renders and quits cleanly. PASS. +- ctrl+shift+a, ctrl+shift+c, ctrl+shift+v: select all highlighted, copied text pasted back. PASS. +- ctrl+= x4: font size 12 -> 16, glyphs visibly larger. PASS. +- The "panic after the first screenshot" did not reproduce; the likely cause was the selection panic (item 1), now fixed. Observed instead: a screenshot taken <~1.6 s after opening a new tab/split can fail with "no frame has been presented yet" (new surface's first frame is slow in a debug build); use longer `wait`s. +- A `quit` with a child shell lacking shell integration (`sh`) shows the close-confirmation dialog, so the process stays alive (expected behaviour). + +## Still open +- Not exercised in the GTK check: mouse, IME via real IM, clipboard confirmation dialogs. +- Runtime warnings unchanged: `unimplemented action=CellSize`, cgroup scope creation failing in this sandbox. diff --git a/src/main.rs b/src/main.rs index d78f838..1d26221 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,10 @@ fn main() -> ExitCode { let action_requested = !matches!(ghostty_cli::ghostty::detect(&args), Ok(None)); global::init(InitOptions { action: action_requested, + resources_dir: Some(ghostty_apprt_gtk::flatpak::resources_dir), }); + // Zig `global.init` runs `ensureLocale` before anything locale dependent. + ghostty_apprt_gtk::class::application::init_locale(); build_info::set(build_info()); // Execute our action if we have one.