Files
ghostty-rs/crates/ghostty-core/tests/e2e_bindings.rs
T
ddidderr 75bf604375 feat(lib): port libghostty, Ghostty's embedded C API
Ports apprt/embedded.zig, main_c.zig and config/CApi.zig + c_get.zig as
crates/ghostty-lib (cdylib/staticlib/rlib `libghostty`), exporting every
function of the unchanged include/ghostty.h - the ABI Ghostty's Swift
macOS/iOS app links against. The embedded runtime implements the
runtime traits over the host's C callbacks, so the real core drives it.

- ghostty-core selects the renderer per target: Metal on Apple (surface
  NSView/UIView via RtSurface::platform_view), OpenGL elsewhere.
- ghostty_config_get follows Zig's c_get rules via a CValue type (143
  supported keys, checked against the Zig field types).
- The inspector C API is complete; its Metal backend replaces ImGui's
  Metal renderer and has not run.

Verification: generated C ABI probe (sizeof/alignof/offsetof of every
struct/union, 755 enum values, every function linked, nm exports, headers
byte-identical to upstream); a C host on Linux covering init, CLI
actions, config, app, keys, focus and color scheme; an opt-in
test-platform feature creating real surfaces on Linux with headless EGL
and a pty (13 tests). Apple targets are clippy-clean but not run; the
Swift app has not been built against it.

Test Plan:
- cargo test -p ghostty-lib (and --features test-platform)
- cargo clippy --target aarch64-apple-darwin -p ghostty-lib (cross env
  in docs/rewrite/notes/macos-M1.md)
2026-10-12 06:38:33 +02:00

428 lines
15 KiB
Rust

//! Keybinding actions end to end (default and configured bindings).
// These drive the real surface over a headless OpenGL context; the Apple targets render
// with Metal.
#![cfg(not(target_vendor = "apple"))]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::fmt::Write as _;
mod common;
use common::*;
use ghostty_apprt::{
action::{Action, GotoSplit, SplitDirection},
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:?}");
}
}