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:
1 parent
d650e5171b
commit
be6d706ba5
33 files changed
+3016
-222
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user