test(core): add headless end-to-end tests and fix bugs they found

Adds crates/ghostty-core/tests/e2e_*.rs: 81 tests that drive the real
core Surface, termio, pty and child processes through a fake runtime
(recording apprt actions, in-memory clipboard) with a headless renderer
(ghostty-renderer/src/headless.rs). They cover keys (legacy, DECCKM,
kitty, modifyOtherKeys), keybinding actions and sequences, mouse
selection/reporting/scrolling/links, clipboard incl. OSC 52 and paste
safety, resize, focus, titles/pwd/bell/notifications/progress, child
exit, and IME preedit.

Fixes for issues found by the tests and by the Codex audit
(docs/rewrite/notes/review-core-codex.md, all 9 items):
- replacing a selection with copy-on-select panicked (stale tracked
  handles); global new_window no longer inherits a parent; the child
  environment uses the runtime's env wholesale so GTK's removals stick;
  Flatpak host resources path; locale recovery (ensureLocale) in
  apprt-gtk before GTK init and for CLI actions; failed screen-file
  opens propagate errors; OSC 52 base64 rules, CSI/ESC binding length
  limits and GHOSTTY_LOG parsing match Zig.
- Also includes V2's termio cleanups (tmux viewer wiring in the stream
  handler, Pty slave semantics docs, shared cgroup wait loop).

apprt-gtk gains a debug input replay hook (GHOSTTY_DEBUG_INPUT_SCRIPT)
exercising the real GTK key path. With it, the real app was verified on
Wayland: typing and output, new tab, splits, select-all/copy/paste and
font size all work (docs/rewrite/notes/verification-V1.md). Not yet
exercised at GTK level: mouse, real IM-context IME, clipboard dialogs.

Test Plan:
- cargo test --workspace (2983 passed)
- cargo test -p ghostty-core --tests (81 e2e tests)
This commit is contained in:
ddidderr committed 2026-10-12 03:58:19 +02:00
1 parent 7cdc0e9055
commit 2f58f0214d
33 files changed
+3016 -222

No files matched your search

