Zig parses config values (cli/args.zig, Config.zig field parsers, config/string.zig) from raw byte slices, and each field type decides whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The port escape-encoded raw bytes into Strings at the line reader and let fields parse the escaped text, so fields could observe encoding artifacts; successive audits kept finding such leaks (RV5-RV8). Field parsers now receive the original bytes: text fields validate UTF-8 where Zig does, byte/path fields store bytes (EncodedOs/OsString). This resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md), each with a regression test: Windows path conversion fails with BadPathName instead of substituting U+FFFD; Windows process launch uses strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired- surrogate encodings like Zig; literal U+FDD0 Unicode key triggers; selection-word-chars and clipboard-codepoint-map reject invalid UTF-8; font style / codepoint-map family names use original bytes; C title actions forward bytes; D-Bus new-window/Flatpak conversions match Zig. Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md. Also moves the bundled iTerm2-Color-Schemes license out of resources/themes/ so +list-themes no longer lists it as a theme (output is now byte-identical to Ghostty 1.3.1). Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator. Test Plan: - cargo test --workspace --no-fail-fast (3428 passed, exit 0) - just check-macos (both), just check-windows, just check-wasm - +show-config --default --docs, +list-keybinds, +list-actions --docs, +list-themes --plain compared with /usr/bin/ghostty
451 lines
15 KiB
Rust
451 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.as_bytes(), 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 config_loaded_text_csi_esc_write_decoded_bytes() {
|
|
let h = harness!(Harness::recorder("", &[]));
|
|
// Byte FF and a literal U+FDD0 must reach the pty unchanged, not as
|
|
// the reversible escape encoding.
|
|
for (kind, raw, want) in [
|
|
("text", &b"a\xffb"[..], b"a\xffb".to_vec()),
|
|
(
|
|
"text",
|
|
"\u{fdd0}".as_bytes(),
|
|
"\u{fdd0}".as_bytes().to_vec(),
|
|
),
|
|
("csi", &b"\xff"[..], b"\x1b[\xff".to_vec()),
|
|
("esc", "\u{fdd0}".as_bytes(), b"\x1b\xef\xb7\x90".to_vec()),
|
|
] {
|
|
h.mark();
|
|
let a = ghostty_input::binding::Action::parse_bytes(&[kind.as_bytes(), b":", raw].concat())
|
|
.unwrap();
|
|
assert!(h.surface.perform_binding_action(&a).unwrap());
|
|
h.expect_input(&want);
|
|
}
|
|
}
|
|
|
|
#[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:?}");
|
|
}
|
|
}
|