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
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user