@@ -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);
@@ -128,13 +128,16 @@ mod imp {
}
#[template_callback]
fn reveal_clicked(&self, _button: &gtk::Button) {
self.set_blurred(false, false, true);
fn reveal_clicked(_button: &gtk::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: &gtk::Button) {
self.set_blurred(true, true, false);
fn hide_clicked(_button: &gtk::Button, this: &super::ClipboardConfirmationDialog) {
this.imp().set_blurred(true, true, false);
}
}
@@ -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: &gtk::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: &gtk::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: &gtk::Paned,
_pspec: glib::ParamSpec,
this: &super::SplitTreeSplit,
) {
// The emitter is the child `Paned`; the template instance is the
// trailing user-data argument.
this.refresh();
}
}
+122 -11
View File
@@ -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<char> = key_event.keyval().to_unicode();
let keyval_unicode_unshifted =
gtk_key::keyval_unicode_unshifted(&*imp.gl_area, key_event, keycode);
let keyval_unicode: Option<char> = 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)
+246
View File
@@ -0,0 +1,246 @@
//! Debug-only input replay (`GHOSTTY_DEBUG_INPUT_SCRIPT=<file>`).
//!
//! 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<Vec<Step>, 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<Step>,
next: RefCell<usize>,
}
impl Runner {
fn active_surface(&self) -> Option<Surface> {
self.app
.active_window()?
.downcast::<Window>()
.ok()?
.get_active_surface()
}
/// Run steps until the next `wait`, which reschedules us.
fn run(self: &Rc<Self>) {
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());
}
}
+29 -6
View File
@@ -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<gtk::Widget>,
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.
+1
View File
@@ -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;
+9 -21
View File
@@ -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<u32>) -> 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(())
+5 -29
View File
@@ -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<u8> {
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::*;
+7
View File
@@ -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(())
+32 -9
View File
@@ -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<GlobalState> = 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<fn() -> std::io::Result<ResourcesDir>>,
}
/// 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());
}
}
+6 -26
View File
@@ -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.
+12 -21
View File
@@ -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<formatter::CodepointMap> = 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<formatter::CodepointMap> =
cfg.clipboard_codepoint_map.map.list.clone();
let opts = formatter::Options {
emit: Format::Plain, // We'll override this below
unwrap: true,
+3 -6
View File
@@ -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.
+49 -27
View File
@@ -1334,7 +1334,14 @@ impl Surface {
t: &mut ghostty_terminal::terminal::Terminal,
sel: Option<Selection>,
) -> 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<dyn std::error::Error>> {
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<u8>)> {
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<String, String> {
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<SurfaceId> {
Some(self.id)
}
+1 -1
View File
@@ -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();
+673
View File
@@ -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<Vec<(Target<SurfaceId>, Action)>>,
/// In-memory clipboards (plain text).
pub clipboards: RefCell<HashMap<u8, String>>,
pub writes: RefCell<Vec<ClipboardWrite>>,
pending: RefCell<VecDeque<(Clipboard, ClipboardRequest)>>,
pub confirmations: RefCell<Vec<Confirmation>>,
pub closes: RefCell<Vec<bool>>,
pub size: Cell<SurfaceSize>,
pub scale: Cell<ContentScale>,
pub cursor: Cell<CursorPos>,
/// What `perform_action(ShowChildExited)` returns (GTK: true).
pub show_child_exited: Cell<bool>,
pub title: RefCell<Option<String>>,
gl: Rc<Headless>,
}
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<SurfaceId>, 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<String> {
None
}
fn get_title(&self) -> Option<String> {
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<String>,
/// `sh -c` script; `{out}` is replaced by the recording file path.
pub script: Option<String>,
/// Surface size in pixels (default 800x600).
pub size: Option<(u32, u32)>,
pub scale: Option<f32>,
pub show_child_exited: Option<bool>,
}
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<FakeRt>,
pub surface: Rc<Surface>,
pub out: PathBuf,
pub id: SurfaceId,
base: Cell<usize>,
// Dropped last: the GL context must outlive the surface.
_gl: Rc<Headless>,
}
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<Harness> {
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<String> = 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<dyn RtApp> = rt.clone();
let rt_surface: Rc<dyn RtSurface> = 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> {
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<Harness> {
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<Harness> {
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<u8> {
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<Action> {
self.rt
.actions
.borrow()
.iter()
.map(|(_, a)| a.clone())
.collect()
}
pub fn action_names(&self) -> Vec<String> {
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<T>(&self, what: &str, f: impl Fn(&Action) -> Option<T>) -> 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<Mods>) {
self.rt.cursor.set(p);
self.surface.cursor_pos_callback(p, mods).unwrap();
}
pub fn move_to(&self, col: u32, row: u32, mods: Option<Mods>) {
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<String> {
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,
}
};
}
+422
View File
@@ -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::<u8>::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::<u8>::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::<u8>::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::<u8>::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:?}");
}
}
+119
View File
@@ -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<u32>, row: u32) -> Vec<u8> {
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::<u8>::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::<u8>::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());
}
+255
View File
@@ -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> {
Harness::recorder(setup, args)
}
fn rec(setup: &str) -> Option<Harness> {
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::<u8>::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");
}
+358
View File
@@ -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> {
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::<u8>::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::<u8>::new());
}
fn osc8_child() -> Option<Harness> {
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::<u8>::new());
let _ = Key::Enter;
}
+320
View File
@@ -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::<u8>::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<Harness> {
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::<u8>::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::<u8>::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");
}
+115
View File
@@ -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<egl::EGL1_5>,
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<Headless, String> {
// SAFETY: loads the system libEGL.
let egl = unsafe { egl::DynamicInstance::<egl::EGL1_5>::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.
}
}
+1
View File
@@ -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;
+1 -18
View File
@@ -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.
+14 -4
View File
@@ -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 (`<ws>/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());
+7 -2
View File
@@ -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,
+66 -17
View File
@@ -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<Box<ghostty_terminal::tmux::Viewer>>,
/// 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();
+5 -7
View File
@@ -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<bool>,
pub cursor_color: Option<TerminalColor>,
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,
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 `<workspace>/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.
+47
View File
@@ -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.
+3
View File
@@ -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.