From 3266108c102f73faf37e270daefb0ca4fd0ba177 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 01:33:44 +0200 Subject: [PATCH] feat(core): port App/Surface and wire up the runnable ghostty binary This is the integration step that turns the ported crates into a working terminal: `cargo run` now opens a GTK window running the user's shell. ghostty-core ports App.zig, Surface.zig (split into keyboard, mouse, binding, clipboard and config submodules), surface_mouse.zig and global.zig, and implements the runtime traits. perform_binding_action handles every binding action. Cross-crate integration: - The core<->runtime traits (CoreApp, CoreSurface, RtApp, RtSurface, Waker) move from apprt-gtk into ghostty_apprt::runtime_api so the core stays runtime-agnostic; Scrollbar/RendererHealth stand-ins are replaced by the real terminal/renderer types. - termio now uses ghostty_renderer::state::{State, SharedState}; the renderer calls kitty_update; image-storage-limit is applied at init and on config change. - The search thread locks the terminal through a TerminalLock trait so it can share the render state mutex; termio's Wakeup gained a hook to wake the renderer thread. - apprt-gtk: GL proc addresses are resolved through libepoxy's epoxy_glFoo pointer variables (plain glFoo symbols resolved to null), default DPI fixed to 96, libadwaita version read without GTK init so --version works headless. The root binary dispatches +actions to ghostty-cli (list-fonts, show-face and new-window are now wired) and otherwise runs the GTK app with the real core. Verified at runtime on Wayland and X11: shell prompt and colored output render, child exit closes the window cleanly. Not yet verified: keyboard and mouse input, clipboard, IME, tabs and splits (no key injection was possible in this environment). GHOSTTY_DEBUG_SCREENSHOT= dumps the renderer's last frame for headless checks. Test Plan: - cargo test --workspace (2890 passed) - cargo clippy --workspace --all-targets -- -D warnings - GSETTINGS_BACKEND=memory GHOSTTY_RESOURCES_DIR=/usr/share/ghostty \ cargo run --- Cargo.lock | 16 + Cargo.toml | 3 + crates/ghostty-apprt-gtk/examples/demo.rs | 13 +- crates/ghostty-apprt-gtk/src/adw_version.rs | 18 +- crates/ghostty-apprt-gtk/src/app.rs | 2 +- .../src/class/application.rs | 2 +- crates/ghostty-apprt-gtk/src/class/surface.rs | 29 +- crates/ghostty-apprt-gtk/src/gl.rs | 24 +- crates/ghostty-apprt-gtk/src/lib.rs | 1 - crates/ghostty-apprt/Cargo.toml | 2 + crates/ghostty-apprt/src/lib.rs | 1 + .../src/runtime_api.rs} | 25 +- crates/ghostty-apprt/src/surface.rs | 26 +- crates/ghostty-cli/Cargo.toml | 2 + crates/ghostty-cli/src/list_fonts.rs | 74 +- crates/ghostty-cli/src/new_window.rs | 78 +- crates/ghostty-cli/src/show_face.rs | 156 +- crates/ghostty-core/Cargo.toml | 21 +- crates/ghostty-core/src/app.rs | 634 +++++ crates/ghostty-core/src/global.rs | 230 ++ crates/ghostty-core/src/lib.rs | 34 +- crates/ghostty-core/src/surface/binding.rs | 1327 +++++++++ crates/ghostty-core/src/surface/clipboard.rs | 401 +++ crates/ghostty-core/src/surface/config.rs | 87 + crates/ghostty-core/src/surface/keyboard.rs | 1138 ++++++++ crates/ghostty-core/src/surface/mod.rs | 1941 +++++++++++++ crates/ghostty-core/src/surface/mouse.rs | 2472 +++++++++++++++++ crates/ghostty-core/src/surface_mouse.rs | 288 ++ crates/ghostty-renderer/src/generic.rs | 13 +- crates/ghostty-terminal/src/search/thread.rs | 33 +- crates/ghostty-termio/src/backend.rs | 4 +- crates/ghostty-termio/src/exec.rs | 8 +- crates/ghostty-termio/src/lib.rs | 2 +- crates/ghostty-termio/src/state.rs | 66 +- crates/ghostty-termio/src/termio.rs | 66 +- crates/ghostty-termio/src/thread.rs | 3 +- crates/ghostty-termio/src/wakeup.rs | 24 +- .../ghostty-termio/tests/exec_integration.rs | 33 +- docs/rewrite/INTEGRATION_TODO.md | 19 +- docs/rewrite/notes/ghostty-cli.md | 10 + docs/rewrite/notes/ghostty-core.md | 47 + src/main.rs | 107 +- 42 files changed, 9301 insertions(+), 179 deletions(-) rename crates/{ghostty-apprt-gtk/src/core.rs => ghostty-apprt/src/runtime_api.rs} (91%) create mode 100644 crates/ghostty-core/src/app.rs create mode 100644 crates/ghostty-core/src/global.rs create mode 100644 crates/ghostty-core/src/surface/binding.rs create mode 100644 crates/ghostty-core/src/surface/clipboard.rs create mode 100644 crates/ghostty-core/src/surface/config.rs create mode 100644 crates/ghostty-core/src/surface/keyboard.rs create mode 100644 crates/ghostty-core/src/surface/mod.rs create mode 100644 crates/ghostty-core/src/surface/mouse.rs create mode 100644 crates/ghostty-core/src/surface_mouse.rs create mode 100644 docs/rewrite/notes/ghostty-core.md diff --git a/Cargo.lock b/Cargo.lock index 027623e..4dc3122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1208,6 +1208,8 @@ dependencies = [ "ghostty-config", "ghostty-datastruct", "ghostty-input", + "ghostty-renderer", + "ghostty-terminal", "ghostty-vt", "thiserror 2.0.21", ] @@ -1248,7 +1250,9 @@ name = "ghostty-cli" version = "0.1.0" dependencies = [ "flate2", + "ghostty-apprt", "ghostty-config", + "ghostty-font", "ghostty-input", "ghostty-os", "log", @@ -1275,6 +1279,9 @@ dependencies = [ name = "ghostty-core" version = "0.1.0" dependencies = [ + "base64 0.23.1", + "crossbeam-channel", + "ghostty-apprt", "ghostty-config", "ghostty-datastruct", "ghostty-font", @@ -1283,6 +1290,12 @@ dependencies = [ "ghostty-renderer", "ghostty-terminal", "ghostty-termio", + "ghostty-unicode", + "ghostty-vt", + "log", + "onig", + "png", + "thiserror 2.0.21", ] [[package]] @@ -1361,9 +1374,12 @@ dependencies = [ name = "ghostty-rs" version = "0.1.0" dependencies = [ + "ghostty-apprt", "ghostty-apprt-gtk", + "ghostty-cli", "ghostty-config", "ghostty-core", + "log", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 144a8a9..8f2214c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ path = "src/main.rs" ghostty-core.workspace = true ghostty-config.workspace = true ghostty-apprt-gtk.workspace = true +ghostty-apprt.workspace = true +ghostty-cli.workspace = true +log = "0.4" [lints] workspace = true diff --git a/crates/ghostty-apprt-gtk/examples/demo.rs b/crates/ghostty-apprt-gtk/examples/demo.rs index fff865c..fa20a33 100644 --- a/crates/ghostty-apprt-gtk/examples/demo.rs +++ b/crates/ghostty-apprt-gtk/examples/demo.rs @@ -19,14 +19,14 @@ use std::sync::Arc; use std::time::Duration; use ghostty_apprt::action::{self, Action, Target}; +use ghostty_apprt::runtime_api::{ + AppMessage, CellSize, ClipboardError, CoreApp, CoreError, CoreSurface, DesiredFontSize, + InputEffect, KeyEvent, RtApp, RtSurface, SurfaceId, +}; use ghostty_apprt::structs::{ ClipboardRequest, ColorScheme, ContentScale, CursorPos, ImePos, SurfaceSize, }; use ghostty_apprt_gtk::app::App; -use ghostty_apprt_gtk::core::{ - AppMessage, CellSize, ClipboardError, CoreApp, CoreError, CoreSurface, DesiredFontSize, - InputEffect, KeyEvent, RtApp, RtSurface, SurfaceId, -}; use ghostty_config::config::Config; use ghostty_input::binding::set::Value as BindingValue; use ghostty_input::binding::{self, Action as Binding}; @@ -204,9 +204,8 @@ impl CoreSurface for DemoSurface { fn renderer_realized(&self) -> Result<(), CoreError> { // SAFETY: the GLArea context is current while realizing; the loader // resolves symbols through libepoxy which dispatches to it. - let gl = unsafe { - glow::Context::from_loader_function(ghostty_apprt_gtk::gl::proc_address) - }; + let gl = + unsafe { glow::Context::from_loader_function(ghostty_apprt_gtk::gl::proc_address) }; *self.gl.borrow_mut() = Some(gl); Ok(()) } diff --git a/crates/ghostty-apprt-gtk/src/adw_version.rs b/crates/ghostty-apprt-gtk/src/adw_version.rs index 9c8313f..fe55727 100644 --- a/crates/ghostty-apprt-gtk/src/adw_version.rs +++ b/crates/ghostty-apprt-gtk/src/adw_version.rs @@ -6,12 +6,20 @@ use crate::gtk_version::Version; pub const COMPTIME_VERSION: Version = (1, 5, 0); /// Returns the runtime libadwaita version. +#[allow(unsafe_code)] pub fn get_runtime_version() -> Version { - ( - adw::major_version(), - adw::minor_version(), - adw::micro_version(), - ) + // The safe `adw::major_version()` etc. assert that libadwaita was + // initialized, but the C functions don't need it (and `+version` has no + // display to initialize GTK with). + // SAFETY: these functions only return compile-time constants of the + // loaded library and have no preconditions. + unsafe { + ( + adw::ffi::adw_get_major_version(), + adw::ffi::adw_get_minor_version(), + adw::ffi::adw_get_micro_version(), + ) + } } /// Logs the build and runtime libadwaita version. diff --git a/crates/ghostty-apprt-gtk/src/app.rs b/crates/ghostty-apprt-gtk/src/app.rs index 9f0bf1d..bb7d1e4 100644 --- a/crates/ghostty-apprt-gtk/src/app.rs +++ b/crates/ghostty-apprt-gtk/src/app.rs @@ -14,7 +14,7 @@ use ghostty_apprt::action::{Action, Target}; use ghostty_apprt::ipc; use crate::class::application::{Application, RunError}; -use crate::core::{CoreApp, SurfaceId, Waker}; +use ghostty_apprt::runtime_api::{CoreApp, SurfaceId, Waker}; /// This is detected by the Renderer, in which case it sends a /// `redraw_surface` message so that we can call `drawFrame` ourselves from diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs index d905ff2..aac6397 100644 --- a/crates/ghostty-apprt-gtk/src/class/application.rs +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -41,9 +41,9 @@ use crate::class::split_tree::SplitTree; use crate::class::surface::{Size, Surface}; use crate::class::tab::Tab; use crate::class::window::{SelectTab, TabOverrides, Window}; -use crate::core::{AppMessage, CoreApp, RtApp, SurfaceId}; use crate::css::CssProviders; use crate::winproto; +use ghostty_apprt::runtime_api::{AppMessage, CoreApp, RtApp, SurfaceId}; /// Errors from [`Application::run_loop`]. #[derive(Debug, thiserror::Error)] diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs index 4afc13d..7042d11 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -5,7 +5,7 @@ //! scrolled window), embedding a `GtkGLArea` that the core renders into. //! //! The "apprt Surface" of Zig (`Surface.zig`, the object the core calls back -//! into) is [`RtSurfaceHandle`], which implements [`crate::core::RtSurface`] +//! into) is [`RtSurfaceHandle`], which implements [`ghostty_apprt::runtime_api::RtSurface`] //! and holds only a weak reference to the widget. use std::cell::{Cell, RefCell}; @@ -43,11 +43,11 @@ use crate::class::search_overlay::SearchOverlay; use crate::class::surface_child_exited::SurfaceChildExited; use crate::class::title_dialog::{self, TitleDialog}; use crate::class::window::Window; -use crate::core::{ +use crate::key as gtk_key; +use ghostty_apprt::runtime_api::{ ClipboardError, CoreSurface, DesiredFontSize, InputEffect, MIN_WINDOW_HEIGHT_CELLS, MIN_WINDOW_WIDTH_CELLS, RtSurface, SurfaceId, }; -use crate::key as gtk_key; /// Simple dimensions struct for the surface used by various properties /// (Zig: `Surface.Size`, GType `GhosttySurfaceSize`). @@ -1834,6 +1834,29 @@ impl RtSurface for RtSurfaceHandle { .unwrap_or_default() } + fn gl_proc_address(&self, name: &str) -> *const std::ffi::c_void { + crate::gl::proc_address(name) + } + + fn create_scope_fn(&self) -> Option { + // GDBusConnection is thread-safe; the closure runs on the IO thread + // right after fork. + let dbus = gtk::gio::Application::default().and_then(|a| a.dbus_connection())?; + Some(std::sync::Arc::new(move |pid, memory_high, tasks_max| { + Some( + crate::cgroup::create_scope( + &dbus, + pid, + crate::cgroup::Options { + memory_high, + tasks_max, + }, + ) + .map_err(|e| e.to_string()), + ) + })) + } + fn redraw_inspector(&self) { if let Some(s) = self.gobj() { s.redraw_inspector(); diff --git a/crates/ghostty-apprt-gtk/src/gl.rs b/crates/ghostty-apprt-gtk/src/gl.rs index 9521b16..753b79c 100644 --- a/crates/ghostty-apprt-gtk/src/gl.rs +++ b/crates/ghostty-apprt-gtk/src/gl.rs @@ -1,8 +1,8 @@ //! OpenGL function loader for the `GLArea`. //! -//! GTK uses libepoxy, which exports dispatching stubs for every GL entry -//! point and resolves them against whichever context (EGL or GLX) is current. -//! So `dlsym` on libepoxy gives us a loader that works on both Wayland and +//! GTK uses libepoxy, which exports a lazily-resolving function pointer for +//! every GL entry point (resolved against whichever context, EGL or GLX, is +//! current). So reading those pointers gives us a loader that works on both Wayland and //! X11, which is what `ghostty-renderer`'s `prepare_context(|name| ..)` and //! `glow::Context::from_loader_function` need. Call it only while the GLArea //! context is current. @@ -28,17 +28,25 @@ fn epoxy() -> Option<&'static libloading::Library> { } /// Resolve an OpenGL function by name (null if unavailable). +/// +/// libepoxy does not export `glFoo` symbols; it exports a function pointer +/// *variable* `epoxy_glFoo` per entry point. The variable initially points +/// at a resolver stub which, on first call, resolves the real function for +/// the current context (EGL/GLX) and rewrites the variable. Reading the +/// variable therefore yields a callable function pointer. #[must_use] pub fn proc_address(name: &str) -> *const c_void { let Some(lib) = epoxy() else { return std::ptr::null(); }; - // SAFETY: we only read the symbol's address; the library is kept alive - // for the whole process lifetime by the `static` above, so the returned - // pointer stays valid. Callers cast it to the correct GL signature. + let symbol = format!("epoxy_{name}"); + // SAFETY: we read the pointer value stored in the exported variable; the + // library is kept alive for the whole process lifetime by the `static` + // above, so the returned pointer stays valid. Callers cast it to the + // correct GL signature. #[allow(unsafe_code)] unsafe { - lib.get::<*const c_void>(name.as_bytes()) - .map_or(std::ptr::null(), |sym| *sym) + lib.get::<*const *const c_void>(symbol.as_bytes()) + .map_or(std::ptr::null(), |sym| **sym) } } diff --git a/crates/ghostty-apprt-gtk/src/lib.rs b/crates/ghostty-apprt-gtk/src/lib.rs index 50fdabd..cb094c0 100644 --- a/crates/ghostty-apprt-gtk/src/lib.rs +++ b/crates/ghostty-apprt-gtk/src/lib.rs @@ -5,7 +5,6 @@ pub mod adw_version; pub mod app; pub mod cgroup; pub mod class; -pub mod core; pub mod css; pub mod ext; pub mod flatpak; diff --git a/crates/ghostty-apprt/Cargo.toml b/crates/ghostty-apprt/Cargo.toml index 88c75c1..d61fc5c 100644 --- a/crates/ghostty-apprt/Cargo.toml +++ b/crates/ghostty-apprt/Cargo.toml @@ -9,6 +9,8 @@ ghostty-vt.workspace = true ghostty-input.workspace = true ghostty-config.workspace = true ghostty-datastruct.workspace = true +ghostty-terminal.workspace = true +ghostty-renderer.workspace = true thiserror = "2.0.21" [lints] diff --git a/crates/ghostty-apprt/src/lib.rs b/crates/ghostty-apprt/src/lib.rs index 6da24b2..b7097c1 100644 --- a/crates/ghostty-apprt/src/lib.rs +++ b/crates/ghostty-apprt/src/lib.rs @@ -7,6 +7,7 @@ pub mod action; pub mod ipc; pub mod runtime; +pub mod runtime_api; pub mod structs; pub mod surface; diff --git a/crates/ghostty-apprt-gtk/src/core.rs b/crates/ghostty-apprt/src/runtime_api.rs similarity index 91% rename from crates/ghostty-apprt-gtk/src/core.rs rename to crates/ghostty-apprt/src/runtime_api.rs index 56f8ee6..6475113 100644 --- a/crates/ghostty-apprt-gtk/src/core.rs +++ b/crates/ghostty-apprt/src/runtime_api.rs @@ -1,4 +1,4 @@ -//! The trait boundary between this app runtime and `ghostty-core`. +//! The trait boundary between app runtimes (apprt-gtk) and `ghostty-core`. //! //! In Zig, `apprt/gtk` calls directly into `CoreApp` (App.zig) and //! `CoreSurface` (Surface.zig). `ghostty-core` isn't ported yet (and it can't @@ -18,8 +18,8 @@ use std::rc::Rc; use std::sync::Arc; -use ghostty_apprt::action::{Action, Target}; -use ghostty_apprt::structs::{ +use crate::action::{Action, Target}; +use crate::structs::{ Clipboard, ClipboardRequest, ColorScheme, ContentScale, CursorPos, ImePos, SurfaceSize, }; use ghostty_config::config::Config; @@ -273,8 +273,27 @@ pub trait RtSurface { /// cleanups). fn default_termio_env(&self) -> Vec<(String, String)>; fn redraw_inspector(&self) {} + /// Resolve an OpenGL function pointer for the surface's GL context + /// (GTK: dlsym on libepoxy). Called with the context current. Null if + /// the symbol is unknown. + fn gl_proc_address(&self, name: &str) -> *const std::ffi::c_void { + let _ = name; + std::ptr::null() + } + /// Callback to create a transient systemd scope for the child process + /// (Zig: `apprt/gtk/cgroup.zig` + `post_fork`). Called on the main + /// thread when the surface is created; the returned closure is called + /// from the IO thread after fork. `None` disables cgroup isolation. + fn create_scope_fn(&self) -> Option { + None + } } +/// `fn(pid, memory_high, tasks_max)`: `None` if there is no D-Bus +/// connection, `Some(Err(msg))` on failure. +pub type CreateScopeFn = + Arc, Option) -> Option> + Send + Sync>; + /// Thread-safe handle to wake the app thread (`App.wakeup`). Core's threads /// call [`Waker::wake`]; the GTK main loop then runs `CoreApp::tick`. pub trait Waker: Send + Sync { diff --git a/crates/ghostty-apprt/src/surface.rs b/crates/ghostty-apprt/src/surface.rs index b743518..2da199a 100644 --- a/crates/ghostty-apprt/src/surface.rs +++ b/crates/ghostty-apprt/src/surface.rs @@ -21,28 +21,10 @@ pub const NOTIFICATION_TITLE_LEN: usize = 64; /// `[255:0]u8` => 256 bytes including the sentinel. pub const NOTIFICATION_BODY_LEN: usize = 256; -// TODO(integration): `terminal.Scrollbar` lives in ghostty-terminal -// (page_list::Scrollbar); apprt doesn't depend on it. Local stand-in with -// identical fields. -/// Scrollbar geometry in rows. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct Scrollbar { - /// Total scrollable rows. - pub total: usize, - /// Viewport's offset from the oldest row. - pub offset: usize, - /// Visible row count. - pub len: usize, -} - -// TODO(integration): `renderer.Health` lives in ghostty-renderer -// (backend::Health); apprt doesn't depend on it. -/// The health status of a renderer. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RendererHealth { - Healthy, - Unhealthy, -} +/// The health status of a renderer (`renderer.Health`). +pub use ghostty_renderer::backend::Health as RendererHealth; +/// Scrollbar geometry in rows (`terminal.Scrollbar`). +pub use ghostty_terminal::page_list::Scrollbar; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReportTitleStyle { diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index 155b445..b989660 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -6,7 +6,9 @@ license.workspace = true [dependencies] flate2 = "1.1.10" +ghostty-apprt.workspace = true ghostty-config.workspace = true +ghostty-font.workspace = true ghostty-input.workspace = true ghostty-os.workspace = true log = "0.4.34" diff --git a/crates/ghostty-cli/src/list_fonts.rs b/crates/ghostty-cli/src/list_fonts.rs index a68fefc..a82cedd 100644 --- a/crates/ghostty-cli/src/list_fonts.rs +++ b/crates/ghostty-cli/src/list_fonts.rs @@ -1,7 +1,4 @@ //! `+list-fonts` (ZIG/src/cli/list_fonts.zig). -//! -//! DEFERRED: needs the font crate (font discovery). Only the options and -//! help are ported; running the action reports that it is not available. use ghostty_config::config_struct; @@ -52,6 +49,73 @@ pub fn run(args: &[String]) -> Result { let mut opts = Options::default(); crate::parse_options(&mut opts, args)?; - // TODO(integration): port the discovery/listing once ghostty-font lands. - Err(Error::NotYetAvailable("list-fonts")) + use std::collections::HashMap; + use std::io::Write; + + // We'll be putting our fonts into a list categorized by family + // so it is easier to read the output. + let mut families: Vec = Vec::new(); + let mut map: HashMap> = HashMap::new(); + + // Look up all available fonts + let disco = ghostty_font::Discover::init(); + let desc = ghostty_font::Descriptor { + monospace: opts.family.is_none(), + family: opts.family.clone(), + style: opts.style.clone(), + bold: opts.bold, + italic: opts.italic, + ..ghostty_font::Descriptor::default() + }; + let mut it = disco + .discover(&desc) + .map_err(|e| Error::Other(e.to_string()))?; + while let Some(face) = it.next().map_err(|e| Error::Other(e.to_string()))? { + let family = match face.family_name() { + Ok(v) => v, + Err(err) => { + log::error!("failed to get font family name: {err}"); + continue; + } + }; + let full_name = match face.name() { + Ok(v) => v, + Err(err) => { + log::error!("failed to get font name: {err}"); + continue; + } + }; + let list = map.entry(family.clone()).or_insert_with(|| { + families.push(family); + Vec::new() + }); + list.push(full_name); + } + + // Sort our keys. + if opts.family.is_none() { + families.sort_unstable(); + } + + // Output each + let mut stdout = std::io::BufWriter::new(std::io::stdout().lock()); + for family in &families { + let Some(list) = map.get_mut(family) else { + continue; + }; + if list.is_empty() { + continue; + } + if opts.family.is_none() { + list.sort_unstable(); + } + + writeln!(stdout, "{family}")?; + for item in list.iter() { + writeln!(stdout, " {item}")?; + } + writeln!(stdout)?; + } + stdout.flush()?; + Ok(0) } diff --git a/crates/ghostty-cli/src/new_window.rs b/crates/ghostty-cli/src/new_window.rs index ef1d3ff..35e250d 100644 --- a/crates/ghostty-cli/src/new_window.rs +++ b/crates/ghostty-cli/src/new_window.rs @@ -1,13 +1,31 @@ //! `+new-window` (ZIG/src/cli/new_window.zig). //! -//! DEFERRED: sending the IPC needs the GTK app (`apprt.App.performIpc`). -//! The option parsing (including the manual `+new-window` argument gathering -//! hook) is ported; running the action reports that it is not available. +//! Sending the IPC needs the app runtime (`apprt.App.performIpc`); the root +//! binary registers it with [`set_ipc_handler`] so this crate does not +//! depend on GTK. +use std::sync::OnceLock; + +use ghostty_apprt::ipc; use ghostty_config::cli_args::{ArgError, ArgIter, CliTarget}; use crate::error::Error; +/// The runtime's IPC entrypoint (`apprt.App.performIpc`). `Ok(true)` means +/// the action was performed; `Err` carries an error message (use +/// [`IPC_FAILED`] when the runtime already printed a specific message). +pub type IpcHandler = fn(&ipc::Target, &ipc::Action) -> Result; + +/// Error string for `error.IPCFailed`: the runtime already reported it. +pub const IPC_FAILED: &str = "IPCFailed"; + +static IPC_HANDLER: OnceLock = OnceLock::new(); + +/// Register the runtime IPC handler. Only the first call has an effect. +pub fn set_ipc_handler(f: IpcHandler) { + let _ = IPC_HANDLER.set(f); +} + /// Options of `+new-window`. #[derive(Debug, Clone, Default)] pub struct Options { @@ -178,10 +196,42 @@ pub fn run(args: &[String]) -> Result { .insert(0, format!("--working-directory={}", wd.to_string_lossy())); } - // TODO(integration): send the `new_window` IPC (class: `opts.class` or - // auto-detected, arguments: `opts.arguments`) via the GTK apprt's - // `performIpc`. - Err(Error::NotYetAvailable("new-window")) + let target = match opts.class { + Some(class) => ipc::Target::Class(class), + None => ipc::Target::Detect, + }; + let action = ipc::Action::NewWindow(ipc::NewWindow { + arguments: if opts.arguments.is_empty() { + None + } else { + Some(opts.arguments) + }, + }); + + // Without a registered runtime this behaves like `apprt.none`, whose + // performIpc always returns false. + let performed = match IPC_HANDLER.get() { + Some(handler) => match handler(&target, &action) { + Ok(v) => v, + Err(e) if e == IPC_FAILED => { + // The apprt should have printed a more specific error message + // already. + return Ok(1); + } + Err(e) => { + eprint!("Sending the IPC failed: {e}"); + return Ok(1); + } + }, + None => false, + }; + if performed { + return Ok(0); + } + + // If we get here, the platform is not supported. + eprintln!("+new-window is not supported on this platform."); + Ok(1) } #[cfg(test)] @@ -236,4 +286,18 @@ mod tests { let o = parse_str("--help"); assert_eq!(vec!["--help"], o.arguments); } + + #[test] + fn ipc_handler_registration_is_once() { + #[allow(clippy::unnecessary_wraps)] + fn h(_: &ipc::Target, _: &ipc::Action) -> Result { + Ok(true) + } + set_ipc_handler(h); + let r = IPC_HANDLER.get().unwrap()( + &ipc::Target::Detect, + &ipc::Action::NewWindow(ipc::NewWindow { arguments: None }), + ); + assert_eq!(r, Ok(true)); + } } diff --git a/crates/ghostty-cli/src/show_face.rs b/crates/ghostty-cli/src/show_face.rs index f6afa0d..05e2b34 100644 --- a/crates/ghostty-cli/src/show_face.rs +++ b/crates/ghostty-cli/src/show_face.rs @@ -1,10 +1,13 @@ //! `+show-face` (ZIG/src/cli/show_face.zig). -//! -//! DEFERRED: needs the font crate (collection / codepoint resolver). Only the -//! options and help are ported; running the action reports that it is not -//! available. +use ghostty_config::config::Config; +use ghostty_config::diagnostics::Location; use ghostty_config::{config_enum, config_struct, diagnostics::DiagnosticList}; +use ghostty_font::collection::EntryError; +use ghostty_font::collection::GetFaceError; +use ghostty_font::face::DesiredSize; +use ghostty_font::shared_grid_set::DerivedConfig; +use ghostty_font::{self as font, SharedGrid, SharedGridSet}; use crate::error::Error; @@ -73,6 +76,147 @@ pub fn run(args: &[String]) -> Result { let mut opts = Options::default(); crate::parse_options(&mut opts, args)?; - // TODO(integration): port the face lookup once ghostty-font lands. - Err(Error::NotYetAvailable("show-face")) + run_args(&opts, &mut std::io::stdout(), &mut std::io::stdout()) +} + +fn run_args( + opts: &Options, + stdout: &mut dyn std::io::Write, + stderr: &mut dyn std::io::Write, +) -> Result { + // Print out any diagnostics, unless it's likely that the diagnostic was + // generated trying to parse a "normal" configuration setting. Exit with an + // error code if any diagnostics were printed. + const FIELDS: [&str; 4] = ["cp", "string", "style", "presentation"]; + let mut exit = false; + for d in opts.diagnostics.items() { + if !matches!(d.location, Location::Cli(_)) { + continue; + } + if FIELDS.contains(&d.key.as_str()) { + writeln!(stderr, "config error: {d}")?; + exit = true; + } + } + if exit { + return Ok(1); + } + + if opts.cp.is_none() && opts.string.is_none() { + writeln!( + stderr, + "You must specify a codepoint with --cp or a string with --string" + )?; + return Ok(1); + } + + let config = match Config::load() { + Ok(c) => c, + Err(err) => { + write!(stderr, "Unable to load config: {err}")?; + return Ok(1); + } + }; + + // Print out any diagnostics generated from parsing the config, unless + // the diagnostic might have been generated because it's actually an + // action-specific argument. + for d in config.diagnostics.items() { + if FIELDS.contains(&d.key.as_str()) + && matches!(d.location, Location::None | Location::Cli(_)) + { + continue; + } + writeln!(stderr, "config error: {d}")?; + } + + let font_grid_set = match SharedGridSet::new() { + Ok(v) => v, + Err(err) => { + write!(stderr, "Unable to initialize font grid set: {err}")?; + return Ok(1); + } + }; + + let font_size = DesiredSize { + points: config.font_size, + xdpi: 96, + ydpi: 96, + }; + let font_config = DerivedConfig::new(&config); + let (key, grid) = match font_grid_set.ref_(&font_config, font_size) { + Ok(v) => v, + Err(err) => { + write!(stderr, "Unable to get font grid: {err}")?; + return Ok(1); + } + }; + + let style = match opts.style { + Style::Regular => font::Style::Regular, + Style::Bold => font::Style::Bold, + Style::Italic => font::Style::Italic, + Style::BoldItalic => font::Style::BoldItalic, + }; + let presentation = opts.presentation.map(|p| match p { + Presentation::Text => font::Presentation::Text, + Presentation::Emoji => font::Presentation::Emoji, + }); + + let mut rc = None; + if let Some(cp) = opts.cp { + rc = lookup(stdout, stderr, &grid, style, presentation, cp)?; + } + if rc.is_none() + && let Some(string) = &opts.string + { + for ch in string.chars() { + rc = lookup(stdout, stderr, &grid, style, presentation, ch as u32)?; + if rc.is_some() { + break; + } + } + } + + drop(grid); + font_grid_set.deref(&key); + Ok(rc.unwrap_or(0)) +} + +fn lookup( + stdout: &mut dyn std::io::Write, + stderr: &mut dyn std::io::Write, + grid: &SharedGrid, + style: font::Style, + presentation: Option, + cp: u32, +) -> Result, Error> { + let ch = char::from_u32(cp).unwrap_or('\u{FFFD}'); + let Ok(Some(idx)) = grid.get_index(cp, style, presentation) else { + writeln!(stdout, "U+{cp:02X} \u{ab} {ch} \u{bb} not found.")?; + return Ok(None); + }; + + let mut st = grid.write(); + let face = match st.resolver.collection.get_face(idx) { + Ok(f) => f, + Err(GetFaceError::Entry(EntryError::SpecialHasNoFace)) => { + writeln!( + stdout, + "U+{cp:02X} \u{ab} {ch} \u{bb} is handled by Ghostty's internal sprites." + )?; + return Ok(None); + } + Err(err) => { + write!(stderr, "Unable to get face: {err}")?; + return Ok(Some(1)); + } + }; + + let name = face.name(); + writeln!( + stdout, + "U+{cp:02X} \u{ab} {ch} \u{bb} found in face \u{201c}{name}\u{201d}." + )?; + Ok(None) } diff --git a/crates/ghostty-core/Cargo.toml b/crates/ghostty-core/Cargo.toml index 8b506af..5f9b91c 100644 --- a/crates/ghostty-core/Cargo.toml +++ b/crates/ghostty-core/Cargo.toml @@ -5,14 +5,23 @@ edition.workspace = true license.workspace = true [dependencies] +base64 = "0.23.1" +crossbeam-channel = "0.5.17" +ghostty-apprt.workspace = true ghostty-config.workspace = true -ghostty-terminal.workspace = true -ghostty-input.workspace = true -ghostty-font.workspace = true -ghostty-renderer.workspace = true -ghostty-termio.workspace = true -ghostty-os.workspace = true ghostty-datastruct.workspace = true +ghostty-font.workspace = true +ghostty-input.workspace = true +ghostty-os.workspace = true +ghostty-renderer.workspace = true +ghostty-terminal.workspace = true +ghostty-termio.workspace = true +ghostty-unicode.workspace = true +ghostty-vt.workspace = true +log = { version = "0.4", features = ["std"] } +onig = "6.5.3" +png = "0.18.1" +thiserror = "2" [lints] workspace = true diff --git a/crates/ghostty-core/src/app.rs b/crates/ghostty-core/src/app.rs new file mode 100644 index 0000000..381e4a8 --- /dev/null +++ b/crates/ghostty-core/src/app.rs @@ -0,0 +1,634 @@ +//! App is the primary GUI application for ghostty (port of `App.zig`). +//! +//! The Zig `App` is a heap object shared by the runtime and every surface. +//! In Rust the runtime holds an [`App`] (it implements +//! [`ghostty_apprt::runtime_api::CoreApp`]) and surfaces hold a shared +//! [`AppState`]. Everything here is main-thread only except the +//! [`Mailbox`], which is how other threads (renderer, IO, search) talk to +//! the app thread. +//! +//! Surfaces are identified by the [`SurfaceId`] the runtime allocates; the +//! app keeps weak references to them (the runtime owns the strong +//! `Rc`). + +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; +use std::sync::Arc; +use std::time::Instant; + +use crossbeam_channel::{Receiver, Sender, TrySendError}; +use ghostty_apprt::action::{Action as RtAction, QuitTimer, ReloadConfig, Target}; +use ghostty_apprt::runtime_api::{ + AppMessage, CellSize as RtCellSize, CoreApp, CoreError, CoreSurface, DesiredFontSize, RtApp, + RtSurface, SurfaceId, Waker, +}; +use ghostty_apprt::structs::ColorScheme; +use ghostty_apprt::surface::{AppMailbox, Message as SurfaceMessage}; +use ghostty_config::conditional; +use ghostty_config::config::Config; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_font::SharedGridSet; +use ghostty_input::KeyEvent; +use ghostty_input::binding::set::Value as BindingValue; +use ghostty_input::binding::{Action as BindingAction, Scope}; + +use crate::surface::Surface; + +/// Capacity of the app mailbox (Zig: `BlockingQueue(Message, 64)`). +const MAILBOX_CAPACITY: usize = 64; + +/// The message types that can be sent to the app thread. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum Message { + /// Open the configuration file. + OpenConfig, + + /// Create a new terminal window. + NewWindow { + /// The parent surface. + parent: Option, + }, + + /// Close a surface. This notifies the runtime that a surface should + /// close. + Close(SurfaceId), + + /// Quit. + Quit, + + /// A message for a specific surface. + SurfaceMessage { + surface: SurfaceId, + message: SurfaceMessage, + }, + + /// Redraw a surface. This only has an effect for runtimes that use + /// single-threaded draws. To redraw a surface for all runtimes, wake up + /// the renderer thread. The renderer thread will send this message if + /// it needs to. + RedrawSurface(SurfaceId), +} + +/// The way that other threads send the app thread messages (Zig: +/// `App.Mailbox`). Cheap to clone; every push wakes the runtime. +#[derive(Clone)] +pub struct Mailbox { + tx: Sender, + waker: Arc, +} + +impl std::fmt::Debug for Mailbox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Mailbox").finish_non_exhaustive() + } +} + +impl Mailbox { + /// Send a message to the app. Returns the queue size after the push; + /// zero means the push failed (full queue + instant/expired timeout). + pub fn push(&self, msg: Message, timeout: Timeout) -> usize { + let ok = match timeout { + Timeout::Instant => match self.tx.try_send(msg) { + Ok(()) => true, + Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => false, + }, + Timeout::Forever => self.tx.send(msg).is_ok(), + Timeout::Ns(ns) => self + .tx + .send_timeout(msg, std::time::Duration::from_nanos(ns)) + .is_ok(), + }; + if !ok { + return 0; + } + + // Wake up our app loop + self.waker.wake(); + self.tx.len().max(1) + } +} + +impl AppMailbox for Mailbox { + fn push_surface_message( + &self, + surface: SurfaceId, + message: SurfaceMessage, + timeout: Timeout, + ) -> usize { + self.push(Message::SurfaceMessage { surface, message }, timeout) + } +} + +/// State shared between the [`App`] and its surfaces (main thread only). +pub struct AppState { + /// The list of surfaces that are currently active, in creation order. + surfaces: RefCell)>>, + + /// This is true if the app that Ghostty is in is focused. This may mean + /// that no surfaces (terminals) are focused but the app is still + /// focused, i.e. maybe an about window. + focused: Cell, + + /// The last focused surface. This surface may not be valid; you must + /// always validate it with [`AppState::focused_surface`]. + focused_surface: Cell>, + + /// The mailbox other threads use to send this thread messages. + pub mailbox: Mailbox, + mailbox_rx: Receiver, + + /// The set of font grids shared by surfaces with the same font + /// configuration. + pub font_grid_set: Arc, + + /// Used to rate limit desktop notifications. + pub last_notification_time: Cell>, + pub last_notification_digest: Cell, + + /// The conditional state of the configuration. This applies to the + /// app-level config and as a default for new surfaces. + pub config_conditional_state: Cell, + + /// The runtime app, remembered from the first surface so the quit timer + /// can be started when the last surface goes away. + rt_app: RefCell>>, + + /// Set to false once we've created at least one surface. This never + /// goes true again. Surfaces use it to determine if they are the first. + pub first: Cell, +} + +impl AppState { + /// Ids and strong references of all live surfaces. + pub fn surfaces(&self) -> Vec> { + self.surfaces + .borrow() + .iter() + .filter_map(|(_, w)| w.upgrade()) + .collect() + } + + /// Look up a live surface. + pub fn surface(&self, id: SurfaceId) -> Option> { + self.surfaces + .borrow() + .iter() + .find(|(i, _)| *i == id) + .and_then(|(_, w)| w.upgrade()) + } + + /// Whether the surface is known to the app (Zig: `hasSurface`). + pub fn has_surface(&self, id: SurfaceId) -> bool { + self.surfaces.borrow().iter().any(|(i, _)| *i == id) + } + + /// The last focused surface (Zig: `focusedSurface`). Only valid on the + /// main thread. + pub fn focused_surface(&self) -> Option> { + self.surface(self.focused_surface.get()?) + } + + /// Mark a surface as the focused one (Zig: `focusSurface`). + pub fn focus_surface(&self, id: SurfaceId) { + if !self.has_surface(id) { + return; + } + self.focused_surface.set(Some(id)); + } + + /// Whether the whole app is focused. + pub fn focused(&self) -> bool { + self.focused.get() + } +} + +/// The core application. Implements [`CoreApp`] for the runtime. +pub struct App { + state: Rc, +} + +impl App { + /// Create a new app instance (Zig: `App.create`). `waker` is how + /// non-main threads request an app `tick`. + pub fn new(waker: Arc) -> Result { + let font_grid_set = Arc::new(SharedGridSet::new()?); + let (tx, rx) = crossbeam_channel::bounded(MAILBOX_CAPACITY); + Ok(App { + state: Rc::new(AppState { + surfaces: RefCell::new(Vec::new()), + focused: Cell::new(true), + focused_surface: Cell::new(None), + mailbox: Mailbox { tx, waker }, + mailbox_rx: rx, + font_grid_set, + last_notification_time: Cell::new(None), + last_notification_digest: Cell::new(0), + config_conditional_state: Cell::new(conditional::State::default()), + rt_app: RefCell::new(None), + first: Cell::new(true), + }), + }) + } + + /// The shared state surfaces use. + #[must_use] + pub fn state(&self) -> &Rc { + &self.state + } + + /// A clone of the mailbox for other threads. + #[must_use] + pub fn mailbox(&self) -> Mailbox { + self.state.mailbox.clone() + } + + /// Handle an app-level focus event. This should be called whenever the + /// focus state of the entire app containing Ghostty changes. + pub fn focus_event(&self, focused: bool) { + // Prevent redundant focus events + if self.state.focused.get() == focused { + return; + } + + log::debug!("focus event focused={focused}"); + self.state.focused.set(focused); + } + + /// Returns true if the given key event would trigger a keybinding if it + /// were to be processed (Zig: `keyEventIsBinding`). + #[must_use] + pub fn key_event_is_binding(&self, config: &Config, event: &KeyEvent<'_>) -> bool { + match event.action { + ghostty_input::Action::Release => return false, + ghostty_input::Action::Press | ghostty_input::Action::Repeat => {} + } + + // If we have a keybinding for this event then we return true. + config.keybind.set.get_event(event).is_some() + } + + /// Handle a key event at the app-scope. If this key event is used, this + /// will return true and the caller shouldn't continue processing the + /// event (Zig: `keyEvent`). + /// + /// If the app currently has focus then all key events are processed. If + /// the app does not have focus then only global key events are + /// processed. + pub fn key_event(&self, rt: &dyn RtApp, config: &Config, event: &KeyEvent<'_>) -> bool { + match event.action { + // We don't care about key release events. + ghostty_input::Action::Release => return false, + // Continue processing key press events. + ghostty_input::Action::Press | ghostty_input::Action::Repeat => {} + } + + // Get the keybind entry for this event. We don't support key + // sequences so we can look directly in the top-level set. + let Some((_, value)) = config.keybind.set.get_event(event) else { + return false; + }; + let (actions, flags) = match value { + // Sequences aren't supported. Our configuration parser verifies + // this for global keybinds but we may still get an entry for a + // non-global keybind. + BindingValue::Leader(_) => return false, + BindingValue::Leaf(leaf) => (vec![leaf.action.clone()], leaf.flags), + BindingValue::LeafChained(leaf) => (leaf.actions.clone(), leaf.flags), + }; + + // If we aren't focused, then we only process global keybinds. + if !self.state.focused.get() && !flags.global { + return false; + } + + // Global keybinds are done using performAll so that they can target + // all surfaces too. + if flags.global { + self.perform_all_chained_action(rt, &actions); + return true; + } + + // If we are focused, then we process keybinds only if they are + // app-scoped. Otherwise, we do nothing. Surface-scoped should be + // processed by Surface.keyEvent. For chained actions, all actions + // must be app-scoped. + if actions.iter().any(|a| a.scope() != Scope::App) { + return false; + } + for action in &actions { + if let Err(err) = self.perform_action(rt, action) { + log::warn!( + "error performing app keybind action action={} err={err}", + action.key().name() + ); + } + } + + true + } + + /// Perform a binding action. This only accepts actions that are scoped + /// to the app. Callers can use [`App::perform_all_action`] to perform + /// any action and any non-app-scoped actions will be performed on all + /// surfaces. + pub fn perform_action(&self, rt: &dyn RtApp, action: &BindingAction) -> Result<(), CoreError> { + match action { + BindingAction::Unbind => unreachable!("unbind is never performed"), + BindingAction::Ignore => {} + BindingAction::Quit => { + rt.perform_action(Target::App, RtAction::Quit); + } + BindingAction::NewWindow => self.new_window(rt, None), + BindingAction::OpenConfig => { + rt.perform_action(Target::App, RtAction::OpenConfig); + } + BindingAction::ReloadConfig => { + rt.perform_action(Target::App, RtAction::ReloadConfig(ReloadConfig::default())); + } + BindingAction::CloseAllWindows => { + rt.perform_action(Target::App, RtAction::CloseAllWindows); + } + BindingAction::ToggleQuickTerminal => { + rt.perform_action(Target::App, RtAction::ToggleQuickTerminal); + } + BindingAction::ToggleVisibility => { + rt.perform_action(Target::App, RtAction::ToggleVisibility); + } + BindingAction::CheckForUpdates => { + rt.perform_action(Target::App, RtAction::CheckForUpdates); + } + BindingAction::ShowGtkInspector => { + rt.perform_action(Target::App, RtAction::ShowGtkInspector); + } + BindingAction::Undo => { + rt.perform_action(Target::App, RtAction::Undo); + } + BindingAction::Redo => { + rt.perform_action(Target::App, RtAction::Redo); + } + other => { + // Not app-scoped; the caller is expected to have checked. + return Err(format!("not an app-scoped action: {}", other.key().name()).into()); + } + } + Ok(()) + } + + /// Performs a chained action. We will continue executing each action + /// even if there is a failure in a prior action. + pub fn perform_all_chained_action(&self, rt: &dyn RtApp, actions: &[BindingAction]) { + for action in actions { + if let Err(err) = self.perform_all_action(rt, action) { + log::warn!( + "error performing chained action action={} err={err}", + action.key().name() + ); + } + } + } + + /// Perform an app-wide binding action. If the action is surface-specific + /// then it will be performed on all surfaces. To perform only + /// app-scoped actions, use [`App::perform_action`]. + pub fn perform_all_action( + &self, + rt: &dyn RtApp, + action: &BindingAction, + ) -> Result<(), CoreError> { + match action.scope() { + // App-scoped actions are handled by the app so that they aren't + // repeated for each surface (since each surface forwards + // app-scoped actions back up). + Scope::App => self.perform_action(rt, action), + + // Surface-scoped actions are performed on all surfaces. Errors + // are logged but processing continues. + Scope::Surface => { + for surface in self.state.surfaces() { + if let Err(err) = surface.perform_binding_action(action) { + log::warn!( + "error performing binding action on surface id={} err={err}", + surface.id() + ); + } + } + Ok(()) + } + } + } + + /// Create a new window (Zig: `newWindow`). + pub fn new_window(&self, rt: &dyn RtApp, parent: Option) { + let target = match parent { + Some(p) if self.state.has_surface(p) => Target::Surface(p), + _ => Target::App, + }; + + rt.perform_action(target, RtAction::NewWindow); + } + + /// Drain the mailbox. + fn drain_mailbox(&self, rt: &dyn RtApp) { + while let Ok(message) = self.state.mailbox_rx.try_recv() { + match message { + Message::OpenConfig => { + let _ = self.perform_action(rt, &BindingAction::OpenConfig); + } + Message::NewWindow { parent } => self.new_window(rt, parent), + Message::Close(id) => self.close_surface(id), + Message::SurfaceMessage { surface, message } => { + self.surface_message(surface, message); + } + Message::RedrawSurface(id) => { + if self.state.has_surface(id) { + rt.perform_action(Target::Surface(id), RtAction::Render); + } + } + + // If we're quitting, then we set the quit flag and stop + // draining the mailbox immediately. This lets us defer + // mailbox processing to the next tick so that the apprt can + // try to quit as quickly as possible. + Message::Quit => { + log::info!("quit message received, short circuiting mailbox drain"); + let _ = self.perform_action(rt, &BindingAction::Quit); + return; + } + } + } + } + + /// Close a surface (Zig: `closeSurface`). + pub fn close_surface(&self, id: SurfaceId) { + if let Some(surface) = self.state.surface(id) { + surface.close(); + } + } + + /// Handle a surface message. Messages for surfaces that are gone are + /// dropped: the window probably quit before we handled the message. + fn surface_message(&self, id: SurfaceId, msg: SurfaceMessage) { + if let Some(surface) = self.state.surface(id) + && let Err(err) = surface.handle_message(msg) + { + log::warn!("error handling surface message id={id} err={err}"); + } + } + + /// Add an initialized surface. Since we have non-zero surfaces, the quit + /// timer is cancelled. + pub(crate) fn add_surface(&self, id: SurfaceId, surface: &Rc, rt: &Rc) { + *self.state.rt_app.borrow_mut() = Some(Rc::clone(rt)); + self.state + .surfaces + .borrow_mut() + .push((id, Rc::downgrade(surface))); + + // It is up to the apprt if there is a quit timer at all and if it + // should be canceled. + rt.perform_action(Target::App, RtAction::QuitTimer(QuitTimer::Stop)); + } +} + +impl CoreApp for App { + fn tick(&self, rt: &dyn RtApp) -> Result<(), CoreError> { + // Drain our mailbox + self.drain_mailbox(rt); + Ok(()) + } + + fn update_config(&self, rt: &dyn RtApp, config: &Arc) -> Result<(), CoreError> { + // Go through and update all of the surface configurations. + for surface in self.state.surfaces() { + surface.handle_message(SurfaceMessage::ChangeConfig(Arc::clone(config)))?; + } + + // Apply our conditional state. If we fail to apply the conditional + // state then we log and attempt to move forward with the old config. + // We only apply this to the app-level config because the surface + // config applies its own conditional state. + let applied = + match config.change_conditional_state(self.state.config_conditional_state.get()) { + Ok(Some(c)) => Arc::new(c), + Ok(None) => Arc::clone(config), + Err(err) => { + log::warn!("failed to apply conditional state to config err={err}"); + Arc::clone(config) + } + }; + + // Notify the apprt that the app has changed configuration. + rt.perform_action( + Target::App, + RtAction::ConfigChange(ghostty_apprt::action::ConfigChange { config: applied }), + ); + Ok(()) + } + + fn needs_confirm_quit(&self) -> bool { + self.state.surfaces().iter().any(|s| s.needs_confirm_quit()) + } + + fn color_scheme_event(&self, rt: &dyn RtApp, scheme: ColorScheme) { + let new_scheme = match scheme { + ColorScheme::Light => conditional::Theme::Light, + ColorScheme::Dark => conditional::Theme::Dark, + }; + + // If our scheme didn't change, then we don't do anything. + let mut state = self.state.config_conditional_state.get(); + if state.theme == new_scheme { + return; + } + + // Setup our conditional state which has the current color theme. + state.theme = new_scheme; + self.state.config_conditional_state.set(state); + + // Request our configuration be reloaded because the new scheme may + // impact the colors of the app. + rt.perform_action( + Target::App, + RtAction::ReloadConfig(ReloadConfig { soft: true }), + ); + } + + fn perform_all_action(&self, rt: &dyn RtApp, action: &BindingAction) -> Result<(), CoreError> { + App::perform_all_action(self, rt, action) + } + + fn push_message(&self, msg: AppMessage) { + let m = match msg { + AppMessage::OpenConfig => Message::OpenConfig, + AppMessage::NewWindow => Message::NewWindow { parent: None }, + AppMessage::PresentSurface(id) => Message::SurfaceMessage { + surface: id, + message: SurfaceMessage::PresentSurface, + }, + }; + self.state.mailbox.push(m, Timeout::Forever); + } + + fn new_surface( + &self, + id: SurfaceId, + config: Config, + rt_app: Rc, + rt_surface: Rc, + ) -> Result, CoreError> { + let surface = Surface::new( + id, + Rc::clone(&self.state), + Arc::new(config), + Rc::clone(&rt_app), + rt_surface, + )?; + self.add_surface(id, &surface, &rt_app); + Ok(surface) + } + + fn delete_surface(&self, id: SurfaceId) { + // If this surface is the focused surface then we need to clear it. + if self.state.focused_surface.get() == Some(id) { + self.state.focused_surface.set(None); + } + + let empty = { + let mut surfaces = self.state.surfaces.borrow_mut(); + surfaces.retain(|(i, _)| *i != id); + surfaces.is_empty() + }; + + // If we have no surfaces, we can start the quit timer. It is up to + // the apprt to determine if this is necessary. + if empty && let Some(rt) = self.state.rt_app.borrow().clone() { + rt.perform_action(Target::App, RtAction::QuitTimer(QuitTimer::Start)); + } + } + + fn surface_ids(&self) -> Vec { + self.state + .surfaces + .borrow() + .iter() + .map(|(i, _)| *i) + .collect() + } + + fn cell_size(&self, config: &Config, font_size: DesiredFontSize) -> Option { + let derived = ghostty_font::shared_grid_set::DerivedConfig::new(config); + let size = ghostty_font::face::DesiredSize { + points: font_size.points, + xdpi: font_size.xdpi, + ydpi: font_size.ydpi, + }; + let (key, grid) = self.state.font_grid_set.ref_(&derived, size).ok()?; + let cell = grid.cell_size(); + self.state.font_grid_set.deref(&key); + Some(RtCellSize { + width: cell.width, + height: cell.height, + }) + } +} diff --git a/crates/ghostty-core/src/global.rs b/crates/ghostty-core/src/global.rs new file mode 100644 index 0000000..71d24fc --- /dev/null +++ b/crates/ghostty-core/src/global.rs @@ -0,0 +1,230 @@ +//! Global process state (port of `global.zig`). +//! +//! The Zig `GlobalState` also owns the allocator and the parsed CLI action; +//! in Rust the allocator is the global one and CLI action dispatch happens in +//! the root binary before this is initialized, so what remains is: logging, +//! signal setup, rlimits and the resources directory. + +use std::io::Write as _; +use std::sync::OnceLock; + +use ghostty_os::file::Rlimit; +use ghostty_os::resourcesdir::ResourcesDir; + +/// Where logging should go (Zig: `GlobalState.Logging`, a packed struct +/// parsed from `GHOSTTY_LOG`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Logging { + /// Whether to log to stderr. + pub stderr: bool, +} + +impl Default for Logging { + fn default() -> Self { + Logging { stderr: true } + } +} + +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. + #[must_use] + pub fn parse(v: &str) -> Logging { + let mut out = Logging { stderr: false }; + for part in v.split(',') { + let part = part.trim(); + let (name, value) = match part.strip_prefix("no-") { + Some(n) => (n, false), + None => (part, true), + }; + match name { + "stderr" => out.stderr = value, + "macos" | "" => {} + _ => return Logging::default(), + } + } + out + } +} + +/// Maintains the Unix resource limits that we set for our process. This can +/// be used to restore the limits to their original values. +#[derive(Debug, Clone, Copy, Default)] +pub struct ResourceLimits { + pub nofile: Option, +} + +impl ResourceLimits { + #[must_use] + pub fn init() -> ResourceLimits { + ResourceLimits { + // Maximize the number of file descriptors we can have open + // because we can consume a lot of them if we make many terminals. + nofile: ghostty_os::file::fix_max_files(), + } + } + + /// Restore the original limits (the child does this after fork). + pub fn restore(&self) { + if let Some(lim) = self.nofile { + ghostty_os::file::restore_max_files(lim); + } + } +} + +/// The global state (initialized once by [`init`]). +#[derive(Debug)] +pub struct GlobalState { + pub logging: Logging, + pub rlimits: ResourceLimits, + + /// The app resources directory, equivalent to zig-out/share when we + /// build from source. Empty if we can't detect it. + pub resources_dir: ResourcesDir, +} + +static STATE: OnceLock = OnceLock::new(); + +/// Options for [`init`]. +#[derive(Debug, 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 global state. If [`init`] was never called, a default state (no +/// resources dir, no rlimit changes) is created. +pub fn state() -> &'static GlobalState { + STATE.get_or_init(|| GlobalState { + logging: Logging::default(), + rlimits: ResourceLimits::default(), + resources_dir: ResourcesDir::default(), + }) +} + +/// Initialize the global state: logging, rlimits, resources dir and i18n. +/// Call once, early (Zig: `GlobalState.init`). +pub fn init(opts: InitOptions) -> &'static GlobalState { + STATE.get_or_init(|| { + let mut logging = Logging::default(); + + // If we have an action executing, we disable logging by default + // since we write to stderr we don't want logs messing up our + // output. + if opts.action { + logging.stderr = false; + } + + // Env vars are useful for logging too because they are easy to set. + if let Ok(v) = std::env::var("GHOSTTY_LOG") { + logging = Logging::parse(&v); + } + + init_logger(logging); + + // SIGPIPE is ignored by the Rust runtime already (it sets SIG_IGN + // before main), which is what Zig's `initSignals` does. + + // As early as possible, initialize our resource limits. + let rlimits = ResourceLimits::init(); + + // 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() { + Ok(d) => d, + Err(err) => { + log::warn!("failed to find resources dir err={err}"); + ResourcesDir::default() + } + }; + + // Setup i18n + if let Some(v) = resources_dir.app() + && let Err(err) = ghostty_os::i18n::init(v) + { + log::warn!("failed to init i18n, translations will not be available err={err}"); + } + + GlobalState { + logging, + rlimits, + resources_dir, + } + }) +} + +/// The `log` backend, formatted like Zig's `logFn`: +/// `info(scope): message` on stderr. +struct Logger { + stderr: bool, + max: log::LevelFilter, +} + +impl log::Log for Logger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + self.stderr && metadata.level() <= self.max + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + + let level = match record.level() { + log::Level::Error => "error", + log::Level::Warn => "warning", + log::Level::Info => "info", + log::Level::Debug | log::Level::Trace => "debug", + }; + let target = record.target(); + let scope = target + .strip_prefix("ghostty_") + .unwrap_or(target) + .split("::") + .next() + .unwrap_or(target); + let _ = writeln!( + std::io::stderr().lock(), + "{level}({scope}): {}", + record.args() + ); + } + + fn flush(&self) {} +} + +fn init_logger(logging: Logging) { + // Our log level is always at least info in every build mode; debug + // builds (or RUST_LOG=debug) also show debug messages. + let max = match std::env::var("RUST_LOG").ok().as_deref() { + Some("trace") => log::LevelFilter::Trace, + Some("debug") => log::LevelFilter::Debug, + Some("warn") => log::LevelFilter::Warn, + Some("error") => log::LevelFilter::Error, + Some("info") => log::LevelFilter::Info, + _ if cfg!(debug_assertions) => log::LevelFilter::Debug, + _ => log::LevelFilter::Info, + }; + let logger = Box::new(Logger { + stderr: logging.stderr, + max, + }); + if log::set_boxed_logger(logger).is_ok() { + log::set_max_level(max); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logging_parse() { + assert!(Logging::parse("stderr").stderr); + assert!(!Logging::parse("no-stderr").stderr); + assert!(!Logging::parse("macos").stderr); + assert_eq!(Logging::parse("garbage"), Logging::default()); + } +} diff --git a/crates/ghostty-core/src/lib.rs b/crates/ghostty-core/src/lib.rs index f4a6c89..57de69e 100644 --- a/crates/ghostty-core/src/lib.rs +++ b/crates/ghostty-core/src/lib.rs @@ -1 +1,33 @@ -//! ghostty-core: see docs/rewrite/ARCHITECTURE.md for the module map. +//! ghostty-core: the terminal application core (`App.zig`, `Surface.zig`, +//! `surface_mouse.zig`, `global.zig`). See `docs/rewrite/notes/ghostty-core.md`. +//! +//! The core is runtime-agnostic: it talks to the app runtime (GTK) through +//! the traits in `ghostty_apprt::runtime_api` and implements +//! [`CoreApp`](ghostty_apprt::runtime_api::CoreApp) / +//! [`CoreSurface`](ghostty_apprt::runtime_api::CoreSurface). + +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::assert_is_empty))] +// Faithful port of long Zig functions with many Result-returning callbacks. +#![allow( + clippy::nonminimal_bool, + clippy::question_mark, + clippy::unnecessary_wraps, + clippy::struct_field_names, + clippy::needless_pass_by_value, + clippy::needless_pass_by_ref_mut, + clippy::collapsible_if, + clippy::assigning_clones, + clippy::items_after_statements, + clippy::type_complexity, + clippy::too_many_arguments, + clippy::wildcard_imports, + clippy::manual_let_else +)] + +pub mod app; +pub mod global; +pub mod surface; +pub mod surface_mouse; + +pub use app::App; +pub use surface::Surface; diff --git a/crates/ghostty-core/src/surface/binding.rs b/crates/ghostty-core/src/surface/binding.rs new file mode 100644 index 0000000..751cdee --- /dev/null +++ b/crates/ghostty-core/src/surface/binding.rs @@ -0,0 +1,1327 @@ +//! `performBindingAction`, search, and screen file dumps (Zig `Surface.zig`: +//! `Search`, `searchCallback`, `dumpText`, `performBindingAction`, +//! `closingAction`, `writeScreenFile`). + +use std::io::Write as _; +use std::sync::Arc; +use std::thread::JoinHandle; + +use ghostty_apprt::action::{ + Action as RtAction, CloseTabMode as RtCloseTabMode, FloatWindow, Fullscreen, + GotoSplit as RtGotoSplit, GotoTab, GotoWindow as RtGotoWindow, Inspector as RtInspector, + KeyTable, MoveTab, OpenUrl, OpenUrlKind, PromptTitle, Readonly, ReloadConfig, + ResizeSplit as RtResizeSplit, ResizeSplitDirection, SecureInput, SetTitle, + SplitDirection as RtSplit, StartSearch, Target, +}; +use ghostty_apprt::runtime_api::{CoreError, RtApp, SurfaceId}; +use ghostty_apprt::structs::{Clipboard, ClipboardRequest}; +use ghostty_apprt::surface::Message as SurfaceMessage; +use ghostty_config::types_a::NonNativeFullscreen; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_input::binding::{ + Action, AdjustSelection, CloseTabMode, CrashThread, InspectorMode, NavigateSearch, Scope, + SplitDirection, SplitFocusDirection, SplitResizeDirection, WriteScreen, WriteScreenAction, + WriteScreenFormat, +}; +use ghostty_renderer::message::Message as RendererMessage; +use ghostty_renderer::state::SharedState; +use ghostty_renderer::thread::Handle as RendererHandle; +use ghostty_terminal::formatter::{self, Content, Format, ScreenFormatter}; +use ghostty_terminal::point::Tag; +use ghostty_terminal::screen::Scroll; +use ghostty_terminal::search::screen::Select; +use ghostty_terminal::search::thread::{ + Event, Message as SearchMessage, Options as SearchOptions, TerminalLock, TerminalRef, + Thread as SearchThread, +}; +use ghostty_terminal::selection::{Adjustment, Order, Selection}; +use ghostty_terminal::terminal::{ScrollViewport, Terminal}; +use ghostty_termio::message::Message as IoMessage; +use ghostty_termio::{SharedStateExt, TerminalGuard}; + +use super::Surface; +use crate::app::Mailbox as AppMailbox; + +/// The maximum number of key tables that can be active at any given time +/// (Zig: `max_active_key_tables`). Enforced by the keyboard module. +pub const MAX_ACTIVE_KEY_TABLES: usize = 8; + +// ---------------------------------------------------------------------- +// Search + +/// The search state for the surface (Zig: `Surface.Search`). +pub struct Search { + handle: ghostty_terminal::search::thread::Handle, + thread: Option>, +} + +/// Lets the search thread lock the terminal inside the shared render state. +struct StateLock(SharedState); + +impl TerminalLock for StateLock { + fn lock_terminal(&self) -> Box { + Box::new(self.0.lock_terminal()) + } +} + +// `TerminalGuard` derefs mutably to the terminal. +fn _assert_guard(g: TerminalGuard<'_>) -> impl TerminalRef + '_ { + g +} + +impl Search { + /// Start the search thread (nothing is searched until a needle is sent). + fn start(surface: &Surface) -> Result { + let renderer = surface.renderer_handle.clone(); + let mailbox = surface.surface_mailbox(); + let cb = Arc::new(move |event: Event| search_callback(&renderer, &mailbox, event)); + let (handle, thread) = SearchThread::spawn(SearchOptions { + terminal: Arc::new(StateLock(Arc::clone(&surface.renderer_state))), + event_cb: Some(cb), + })?; + Ok(Search { + handle, + thread: Some(thread), + }) + } + + /// Stop the search thread and free the state. + pub fn deinit(mut self) { + // Notify the thread to stop + self.handle.stop(); + + // Wait for the OS thread to quit + if let Some(t) = self.thread.take() + && t.join().is_err() + { + log::warn!("search thread panicked"); + } + } +} + +/// Events from the search thread (Zig: `searchCallback_`). +/// +/// IMPORTANT: this runs on the SEARCH THREAD! It must only touch things that +/// are safe from any thread. +fn search_callback( + renderer: &RendererHandle, + mailbox: &ghostty_apprt::surface::Mailbox, + event: Event, +) { + match event { + Event::ViewportMatches(matches) => { + renderer.send(RendererMessage::SearchViewportMatches(matches)); + } + + Event::SelectedMatch(selected) => { + if let Some(sel) = selected { + renderer.send(RendererMessage::SearchSelectedMatch(Some(sel.highlight))); + + // Send the selected index to the surface mailbox + mailbox.push( + SurfaceMessage::SearchSelected(Some(sel.idx)), + Timeout::Forever, + ); + } else { + // Reset our selected match + renderer.send(RendererMessage::SearchSelectedMatch(None)); + + // Reset the selected index + mailbox.push(SurfaceMessage::SearchSelected(None), Timeout::Forever); + } + } + + Event::TotalMatches(total) => { + mailbox.push(SurfaceMessage::SearchTotal(Some(total)), Timeout::Forever); + } + + // When we quit, tell our renderer to reset any search state. + Event::Quit => { + renderer.send(RendererMessage::SearchSelectedMatch(None)); + renderer.send(RendererMessage::SearchViewportMatches(Vec::new())); + + // Reset search totals in the surface + mailbox.push(SurfaceMessage::SearchTotal(None), Timeout::Forever); + mailbox.push(SurfaceMessage::SearchSelected(None), Timeout::Forever); + } + + // Unhandled, so far. + Event::Complete => {} + } +} + +// ---------------------------------------------------------------------- +// Text dumping + +/// Represents text read from the terminal and some metadata about it that is +/// often useful to apprts (Zig: `Surface.Text`). +#[derive(Debug, Clone, PartialEq)] +pub struct Text { + /// The text that was read from the terminal. + pub text: String, + + /// The viewport information about this text, if it is visible in the + /// viewport. + pub viewport: Option, +} + +/// See [`Text::viewport`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TextViewport { + /// The top-left corner of the selection in pixels within the viewport. + pub tl_px_x: f64, + pub tl_px_y: f64, + + /// The linear offset of the start of the selection and the length. This + /// is "linear" in the sense that it is the offset in the flattened + /// viewport as a single array of text. + /// + /// Note: these values are currently wrong if there is a partially + /// visible selection in the viewport (i.e. the top-left or bottom-right + /// of the selection is outside the viewport). The wrong values will + /// always be within the text bounds so we aren't risking an overflow. + pub offset_start: u32, + pub offset_len: u32, +} + +/// Pixel metrics needed to place a text selection within the viewport. +#[derive(Debug, Clone, Copy)] +pub struct ViewportMetrics { + pub cell_width: u32, + pub cell_height: u32, + pub cell_baseline: u32, + pub padding_left: u32, + pub padding_top: u32, + pub scale_x: f32, + pub scale_y: f32, +} + +/// The viewport placement of `sel` (the second half of Zig's +/// `dumpTextLocked`). +#[must_use] +pub fn text_viewport(t: &Terminal, sel: Selection, m: ViewportMetrics) -> Option { + let screen = t.screen(); + let pages = &screen.pages; + + // If our bottom right pin is before the viewport, then we can't + // possibly have this text be within the viewport. + let vp_tl_pin = pages.get_top_left(Tag::Viewport)?; + let br_pin = sel.bottom_right(pages); + if br_pin.before(pages, vp_tl_pin) { + return None; + } + + // If our top-left pin is after the viewport, then we can't possibly have + // this text be within the viewport. + let Some(vp_br_pin) = pages.get_bottom_right(Tag::Viewport) else { + log::warn!("viewport bottom-right pin not found, bug?"); + return None; + }; + let tl_pin = sel.top_left(pages); + if vp_br_pin.before(pages, tl_pin) { + return None; + } + + // We established that our top-left somewhere before the viewport + // bottom-right and that our bottom-right is somewhere after the + // top-left. This means that at least some portion of our selection is + // within the viewport. + + // Our top-left point. If it doesn't exist in the viewport it must be + // before and we can return (0,0). + let tl_coord = pages + .point_from_pin(Tag::Viewport, tl_pin) + .map_or((0u32, 0u32), |p| { + let c = p.coord(); + (u32::from(c.x), c.y) + }); + + // Our bottom-right point. If it doesn't exist in the viewport it must be + // the bottom-right of the viewport. + let br_coord = pages + .point_from_pin(Tag::Viewport, br_pin) + .or_else(|| pages.point_from_pin(Tag::Viewport, vp_br_pin)) + .map(|p| { + let c = p.coord(); + (u32::from(c.x), c.y) + })?; + + // Our sizes are all scaled so we need to send the unscaled values back. + let x = { + // Simple x * cell width gives the left + let mut x = f64::from(tl_coord.0 * m.cell_width); + // Add padding + x += f64::from(m.padding_left); + // Scale + x / f64::from(m.scale_x) + }; + let y = { + // Simple y * cell height gives the top + let mut y = f64::from(tl_coord.1 * m.cell_height); + // We want the text baseline + y += f64::from(m.cell_height); + y -= f64::from(m.cell_baseline); + // Add padding + y += f64::from(m.padding_top); + // Scale + y / f64::from(m.scale_y) + }; + + // Utilize viewport sizing to convert to offsets + let cols = u32::from(pages.cols); + let start = tl_coord.1 * cols + tl_coord.0; + let end = br_coord.1 * cols + br_coord.0; + + Some(TextViewport { + tl_px_x: x, + tl_px_y: y, + offset_start: start, + offset_len: end.saturating_sub(start), + }) +} + +// ---------------------------------------------------------------------- +// Screen files + +/// The portion of the screen to write for `writeScreenFile`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteScreenLoc { + /// Full screen + Screen, + /// History (scrollback) + History, + /// Selected text + Selection, +} + +impl WriteScreenLoc { + fn name(self) -> &'static str { + match self { + WriteScreenLoc::Screen => "screen", + WriteScreenLoc::History => "history", + WriteScreenLoc::Selection => "selection", + } + } +} + +/// The file name used for a screen dump. +#[must_use] +pub fn screen_file_name(loc: WriteScreenLoc, emit: WriteScreenFormat) -> String { + format!( + "{}.{}", + loc.name(), + match emit { + WriteScreenFormat::Plain | WriteScreenFormat::Vt => "txt", + WriteScreenFormat::Html => "html", + } + ) +} + +/// Format the requested portion of the active screen. Returns `None` when +/// there is no data (no selection, no history, ...). +pub fn screen_file_contents( + t: &Terminal, + loc: WriteScreenLoc, + emit: WriteScreenFormat, +) -> Result, CoreError> { + // We only dump history if we have history. We still keep the file and + // write the empty file to the pty so that this command always works on + // the primary screen. + let screen = t.screen(); + let pages = &screen.pages; + let sel = match loc { + WriteScreenLoc::History => { + // We do not support this for alternate screens because they + // don't have scrollback anyways. + if t.screens.active_key == ghostty_terminal::screen_set::Key::Alternate { + None + } else { + match ( + pages.get_top_left(Tag::History), + pages.get_bottom_right(Tag::History), + ) { + (Some(tl), Some(br)) => Some(Selection::new(tl, br, false)), + _ => None, + } + } + } + + WriteScreenLoc::Screen => match ( + pages.get_top_left(Tag::Screen), + pages.get_bottom_right(Tag::Screen), + ) { + (Some(tl), Some(br)) => Some(Selection::new(tl, br, false)), + _ => None, + }, + + WriteScreenLoc::Selection => screen.selection, + }; + + // If we have no selection we have no data so we do nothing. + let Some(sel) = sel else { + return Ok(None); + }; + + let mut opts = formatter::Options::new(match emit { + WriteScreenFormat::Plain => Format::Plain, + WriteScreenFormat::Vt => Format::Vt, + WriteScreenFormat::Html => Format::Html, + }); + opts.unwrap = true; + opts.trim = false; + opts.background = t.colors.background.get(); + opts.foreground = t.colors.foreground.get(); + opts.palette = Some(t.colors.palette.current); + let mut f = ScreenFormatter::new(screen, opts); + f.content = Content::Selection(Some(sel.ordered(pages, Order::Forward))); + Ok(Some( + f.format_string() + .map_err(|e| format!("error formatting screen: {e:?}"))?, + )) +} + +/// Write `contents` to `/.` (mode 0600) and return the +/// canonical path. The temporary directory is intentionally left behind so +/// the file stays around for the user (Zig does not delete it either). +pub fn write_screen_temp_file( + contents: &str, + loc: WriteScreenLoc, + emit: WriteScreenFormat, +) -> Result { + use std::os::unix::fs::OpenOptionsExt as _; + + // Create a temporary directory to store our scrollback. + let tmp_dir = ghostty_os::temp_dir::TempDir::init()?; + let filename = screen_file_name(loc, emit); + let file_path = tmp_dir.path().join(&filename); + + let result = (|| -> Result { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&file_path)?; + file.write_all(contents.as_bytes())?; + file.flush()?; + Ok(std::fs::canonicalize(&file_path)? + .to_string_lossy() + .into_owned()) + })(); + + match result { + Ok(path) => { + // Keep the directory (and thus the file) on disk. + std::mem::forget(tmp_dir); + Ok(path) + } + Err(err) => { + tmp_dir.deinit(); + Err(err) + } + } +} + +/// Returns true if performing the given action result in closing the +/// surface. This is used to determine if our self pointer is still valid +/// after performing some binding action. +#[must_use] +#[allow(dead_code)] +pub(crate) fn closing_action(action: &Action) -> bool { + matches!( + action, + Action::CloseSurface | Action::CloseWindow | Action::CloseTab(_) + ) +} + +/// The number of rows to scroll for `scroll_page_fractional`. +#[must_use] +pub fn fractional_scroll_delta(fraction: f32, rows: u16) -> isize { + (fraction * f32::from(rows)).trunc() as isize +} + +/// Clamp a font size delta like Zig (`@max(0, @min(255, delta))`). +#[must_use] +pub fn clamp_font_delta(delta: f32) -> f32 { + delta.clamp(0.0, 255.0) +} + +fn adjustment_for(a: AdjustSelection) -> Adjustment { + match a { + AdjustSelection::Left => Adjustment::Left, + AdjustSelection::Right => Adjustment::Right, + AdjustSelection::Up => Adjustment::Up, + AdjustSelection::Down => Adjustment::Down, + AdjustSelection::PageUp => Adjustment::PageUp, + AdjustSelection::PageDown => Adjustment::PageDown, + AdjustSelection::Home => Adjustment::Home, + AdjustSelection::End => Adjustment::End, + AdjustSelection::BeginningOfLine => Adjustment::BeginningOfLine, + AdjustSelection::EndOfLine => Adjustment::EndOfLine, + } +} + +/// Adjust the selection of the active screen and scroll the endpoint into +/// view. Returns false if there is no selection (the action isn't +/// performed). +pub fn adjust_selection(t: &mut Terminal, direction: AdjustSelection) -> bool { + let screen = t.screen_mut(); + let Some(mut sel) = screen.selection else { + // If we don't have a selection we do not perform this action, + // allowing the keybind to fall through to the terminal. + return false; + }; + sel.adjust(&mut screen.pages, adjustment_for(direction)); + screen.selection = Some(sel); + + // If the selection endpoint is outside of the current viewpoint, scroll + // it in to view. Note we always specifically use sel.end because that is + // what adjust modifies. + 'scroll: { + let viewport_tl = screen + .pages + .get_top_left(Tag::Viewport) + .expect("viewport top left"); + let Some(viewport_br) = screen.pages.get_bottom_right(Tag::Viewport) else { + break 'scroll; + }; + let end = sel.end(&screen.pages); + if end.is_between(&screen.pages, viewport_tl, viewport_br) { + break 'scroll; + } + + // Our end point is not within the viewport. If the end point is + // after the br then we need to adjust the end so that it is at the + // bottom right of the viewport. + let target = if end.before(&screen.pages, viewport_tl) { + end + } else { + let rows = usize::from(screen.pages.rows); + end.up(&screen.pages, rows - 1).unwrap_or(end) + }; + + screen.scroll(Scroll::Pin(target)); + } + + // Queue a render so its shown + screen.dirty.selection = true; + true +} + +/// Perform an app-scoped binding action (Zig: `App.performAction`). +fn perform_app_action(rt: &dyn RtApp, action: &Action) { + let a = match action { + Action::Ignore => return, + Action::Unbind => { + log::warn!("unbind action reached the app"); + return; + } + Action::Quit => RtAction::Quit, + Action::NewWindow => RtAction::NewWindow, + Action::OpenConfig => RtAction::OpenConfig, + Action::ReloadConfig => RtAction::ReloadConfig(ReloadConfig::default()), + Action::CloseAllWindows => RtAction::CloseAllWindows, + Action::ToggleQuickTerminal => RtAction::ToggleQuickTerminal, + Action::ToggleVisibility => RtAction::ToggleVisibility, + Action::CheckForUpdates => RtAction::CheckForUpdates, + Action::ShowGtkInspector => RtAction::ShowGtkInspector, + Action::Undo => RtAction::Undo, + Action::Redo => RtAction::Redo, + _ => return, + }; + rt.perform_action(Target::App, a); +} + +impl Surface { + /// Grab the value of text at the given selection point. Note that the + /// selection structure is used as a way to determine the area of the + /// screen to read from, it doesn't have to match the user's current + /// selection state (Zig: `dumpText`). + pub fn dump_text(&self, sel: Selection) -> Result { + let t = self.lock_terminal(); + self.dump_text_locked(&t, sel) + } + + /// Same as [`Surface::dump_text`] but assumes the renderer state mutex + /// is already held. + pub fn dump_text_locked(&self, t: &Terminal, sel: Selection) -> Result { + // Read out the text + let text = t + .screen() + .selection_string(ghostty_terminal::screen::SelectionString { sel, trim: false }); + + let scale = self.rt_surface.get_content_scale(); + let size = self.size.get(); + let baseline = self.font.borrow().metrics.cell_baseline; + let viewport = text_viewport( + t, + sel, + ViewportMetrics { + cell_width: size.cell.width, + cell_height: size.cell.height, + cell_baseline: baseline, + padding_left: size.padding.left, + padding_top: size.padding.top, + scale_x: scale.x, + scale_y: scale.y, + }, + ); + + 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( + &self, + loc: WriteScreenLoc, + write_screen: WriteScreen, + ) -> Result<(), CoreError> { + // Write the scrollback contents. This requires a lock. + let contents = { + let t = self.lock_terminal(); + screen_file_contents(&t, loc, write_screen.emit)? + }; + let Some(contents) = contents else { + return Ok(()); + }; + + let path = write_screen_temp_file(&contents, loc, write_screen.emit)?; + + match write_screen.action { + WriteScreenAction::Copy => { + self.rt_surface.set_clipboard( + Clipboard::Standard, + &[("text/plain".to_owned(), path)], + false, + ); + } + WriteScreenAction::Open => self.open_url_with_fallback(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(()) + } + + /// Update `mouse-reporting` on this surface's config (Zig mutates the + /// derived config in place). + fn toggle_mouse_reporting(&self) { + let mut config = (*self.cfg().config).clone(); + config.mouse_reporting = !config.mouse_reporting; + log::debug!("mouse reporting toggled: {}", config.mouse_reporting); + match super::config::DerivedConfig::new(Arc::new(config)) { + Ok(d) => *self.config.borrow_mut() = std::rc::Rc::new(d), + Err(err) => log::warn!("failed to toggle mouse reporting err={err}"), + } + } + + /// Adjust the font size and mark whether it was manually adjusted. + fn adjust_font_size( + &self, + f: impl FnOnce(f32, f32) -> f32, + adjusted: bool, + ) -> Result<(), CoreError> { + let mut size = self.font.borrow().size; + size.points = f(size.points, self.cfg().original_font_size); + self.set_font_size(size)?; + self.font.borrow_mut().adjusted = adjusted; + Ok(()) + } + + /// Perform a binding action. A binding is a keybinding. This function + /// must be called from the GUI thread. + /// + /// This function returns true if the binding action was performed. This + /// may return false if the binding action is not supported or if the + /// binding action would do nothing (i.e. previous tab with no tabs). + /// + /// NOTE: At the time of writing this comment, only previous/next tab + /// will ever return false. We can expand this in the future if it + /// becomes useful. We did previous/next tab so we could implement #498. + pub fn perform_binding_action(&self, action: &Action) -> Result { + // Forward app-scoped actions to the app. Some app-scoped actions are + // special-cased here because they do some special things when + // performed from the surface. + if action.scope() == Scope::App { + match action { + // The new window has this surface as its parent. + Action::NewWindow => { + self.rt_app + .perform_action(Target::Surface(self.id), RtAction::NewWindow); + } + + // Undo and redo both support both surface and app targeting. + // If we are triggering on a surface then we perform the + // action with the surface target. + Action::Undo => return Ok(self.perform(RtAction::Undo)), + Action::Redo => return Ok(self.perform(RtAction::Redo)), + + _ => perform_app_action(self.rt_app.as_ref(), action), + } + return Ok(true); + } + + match action { + Action::Csi(data) | Action::Esc(data) => { + // We need to send the CSI/ESC sequence as a single write + // request. If you split it across two then the shell can + // interpret it as two literals. + let full = if matches!(action, Action::Csi(_)) { + format!("\x1b[{data}") + } else { + format!("\x1b{data}") + }; + self.queue_io(IoMessage::write_req(full.as_bytes())); + + // CSI/ESC triggers a scroll. + self.scroll_to_bottom(&mut self.lock_terminal()); + } + + Action::Text(data) => { + let text = match ghostty_config::string::parse(data) { + Ok(t) => t, + Err(err) => { + log::warn!("error parsing text binding text={data} err={err:?}"); + return Ok(true); + } + }; + self.queue_io(IoMessage::write_req(text.as_bytes())); + + // Text triggers a scroll. + self.scroll_to_bottom(&mut self.lock_terminal()); + } + + Action::CursorKey(ck) => { + // We send a different sequence depending on if we're in + // cursor keys mode. We're in "normal" mode if cursor keys + // mode is NOT set. + let normal = { + let mut t = self.lock_terminal(); + + // With the lock held, we must scroll to the bottom. We + // always scroll to the bottom for these inputs. + self.scroll_to_bottom(&mut t); + + !t.modes.get(ghostty_vt::modes::Mode::CursorKeys) + }; + + if normal { + self.queue_io(IoMessage::write_req(ck.normal.as_bytes())); + } else { + self.queue_io(IoMessage::write_req(ck.application.as_bytes())); + } + } + + Action::Reset => { + self.lock_terminal().full_reset(); + } + + Action::StartSearch => { + // To save resources, we don't actually start a search here, + // we just notify the apprt. The real thread will start when + // the first needles are set. + return Ok(self.perform(RtAction::StartSearch(StartSearch { + needle: String::new(), + }))); + } + + Action::SearchSelection => { + let Some(selection) = self.selection_string() else { + return Ok(false); + }; + return Ok(self.perform(RtAction::StartSearch(StartSearch { needle: selection }))); + } + + Action::EndSearch => { + // We only return that this was performed if we actually + // stopped a search, but we also send the apprt end_search so + // that GUIs can clean up stale stuff. + let search = self.search.borrow_mut().take(); + let performed = search.is_some(); + if let Some(s) = search { + s.deinit(); + } + + self.perform(RtAction::EndSearch); + return Ok(performed); + } + + Action::Search(text) => { + if self.search.borrow().is_none() { + // If we're stopping the search and we had no prior + // search, then there is nothing to do. + if text.is_empty() { + return Ok(false); + } + + let s = Search::start(self)?; + *self.search.borrow_mut() = Some(s); + } + + // Zero-length text means stop searching. + if text.is_empty() { + let s = self.search.borrow_mut().take(); + if let Some(s) = s { + s.deinit(); + } + } else if let Some(s) = self.search.borrow().as_ref() { + s.handle + .send(SearchMessage::ChangeNeedle(text.as_bytes().to_vec())); + s.handle.wakeup(); + } + } + + Action::NavigateSearch(nav) => { + let search = self.search.borrow(); + let Some(s) = search.as_ref() else { + return Ok(false); + }; + s.handle.send(SearchMessage::Select(match nav { + NavigateSearch::Next => Select::Next, + NavigateSearch::Previous => Select::Prev, + })); + s.handle.wakeup(); + } + + Action::CopyToClipboard(format) => { + let cfg = self.cfg(); + let mut t = self.lock_terminal(); + + if let Some(sel) = t.screen().selection { + self.copy_selection_to_clipboards(&t, sel, &[Clipboard::Standard], *format); + + // Clear the selection if configured to do so. + if cfg.selection_clear_on_copy { + if let Err(err) = self.set_selection(&mut t, None) { + log::warn!("failed to clear selection after copy err={err}"); + } else { + self.queue_render(); + } + } + + return Ok(true); + } + + return Ok(false); + } + + Action::CopyUrlToClipboard => { + // If the mouse isn't over a link, nothing we can do. + let Some(url) = self.link_url_at_cursor() else { + return Ok(false); + }; + self.rt_surface.set_clipboard( + Clipboard::Standard, + &[("text/plain".to_owned(), url)], + false, + ); + return Ok(true); + } + + Action::CopyTitleToClipboard => { + return Ok(self.perform(RtAction::CopyTitleToClipboard)); + } + + Action::PasteFromClipboard => { + return Ok( + self.start_clipboard_request(Clipboard::Standard, ClipboardRequest::Paste) + ); + } + + Action::PasteFromSelection => { + return Ok( + self.start_clipboard_request(Clipboard::Selection, ClipboardRequest::Paste) + ); + } + + Action::IncreaseFontSize(delta) => { + // Max delta is somewhat arbitrary. + let clamped = clamp_font_delta(*delta); + log::debug!("increase font size={clamped}"); + + // Max point size is somewhat arbitrary. + self.adjust_font_size(|p, _| (p + clamped).min(255.0), true)?; + } + + Action::DecreaseFontSize(delta) => { + // Max delta is somewhat arbitrary. + let clamped = clamp_font_delta(*delta); + log::debug!("decrease font size={clamped}"); + + self.adjust_font_size(|p, _| (p - clamped).max(1.0), true)?; + } + + Action::ResetFontSize => { + log::debug!("reset font size"); + + // Reset font size also resets the manual adjustment state + self.adjust_font_size(|_, original| original, false)?; + } + + Action::SetFontSize(points) => { + log::debug!("set font size={points}"); + + let points = *points; + self.adjust_font_size(|_, _| points.clamp(1.0, 255.0), true)?; + } + + Action::PromptSurfaceTitle => { + return Ok(self.perform(RtAction::PromptTitle(PromptTitle::Surface))); + } + + Action::PromptTabTitle => { + return Ok(self.perform(RtAction::PromptTitle(PromptTitle::Tab))); + } + + Action::SetSurfaceTitle(v) => { + return Ok(self.perform(RtAction::SetTitle(SetTitle { title: v.clone() }))); + } + + Action::SetTabTitle(v) => { + return Ok(self.perform(RtAction::SetTabTitle(SetTitle { title: v.clone() }))); + } + + Action::ClearScreen => { + // This is a duplicate of some of the logic in + // termio.clearScreen but we need to do this here so we can + // know the answer before we send the message. If the + // currently active screen is on the alternate screen then + // clear screen does nothing so we want to return false so + // the keybind can be unconsumed. + if self.lock_terminal().screens.active_key + == ghostty_terminal::screen_set::Key::Alternate + { + return Ok(false); + } + + self.queue_io(IoMessage::ClearScreen { history: true }); + } + + Action::ScrollToTop => { + self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Top)); + } + + Action::ScrollToBottom => { + self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Bottom)); + } + + Action::ScrollToRow(n) => { + self.lock_terminal().screen_mut().scroll(Scroll::Row(*n)); + self.queue_render(); + } + + Action::ScrollToSelection => { + { + let mut t = self.lock_terminal(); + let screen = t.screen_mut(); + let Some(sel) = screen.selection else { + return Ok(false); + }; + let tl = sel.top_left(&screen.pages); + screen.scroll(Scroll::Pin(tl)); + } + + self.queue_render(); + } + + Action::ScrollPageUp => { + let rows = self.size.get().grid().rows as isize; + self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta(-rows))); + } + + Action::ScrollPageDown => { + let rows = self.size.get().grid().rows as isize; + self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta(rows))); + } + + Action::ScrollPageFractional(fraction) => { + let delta = fractional_scroll_delta(*fraction, self.size.get().grid().rows); + self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta(delta))); + } + + Action::ScrollPageLines(lines) => { + self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta( + isize::from(*lines), + ))); + } + + Action::JumpToPrompt(delta) => { + self.queue_io(IoMessage::JumpToPrompt(isize::from(*delta))); + } + + Action::WriteScreenFile(v) => self.write_screen_file(WriteScreenLoc::Screen, *v)?, + Action::WriteScrollbackFile(v) => { + self.write_screen_file(WriteScreenLoc::History, *v)?; + } + Action::WriteSelectionFile(v) => { + self.write_screen_file(WriteScreenLoc::Selection, *v)?; + } + + Action::NewTab => return Ok(self.perform(RtAction::NewTab)), + + Action::CloseTab(v) => { + return Ok(self.perform(RtAction::CloseTab(match v { + CloseTabMode::This => RtCloseTabMode::This, + CloseTabMode::Other => RtCloseTabMode::Other, + CloseTabMode::Right => RtCloseTabMode::Right, + }))); + } + + Action::PreviousTab => return Ok(self.perform(RtAction::GotoTab(GotoTab::Previous))), + Action::NextTab => return Ok(self.perform(RtAction::GotoTab(GotoTab::Next))), + Action::LastTab => return Ok(self.perform(RtAction::GotoTab(GotoTab::Last))), + Action::GotoTab(v) => { + return Ok(self.perform(RtAction::GotoTab(GotoTab::Index(*v as i32)))); + } + + Action::MoveTab(position) => { + return Ok(self.perform(RtAction::MoveTab(MoveTab { amount: *position }))); + } + + Action::NewSplit(direction) => { + let size = self.size.get(); + return Ok(self.perform(RtAction::NewSplit(match direction { + SplitDirection::Right => RtSplit::Right, + SplitDirection::Left => RtSplit::Left, + SplitDirection::Down => RtSplit::Down, + SplitDirection::Up => RtSplit::Up, + SplitDirection::Auto => { + if size.screen.width > size.screen.height { + RtSplit::Right + } else { + RtSplit::Down + } + } + }))); + } + + Action::GotoSplit(direction) => { + return Ok(self.perform(RtAction::GotoSplit(match direction { + SplitFocusDirection::Previous => RtGotoSplit::Previous, + SplitFocusDirection::Next => RtGotoSplit::Next, + SplitFocusDirection::Up => RtGotoSplit::Up, + SplitFocusDirection::Left => RtGotoSplit::Left, + SplitFocusDirection::Down => RtGotoSplit::Down, + SplitFocusDirection::Right => RtGotoSplit::Right, + }))); + } + + Action::GotoWindow(direction) => { + return Ok(self.perform(RtAction::GotoWindow(match direction { + ghostty_input::binding::GotoWindow::Previous => RtGotoWindow::Previous, + ghostty_input::binding::GotoWindow::Next => RtGotoWindow::Next, + }))); + } + + Action::ResizeSplit(value) => { + return Ok(self.perform(RtAction::ResizeSplit(RtResizeSplit { + amount: value.1, + direction: match value.0 { + SplitResizeDirection::Up => ResizeSplitDirection::Up, + SplitResizeDirection::Down => ResizeSplitDirection::Down, + SplitResizeDirection::Left => ResizeSplitDirection::Left, + SplitResizeDirection::Right => ResizeSplitDirection::Right, + }, + }))); + } + + Action::EqualizeSplits => return Ok(self.perform(RtAction::EqualizeSplits)), + Action::ToggleSplitZoom => return Ok(self.perform(RtAction::ToggleSplitZoom)), + + Action::ToggleReadonly => { + let ro = !self.readonly.get(); + self.readonly.set(ro); + self.perform(RtAction::Readonly(if ro { + Readonly::On + } else { + Readonly::Off + })); + return Ok(true); + } + + Action::ResetWindowSize => return Ok(self.perform(RtAction::ResetWindowSize)), + Action::ToggleMaximize => return Ok(self.perform(RtAction::ToggleMaximize)), + + Action::ToggleFullscreen => { + return Ok(self.perform(RtAction::ToggleFullscreen( + match self.cfg().macos_non_native_fullscreen { + NonNativeFullscreen::False => Fullscreen::Native, + NonNativeFullscreen::True => Fullscreen::MacosNonNative, + NonNativeFullscreen::VisibleMenu => Fullscreen::MacosNonNativeVisibleMenu, + NonNativeFullscreen::PaddedNotch => Fullscreen::MacosNonNativePaddedNotch, + }, + ))); + } + + Action::ToggleWindowDecorations => { + return Ok(self.perform(RtAction::ToggleWindowDecorations)); + } + Action::ToggleTabOverview => return Ok(self.perform(RtAction::ToggleTabOverview)), + Action::ToggleWindowFloatOnTop => { + return Ok(self.perform(RtAction::FloatWindow(FloatWindow::Toggle))); + } + Action::ToggleSecureInput => { + return Ok(self.perform(RtAction::SecureInput(SecureInput::Toggle))); + } + + Action::ToggleMouseReporting => self.toggle_mouse_reporting(), + + Action::ToggleCommandPalette => { + return Ok(self.perform(RtAction::ToggleCommandPalette)); + } + Action::ToggleBackgroundOpacity => { + return Ok(self.perform(RtAction::ToggleBackgroundOpacity)); + } + Action::ShowOnScreenKeyboard => { + return Ok(self.perform(RtAction::ShowOnScreenKeyboard)); + } + + Action::SelectAll => { + let mut t = self.lock_terminal(); + let sel = t.screen().select_all(); + if let Some(s) = sel { + self.set_selection(&mut t, Some(s))?; + self.queue_render(); + } + } + + Action::Inspector(mode) => { + return Ok(self.perform(RtAction::Inspector(match mode { + InspectorMode::Toggle => RtInspector::Toggle, + InspectorMode::Show => RtInspector::Show, + InspectorMode::Hide => RtInspector::Hide, + }))); + } + + Action::CloseSurface => self.close(), + + Action::CloseWindow => return Ok(self.perform(RtAction::CloseWindow)), + + Action::ActivateKeyTable(name) => return self.activate_key_table(name, false), + Action::ActivateKeyTableOnce(name) => return self.activate_key_table(name, true), + + Action::DeactivateKeyTable => return Ok(self.deactivate_key_table()), + + Action::DeactivateAllKeyTables => return Ok(self.deactivate_all_key_tables()), + + Action::EndKeySequence => { + // End the key sequence and flush queued keys to the + // terminal, but don't encode the key that triggered this + // action. This will do that because leaf keys (keys with + // bindings) aren't in the queued encoding list. + self.end_key_sequence( + super::keyboard::SequenceAction::Flush, + super::keyboard::SequenceFree::Retain, + ); + } + + Action::Crash(location) => match location { + CrashThread::Main => panic!("crash binding action, crashing intentionally"), + + CrashThread::Render => { + self.renderer_handle.send(RendererMessage::Crash); + self.queue_render(); + } + + CrashThread::Io => self.queue_io(IoMessage::Crash), + }, + + Action::AdjustSelection(direction) => { + let performed = adjust_selection(&mut self.lock_terminal(), *direction); + if !performed { + return Ok(false); + } + self.queue_render(); + } + + // App-scoped actions were handled above. + Action::Ignore + | Action::Unbind + | Action::NewWindow + | Action::OpenConfig + | Action::ReloadConfig + | Action::CloseAllWindows + | Action::ToggleQuickTerminal + | Action::ToggleVisibility + | Action::CheckForUpdates + | Action::ShowGtkInspector + | Action::Undo + | Action::Redo + | Action::Quit => unreachable!("app-scoped actions are handled above"), + } + + Ok(true) + } + + /// Notify the runtime that a key table was activated (used by the + /// keyboard module when it implements `activate_key_table`). + #[allow(dead_code)] + pub(crate) fn notify_key_table(&self, kt: KeyTable) { + self.perform(RtAction::KeyTable(kt)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ghostty_terminal::terminal::Options; + + fn term(cols: u16, rows: u16) -> Terminal { + Terminal::new(Options::new(cols, rows)).unwrap() + } + + #[test] + fn closing_action_table() { + assert!(closing_action(&Action::CloseSurface)); + assert!(closing_action(&Action::CloseWindow)); + assert!(closing_action(&Action::CloseTab(CloseTabMode::This))); + assert!(!closing_action(&Action::NewTab)); + assert!(!closing_action(&Action::Reset)); + } + + #[test] + fn scroll_and_font_math() { + assert_eq!(fractional_scroll_delta(0.5, 24), 12); + assert_eq!(fractional_scroll_delta(-0.5, 25), -12); + assert_eq!(fractional_scroll_delta(1.0, 10), 10); + assert!((clamp_font_delta(-3.0) - 0.0).abs() < f32::EPSILON); + assert!((clamp_font_delta(300.0) - 255.0).abs() < f32::EPSILON); + } + + #[test] + fn screen_file_names() { + assert_eq!( + screen_file_name(WriteScreenLoc::Screen, WriteScreenFormat::Plain), + "screen.txt" + ); + assert_eq!( + screen_file_name(WriteScreenLoc::History, WriteScreenFormat::Vt), + "history.txt" + ); + assert_eq!( + screen_file_name(WriteScreenLoc::Selection, WriteScreenFormat::Html), + "selection.html" + ); + } + + #[test] + fn screen_file_contents_and_write() { + let mut t = term(20, 3); + t.print_string("hello\r\nworld").unwrap(); + let s = screen_file_contents(&t, WriteScreenLoc::Screen, WriteScreenFormat::Plain) + .unwrap() + .unwrap(); + assert!(s.contains("hello")); + assert!(s.contains("world")); + + // No selection and no history: nothing to write. + assert!( + screen_file_contents(&t, WriteScreenLoc::Selection, WriteScreenFormat::Plain) + .unwrap() + .is_none() + ); + + let path = + write_screen_temp_file(&s, WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap(); + assert!(path.ends_with("screen.txt")); + let read = std::fs::read_to_string(&path).unwrap(); + assert_eq!(read, s); + let mode = std::os::unix::fs::PermissionsExt::mode( + &std::fs::metadata(&path).unwrap().permissions(), + ); + assert_eq!(mode & 0o777, 0o600); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); + } + + #[test] + fn adjust_selection_without_selection() { + let mut t = term(10, 3); + assert!(!adjust_selection(&mut t, AdjustSelection::Left)); + } + + #[test] + fn adjust_selection_moves_end() { + let mut t = term(10, 3); + t.print_string("abcdef").unwrap(); + let sel = { + let pages = &t.screen().pages; + let a = pages + .pin(ghostty_terminal::point::Point::Active( + ghostty_terminal::point::Coordinate { x: 0, y: 0 }, + )) + .unwrap(); + let b = pages + .pin(ghostty_terminal::point::Point::Active( + ghostty_terminal::point::Coordinate { x: 2, y: 0 }, + )) + .unwrap(); + Selection::new(a, b, false) + }; + t.screen_mut().select(Some(sel)).unwrap(); + assert!(adjust_selection(&mut t, AdjustSelection::Right)); + let s = t.screen(); + let end = s.selection.unwrap().end(&s.pages); + assert_eq!(end.x, 3); + assert!(s.dirty.selection); + } + + #[test] + fn text_viewport_placement() { + let mut t = term(10, 3); + t.print_string("abcdef").unwrap(); + let (a, b) = { + let pages = &t.screen().pages; + let p = |x| { + pages + .pin(ghostty_terminal::point::Point::Active( + ghostty_terminal::point::Coordinate { x, y: 0 }, + )) + .unwrap() + }; + (p(1), p(3)) + }; + let m = ViewportMetrics { + cell_width: 10, + cell_height: 20, + cell_baseline: 4, + padding_left: 2, + padding_top: 3, + scale_x: 2.0, + scale_y: 1.0, + }; + let v = text_viewport(&t, Selection::new(a, b, false), m).unwrap(); + assert!((v.tl_px_x - 6.0).abs() < 1e-9); // (10 + 2) / 2 + assert!((v.tl_px_y - 19.0).abs() < 1e-9); // 0 + 20 - 4 + 3 + assert_eq!(v.offset_start, 1); + assert_eq!(v.offset_len, 2); + } +} diff --git a/crates/ghostty-core/src/surface/clipboard.rs b/crates/ghostty-core/src/surface/clipboard.rs new file mode 100644 index 0000000..dca021f --- /dev/null +++ b/crates/ghostty-core/src/surface/clipboard.rs @@ -0,0 +1,401 @@ +//! Clipboard handling for a surface (Zig `Surface.zig`: `clipboardWrite`, +//! `copySelectionToClipboards`, clipboard requests, paste, OSC 52 and +//! desktop notifications). + +use std::hash::{Hash, Hasher}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use ghostty_apprt::action::{Action as RtAction, DesktopNotification}; +use ghostty_apprt::runtime_api::ClipboardError; +use ghostty_apprt::structs::{Clipboard, ClipboardRequest}; +use ghostty_config::types_b::ClipboardAccess; +use ghostty_input::binding::CopyToClipboard; +use ghostty_input::paste; +use ghostty_terminal::formatter::{self, Format, ScreenFormatter}; +use ghostty_terminal::selection::Selection; +use ghostty_terminal::terminal::Terminal; +use ghostty_termio::message::Message as IoMessage; +use ghostty_vt::modes::Mode; + +use super::Surface; +use super::config::DerivedConfig; + +/// `input.paste.Options.fromTerminal`. +#[must_use] +pub fn paste_options_from_terminal(t: &Terminal) -> paste::Options { + paste::Options { + bracketed: t.modes.get(Mode::BracketedPaste), + } +} + +impl Surface { + /// Handle an OSC 52 write: `data` is base64 (Zig `clipboardWrite`). + pub(crate) fn clipboard_write(&self, data: &[u8], loc: Clipboard) { + let cfg = self.cfg(); + if cfg.clipboard_write == ClipboardAccess::Deny { + log::info!( + "application attempted to write clipboard, but 'clipboard-write' is set to deny" + ); + 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. + let lenient = base64::engine::GeneralPurpose::new( + &base64::alphabet::STANDARD, + base64::engine::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), + ); + let Ok(decoded) = lenient.decode(data) else { + log::info!("application sent invalid base64 data for OSC 52"); + return; + }; + let text = String::from_utf8_lossy(&decoded).into_owned(); + + // When clipboard-write is "ask" a prompt is displayed to the user + // asking them to confirm the clipboard access. Each app runtime + // handles this differently. + let confirm = cfg.clipboard_write == ClipboardAccess::Ask; + self.rt_surface + .set_clipboard(loc, &[("text/plain".to_owned(), text)], confirm); + } + + /// Format the selection and put it on the given clipboards (Zig + /// `copySelectionToClipboards`). The caller holds the terminal lock. + pub(crate) fn copy_selection_to_clipboards( + &self, + t: &Terminal, + sel: Selection, + clipboards: &[Clipboard], + format: CopyToClipboard, + ) { + let cfg = self.cfg(); + + // The options we'll use for all formatting. We'll just override the + // emit format. + let codepoint_map: Vec = 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 opts = formatter::Options { + emit: Format::Plain, // We'll override this below + unwrap: true, + trim: cfg.clipboard_trim_trailing_spaces, + codepoint_map: Some(codepoint_map), + background: t.colors.background.get(), + foreground: t.colors.foreground.get(), + palette: Some(t.colors.palette.current), + }; + + let run = |opts: formatter::Options| -> Option { + let mut f = ScreenFormatter::new(t.screen(), opts); + f.content = formatter::Content::Selection(Some(sel)); + match f.format_string() { + Ok(s) => Some(s), + Err(err) => { + log::error!("error formatting selection for clipboard err={err:?}"); + None + } + } + }; + + let mut contents: Vec<(String, String)> = Vec::new(); + match format { + CopyToClipboard::Plain => { + let Some(s) = run(opts) else { return }; + contents.push(("text/plain".to_owned(), s)); + } + + CopyToClipboard::Vt => { + let mut o = opts; + o.emit = Format::Vt; + // Note: We don't apply codepoint mappings to VT format since + // it contains escape sequences that should be preserved + // as-is + let Some(s) = run(o) else { return }; + contents.push(("text/plain".to_owned(), s)); + } + + CopyToClipboard::Html => { + let mut o = opts; + o.emit = Format::Html; + // Note: We don't apply codepoint mappings to HTML format + // since HTML has its own character encoding and entity + // system + let Some(s) = run(o) else { return }; + contents.push(("text/html".to_owned(), s)); + } + + CopyToClipboard::Mixed => { + // First, generate plain text with codepoint mappings applied + let Some(plain) = run(opts.clone()) else { + return; + }; + contents.push(("text/plain".to_owned(), plain)); + + // Second, generate HTML without codepoint mappings + let mut o = opts; + o.emit = Format::Html; + // We purposely don't emit background/foreground for mixed + // mode because the HTML contents is often used for rich text + // input and with trimmed spaces it looks pretty bad. + o.background = None; + o.foreground = None; + let Some(html) = run(o) else { return }; + contents.push(("text/html".to_owned(), html)); + } + } + + for clipboard in clipboards { + self.rt_surface.set_clipboard(*clipboard, &contents, false); + } + } + + /// Start a clipboard request, with some basic validation. For example, + /// an OSC 52 request is not actually requested if OSC 52 is disabled. + /// + /// Returns true if the request was started, false if it was not (e.g., + /// clipboard doesn't contain text for paste requests). This allows + /// performable keybinds to pass through when the action cannot be + /// performed. + pub(crate) fn start_clipboard_request(&self, loc: Clipboard, req: ClipboardRequest) -> bool { + match req { + ClipboardRequest::Paste => {} // always allowed + ClipboardRequest::Osc52Read(_) => { + if self.cfg().clipboard_read == ClipboardAccess::Deny { + log::info!( + "application attempted to read clipboard, but 'clipboard-read' is set to deny" + ); + return false; + } + } + + // No clipboard write code paths travel through this function + ClipboardRequest::Osc52Write(_) => unreachable!(), + } + + self.rt_surface.clipboard_request(loc, req) + } + + /// The runtime answers a clipboard request (Zig: + /// `completeClipboardRequest`). + pub fn complete_clipboard_request( + &self, + req: ClipboardRequest, + data: &str, + confirmed: bool, + ) -> Result<(), ClipboardError> { + match req { + ClipboardRequest::Paste => self.complete_clipboard_paste(data.as_bytes(), confirmed), + + ClipboardRequest::Osc52Read(clipboard) => { + self.complete_clipboard_read_osc52(data.as_bytes(), clipboard, confirmed) + } + + ClipboardRequest::Osc52Write(clipboard) => { + self.rt_surface.set_clipboard( + clipboard, + &[("text/plain".to_owned(), data.to_owned())], + !confirmed, + ); + Ok(()) + } + } + } + + /// Paste text (also the target of `text_callback`). + pub(crate) fn complete_clipboard_paste( + &self, + data: &[u8], + allow_unsafe: bool, + ) -> Result<(), ClipboardError> { + if data.is_empty() { + return Ok(()); + } + + let cfg = self.cfg(); + let encode_opts = { + let mut t = self.lock_terminal(); + let opts = paste_options_from_terminal(&t); + + // If we have paste protection enabled, we detect unsafe pastes + // and return an error. The error approach allows apprt to + // attempt to complete the paste before falling back to + // requesting confirmation. + // + // We do not do this for bracketed pastes because bracketed + // pastes are by definition safe since they're framed. + let unsafe_paste = 'unsafe_paste: { + // If we've disabled paste protection then we always allow + // the paste. + if !cfg.clipboard_paste_protection { + break 'unsafe_paste false; + } + + // If we're allowed to paste unsafe data then we always allow + // the paste. This is set during confirmation usually. + if allow_unsafe { + break 'unsafe_paste false; + } + + if opts.bracketed { + // If we're bracketed and the paste contains and ending + // bracket then something naughty might be going on and + // we never trust it. + if data.windows(6).any(|w| w == b"\x1b[201~") { + break 'unsafe_paste true; + } + + // If we are bracketed and configured to trust that then + // the paste is not unsafe. + if cfg.clipboard_paste_bracketed_safe { + break 'unsafe_paste false; + } + } + + !paste::is_safe(data) + }; + + if unsafe_paste { + log::info!("potentially unsafe paste detected, rejecting until confirmation"); + return Err(ClipboardError::UnsafePaste); + } + + // With the lock held, we must scroll to the bottom. We always + // scroll to the bottom for these inputs. + self.scroll_to_bottom(&mut t); + + opts + }; + + // Encode the data. In most cases this doesn't require any copies, + // so we optimize for that case. + let mut data_duped: Vec; + let vecs = match paste::encode(data, encode_opts) { + Ok(v) => v, + Err(paste::Error::MutableRequired) => { + data_duped = data.to_vec(); + paste::encode_mut(&mut data_duped, encode_opts) + } + }; + + for vec in vecs { + if !vec.is_empty() { + self.queue_io(IoMessage::write_req(vec)); + } + } + Ok(()) + } + + fn complete_clipboard_read_osc52( + &self, + data: &[u8], + clipboard_type: Clipboard, + confirmed: bool, + ) -> Result<(), ClipboardError> { + let cfg = self.cfg(); + + // We should never get here if clipboard-read is set to deny + debug_assert_ne!(cfg.clipboard_read, ClipboardAccess::Deny); + + // If clipboard-read is set to ask and we haven't confirmed with the + // user, do that now + if cfg.clipboard_read == ClipboardAccess::Ask && !confirmed { + return Err(ClipboardError::UnauthorizedPaste); + } + + // Even if the clipboard data is empty we reply, since presumably the + // client app is expecting a reply. + let kind = match clipboard_type { + Clipboard::Standard => 'c', + Clipboard::Selection => 's', + Clipboard::Primary => 'p', + }; + + // Wrap our data with the OSC code + let mut buf = format!("\x1b]52;{kind};").into_bytes(); + buf.extend_from_slice(STANDARD.encode(data).as_bytes()); + buf.extend_from_slice(b"\x1b\\"); + + self.queue_io(IoMessage::write_req(&buf)); + Ok(()) + } + + /// Set `clipboard-read` / `clipboard-write` to a fixed value for this + /// surface (the runtime calls this after the user chose "remember"). + pub(crate) fn set_config_clipboard(&self, read: bool, access: ClipboardAccess) { + let mut config = (*self.cfg().config).clone(); + if read { + config.clipboard_read = access; + } else { + config.clipboard_write = access; + } + match DerivedConfig::new(Arc::new(config)) { + Ok(d) => *self.config.borrow_mut() = Rc::new(d), + Err(err) => log::warn!("failed to update clipboard access err={err}"), + } + } + + /// Show a desktop notification, rate limited (Zig + /// `showDesktopNotification`). + pub(crate) fn show_desktop_notification(&self, title: &str, body: &str) { + let now = Instant::now(); + + // Set a limit of one desktop notification per second so that the OS + // doesn't kill us when we run out of resources. + let last = self.app.last_notification_time.get(); + if let Some(last) = last + && now.duration_since(last) < Duration::from_secs(1) + { + log::warn!("rate limiting desktop notifications"); + return; + } + + let new_digest = { + let mut hash = std::collections::hash_map::DefaultHasher::new(); + title.hash(&mut hash); + body.hash(&mut hash); + hash.finish() + }; + + // Set a limit of one notification per five seconds for desktop + // notifications with identical content. + if let Some(last) = last + && self.app.last_notification_digest.get() == new_digest + && now.duration_since(last) < Duration::from_secs(5) + { + log::warn!("suppressing identical desktop notification"); + return; + } + + self.app.last_notification_time.set(Some(now)); + self.app.last_notification_digest.set(new_digest); + self.perform(RtAction::DesktopNotification(DesktopNotification { + title: title.to_owned(), + body: body.to_owned(), + })); + } + + /// `textCallback`: text input that is pasted verbatim. + pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> { + self.complete_clipboard_paste(text.as_bytes(), true) + } +} diff --git a/crates/ghostty-core/src/surface/config.rs b/crates/ghostty-core/src/surface/config.rs new file mode 100644 index 0000000..6475173 --- /dev/null +++ b/crates/ghostty-core/src/surface/config.rs @@ -0,0 +1,87 @@ +//! The configuration a surface has (Zig: `Surface.DerivedConfig`). +//! +//! Zig copies a subset of the main config so no pointer to it needs to stay +//! alive. In Rust the whole (conditional-state-applied) [`Config`] is kept +//! behind an `Arc`, which is the same thing, and `DerivedConfig` derefs to +//! it so the fields keep their Zig names (`cfg.copy_on_select`, ...). The +//! few values that are *computed* in Zig live as fields of this struct. + +use std::ops::Deref; +use std::sync::Arc; + +use ghostty_config::config::Config; +use ghostty_input::link::{Action as LinkAction, Highlight}; +use onig::Regex; + +use ghostty_renderer::size::Padding; + +/// A link with its compiled regex (Zig: `DerivedConfig.Link`). +pub struct Link { + pub regex: Regex, + pub action: LinkAction, + pub highlight: Highlight, +} + +/// See the module docs. +pub struct DerivedConfig { + /// The config this was derived from (conditional state already applied). + pub config: Arc, + + /// `font-size`. + pub original_font_size: f32, + + /// `click-repeat-interval` in nanoseconds (the config is in ms). + pub mouse_interval: u64, + + /// `abnormal-command-exit-runtime` (ms). + pub abnormal_command_exit_runtime_ms: u32, + + /// `selection-word-chars` codepoints. + pub selection_word_chars: Vec, + + /// The compiled `link` entries. + pub links: Vec, +} + +impl Deref for DerivedConfig { + type Target = Config; + fn deref(&self) -> &Config { + &self.config + } +} + +impl DerivedConfig { + /// Derive the surface configuration. Fails if a link regex is invalid. + pub fn new(config: Arc) -> Result { + // Build all of our links + let mut links = Vec::with_capacity(config.link.links.len()); + for link in &config.link.links { + links.push(Link { + regex: Regex::new(&link.regex)?, + action: link.action, + highlight: link.highlight, + }); + } + + Ok(DerivedConfig { + original_font_size: config.font_size, + mouse_interval: u64::from(config.click_repeat_interval) * 1_000_000, // 500ms + abnormal_command_exit_runtime_ms: config.abnormal_command_exit_runtime, + selection_word_chars: config.selection_word_chars.codepoints.clone(), + links, + config, + }) + } + + /// Padding scaled by the DPI (Zig: `scaledPadding`). + #[must_use] + pub fn scaled_padding(&self, x_dpi: f32, y_dpi: f32) -> Padding { + let scale = |v: u32, dpi: f32| -> u32 { (v as f32 * dpi / 72.0).floor() as u32 }; + Padding { + top: scale(self.window_padding_y.top_left, y_dpi), + bottom: scale(self.window_padding_y.bottom_right, y_dpi), + left: scale(self.window_padding_x.top_left, x_dpi), + right: scale(self.window_padding_x.bottom_right, x_dpi), + } + } +} diff --git a/crates/ghostty-core/src/surface/keyboard.rs b/crates/ghostty-core/src/surface/keyboard.rs new file mode 100644 index 0000000..0fb0639 --- /dev/null +++ b/crates/ghostty-core/src/surface/keyboard.rs @@ -0,0 +1,1138 @@ +//! Keyboard input for a surface (Zig `Surface.zig`: `keyEventIsBinding`, +//! `keyCallback`, `maybeHandleBinding`, key sequences/tables, `encodeKey`, +//! the key-table binding actions and the key release on focus loss). +//! +//! # Deviations from Zig +//! +//! Zig keeps raw pointers into the config (`*const Binding.Set`) for the +//! current sequence and the key table stack. Here the sequence is stored as +//! a *path* (where it started plus the triggers pressed so far) and tables +//! are stored by name; both are resolved against the current config each +//! time. A config change drops them anyway (`update_config` calls +//! [`Surface::end_key_sequence`] and [`Surface::deactivate_all_key_tables`]). + +use ghostty_apprt::action::{Action as RtAction, KeySequence, KeyTable}; +use ghostty_apprt::runtime_api::{CoreError, InputEffect}; +use ghostty_input::binding::set::Value; +use ghostty_input::binding::{Action as BindingAction, Flags, Scope, Set, Trigger, TriggerKey}; +use ghostty_input::key_encode; +use ghostty_input::{Action as KeyAction, Key, KeyEvent, Mods}; +use ghostty_terminal::terminal::{MouseEvents, ScrollViewport, Terminal}; +use ghostty_termio::message::Message as IoMessage; +use ghostty_vt::modes::Mode; +use ghostty_vt::mouse_shape::MouseShape; + +use super::Surface; +use super::config::DerivedConfig; + +/// The maximum number of key tables that can be active at any given time. +/// `activate_key_table` calls after this are ignored. +pub const MAX_ACTIVE_KEY_TABLES: usize = 8; + +/// Where a sequence (or a lookup) is rooted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SetRoot { + /// The root keybinding set. + Root, + /// A named key table. + Table(String), +} + +/// The currently active key sequence: the set it started in plus the +/// triggers (leader keys) pressed so far. +#[derive(Debug, Clone, PartialEq)] +pub struct SequencePath { + pub root: SetRoot, + pub path: Vec, +} + +/// An entry of the key table stack. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableEntry { + pub name: String, + pub once: bool, +} + +/// Keyboard state for the surface (Zig: `Surface.Keyboard`). +#[derive(Default)] +pub struct Keyboard { + /// The currently active key sequence for the surface. If this is `None` + /// then we're not currently in a key sequence. + pub sequence: Option, + + /// The queued keys when we're in the middle of a sequenced binding. + /// These are flushed when the sequence is completed and unconsumed or + /// invalid. Naturally bounded by the configuration maximum sequence + /// length. + pub sequence_queued: Vec, + + /// The stack of tables that is currently active. The first value in this + /// is the first activated table (NOT the default keybinding set). + /// Bounded by [`MAX_ACTIVE_KEY_TABLES`]. + pub table_stack: Vec, + + /// The hash value of the last keybinding trigger that we performed. Only + /// set if the last key input matched a keybinding, consumed it, and + /// performed it. Used to prevent sending release/repeat events for + /// handled bindings. + pub last_trigger: Option, +} + +/// An owned copy of a [`KeyEvent`] (the `utf8` text is owned). Used for the +/// "currently pressed key" so a release can be sent on focus loss. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedKeyEvent { + pub action: KeyAction, + pub key: Key, + pub mods: Mods, + pub consumed_mods: Mods, + pub composing: bool, + pub utf8: String, + pub unshifted_codepoint: u32, +} + +impl Default for OwnedKeyEvent { + fn default() -> Self { + Self::from_event(&KeyEvent::default()) + } +} + +impl OwnedKeyEvent { + /// Copy an event. + #[must_use] + pub fn from_event(e: &KeyEvent<'_>) -> Self { + OwnedKeyEvent { + action: e.action, + key: e.key, + mods: e.mods, + consumed_mods: e.consumed_mods, + composing: e.composing, + utf8: e.utf8.to_owned(), + unshifted_codepoint: e.unshifted_codepoint, + } + } + + /// Borrow as a [`KeyEvent`]. + #[must_use] + pub fn as_event(&self) -> KeyEvent<'_> { + KeyEvent { + action: self.action, + key: self.key, + mods: self.mods, + consumed_mods: self.consumed_mods, + composing: self.composing, + utf8: &self.utf8, + unshifted_codepoint: self.unshifted_codepoint, + } + } +} + +/// What to do with queued sequence inputs (Zig: `KeySequenceQueued`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SequenceAction { + Drop, + Flush, +} + +/// Memory handling of the queue afterwards (Zig: `KeySequenceMemory`). Both +/// release the queued entries in Rust; kept for parity with the Zig API. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SequenceFree { + Free, + Retain, +} + +/// `key_encode.Options.fromTerminal`. The macOS option-as-alt value can't be +/// known from the terminal state. +#[must_use] +pub fn encode_options_from_terminal(t: &Terminal) -> key_encode::Options { + key_encode::Options { + alt_esc_prefix: t.modes.get(Mode::AltEscPrefix), + cursor_key_application: t.modes.get(Mode::CursorKeys), + keypad_key_application: t.modes.get(Mode::KeypadKeys), + ignore_keypad_with_numlock: t.modes.get(Mode::IgnoreKeypadWithNumlock), + modify_other_keys_state_2: t.flags.modify_other_keys_2, + kitty_flags: t.screens.active().kitty_keyboard.current(), + macos_option_as_alt: ghostty_input::OptionAsAlt::False, + } +} + +/// Resolve a set by root (Zig pointer) against a config. +fn root_set<'a>(cfg: &'a DerivedConfig, root: &SetRoot) -> Option<&'a Set> { + match root { + SetRoot::Root => Some(&cfg.keybind.set), + SetRoot::Table(name) => cfg + .keybind + .tables + .iter() + .find(|(n, _)| n == name) + .map(|(_, s)| s), + } +} + +/// Resolve the set a sequence path points at. +fn sequence_set<'a>(cfg: &'a DerivedConfig, seq: &SequencePath) -> Option<&'a Set> { + let mut set = root_set(cfg, &seq.root)?; + for trigger in &seq.path { + match set.get_entry(*trigger)?.1 { + Value::Leader(next) => set = next, + _ => return None, + } + } + Some(set) +} + +/// What a lookup found, with everything borrowed copied out. +enum Found { + /// A leader key: the new sequence path. + Leader(Trigger, SequencePath), + /// A leaf (possibly chained). + Leaf { + actions: Vec, + flags: Flags, + }, +} + +fn found_from(trigger: Trigger, value: &Value, origin: SetRoot, parent_path: &[Trigger]) -> Found { + match value { + Value::Leader(_) => { + let mut path = parent_path.to_vec(); + path.push(trigger); + Found::Leader(trigger, SequencePath { root: origin, path }) + } + Value::Leaf(l) => Found::Leaf { + actions: vec![l.action.clone()], + flags: l.flags, + }, + Value::LeafChained(l) => Found::Leaf { + actions: l.actions.clone(), + flags: l.flags, + }, + } +} + +/// `SurfaceMouse.keyToMouseShape` (surface_mouse.zig), needed for the +/// cursor shape update when modifier keys change. +fn key_to_mouse_shape( + physical_key: Key, + mouse_event: MouseEvents, + mods: Mods, + over_link: bool, + hidden: bool, +) -> Option { + let eligible = physical_key.ctrl_or_super() + || physical_key.left_or_right_shift() + || physical_key.left_or_right_alt(); + if !eligible { + return None; + } + + // Link hover or hidden state overrides any other shape processing. + if over_link || hidden { + return None; + } + + let rect_select = mods.ctrl_or_super() && mods.alt; + if mouse_event == MouseEvents::None { + // Default terminal mode: text + Some(if rect_select { + MouseShape::Crosshair + } else { + MouseShape::Text + }) + } else if mods.shift && rect_select { + // Mouse tracking mode, overriding with shift + rectangle select. + Some(MouseShape::Crosshair) + } else if mods.shift { + Some(MouseShape::Text) + } else { + Some(MouseShape::Default) + } +} + +fn closing_action(action: &BindingAction) -> bool { + matches!( + action, + BindingAction::CloseSurface | BindingAction::CloseWindow | BindingAction::CloseTab(_) + ) +} + +impl Surface { + /// Apply key remappings (Zig: at the top of `keyEventIsBinding` and + /// `keyCallback`). Returns the event with remapped mods. + fn remap_event<'a>(cfg: &DerivedConfig, event: &KeyEvent<'a>) -> KeyEvent<'a> { + let mut event = event.clone(); + if cfg.key_remap.is_remapped(event.mods) { + event.mods = cfg.key_remap.apply(event.mods); + } + event + } + + /// Returns the flags of the binding the given key event would trigger, + /// if any. This is useful for determining if a key event should be sent + /// to the terminal or not. + /// + /// Note that this function does not check if the binding itself is + /// performable, only if the key event would trigger a binding. + pub fn key_event_is_binding(&self, event: &KeyEvent<'_>) -> Option { + let cfg = self.cfg(); + + // Apply key remappings for consistency with key_callback + let event = Self::remap_event(&cfg, event); + + match event.action { + KeyAction::Release => return None, + KeyAction::Press | KeyAction::Repeat => {} + } + + let (seq, tables) = { + let kb = self.keyboard.borrow(); + (kb.sequence.clone(), kb.table_stack.clone()) + }; + + let value = 'entry: { + // If we're in a sequence, check the sequence set + if let Some(seq) = &seq { + let set = sequence_set(&cfg, seq)?; + break 'entry set.get_event(&event)?.1; + } + + // Check active key tables (inner-most to outer-most) + for t in tables.iter().rev() { + if let Some(set) = root_set(&cfg, &SetRoot::Table(t.name.clone())) + && let Some((_, v)) = set.get_event(&event) + { + break 'entry v; + } + } + + // Check the root set + cfg.keybind.set.get_event(&event)?.1 + }; + + Some(match value { + Value::Leader(_) => Flags { + // Zig: `.{}` which are the struct defaults. + ..Flags::default() + }, + Value::Leaf(l) => l.flags, + Value::LeafChained(l) => l.flags, + }) + } + + /// Called for any key events. This handles keybindings, encoding and + /// sending to the terminal, etc. + #[allow(clippy::needless_pass_by_value)] + pub fn key_callback(&self, event_orig: KeyEvent<'_>) -> Result { + let cfg = self.cfg(); + + // Apply key remappings to transform modifiers before any + // processing. This allows users to remap modifier keys at the app + // level. + let event = Self::remap_event(&cfg, &event_orig); + + // Handle keybindings first. We need to handle this on all events + // (press, repeat, release) because a press may perform a binding but + // a release should not encode if we consumed the press. + if let Some(v) = self.maybe_handle_binding(&cfg, &event) { + return Ok(v); + } + + // If we allow KAM and KAM is enabled then we do nothing. + if cfg.vt_kam_allowed && self.lock_terminal().modes.get(Mode::DisableKeyboard) { + return Ok(InputEffect::Consumed); + } + + // If this input event has text, then we hide the mouse if + // configured. We only do this on pressed events to avoid hiding the + // mouse when we change focus due to a keybinding (i.e. switching + // tabs). + if cfg.mouse_hide_while_typing + && event.action == KeyAction::Press + && !self.mouse.borrow().hidden + && !event.utf8.is_empty() + { + self.hide_mouse(); + } + + // If our mouse modifiers change we may need to change our link + // highlight state. + let mouse_mods = self.mouse.borrow().mods; + if !mouse_mods.equal(event.mods) { + self.refresh_mouse_for_mods_change(&event); + } + + // Process the cursor state logic. This will update the cursor shape + // if needed, depending on the key state. + let shape = { + let mouse_event = self.lock_terminal().flags.mouse_event; + let m = self.mouse.borrow(); + key_to_mouse_shape(event.key, mouse_event, m.mods, m.over_link, m.hidden) + }; + if let Some(shape) = shape { + self.perform(RtAction::MouseShape(shape)); + } + + // We've processed a key event that produced some data so we want to + // track the last pressed key. + { + let mut pressed = self.pressed_key.borrow_mut(); + let prev = pressed.take(); + *pressed = 'event: { + // We need to unset the allocated fields that will become + // invalid + let mut copy = OwnedKeyEvent::from_event(&event); + copy.utf8.clear(); + + // If we have a previous pressed key and we're releasing it + // then we set it to invalid to prevent repeating the + // release event. + if event.action == KeyAction::Release { + // if we didn't have a previous event and this is a + // release event then we just want to set it to null. + let Some(prev) = prev else { + break 'event None; + }; + if prev.key == copy.key { + copy.key = Key::Unidentified; + } + } + + // If our key is invalid and we have no mods, then we're + // done! This helps catch the state that we naturally + // released all keys. + if copy.key == Key::Unidentified && copy.mods.empty() { + break 'event None; + } + + Some(copy) + }; + } + + // Encode and send our key. If we didn't encode anything, then we + // return the effect as ignored. + let Some(write_req) = self.encode_key(&event) else { + // No valid request means that we didn't encode anything. + return Ok(InputEffect::Ignored); + }; + + // If our process is exited and we press a key that results in an + // encoded value, we close the surface. We want to eventually move + // this behavior to the apprt probably. + if self.child_exited.get() { + self.close(); + return Ok(InputEffect::Closed); + } + + self.queue_io(write_req); + + // If our event is any keypress that isn't a modifier and we + // generated some data to send to the pty, then we move the viewport + // down to the bottom. We also clear the selection for any key other + // then modifiers. + if !event.key.modifier() { + { + let mut t = self.lock_terminal(); + if cfg.selection_clear_on_typing || event.key == Key::Escape { + let _ = self.set_selection(&mut t, None); + } + + if cfg.scroll_to_bottom.keystroke { + t.scroll_viewport(ScrollViewport::Bottom); + } + } + self.queue_render(); + } + + Ok(InputEffect::Consumed) + } + + /// The modifier-change block of `keyCallback` (`mouse_mods` label). + fn refresh_mouse_for_mods_change(&self, event: &KeyEvent<'_>) { + // Update our modifiers, this will update mouse mods too + self.mods_changed(event.mods); + + let (mouse_event, mouse_shape) = { + let t = self.lock_terminal(); + (t.flags.mouse_event, t.mouse_shape) + }; + let shift = self.mouse.borrow().mods.shift; + + // We only refresh links if + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the + // terminal + if mouse_event == MouseEvents::None + || (shift && !self.mouse_shift_capture(&self.lock_terminal())) + { + // Refresh our link state + let pos = self.rt_surface.get_cursor_pos(); + let over_link = self.mouse.borrow().over_link; + let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + if let Err(err) = self.mouse_refresh_links(pos, vp, over_link) { + log::warn!("failed to refresh links err={err}"); + } + } else if mouse_event != MouseEvents::None && !shift { + // If we have mouse reports on and we don't have shift pressed, + // we reset state + self.perform(RtAction::MouseShape(mouse_shape)); + self.perform(RtAction::MouseOverLink( + ghostty_apprt::action::MouseOverLink { url: String::new() }, + )); + self.queue_render(); + } + } + + /// Maybe handles a binding for a given event and if so returns the + /// effect. Returns `None` if the event is not handled in any way and + /// processing should continue. + fn maybe_handle_binding( + &self, + cfg: &DerivedConfig, + event: &KeyEvent<'_>, + ) -> Option { + match event.action { + // Release events never trigger a binding but we need to check + // if we consumed the press event so we don't encode the + // release. + KeyAction::Release => { + if let Some(last) = self.keyboard.borrow().last_trigger + && last == event.binding_hash() + { + // We don't reset the last trigger on release because an + // apprt may send multiple release events for a single + // press event. + return Some(InputEffect::Consumed); + } + + return None; + } + + // Carry on processing. + KeyAction::Press | KeyAction::Repeat => {} + } + + let (seq, tables) = { + let kb = self.keyboard.borrow(); + (kb.sequence.clone(), kb.table_stack.clone()) + }; + + // Find an entry in the keybind set that matches our event. + let found = 'entry: { + // Handle key sequences first. + if let Some(seq) = &seq { + // Get our entry from the set for the given event. + if let Some(set) = sequence_set(cfg, seq) + && let Some((t, v)) = set.get_event(event) + { + break 'entry found_from(*t, v, seq.root.clone(), &seq.path); + } + + // No entry found. We need to encode everything up to this + // point and send to the pty since we're in a sequence. + + // We ignore modifiers so that nested sequences such as + // ctrl+a>ctrl+b>c work. + if event.key.modifier() { + return None; + } + + // If we have a catch-all of ignore, then we special case + // our invalid sequence handling to ignore it. + if self.catch_all_is_ignore(cfg) { + self.end_key_sequence(SequenceAction::Drop, SequenceFree::Retain); + return Some(InputEffect::Ignored); + } + + // Encode everything up to this point + self.end_key_sequence(SequenceAction::Flush, SequenceFree::Retain); + + return None; + } + + // No currently active sequence, move on to tables. For tables, + // we search inner-most table to outer-most. The table stack + // does NOT include the root set. + for (i, table) in tables.iter().rev().enumerate() { + let root = SetRoot::Table(table.name.clone()); + if let Some(set) = root_set(cfg, &root) + && let Some((t, v)) = set.get_event(event) + { + // If this is a one-shot activation AND its the + // currently active table, then we deactivate it after + // this. Note: we may want to change the semantics here + // to remove this table no matter where it is in the + // stack, maybe. + let f = found_from(*t, v, root, &[]); + if table.once && i == 0 { + self.deactivate_key_table(); + } + break 'entry f; + } + } + + // No table, use our default set + let (t, v) = cfg.keybind.set.get_event(event)?; + found_from(*t, v, SetRoot::Root, &[]) + }; + + // Determine if this entry has an action or if its a leader key. + let (actions, flags) = match found { + Found::Leader(trigger, path) => { + // Setup the next set we'll look at. + self.keyboard.borrow_mut().sequence = Some(path); + + // Store this event so that we can drain and encode on + // invalid. We don't need to cap this because it is + // naturally capped by the config validation. + if let Some(req) = self.encode_key(event) { + self.keyboard.borrow_mut().sequence_queued.push(req); + } + + // Start or continue our key sequence + self.perform(RtAction::KeySequence(KeySequence::Trigger(trigger))); + + return Some(InputEffect::Consumed); + } + Found::Leaf { actions, flags } => (actions, flags), + }; + + // consumed determines if the input is consumed or if we continue + // encoding the key (if we have a key to encode). + // + // If the consumed flag is explicitly set, then we are consumed. If + // the global or all flag is set, we always consume. + let consumed = flags.consumed || flags.global || flags.all; + + // We have an action, so at this point we're handling SOMETHING so we + // reset the last trigger to null. We only set this if we actually + // perform an action (below). An action also always resets the + // sequence set. + { + let mut kb = self.keyboard.borrow_mut(); + kb.last_trigger = None; + kb.sequence = None; + } + + // Attempt to perform the action + log::debug!("key event binding flags={flags:?} action={actions:?}"); + let performed = 'performed: { + // If this is a global or all action, then we perform it on the + // app and it applies to every surface. + if flags.global || flags.all { + self.perform_all_chained_action(&actions); + + // "All" actions are always performed since they are global. + break 'performed true; + } + + // Perform each action. We are performed if ANY of the chained + // actions perform. + let mut performed = false; + for action in &actions { + match self.perform_binding_action(action) { + Ok(v) => performed = performed || v, + Err(err) => log::info!( + "key binding action failed action={} err={err}", + action.key().name() + ), + } + } + + performed + }; + + if performed { + // If we performed an action and it was a closing action, our + // "self" is not safe to use anymore so we need to just exit + // immediately. + if actions.iter().any(closing_action) { + log::debug!("key binding is a closing binding, halting key event processing"); + return Some(InputEffect::Closed); + } + + // If our action was "ignore" then we return the special input + // effect of "ignored". + if actions.iter().any(|a| matches!(a, BindingAction::Ignore)) { + // If we're in a sequence, clear it. + self.end_key_sequence(SequenceAction::Drop, SequenceFree::Retain); + + return Some(InputEffect::Ignored); + } + } + + // If we have the performable flag and the action was not performed, + // then we act as though a binding didn't exist. + if flags.performable && !performed { + // If we're in a sequence, we treat this as if we pressed a key + // that doesn't exist in the sequence. Reset our sequence and + // flush any queued events. + self.end_key_sequence(SequenceAction::Flush, SequenceFree::Retain); + + return None; + } + + // If we consume this event, then we are done. If we don't consume + // it, we processed the action but we still want to process our + // encodings, too. + if consumed { + // If we had queued events, we deinit them since we consumed + self.end_key_sequence(SequenceAction::Drop, SequenceFree::Retain); + + // Store our last trigger so we don't encode the release event + self.keyboard.borrow_mut().last_trigger = Some(event.binding_hash()); + + return Some(InputEffect::Consumed); + } + + // If we didn't perform OR we didn't consume, then we want to encode + // any queued events for a sequence. + self.end_key_sequence(SequenceAction::Flush, SequenceFree::Retain); + + None + } + + /// Perform actions on the app and every surface (Zig: + /// `App.performAllChainedAction`; reimplemented here because the app + /// handle isn't reachable from a surface). + fn perform_all_chained_action(&self, actions: &[BindingAction]) { + for action in actions { + match action.scope() { + Scope::App => self.perform_app_action(action), + Scope::Surface => { + for surface in self.app.surfaces() { + if let Err(err) = surface.perform_binding_action(action) { + log::warn!( + "error performing chained action action={} err={err}", + action.key().name() + ); + } + } + } + } + } + } + + /// `App.performAction` for app-scoped binding actions. + fn perform_app_action(&self, action: &BindingAction) { + use ghostty_apprt::action::{ReloadConfig, Target}; + let rt = match action { + BindingAction::Quit => RtAction::Quit, + BindingAction::NewWindow => RtAction::NewWindow, + BindingAction::OpenConfig => RtAction::OpenConfig, + BindingAction::ReloadConfig => RtAction::ReloadConfig(ReloadConfig::default()), + BindingAction::CloseAllWindows => RtAction::CloseAllWindows, + BindingAction::ToggleQuickTerminal => RtAction::ToggleQuickTerminal, + BindingAction::ToggleVisibility => RtAction::ToggleVisibility, + BindingAction::CheckForUpdates => RtAction::CheckForUpdates, + BindingAction::ShowGtkInspector => RtAction::ShowGtkInspector, + BindingAction::Undo => RtAction::Undo, + 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); + } + + /// Deactivate every key table. Returns whether any was active. + pub(crate) fn deactivate_all_key_tables(&self) -> bool { + { + let mut kb = self.keyboard.borrow_mut(); + // No key table active. This does nothing. + if kb.table_stack.is_empty() { + return false; + } + + // Clear the entire table stack. + kb.table_stack.clear(); + } + + // Notify the UI. + self.perform(RtAction::KeyTable(KeyTable::DeactivateAll)); + true + } + + /// Activate a key table by name (Zig: `activate_key_table[_once]`). + /// Returns whether the table was activated. + #[allow(dead_code, clippy::unnecessary_wraps)] // used by the binding-actions module + pub(crate) fn activate_key_table(&self, name: &str, once: bool) -> Result { + let cfg = self.cfg(); + + // Look up the table in our config + if root_set(&cfg, &SetRoot::Table(name.to_owned())).is_none() { + log::debug!("key table not found: {name}"); + return Ok(false); + } + + { + let mut kb = self.keyboard.borrow_mut(); + + // If this is the same table as is currently active, then do + // nothing. + if kb.table_stack.last().is_some_and(|t| t.name == name) { + log::debug!("ignoring duplicate activate table: {name}"); + return Ok(false); + } + + // If we're already at the max, ignore it. + if kb.table_stack.len() >= MAX_ACTIVE_KEY_TABLES { + log::info!("ignoring activate table, max depth reached: {name}"); + return Ok(false); + } + + // Add the table to the stack. + kb.table_stack.push(TableEntry { + name: name.to_owned(), + once, + }); + } + + // Notify the UI. + self.perform(RtAction::KeyTable(KeyTable::Activate(name.to_owned()))); + + log::debug!("key table activated: {name}"); + Ok(true) + } + + /// Deactivate the inner-most key table (Zig: `deactivate_key_table`). + /// Returns whether a table was active. + pub(crate) fn deactivate_key_table(&self) -> bool { + { + let mut kb = self.keyboard.borrow_mut(); + // No key table active. This does nothing. + if kb.table_stack.pop().is_none() { + return false; + } + } + + // Notify the UI. + self.perform(RtAction::KeyTable(KeyTable::Deactivate)); + true + } + + /// This checks if the current keybinding sets have a catch_all binding + /// with `ignore`. This is used to determine some special input cases. + fn catch_all_is_ignore(&self, cfg: &DerivedConfig) -> bool { + let trigger = Trigger { + key: TriggerKey::CatchAll, + mods: Mods::default(), + }; + + let tables = self.keyboard.borrow().table_stack.clone(); + let mut value = None; + for t in tables.iter().rev() { + if let Some(set) = root_set(cfg, &SetRoot::Table(t.name.clone())) + && let Some(v) = set.get(trigger) + { + value = Some(v); + break; + } + } + let Some(value) = value.or_else(|| cfg.keybind.set.get(trigger)) else { + return false; + }; + + // We have a catch-all entry, see if its an ignore + match value { + Value::Leader(_) => false, + Value::Leaf(l) => matches!(l.action, BindingAction::Ignore), + Value::LeafChained(l) => l.actions.iter().any(|a| matches!(a, BindingAction::Ignore)), + } + } + + /// End a key sequence. Safe to call if no key sequence is active. + /// + /// `action` and `free` determine the behavior of the queued inputs up + /// to this point. + pub(crate) fn end_key_sequence(&self, action: SequenceAction, _free: SequenceFree) { + // Notify apprt key sequence ended + self.perform(RtAction::KeySequence(KeySequence::End)); + + // No matter what we clear our current sequence set. This restores + // the set we look at to the root set. + let queued = { + let mut kb = self.keyboard.borrow_mut(); + kb.sequence = None; + std::mem::take(&mut kb.sequence_queued) + }; + + // If we have no queued data, there is nothing else to do. Run the + // proper action otherwise. + if action == SequenceAction::Flush { + for req in queued { + self.queue_io(req); + } + } + } + + /// Encodes the key event into a write request. Returns `None` if + /// nothing was encoded. + fn encode_key(&self, event: &KeyEvent<'_>) -> Option { + // Build our encoding options, which requires the lock. + let opts = { + let t = self.lock_terminal(); + encode_options_from_terminal(&t) + }; + + let mut data: Vec = Vec::with_capacity(64); + if let Err(err) = key_encode::encode(&mut data, event, &opts) { + log::warn!("error encoding key event err={err}"); + return None; + } + + // Special-case: we did nothing. + if data.is_empty() { + return None; + } + + Some(IoMessage::write_req(&data)) + } + + /// Called from `focus_callback` when focus is lost: if we have a + /// pressed key, send a key release event for it (and for all + /// modifiers). Depending on the apprt, this CAN result in duplicate key + /// release events, but that is better than not sending a key release + /// event at all. + pub(crate) fn release_pressed_key_on_unfocus(&self) { + let Some(mut pressed_key) = self.pressed_key.borrow_mut().take() else { + return; + }; + + // All our actions will be releases + pressed_key.action = KeyAction::Release; + + // Release the full key first + if pressed_key.key != Key::Unidentified { + let ev = pressed_key.as_event(); + match self.key_callback(ev) { + Ok(InputEffect::Closed) => return, + Ok(_) => {} + Err(err) => log::warn!("error releasing key on focus loss err={err}"), + } + } + + // Release any modifiers if set + if pressed_key.mods.empty() { + return; + } + + // Go through all the modifiers and if they're set, release both the + // left and right sides of the modifier. This may not match the exact + // input event but it ensures a full reset. + let original_key = pressed_key.key; + let release = + |pk: &mut OwnedKeyEvent, which: fn(&mut Mods) -> &mut bool, keys: [Key; 2]| { + if !*which(&mut pk.mods) { + return true; + } + *which(&mut pk.mods) = false; + for key in keys { + pk.key = key; + if pk.key != original_key { + let ev = pk.as_event(); + match self.key_callback(ev) { + Ok(InputEffect::Closed) => return false, + Ok(_) => {} + Err(err) => log::warn!("error releasing key on focus loss err={err}"), + } + } + } + true + }; + if !release( + &mut pressed_key, + |m| &mut m.shift, + [Key::ShiftRight, Key::ShiftLeft], + ) { + return; + } + if !release( + &mut pressed_key, + |m| &mut m.ctrl, + [Key::ControlRight, Key::ControlLeft], + ) { + return; + } + if !release( + &mut pressed_key, + |m| &mut m.alt, + [Key::AltRight, Key::AltLeft], + ) { + return; + } + release( + &mut pressed_key, + |m| &mut m.super_, + [Key::MetaRight, Key::MetaLeft], + ); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use ghostty_config::cli_args::ConfigField; + use ghostty_config::config::Config; + use ghostty_terminal::terminal::Options; + + fn cfg_with(binds: &[&str]) -> DerivedConfig { + let mut c = Config::default(); + for b in binds { + c.keybind.parse_cli(Some(b)).unwrap(); + } + DerivedConfig::new(std::sync::Arc::new(c)).unwrap() + } + + #[test] + fn owned_key_event_round_trip() { + let e = KeyEvent { + action: KeyAction::Press, + key: Key::KeyA, + mods: Mods { + ctrl: true, + ..Mods::default() + }, + utf8: "a", + unshifted_codepoint: 'a' as u32, + ..KeyEvent::default() + }; + let o = OwnedKeyEvent::from_event(&e); + assert_eq!(o.as_event(), e); + } + + #[test] + fn closing_actions() { + assert!(closing_action(&BindingAction::CloseSurface)); + assert!(closing_action(&BindingAction::CloseWindow)); + assert!(!closing_action(&BindingAction::Ignore)); + } + + #[test] + fn sequence_path_resolution() { + let cfg = cfg_with(&["ctrl+a>b=new_window", "ctrl+a>c>d=quit"]); + let a = Trigger { + key: TriggerKey::Unicode('a' as u32), + mods: Mods { + ctrl: true, + ..Mods::default() + }, + }; + let seq = SequencePath { + root: SetRoot::Root, + path: vec![a], + }; + let set = sequence_set(&cfg, &seq).expect("leader set"); + let b = Trigger { + key: TriggerKey::Unicode('b' as u32), + mods: Mods::default(), + }; + assert!(matches!(set.get(b), Some(Value::Leaf(_)))); + let c = Trigger { + key: TriggerKey::Unicode('c' as u32), + mods: Mods::default(), + }; + let seq2 = SequencePath { + root: SetRoot::Root, + path: vec![a, c], + }; + assert!(sequence_set(&cfg, &seq2).is_some()); + // A path through a leaf is invalid + let seq3 = SequencePath { + root: SetRoot::Root, + path: vec![a, b], + }; + assert!(sequence_set(&cfg, &seq3).is_none()); + } + + #[test] + fn root_set_tables() { + let cfg = cfg_with(&["foo/x=new_window"]); + assert!(root_set(&cfg, &SetRoot::Table("foo".into())).is_some()); + assert!(root_set(&cfg, &SetRoot::Table("bar".into())).is_none()); + } + + #[test] + fn found_from_leader_extends_path() { + let cfg = cfg_with(&["ctrl+a>b=new_window"]); + let (t, v) = cfg + .keybind + .set + .bindings + .iter() + .find(|(_, v)| matches!(v, Value::Leader(_))) + .unwrap(); + match found_from(t.0, v, SetRoot::Root, &[]) { + Found::Leader(_, p) => assert_eq!(p.path.len(), 1), + Found::Leaf { .. } => panic!("expected leader"), + } + } + + #[test] + fn encode_options_follow_terminal_modes() { + let mut t = Terminal::new(Options::new(10, 5)).unwrap(); + let o = encode_options_from_terminal(&t); + assert!(!o.cursor_key_application); + t.modes.set(Mode::CursorKeys, true); + t.modes.set(Mode::AltEscPrefix, false); + t.flags.modify_other_keys_2 = true; + let o = encode_options_from_terminal(&t); + assert!(o.cursor_key_application); + assert!(!o.alt_esc_prefix); + assert!(o.modify_other_keys_state_2); + assert_eq!(o.kitty_flags.int(), 0); + } + + #[test] + fn mouse_shape_for_keys() { + let none = Mods::default(); + // No specific key pressed + assert_eq!( + key_to_mouse_shape(Key::Unidentified, MouseEvents::None, none, false, false), + None + ); + // Over a link + assert_eq!( + key_to_mouse_shape(Key::ShiftLeft, MouseEvents::None, none, true, false), + None + ); + // Hidden + assert_eq!( + key_to_mouse_shape(Key::ShiftLeft, MouseEvents::None, none, false, true), + None + ); + // Shift in mouse tracking mode -> text + let shift = Mods { + shift: true, + ..Mods::default() + }; + assert_eq!( + key_to_mouse_shape(Key::ShiftLeft, MouseEvents::Normal, shift, false, false), + Some(MouseShape::Text) + ); + // Released in tracking mode -> default + assert_eq!( + key_to_mouse_shape(Key::ShiftLeft, MouseEvents::Normal, none, false, false), + Some(MouseShape::Default) + ); + // ctrl+alt in text mode -> crosshair + let ca = Mods { + ctrl: true, + alt: true, + ..Mods::default() + }; + assert_eq!( + key_to_mouse_shape(Key::AltLeft, MouseEvents::None, ca, false, false), + Some(MouseShape::Crosshair) + ); + assert_eq!( + key_to_mouse_shape(Key::AltLeft, MouseEvents::None, none, false, false), + Some(MouseShape::Text) + ); + } +} diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs new file mode 100644 index 0000000..3e9a776 --- /dev/null +++ b/crates/ghostty-core/src/surface/mod.rs @@ -0,0 +1,1941 @@ +//! Surface represents a single terminal "surface" (port of `Surface.zig`). +//! +//! A terminal surface is a minimal "widget" where the terminal is drawn and +//! responds to events such as keyboard and mouse. Each surface also creates +//! and owns its pty session. +//! +//! The word "surface" is used because it is left to the higher level +//! application runtime to determine if the surface is a window, a tab, a +//! split, a preview pane in a larger window, etc. This struct doesn't care: +//! it just draws and responds to events. The events come from the +//! application runtime so the runtime can determine when and how those are +//! delivered (i.e. with focus, without focus, and so on). +//! +//! # Structure +//! +//! The Zig file is split into several Rust modules that all add `impl +//! Surface` blocks: +//! +//! * this file: the struct, creation/teardown (threads), the message +//! handler, config updates, sizing, fonts, focus, preedit, selection and +//! the [`CoreSurface`] implementation; +//! * [`clipboard`]: OSC 52, paste (safety, bracketed), desktop notifications; +//! * [`keyboard`]: key events, bindings, sequences, key tables, key encoding; +//! * [`mouse`]: mouse buttons/motion/scroll, mouse reporting, selection by +//! mouse, links; +//! * [`binding`]: `performBindingAction`, search, screen file dumps; +//! * [`config`]: the derived configuration. +//! +//! # Threading and interior mutability +//! +//! Every method runs on the runtime's main thread. Other threads (renderer, +//! IO, search) talk to the surface through the app [`Mailbox`]. Methods take +//! `&self`; state lives in `Cell`/`RefCell`. **Never hold a `RefCell` borrow +//! or the terminal lock across a call into the runtime** (`rt_app`, +//! `rt_surface`): the runtime may call straight back into this surface. + +pub mod binding; +pub mod clipboard; +pub mod config; +pub mod keyboard; +pub mod mouse; + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration as StdDuration, Instant}; + +use ghostty_apprt::action::{ + Action as RtAction, CellSize as ActionCellSize, ColorChange, ColorKind, CommandFinished, + ConfigChange, InitialSize, MouseVisibility, Pwd, ReloadConfig, SecureInput, SetTitle, + SizeLimit, Target, +}; +use ghostty_apprt::runtime_api::{ + ClipboardError, CoreError, CoreSurface, DesiredFontSize, InputEffect, RtApp, RtSurface, + SurfaceId, +}; +use ghostty_apprt::structs::{ + ClipboardRequest, ColorScheme, ContentScale, CursorPos, ImePos, SurfaceSize, +}; +use ghostty_apprt::surface::{Mailbox as SurfaceMailboxImpl, Message}; +use ghostty_config::conditional; +use ghostty_config::config::Config; +use ghostty_config::types_a::ConfirmCloseSurface; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_font::face::DesiredSize; +use ghostty_font::metrics::Metrics; +use ghostty_font::shared_grid_set::{DerivedConfig as FontDerivedConfig, Key as FontGridKey}; +use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods}; +use ghostty_renderer::generic::Host as RendererHost; +use ghostty_renderer::message::{FontGridChange, Message as RendererMessage}; +use ghostty_renderer::opengl::OpenGL; +use ghostty_renderer::options::Options as RendererOptions; +use ghostty_renderer::size::{CellSize, Coordinate, Padding, ScreenSize, Size}; +use ghostty_renderer::state::{Preedit, PreeditCodepoint, State as RenderStateData}; +use ghostty_renderer::thread::{Handle as RendererHandle, Running as RendererRunning}; +use ghostty_renderer::{Health, Renderer}; +use ghostty_terminal::page_list::Scrollbar; +use ghostty_terminal::point::Coordinate as PointCoordinate; +use ghostty_terminal::selection::Selection; +use ghostty_termio::exec; +use ghostty_termio::message::Message as IoMessage; +use ghostty_termio::{ + Backend, DerivedConfig as IoDerivedConfig, Exec, Mailbox as IoMailbox, Options as IoOptions, + RendererMailbox, SharedState, SharedStateExt, TerminalGuard, Termio, Thread as IoThread, + Wakeup, +}; + +use crate::app::{AppState, Mailbox, Message as AppMessage}; +use crate::global; +use config::DerivedConfig; + +/// Minimum window size in cells (Zig: `min_window_width_cells`). +pub const MIN_WINDOW_WIDTH_CELLS: u32 = ghostty_apprt::runtime_api::MIN_WINDOW_WIDTH_CELLS; +/// See [`MIN_WINDOW_WIDTH_CELLS`]. +pub const MIN_WINDOW_HEIGHT_CELLS: u32 = ghostty_apprt::runtime_api::MIN_WINDOW_HEIGHT_CELLS; + +/// The default DPI (Zig: `font.face.default_dpi`). +pub const DEFAULT_DPI: f32 = 96.0; + +/// The effect of an input event (Zig: `Surface.InputEffect`). +pub type InputEffectT = InputEffect; + +/// Mouse button count (Zig: `input.MouseButton.max`). +pub const MOUSE_BUTTON_COUNT: usize = 12; + +/// The surface mailbox type termio and the renderer use to message us. +type SurfaceMailboxT = SurfaceMailboxImpl; + +/// Bridge from termio to the renderer thread's mailbox. The IO side is +/// created before the renderer thread exists (the renderer needs the shared +/// terminal state which termio creates), so the handle is filled in later. +#[derive(Default)] +struct RendererLink { + handle: std::sync::OnceLock, +} + +impl RendererMailbox for RendererLink { + fn push(&self, msg: RendererMessage, timeout: Timeout) -> usize { + match self.handle.get() { + Some(h) => usize::from(h.push(msg, timeout)), + None => 0, + } + } +} + +/// What the renderer needs from us (thread-safe, non-blocking). +struct HostImpl { + id: SurfaceId, + mailbox: Mailbox, +} + +impl RendererHost for HostImpl { + fn scrollbar(&self, scrollbar: Scrollbar) -> bool { + self.mailbox.push( + AppMessage::SurfaceMessage { + surface: self.id, + message: Message::Scrollbar(scrollbar), + }, + Timeout::Instant, + ) > 0 + } + + fn renderer_health(&self, health: Health) { + self.mailbox.push( + AppMessage::SurfaceMessage { + surface: self.id, + message: Message::RendererHealth(health), + }, + Timeout::Forever, + ); + } + + fn redraw_surface(&self) -> bool { + self.mailbox + .push(AppMessage::RedrawSurface(self.id), Timeout::Instant) + > 0 + } +} + +/// Font state of the surface. +pub struct FontState { + /// The key of the font grid we hold a reference on. + pub grid_key: FontGridKey, + /// The desired size (points + DPI). + pub size: DesiredSize, + /// The metrics of the current grid. + pub metrics: Metrics, + /// This keeps track of if the font size was ever modified. If it wasn't, + /// then config reloading will change the font. If it was manually + /// adjusted, we don't change it on config reload since we assume the + /// user wants a specific size. + pub adjusted: bool, +} + +/// A single terminal surface. See the module docs. +pub struct Surface { + /// The identifier the runtime gave us (Zig: the `*Surface` pointer). + pub(crate) id: SurfaceId, + + /// The app that this surface is attached to. + pub(crate) app: Rc, + + /// The windowing system surface and app. + pub(crate) rt_app: Rc, + pub(crate) rt_surface: Rc, + + /// The font structures. + pub(crate) font: RefCell, + + /// The renderer for this surface. It is created and destroyed on the + /// main thread (GL context current) and drawn from the main thread. + pub(crate) renderer: Arc, + + /// The render state (terminal + preedit + mouse) behind the renderer + /// mutex (`renderer_state.mutex` in Zig). + pub(crate) renderer_state: SharedState, + + /// The renderer thread. + pub(crate) renderer_handle: RendererHandle, + renderer_thread: RefCell>, + + /// The IO thread and the mailbox used to send it messages. + pub(crate) io_mailbox: IoMailbox, + io_stop: Wakeup, + io_thread: RefCell>>, + + /// The command arguments the IO backend was started with (for the + /// abnormal exit message). + command_args: Vec, + + /// Mouse state. + pub(crate) mouse: RefCell, + + /// Keyboard input state. + pub(crate) keyboard: RefCell, + + /// A currently pressed key. This is used so that we can send a keyboard + /// release event when the surface is unfocused. See Zig docs. + pub(crate) pressed_key: RefCell>, + + /// All our sizing information. + pub(crate) size: Cell, + + /// The configuration derived from the main config. + config: RefCell>, + + /// The conditional state of the configuration. + pub(crate) config_conditional_state: Cell, + + /// This is set to true if our IO thread notifies us our child exited. + pub(crate) child_exited: Cell, + + /// We maintain our focus state and assume we're focused by default. + pub(crate) focused: Cell, + + /// Used to determine whether to continuously scroll. + pub(crate) selection_scroll_active: Cell, + + /// True if the surface is in read-only mode. When read-only, no input is + /// sent to the PTY but terminal-level operations like selections, + /// (native) scrolling, and copy keybinds still work. + pub(crate) readonly: Cell, + + /// Used to send notifications that long running commands have finished. + command_timer: Cell>, + + /// Search state. + pub(crate) search: RefCell>, + + /// Used to rate limit BEL handling. + last_bell_time: Cell>, + + /// Debug screenshot state (see `debug_screenshot`). + created: Instant, + debug_shot_done: Cell, +} + +impl Surface { + /// Create a new surface (Zig: `Surface.init`). This must be called from + /// the main thread with the GL context current. + pub fn new( + id: SurfaceId, + app: Rc, + config_original: Arc, + rt_app: Rc, + rt_surface: Rc, + ) -> Result, CoreError> { + // Apply our conditional state. If we fail to apply the conditional + // state then we log and attempt to move forward with the old config. + let config: Arc = + match config_original.change_conditional_state(app.config_conditional_state.get()) { + Ok(Some(mut c)) => { + // We want to preserve our original working directory. We + // preserve this so directory inheritance works. + c.working_directory = config_original.working_directory.clone(); + Arc::new(c) + } + Ok(None) => Arc::clone(&config_original), + Err(err) => { + log::warn!("failed to apply conditional state to config err={err}"); + Arc::clone(&config_original) + } + }; + + // Get our configuration + let derived_config = Rc::new(DerivedConfig::new(Arc::clone(&config))?); + + // Initialize our renderer with our initialized surface (Zig: + // `Renderer.surfaceInit`): load the GL functions for the current + // context. + let mut api = OpenGL::init(config.alpha_blending); + { + let rt = Rc::clone(&rt_surface); + api.prepare_context(|name| rt.gl_proc_address(name)) + .map_err(|e| format!("failed to prepare the OpenGL context: {e}"))?; + } + + // Determine our DPI configurations so we can properly configure + // font points to pixels and handle other high-DPI scaling factors. + let content_scale = rt_surface.get_content_scale(); + let x_dpi = content_scale.x * DEFAULT_DPI; + let y_dpi = content_scale.y * DEFAULT_DPI; + log::debug!( + "xscale={} yscale={} xdpi={x_dpi} ydpi={y_dpi}", + content_scale.x, + content_scale.y + ); + + // The font size we desire along with the DPI determined for the + // surface. + let font_size = DesiredSize { + points: config.font_size, + xdpi: x_dpi as u16, + ydpi: y_dpi as u16, + }; + + // Setup our font group. This will reuse an existing font group if it + // was already loaded. + let font_derived = FontDerivedConfig::new(&config); + let (font_grid_key, font_grid) = app.font_grid_set.ref_(&font_derived, font_size)?; + + // Build our size struct which has all the sizes we need. + let size = { + let surface_size = rt_surface.get_size(); + let mut size = Size { + screen: ScreenSize { + width: surface_size.width, + height: surface_size.height, + }, + cell: { + let c = font_grid.cell_size(); + CellSize { + width: c.width, + height: c.height, + } + }, + padding: Padding::default(), + }; + + let explicit = derived_config.scaled_padding(x_dpi, y_dpi); + if derived_config.window_padding_balance { + size.balance_padding(explicit); + } else { + size.padding = explicit; + } + size + }; + + let app_mailbox = app.mailbox.clone(); + let host: Arc = Arc::new(HostImpl { + id, + mailbox: app_mailbox.clone(), + }); + + // The renderer itself. + let renderer = Arc::new(Renderer::new( + RendererOptions { + config: ghostty_renderer::DerivedConfig::new(&config)?, + font_grid: Arc::clone(&font_grid), + size, + host: Arc::clone(&host), + }, + api, + )?); + + // The command we're going to execute + let command = if app.first.get() { + config + .initial_command + .clone() + .or_else(|| config.command.clone()) + } else { + config.command.clone() + }; + + // Start our IO implementation + let renderer_link = Arc::new(RendererLink::default()); + let renderer_wakeup = { + let link = Arc::clone(&renderer_link); + Wakeup::with_hook(Arc::new(move || { + if let Some(h) = link.handle.get() { + h.wakeup(); + } + }))? + }; + let io_mailbox = IoMailbox::new()?; + let surface_mailbox: Arc = Arc::new(SurfaceMailboxT { + surface: id, + app: app_mailbox.clone(), + }); + + let (termio, command_args) = { + let mut env = rt_surface_env(rt_surface.as_ref()); + // don't leak GHOSTTY_LOG to any subprocesses + env.remove("GHOSTTY_LOG"); + + let mut ecfg = exec::Config::new(env, config.term.clone()); + ecfg.command = command.clone(); + ecfg.env_override = config.env.clone(); + ecfg.shell_integration = config.shell_integration; + ecfg.shell_integration_features = config.shell_integration_features; + ecfg.cursor_blink = config.cursor_style_blink; + ecfg.working_directory = config + .working_directory + .as_ref() + .and_then(|wd| wd.value()) + .map(str::to_owned); + ecfg.resources_dir = global::state().resources_dir.host().map(str::to_owned); + ecfg.rt_pre_exec_info = ghostty_termio::command::RtPreExecInfo::init(&config); + ecfg.rt_post_fork_info = ghostty_termio::command::RtPostForkInfo::init(&config); + ecfg.create_scope = rt_surface.create_scope_fn(); + ecfg.restore_rlimit = global::state().rlimits.nofile; + + let exec = Exec::new(ecfg)?; + let args = exec.args().to_vec(); + let termio = Termio::init(IoOptions { + size, + full_config: &config, + config: IoDerivedConfig::new(&config), + backend: Backend::Exec(exec), + mailbox: io_mailbox.clone(), + renderer_wakeup, + renderer_mailbox: Arc::clone(&renderer_link) as Arc, + surface_mailbox: Arc::clone(&surface_mailbox), + })?; + (termio, args) + }; + let renderer_state = Arc::clone(termio.renderer_state()); + + // Create the renderer thread + let render_thread = ghostty_renderer::Thread::new( + &config, + Arc::clone(&renderer), + Arc::clone(&renderer_state), + host, + ); + let renderer_handle = render_thread.handle(); + let _ = renderer_link.handle.set(renderer_handle.clone()); + + // Create the IO thread + let io_thread = IoThread::new()?; + let io_stop = io_thread.stop_handle(); + + let surface = Rc::new(Surface { + id, + app: Rc::clone(&app), + rt_app: Rc::clone(&rt_app), + rt_surface: Rc::clone(&rt_surface), + font: RefCell::new(FontState { + grid_key: font_grid_key, + size: font_size, + metrics: font_grid.metrics(), + adjusted: false, + }), + renderer, + renderer_state, + renderer_handle, + renderer_thread: RefCell::new(None), + io_mailbox, + io_stop, + io_thread: RefCell::new(None), + command_args, + mouse: RefCell::new(mouse::Mouse::default()), + keyboard: RefCell::new(keyboard::Keyboard::default()), + pressed_key: RefCell::new(None), + size: Cell::new(size), + config: RefCell::new(derived_config), + config_conditional_state: Cell::new(app.config_conditional_state.get()), + child_exited: Cell::new(false), + focused: Cell::new(true), + selection_scroll_active: Cell::new(false), + readonly: Cell::new(false), + command_timer: Cell::new(None), + search: RefCell::new(None), + last_bell_time: Cell::new(None), + created: Instant::now(), + debug_shot_done: Cell::new(false), + }); + + // Report initial cell size on surface creation + rt_app.perform_action( + Target::Surface(id), + RtAction::CellSize(ActionCellSize { + width: size.cell.width, + height: size.cell.height, + }), + ); + + rt_app.perform_action( + Target::Surface(id), + RtAction::SizeLimit(SizeLimit { + min_width: size.cell.width * MIN_WINDOW_WIDTH_CELLS, + min_height: size.cell.height * MIN_WINDOW_HEIGHT_CELLS, + // No max: + max_width: 0, + max_height: 0, + }), + ); + + // Call our size callback which handles all our retina setup + // Note: this shouldn't be necessary and when we clean up the surface + // init stuff we should get rid of this. But this is required because + // sizeCallback does retina-aware stuff we don't do here and don't + // want to duplicate. + surface.resize(size.screen); + + // Start our renderer thread + *surface.renderer_thread.borrow_mut() = Some(render_thread.spawn()?); + + // Start our IO thread + *surface.io_thread.borrow_mut() = Some( + std::thread::Builder::new() + .name("io".into()) + .spawn(move || io_thread.thread_main(termio))?, + ); + + // Determine our initial window size if configured. We need to do + // this quite late in the process because our height/width are in + // grid dimensions, so we need to know our cell sizes first. + // + // Note: it is important to do this after the renderer is setup + // above. This allows the apprt to fully initialize the surface + // before we start messing with the window. + surface.recompute_initial_size(); + + if let Some(title) = &config.title { + rt_app.perform_action( + Target::Surface(id), + RtAction::SetTitle(SetTitle { + title: title.clone(), + }), + ); + } else if config.xdg_terminal_exec + && let Some(cmd) = &command + { + // For xdg-terminal-exec execution we special-case and set the + // window title to the command being executed. This allows window + // managers to set custom styling based on the command being + // executed. + rt_app.perform_action( + Target::Surface(id), + RtAction::SetTitle(SetTitle { + title: cmd.string(), + }), + ); + } else if let Some(cmd) = &command { + // If a user specifies a command it is appropriate to set the + // title as argv[0]. We know in the case of a direct command it + // has been supplied by the user. We won't set the title in the + // case the shell expands the command as that should typically be + // used to launch a shell which should set its own titles. + if let ghostty_config::command::Command::Direct(args) = cmd + && let Some(first) = args.first() + && !first.is_empty() + { + rt_app.perform_action( + Target::Surface(id), + RtAction::SetTitle(SetTitle { + title: first.clone(), + }), + ); + } + } + + // We are no longer the first surface + app.first.set(false); + + Ok(surface) + } + + // ------------------------------------------------------------------ + // Small shared helpers used by every submodule + + /// The runtime-assigned id of this surface. + #[must_use] + pub fn id(&self) -> SurfaceId { + self.id + } + + /// The current derived config (cheap `Rc` clone; no borrow is kept). + #[must_use] + pub fn cfg(&self) -> Rc { + Rc::clone(&self.config.borrow()) + } + + /// Lock the renderer state and borrow the terminal (the Zig + /// `renderer_state.mutex.lock()` + `io.terminal`). Do not hold across + /// calls into the runtime. + pub fn lock_terminal(&self) -> TerminalGuard<'_> { + self.renderer_state.lock_terminal() + } + + /// Lock the full render state (terminal + preedit + mouse). + pub fn lock_state(&self) -> std::sync::MutexGuard<'_, RenderStateData> { + self.renderer_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Perform a runtime action targeted at this surface, ignoring the + /// result (most callers only log failures in Zig). + pub(crate) fn perform(&self, action: RtAction) -> bool { + self.rt_app.perform_action(Target::Surface(self.id), action) + } + + /// Returns a mailbox that can be used to send messages to this surface. + pub(crate) fn surface_mailbox(&self) -> SurfaceMailboxT { + SurfaceMailboxT { + surface: self.id, + app: self.app.mailbox.clone(), + } + } + + /// Queue a message for the IO thread. + /// + /// We centralize all our logic into this spot so we can intercept + /// messages for example in readonly mode. Do NOT call this while holding + /// the terminal lock (use [`Surface::queue_io_locked`]). + pub(crate) fn queue_io(&self, msg: IoMessage) { + if self.readonly.get() && matches!(msg, IoMessage::Write(_)) { + return; + } + self.io_mailbox.send(msg); + self.io_mailbox.notify(); + } + + #[allow(dead_code)] + /// Like [`Surface::queue_io`] for callers holding the renderer lock: if + /// the queue is full the lock is released while blocking. + pub(crate) fn queue_io_locked<'a>( + &'a self, + msg: IoMessage, + guard: std::sync::MutexGuard<'a, RenderStateData>, + ) -> std::sync::MutexGuard<'a, RenderStateData> { + if self.readonly.get() && matches!(msg, IoMessage::Write(_)) { + return guard; + } + let guard = self + .io_mailbox + .send_locked(msg, &self.renderer_state, guard); + self.io_mailbox.notify(); + guard + } + + /// This queues a render operation with the renderer thread. The render + /// isn't guaranteed to happen immediately but it will happen as soon as + /// practical. + pub(crate) fn queue_render(&self) { + self.renderer_handle.wakeup(); + } + + #[allow(dead_code)] + /// Push a message to the renderer thread (blocking if full) and wake it. + pub(crate) fn send_renderer(&self, msg: RendererMessage) { + self.renderer_handle.send(msg); + } + + /// Current sizes. + #[must_use] + pub fn size(&self) -> Size { + self.size.get() + } + + /// Convert surface-relative pixel coordinates to a viewport cell + /// (Zig: `posToViewport`). + #[must_use] + pub fn pos_to_viewport(&self, xpos: f64, ypos: f64) -> PointCoordinate { + // Get our grid cell + let coord = Coordinate::Surface(ghostty_renderer::size::SurfaceCoord { x: xpos, y: ypos }); + match coord.convert(ghostty_renderer::size::CoordinateTag::Grid, self.size.get()) { + Coordinate::Grid(g) => PointCoordinate { + x: g.x, + y: u32::from(g.y), + }, + _ => PointCoordinate { x: 0, y: 0 }, + } + } + + /// Scroll to the bottom of the viewport. The caller holds the terminal + /// lock. + pub(crate) fn scroll_to_bottom(&self, t: &mut ghostty_terminal::terminal::Terminal) { + t.scroll_viewport(ghostty_terminal::terminal::ScrollViewport::Bottom); + self.queue_render(); + } + + pub(crate) fn hide_mouse(&self) { + { + let mut m = self.mouse.borrow_mut(); + if m.hidden { + return; + } + m.hidden = true; + } + self.perform(RtAction::MouseVisibility(MouseVisibility::Hidden)); + } + + pub(crate) fn show_mouse(&self) { + { + let mut m = self.mouse.borrow_mut(); + if !m.hidden { + return; + } + m.hidden = false; + } + self.perform(RtAction::MouseVisibility(MouseVisibility::Visible)); + } + + // ------------------------------------------------------------------ + // Lifecycle + + /// Close this surface. This will trigger the runtime to start the close + /// process, which should ultimately deinitialize this surface. + pub fn close(&self) { + self.rt_surface.close(self.needs_confirm_quit()); + } + + /// True if the surface requires confirmation to quit. This should be + /// called by apprt to determine if the surface should confirm before + /// quitting. + #[must_use] + pub fn needs_confirm_quit(&self) -> bool { + // If the surface is in read-only mode, always require confirmation + if self.readonly.get() { + return true; + } + + // If the child has exited, then our process is certainly not alive. + // We check this first to avoid the locking overhead below. + if self.child_exited.get() { + return false; + } + + // Check the configuration for confirming close behavior. + match self.cfg().confirm_close_surface { + ConfirmCloseSurface::Always => true, + ConfirmCloseSurface::False => false, + ConfirmCloseSurface::True => !self.lock_terminal().cursor_is_at_prompt(), + } + } + + /// Forces the surface to render (Zig: `draw`). Renderers are required + /// to support `draw_frame` being called from the main thread. + pub fn draw(&self) -> Result<(), CoreError> { + self.renderer.draw_frame(true)?; + Ok(()) + } + + // ------------------------------------------------------------------ + // Messages (called from the app thread) + + /// Called from the app thread to handle mailbox messages to our specific + /// surface. + pub fn handle_message(&self, msg: Message) -> Result<(), CoreError> { + match msg { + Message::ChangeConfig(config) => self.update_config(&config)?, + + Message::SetTitle(buf) => { + // We ignore the message in case the title was set via config. + if self.cfg().title.is_some() { + log::debug!( + "ignoring title change request since static title is set via config" + ); + return Ok(()); + } + + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let title = String::from_utf8_lossy(&buf[..end]).into_owned(); + log::debug!("changing title \"{title}\""); + self.perform(RtAction::SetTitle(SetTitle { title })); + } + + Message::ReportTitle(style) => { + if !self.cfg().title_report { + log::info!("report_title requested, but disabled via config"); + return Ok(()); + } + + let title = self.rt_surface.get_title(); + let data = match style { + ghostty_apprt::surface::ReportTitleStyle::Csi21T => { + format!("\x1b]l{}\x1b\\", title.unwrap_or_default()) + } + }; + self.queue_io(IoMessage::write_req(data.as_bytes())); + } + + Message::ColorChange(change) => { + // Notify our apprt, but don't send a mode 2031 DSR report + // because VT sequences were used to change the color. + use ghostty_vt::color::Dynamic as D; + use ghostty_vt::osc::color::Target; + let kind = match change.target { + Target::Palette(v) => ColorKind::Palette(i32::from(v)), + Target::Dynamic(dyn_) => match dyn_ { + D::Foreground => ColorKind::Foreground, + D::Background => ColorKind::Background, + D::Cursor => ColorKind::Cursor, + // Unsupported dynamic color change notification type + _ => return Ok(()), + }, + // Special colors aren't supported for change notification + Target::Special(_) => return Ok(()), + }; + self.perform(RtAction::ColorChange(ColorChange { + kind, + r: change.color.r, + g: change.color.g, + b: change.color.b, + })); + } + + Message::SetMouseShape(shape) => { + log::debug!("changing mouse shape: {shape:?}"); + self.perform(RtAction::MouseShape(shape)); + } + + Message::ClipboardRead(clipboard) => { + if self.cfg().clipboard_read == ghostty_config::types_b::ClipboardAccess::Deny { + log::info!( + "application attempted to read clipboard, but 'clipboard-read' is set to deny" + ); + return Ok(()); + } + + self.start_clipboard_request( + ghostty_apprt::structs::Clipboard::Standard, + ClipboardRequest::Osc52Read(clipboard), + ); + } + + Message::ClipboardWrite { + clipboard_type, + req, + } => { + self.clipboard_write(req.slice(), clipboard_type); + } + + Message::PwdChange(w) => { + let pwd = String::from_utf8_lossy(w.slice()).into_owned(); + self.perform(RtAction::Pwd(Pwd { pwd })); + } + + Message::Close => self.close(), + + Message::ChildExited(info) => self.child_exited(info), + + Message::DesktopNotification(notification) => { + if !self.cfg().desktop_notifications { + log::info!( + "application attempted to display a desktop notification, but 'desktop-notifications' is disabled" + ); + return Ok(()); + } + + let title = String::from_utf8_lossy(notification.title()).into_owned(); + let body = String::from_utf8_lossy(notification.body()).into_owned(); + self.show_desktop_notification(&title, &body); + } + + Message::RendererHealth(health) => self.update_renderer_health(health), + + Message::Scrollbar(scrollbar) => self.update_scrollbar(scrollbar), + + Message::PresentSurface => { + self.perform(RtAction::PresentTerminal); + } + + Message::PasswordInput(v) => self.password_input(v), + + Message::RingBell => { + let now = Instant::now(); + if let Some(last) = self.last_bell_time.get() + && now.duration_since(last) < StdDuration::from_millis(100) + { + return Ok(()); + } + self.last_bell_time.set(Some(now)); + self.perform(RtAction::RingBell); + } + + Message::ProgressReport(v) => { + self.perform(RtAction::ProgressReport(v)); + } + + Message::SelectionScrollTick(active) => { + self.selection_scroll_active.set(active); + self.selection_scroll_tick()?; + } + + Message::StartCommand => { + self.command_timer.set(Some(Instant::now())); + } + + Message::StopCommand(code) => { + let end = Instant::now(); + let Some(start) = self.command_timer.take() else { + return Ok(()); + }; + + let duration = ghostty_config::types_b::Duration { + duration: end.duration_since(start).as_nanos() as u64, + }; + log::debug!("command took {duration:?}"); + + self.perform(RtAction::CommandFinished(CommandFinished { + exit_code: code, + duration, + })); + } + + Message::SearchTotal(v) => { + self.perform(RtAction::SearchTotal(ghostty_apprt::action::SearchTotal { + total: v, + })); + } + + Message::SearchSelected(v) => { + self.perform(RtAction::SearchSelected( + ghostty_apprt::action::SearchSelected { selected: v }, + )); + } + } + Ok(()) + } + + /// The child process exited. + fn child_exited(&self, info: ghostty_apprt::surface::ChildExited) { + // Mark our flag that we exited immediately + self.child_exited.set(true); + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if info.runtime_ms <= u64::from(self.cfg().abnormal_command_exit_runtime_ms) { + // If the exit code is 0 then it was a good exit. + if info.exit_code != 0 { + log::warn!("abnormal process exit detected, showing error message"); + + // Try and show a GUI message. If it returns true, don't do + // anything else. + if self.perform(RtAction::ShowChildExited(info)) { + return; + } + + // If a native GUI notification was not shown, update our + // terminal to note the abnormal exit. + if let Err(err) = self.child_exited_abnormally(info) { + log::error!("error handling abnormal child exit err={err}"); + } + + return; + } + } + + // We output a message so that the user knows what's going on and + // doesn't think their terminal just froze. We show this + // unconditionally on close even if `wait_after_command` is false and + // the surface closes immediately because if a user does an `undo` to + // restore a closed surface then they will see this message and know + // the process has completed. + // + // First try and show a native GUI message. If the native GUI can't + // be shown, display a text message in the terminal. + if !self.perform(RtAction::ShowChildExited(info)) { + let mut t = self.lock_terminal(); + t.carriage_return(); + let ok = t.linefeed().is_ok() + && t.print_string("Process exited. Press any key to close the terminal.") + .is_ok(); + if ok { + t.modes.set(ghostty_vt::modes::Mode::CursorVisible, false); + + // We also want to ensure that normal keyboard encoding is on + // so that we can close the terminal. We close the terminal + // on any key press that encodes a character. + t.modes.set(ghostty_vt::modes::Mode::DisableKeyboard, false); + t.screen_mut().kitty_keyboard.set( + ghostty_vt::kitty::key::SetMode::Set, + ghostty_vt::kitty::key::Flags::DISABLED, + ); + } + } + + // Waiting after command we stop here. The terminal is updated, our + // state is updated, and now its up to the user to decide what to do. + if self.cfg().wait_after_command { + return; + } + + // If we aren't waiting after the command, then we exit immediately + // with no confirmation. + self.close(); + } + + /// Called when the child process exited abnormally. + fn child_exited_abnormally( + &self, + info: ghostty_apprt::surface::ChildExited, + ) -> Result<(), ghostty_terminal::page::Error> { + use ghostty_vt::sgr::Attribute; + + // Build up our command for the error message + let command = self.command_args.join(" "); + let runtime_str = format!("{} ms", info.runtime_ms); + + let mut t = self.lock_terminal(); + + // No matter what move the cursor back to the column 0. + t.carriage_return(); + + // Reset styles + t.set_attribute(Attribute::Unset)?; + + // If there is data in the viewport, we want to scroll down a little + // bit and write a horizontal rule before writing our message. This + // lets the use see the error message the command may have output. + let viewport_str = t.plain_string()?; + if !viewport_str.is_empty() { + t.linefeed()?; + for _ in 0..t.cols { + t.print(0x2501)?; + } + t.carriage_return(); + t.linefeed()?; + t.linefeed()?; + } + + // Output our error message + t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::BRIGHT_RED))?; + t.set_attribute(Attribute::Bold)?; + t.print_string("Ghostty failed to launch the requested command:")?; + t.set_attribute(Attribute::Unset)?; + + t.carriage_return(); + t.linefeed()?; + t.linefeed()?; + t.print_string(&command)?; + t.set_attribute(Attribute::Unset)?; + + t.carriage_return(); + t.linefeed()?; + t.linefeed()?; + t.print_string("Runtime: ")?; + t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?; + t.print_string(&runtime_str)?; + t.set_attribute(Attribute::Unset)?; + + let exit_code_str = info.exit_code.to_string(); + t.carriage_return(); + t.linefeed()?; + t.print_string("Exit Code: ")?; + t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?; + t.print_string(&exit_code_str)?; + t.set_attribute(Attribute::Unset)?; + + t.carriage_return(); + t.linefeed()?; + t.linefeed()?; + t.print_string("Press any key to close the window.")?; + + // Hide the cursor + t.modes.set(ghostty_vt::modes::Mode::CursorVisible, false); + Ok(()) + } + + /// Called when the terminal detects there is a password input prompt. + fn password_input(&self, v: bool) { + { + let mut t = self.lock_terminal(); + + // If our password input state is unchanged then we don't waste + // time doing anything more. + if t.flags.password_input == v { + return; + } + + t.flags.password_input = v; + } + + // Notify our apprt so it can do whatever it wants. + self.perform(RtAction::SecureInput(if v { + SecureInput::On + } else { + SecureInput::Off + })); + + self.queue_render(); + } + + /// Called when our renderer health state changes. + fn update_renderer_health(&self, health: Health) { + log::warn!("renderer health status change status={health:?}"); + self.perform(RtAction::RendererHealth(health)); + } + + /// Called when the scrollbar state changes. + fn update_scrollbar(&self, scrollbar: Scrollbar) { + self.perform(RtAction::Scrollbar(scrollbar)); + } + + /// This should be called anytime `config_conditional_state` changes so + /// that the apprt can reload the configuration. + fn notify_config_conditional_state(&self) { + self.perform(RtAction::ReloadConfig(ReloadConfig { soft: true })); + } + + // ------------------------------------------------------------------ + // Configuration + + /// Update our configuration at runtime. This can be called by the apprt + /// to set a surface-specific configuration that differs from the app or + /// other surfaces. + pub fn update_config(&self, original: &Arc) -> Result<(), CoreError> { + // Apply our conditional state. If we fail to apply the conditional + // state then we log and attempt to move forward with the old config. + let config: Arc = + match original.change_conditional_state(self.config_conditional_state.get()) { + Ok(Some(c)) => Arc::new(c), + Ok(None) => Arc::clone(original), + Err(err) => { + log::warn!("failed to apply conditional state to config err={err}"); + Arc::clone(original) + } + }; + + // Update our new derived config immediately + let derived = match DerivedConfig::new(Arc::clone(&config)) { + Ok(d) => Rc::new(d), + Err(err) => { + // If the derivation fails then we just log and return. We + // don't hard fail in this case because we don't want to + // error the surface when config fails we just want to keep + // using the old config. + log::error!("error updating configuration err={err}"); + return Ok(()); + } + }; + *self.config.borrow_mut() = Rc::clone(&derived); + + // If our mouse is hidden but we disabled mouse hiding, then show it + // again. + if !derived.mouse_hide_while_typing && self.mouse.borrow().hidden { + self.show_mouse(); + } + + // If we are in the middle of a key sequence, clear it. + self.end_key_sequence(keyboard::SequenceAction::Drop, keyboard::SequenceFree::Free); + + // Deactivate all key tables since they may have changed. Importantly, + // we store references into the config as part of our table stack so + // we can't keep them active across config changes. But this behavior + // also matches key sequences. + self.deactivate_all_key_tables(); + + // Before sending any other config changes, we give the renderer a + // new font grid. We could check to see if there was an actual change + // to the font, but this is easier and pretty rare so it's not a + // performance concern. + // + // (Calling set_font_size builds and sends a new font grid to the + // renderer.) + let font_size = { + let f = self.font.borrow(); + // If we have manually adjusted the font size, keep it that way. + if f.adjusted { + log::info!( + "font size manually adjusted, preserving previous size on config reload" + ); + f.size + } else { + // If we haven't, then we update to the configured font size. + // This allows config changes to update the font size. + let mut size = f.size; + size.points = config.font_size.clamp(1.0, 255.0); + size + } + }; + self.set_font_size(font_size)?; + + // We need to store our configs in a heap-allocated pointer so that + // our messages aren't huge. + let renderer_message = RendererMessage::init_change_config(&config)?; + self.renderer_handle.send(renderer_message); + self.queue_io(IoMessage::ChangeConfig(Box::new(IoDerivedConfig::new( + &config, + )))); + + // With mailbox messages sent, we have to wake them up so they + // process it. + self.queue_render(); + + // If we have a title set then we update our window to have the newly + // configured title. + if let Some(title) = &config.title { + self.perform(RtAction::SetTitle(SetTitle { + title: title.clone(), + })); + } + + // Notify the window + self.perform(RtAction::ConfigChange(ConfigChange { config })); + Ok(()) + } + + /// Recalculate the initial size of the window based on the + /// configuration and invoke the apprt `initial_size` action if + /// necessary. + fn recompute_initial_size(&self) { + let cfg = self.cfg(); + + // Both width and height must be set for this to work, as documented + // on the config options. + if cfg.window_height == 0 || cfg.window_width == 0 { + return; + } + + let scale = self.rt_surface.get_content_scale(); + let size = self.size.get(); + let height = cfg.window_height.max(MIN_WINDOW_HEIGHT_CELLS) * size.cell.height; + let width = cfg.window_width.max(MIN_WINDOW_WIDTH_CELLS) * size.cell.width; + let width_f32 = width as f32; + let height_f32 = height as f32; + + // The final values are affected by content scale and we need to + // account for the padding so we get the exact correct grid size. + let final_width = + (width_f32 / scale.x).ceil() as u32 + size.padding.left + size.padding.right; + let final_height = + (height_f32 / scale.y).ceil() as u32 + size.padding.top + size.padding.bottom; + + self.perform(RtAction::InitialSize(InitialSize { + width: final_width, + height: final_height, + })); + } + + // ------------------------------------------------------------------ + // Text access + + /// Returns true if the terminal has a selection. + #[must_use] + pub fn has_selection(&self) -> bool { + self.lock_terminal().screen().selection.is_some() + } + + /// Returns the selected text. + #[must_use] + pub fn selection_string(&self) -> Option { + let t = self.lock_terminal(); + let sel = t.screen().selection?; + Some( + t.screen() + .selection_string(ghostty_terminal::screen::SelectionString { sel, trim: false }), + ) + } + + /// Returns the pwd of the terminal, if any. This is always copied + /// because the pwd can change at any point from termio. + #[must_use] + pub fn pwd(&self) -> Option { + let t = self.lock_terminal(); + let pwd = t.get_pwd()?; + Some(String::from_utf8_lossy(pwd).into_owned()) + } + + /// Returns the x/y coordinate of where the IME (Input Method Editor) + /// keyboard should be rendered. + #[must_use] + pub fn ime_point(&self) -> ImePos { + let (cursor_x, cursor_y, preedit_width) = { + let st = self.lock_state(); + let cursor = &st.terminal.screen().cursor; + ( + u32::from(cursor.x), + u32::from(cursor.y), + st.preedit.as_ref().map_or(0, Preedit::width) as u32, + ) + }; + + // TODO: need to handle when scrolling and the cursor is not in the + // visible portion of the screen. + + // Our sizes are all scaled so we need to send the unscaled values + // back. + let content_scale = self.rt_surface.get_content_scale(); + let size = self.size.get(); + + // Simple x * cell width gives the top-left corner, then add padding + // offset + let mut x = f64::from(cursor_x * size.cell.width + size.padding.left); + // We want the midpoint + x += f64::from(size.cell.width) / 2.0; + // And scale it + x /= f64::from(content_scale.x); + + // Simple y * cell height gives the top-left corner, then add padding + // offset + let mut y = f64::from(cursor_y * size.cell.height + size.padding.top); + // We want the bottom + y += f64::from(size.cell.height); + // And scale it + y /= f64::from(content_scale.y); + + // Our height for now is always just the cell height because our + // preedit rendering only renders in a single line. + let height = f64::from(size.cell.height) / f64::from(content_scale.y); + + let mut width = f64::from(preedit_width * size.cell.width); + + // Our max width is the remaining screen width after the cursor. We + // don't have to deal with wrapping because the preedit doesn't wrap + // right now. + let screen_width = f64::from(size.terminal().width); + let x_offset = f64::from((cursor_x + 1) * size.cell.width); + let max = screen_width - x_offset; + width = width.min(max); + + ImePos { + x, + y, + width, + height, + } + } + + // ------------------------------------------------------------------ + // Selection + + /// Set the selection contents. This must be called with the renderer + /// mutex held (pass the locked terminal). Copy-on-select clipboard + /// writes happen here too. + pub(crate) fn set_selection( + &self, + t: &mut ghostty_terminal::terminal::Terminal, + sel: Option, + ) -> Result<(), ghostty_terminal::page::Error> { + let prev = t.screen().selection; + t.screen_mut().select(sel)?; + + let cfg = self.cfg(); + + // If copy on select is false then exit early. + use ghostty_config::types_b::CopyOnSelect; + if cfg.copy_on_select == CopyOnSelect::False { + return Ok(()); + } + + // Set our selection clipboard. If the selection is cleared we do not + // clear the clipboard. If the selection is set, we only set the + // clipboard again if it changed, since setting the clipboard can be + // an expensive operation. + let Some(sel) = sel else { + return Ok(()); + }; + if let Some(prev) = prev + && sel.eql(&t.screen().pages, &prev) + { + return Ok(()); + } + + use ghostty_apprt::structs::Clipboard; + match cfg.copy_on_select { + CopyOnSelect::False => unreachable!("handled above with an early exit"), + + // Both standard and selection clipboards are set. + CopyOnSelect::Clipboard => self.copy_selection_to_clipboards( + t, + sel, + &[Clipboard::Standard, Clipboard::Selection], + ghostty_input::binding::CopyToClipboard::Mixed, + ), + + // The selection clipboard is set if supported, otherwise the + // standard. + CopyOnSelect::True => { + let clipboard = if self.rt_surface.supports_clipboard(Clipboard::Selection) { + Clipboard::Selection + } else { + Clipboard::Standard + }; + self.copy_selection_to_clipboards( + t, + sel, + &[clipboard], + ghostty_input::binding::CopyToClipboard::Mixed, + ); + } + } + Ok(()) + } + + // ------------------------------------------------------------------ + // Fonts and sizes + + /// Change the cell size for the terminal grid. This can happen as a + /// result of changing the font size at runtime. + fn set_cell_size(&self, size: CellSize) { + // Update our cell size within our size struct + let mut s = self.size.get(); + s.cell = size; + self.size.set(s); + self.balance_padding_if_needed(); + + // Notify the terminal + self.queue_io(IoMessage::Resize(self.size.get())); + + // Update our terminal default size if necessary. + self.recompute_initial_size(); + + // Notify the window + self.perform(RtAction::CellSize(ActionCellSize { + width: size.width, + height: size.height, + })); + } + + /// Change the font size. This can only be called from the main thread. + pub fn set_font_size(&self, size: DesiredSize) -> Result<(), CoreError> { + log::debug!("set font size size={}", size.points); + + // Update our font size so future changes work + self.font.borrow_mut().size = size; + + // We need to build up a new font stack for this font size. + let derived = FontDerivedConfig::new(&self.cfg().config); + let (font_grid_key, font_grid) = self.app.font_grid_set.ref_(&derived, size)?; + + // Set our cell size + let metrics = font_grid.metrics(); + self.set_cell_size(CellSize { + width: metrics.cell_width, + height: metrics.cell_height, + }); + + // Notify our render thread of the new font stack. The renderer MUST + // accept the new font grid and deref the old. + let old_key = { + let mut f = self.font.borrow_mut(); + let old = std::mem::replace(&mut f.grid_key, font_grid_key.clone()); + f.metrics = metrics; + old + }; + self.renderer_handle + .send(RendererMessage::FontGrid(Box::new(FontGridChange { + grid: font_grid, + set: Arc::clone(&self.app.font_grid_set), + new_key: font_grid_key, + old_key, + }))); + + // Schedule render which also drains our mailbox + self.queue_render(); + Ok(()) + } + + /// The current font size (points + dpi) for runtime queries. + #[must_use] + pub fn font_size(&self) -> DesiredSize { + self.font.borrow().size + } + + /// Called when the runtime's surface size changes. + pub fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError> { + let new_screen_size = ScreenSize { + width: size.width, + height: size.height, + }; + + // Update our screen size, but only if it actually changed. And if + // the screen size didn't change, then our grid size could not have + // changed, so we just return. + if self.size.get().screen.equals(new_screen_size) { + return Ok(()); + } + + self.resize(new_screen_size); + Ok(()) + } + + fn resize(&self, size: ScreenSize) { + // Save our screen size + let mut s = self.size.get(); + s.screen = size; + self.size.set(s); + self.balance_padding_if_needed(); + let s = self.size.get(); + + // Recalculate our grid size. Because Ghostty supports fluid + // resizing, its possible the grid doesn't change at all even if the + // screen size changes. We have to update the IO thread no matter + // what because we send pixel-level sizing to the subprocess. + let grid_size = s.grid(); + if grid_size.columns < 5 && (s.padding.left > 0 || s.padding.right > 0) { + log::warn!( + "WARNING: very small terminal grid detected with padding set. Is your padding reasonable?" + ); + } + if grid_size.rows < 2 && (s.padding.top > 0 || s.padding.bottom > 0) { + log::warn!( + "WARNING: very small terminal grid detected with padding set. Is your padding reasonable?" + ); + } + + // Mail the IO thread + self.queue_io(IoMessage::Resize(s)); + } + + /// Recalculate the balanced padding if needed. + fn balance_padding_if_needed(&self) { + let cfg = self.cfg(); + if !cfg.window_padding_balance { + return; + } + let content_scale = self.rt_surface.get_content_scale(); + let x_dpi = content_scale.x * DEFAULT_DPI; + let y_dpi = content_scale.y * DEFAULT_DPI; + let mut s = self.size.get(); + s.balance_padding(cfg.scaled_padding(x_dpi, y_dpi)); + self.size.set(s); + } + + /// Called to set the preedit state for character input. Preedit is used + /// with dead key states, for example, when typing an accent character. + /// This should be called with `None` to reset the preedit state. + /// + /// The core surface will NOT reset the preedit state on charCallback or + /// keyCallback and we rely completely on the apprt implementation to + /// track the preedit state correctly. + pub fn preedit_callback(&self, preedit: Option<&str>) -> Result<(), CoreError> { + let cfg = self.cfg(); + let mut st = self.lock_state(); + + // We clear our selection when ANY OF: + // 1. We have an existing preedit + // 2. We have preedit text + if (st.preedit.is_some() || preedit.is_some()) && cfg.selection_clear_on_typing { + let _ = self.set_selection(&mut st.terminal, None); + } + + // We always clear our prior preedit + st.preedit = None; + + // Mark preedit dirty flag + st.terminal.flags.dirty.preedit = true; + + // If we have no text, we're done. We queue a render in case we + // cleared a prior preedit (likely). + let Some(text) = preedit else { + drop(st); + self.queue_render(); + return Ok(()); + }; + + // We convert the UTF-8 text to codepoints. + let mut codepoints: Vec = Vec::new(); + for cp in text.chars() { + let width = usize::from(ghostty_unicode::table().get(u32::from(cp)).width); + + // I've never seen a preedit text with a zero-width character. In + // theory its possible but we can't really handle it right now. + // Let's just ignore it. + if width == 0 { + continue; + } + + codepoints.push(PreeditCodepoint { + codepoint: u32::from(cp), + wide: width >= 2, + }); + } + + if !codepoints.is_empty() { + st.preedit = Some(Preedit { codepoints }); + } + drop(st); + self.queue_render(); + Ok(()) + } + + /// Callback for when the surface is fully visible or not, regardless of + /// focus state. + pub fn occlusion_callback(&self, visible: bool) -> Result<(), CoreError> { + self.renderer_handle.send(RendererMessage::Visible(visible)); + self.queue_render(); + Ok(()) + } + + /// Called when the surface gains or loses focus. + pub fn focus_callback(&self, focused: bool) -> Result<(), CoreError> { + // If our focus state is the same we do nothing. + if self.focused.get() == focused { + return Ok(()); + } + self.focused.set(focused); + + // Notify our render thread of the new state + self.renderer_handle.send(RendererMessage::Focus(focused)); + + if focused { + // Notify our app if we gained focus. + self.app.focus_surface(self.id); + } else { + // If we lost focus and we have a keypress, then we want to send + // a key release event for it. + self.release_pressed_key_on_unfocus(); + } + + // Schedule render which also drains our mailbox + self.queue_render(); + + // Whenever our focus changes we unhide the mouse. The mouse will be + // hidden again if the user starts typing. This helps alleviate some + // buggy behavior upstream in macOS with the mouse never becoming + // visible again when tabbing between programs (see #2525). + self.show_mouse(); + + // Update the focus state and notify the terminal + self.lock_terminal().flags.focused = focused; + self.queue_io(IoMessage::Focused(focused)); + Ok(()) + } + + /// Schedule a render (Zig: `refreshCallback`). + pub fn refresh_callback(&self) -> Result<(), CoreError> { + // The point of this callback is to schedule a render, so do that. + self.queue_render(); + Ok(()) + } + + /// The content scale (DPI) of the surface changed. + pub fn content_scale_callback(&self, content_scale: ContentScale) -> Result<(), CoreError> { + // Calculate the new DPI + let x_dpi = content_scale.x * DEFAULT_DPI; + let y_dpi = content_scale.y * DEFAULT_DPI; + + // Update our font size which is dependent on the DPI + let current = self.font.borrow().size; + let mut size = current; + size.xdpi = x_dpi as u16; + size.ydpi = y_dpi as u16; + + // If our DPI didn't actually change, save a lot of work by doing + // nothing. + if size.xdpi == current.xdpi && size.ydpi == current.ydpi { + return Ok(()); + } + + self.set_font_size(size)?; + + // Update our padding which is dependent on DPI. We only do this for + // unbalanced padding since balanced padding is not dependent on DPI. + let cfg = self.cfg(); + if !cfg.window_padding_balance { + let mut s = self.size.get(); + s.padding = cfg.scaled_padding(x_dpi, y_dpi); + self.size.set(s); + } + + // Force a resize event because the change in padding will affect + // pixel-level changes to the renderer and viewport. + self.resize(self.size.get().screen); + Ok(()) + } + + /// The system color scheme changed. + pub fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError> { + let new_scheme = match scheme { + ColorScheme::Light => conditional::Theme::Light, + ColorScheme::Dark => conditional::Theme::Dark, + }; + + // If our scheme didn't change, then we don't do anything. + let mut state = self.config_conditional_state.get(); + if state.theme == new_scheme { + return Ok(()); + } + + // Setup our conditional state which has the current color theme. + state.theme = new_scheme; + self.config_conditional_state.set(state); + self.notify_config_conditional_state(); + + // If mode 2031 is on, then we report the change live. + self.queue_io(IoMessage::ColorSchemeReport { force: false }); + Ok(()) + } + + /// Tell the surface to present itself to the user. + pub fn present_surface(&self) { + self.perform(RtAction::PresentTerminal); + } + + // ------------------------------------------------------------------ + // Renderer display lifecycle (GTK GLArea realize/unrealize) + + /// Developer aid: if `GHOSTTY_DEBUG_SCREENSHOT=` is set, write + /// the last presented frame to that path once, `GHOSTTY_DEBUG_SCREENSHOT_MS` + /// (default 2500) after the surface was created. Must be called with the + /// GL context current (from `draw_frame`). + fn debug_screenshot(&self) { + let Some(path) = std::env::var_os("GHOSTTY_DEBUG_SCREENSHOT") else { + return; + }; + let delay = std::env::var("GHOSTTY_DEBUG_SCREENSHOT_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(2500); + 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; + }; + 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() { + flipped.extend_from_slice(row); + } + let write = || -> Result<(), Box> { + 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}"), + } + } + + /// The GLArea was (re)realized; the GL context is current. + pub fn renderer_realized(&self) -> Result<(), CoreError> { + let rt = Rc::clone(&self.rt_surface); + self.renderer + .display_realized(|name| rt.gl_proc_address(name))?; + Ok(()) + } + + /// The GLArea is being unrealized; the GL context is current. + pub fn renderer_unrealized(&self) { + self.renderer.display_unrealized(); + } +} + +impl Drop for Surface { + /// Zig: `Surface.deinit`. + fn drop(&mut self) { + // Stop search thread + if let Some(s) = self.search.get_mut().take() { + s.deinit(); + } + + // Stop rendering thread + if let Some(running) = self.renderer_thread.get_mut().take() { + running.stop_and_join(); + } + + // Stop our IO thread + if let Err(err) = self.io_stop.notify() { + log::error!("error notifying io thread to stop, may stall err={err}"); + } + if let Some(join) = self.io_thread.get_mut().take() + && join.join().is_err() + { + log::warn!("io thread panicked"); + } + + // Clean up our font grid + let key = self.font.get_mut().grid_key.clone(); + self.app.font_grid_set.deref(&key); + + log::info!("surface closed id={}", self.id); + } +} + +/// The environment for the child: whatever the runtime wants plus the +/// process environment (Zig: `rt_surface.defaultTermioEnv()` falling back to +/// `getEnvMap`). +fn rt_surface_env(rt: &dyn RtSurface) -> std::collections::HashMap { + let mut env = ghostty_os::env::get_env_map(); + for (k, v) in rt.default_termio_env() { + env.insert(k, v); + } + env +} + +// ---------------------------------------------------------------------- +// CoreSurface (what the runtime calls) + +impl CoreSurface for Surface { + fn config(&self) -> Arc { + Arc::clone(&self.cfg().config) + } + + fn key_callback(&self, event: ghostty_input::KeyEvent<'_>) -> Result { + Surface::key_callback(self, event) + } + + fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError> { + Surface::preedit_callback(self, text) + } + + fn focus_callback(&self, focused: bool) -> Result<(), CoreError> { + Surface::focus_callback(self, focused) + } + + fn mouse_button_callback( + &self, + state: MouseButtonState, + button: MouseButton, + mods: Mods, + ) -> Result { + Surface::mouse_button_callback(self, state, button, mods) + } + + fn cursor_pos_callback(&self, pos: CursorPos, mods: Option) -> Result<(), CoreError> { + Surface::cursor_pos_callback(self, pos, mods) + } + + fn scroll_callback(&self, xoff: f64, yoff: f64, mods: ScrollMods) -> Result<(), CoreError> { + Surface::scroll_callback(self, xoff, yoff, mods) + } + + fn mouse_pressure_callback(&self, stage: ghostty_input::MousePressureStage, pressure: f64) { + Surface::mouse_pressure_callback(self, stage, pressure); + } + + fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError> { + Surface::size_callback(self, size) + } + + fn content_scale_callback(&self, scale: ContentScale) -> Result<(), CoreError> { + Surface::content_scale_callback(self, scale) + } + + fn ime_point(&self) -> ImePos { + Surface::ime_point(self) + } + + fn has_selection(&self) -> bool { + Surface::has_selection(self) + } + + fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError> { + Surface::color_scheme_callback(self, scheme) + } + + fn update_config(&self, config: &Arc) -> Result<(), CoreError> { + Surface::update_config(self, config) + } + + fn perform_binding_action( + &self, + action: &ghostty_input::binding::Action, + ) -> Result { + Surface::perform_binding_action(self, action) + } + + fn complete_clipboard_request( + &self, + req: ClipboardRequest, + data: &str, + confirmed: bool, + ) -> Result<(), ClipboardError> { + Surface::complete_clipboard_request(self, req, data, confirmed) + } + + fn set_clipboard_read_allowed(&self, allow: bool) { + use ghostty_config::types_b::ClipboardAccess; + self.set_config_clipboard( + true, + if allow { + ClipboardAccess::Allow + } else { + ClipboardAccess::Deny + }, + ); + } + + fn set_clipboard_write_allowed(&self, allow: bool) { + use ghostty_config::types_b::ClipboardAccess; + self.set_config_clipboard( + false, + if allow { + ClipboardAccess::Allow + } else { + ClipboardAccess::Deny + }, + ); + } + + fn needs_confirm_quit(&self) -> bool { + Surface::needs_confirm_quit(self) + } + + fn child_exited(&self) -> bool { + self.child_exited.get() + } + + fn renderer_realized(&self) -> Result<(), CoreError> { + Surface::renderer_realized(self) + } + + fn renderer_unrealized(&self) { + Surface::renderer_unrealized(self); + } + + fn draw_frame(&self, _default_framebuffer: u32) -> Result<(), CoreError> { + // GTK's GLArea binds its own FBO before `render`; the renderer + // queries GL_FRAMEBUFFER_BINDING itself. + self.renderer.draw_frame(false)?; + self.debug_screenshot(); + Ok(()) + } + + fn id(&self) -> Option { + Some(self.id) + } + + fn readonly(&self) -> bool { + self.readonly.get() + } + + fn grid_size(&self) -> (u32, u32) { + let g = self.size.get().grid(); + (u32::from(g.columns), u32::from(g.rows)) + } + + fn font_size(&self) -> DesiredFontSize { + let s = self.font.borrow().size; + DesiredFontSize { + points: s.points, + xdpi: s.xdpi, + ydpi: s.ydpi, + } + } +} diff --git a/crates/ghostty-core/src/surface/mouse.rs b/crates/ghostty-core/src/surface/mouse.rs new file mode 100644 index 0000000..cfaf515 --- /dev/null +++ b/crates/ghostty-core/src/surface/mouse.rs @@ -0,0 +1,2472 @@ +//! Mouse handling for a surface (Zig `Surface.zig`: mouse reporting, button, +//! cursor position, scroll, selection by mouse, links). +//! +//! Locking rules: the renderer-state lock is only held while touching the +//! terminal. Anything that calls into the runtime (actions, clipboard +//! requests, opening URLs) and any message for the IO thread is collected in +//! a [`Deferred`] and executed after the lock is released. + +use std::time::Instant; + +use ghostty_apprt::action::{Action as RtAction, MouseOverLink, OpenUrl, OpenUrlKind}; +use ghostty_apprt::runtime_api::CoreError; +use ghostty_apprt::structs::{Clipboard, ClipboardRequest, CursorPos}; +use ghostty_config::types_a::LinkPreviews; +use ghostty_config::types_b::{ + CopyOnSelect, MouseShiftCapture as ConfigShiftCapture, RightClickAction, +}; +use ghostty_input::link::{Action as LinkAction, Highlight}; +use ghostty_input::{Mods, MouseButton, MouseButtonState, MousePressureStage, ScrollMods}; +use ghostty_renderer::size::{Coordinate as SizeCoordinate, CoordinateTag, Size, SurfaceCoord}; +use ghostty_renderer::state::State as RenderStateData; +use ghostty_terminal::page_list::{Direction, PageList, Pin, TrackedPin}; +use ghostty_terminal::point::{Coordinate as PointCoordinate, Point}; +use ghostty_terminal::screen::{Screen, SelectLine, SelectionString, SemanticClick}; +use ghostty_terminal::screen_set::Key as ScreenKey; +use ghostty_terminal::selection::Selection; +use ghostty_terminal::terminal::{ + MouseEvents, MouseFormat, MouseShiftCapture as TerminalShiftCapture, ScrollViewport, Terminal, +}; +use ghostty_termio::message::Message as IoMessage; +use ghostty_vt::modes::Mode; + +use super::{MOUSE_BUTTON_COUNT, Surface}; +use crate::surface_mouse; + +/// Mouse state for the surface (Zig: `Surface.Mouse`). +pub struct Mouse { + /// The last tracked mouse button state by button. + pub click_state: [MouseButtonState; MOUSE_BUTTON_COUNT], + + /// The last mods state when the last mouse button (whatever it was) was + /// pressed or release. + pub mods: Mods, + + /// The point at which the left mouse click happened. This is in screen + /// coordinates so that scrolling preserves the location. Tracked in the + /// page list of `left_click_screen`. + pub left_click_pin: Option, + pub left_click_screen: ScreenKey, + + /// The starting xpos/ypos of the left click. Note that if scrolling + /// occurs, these will point to different "cells", but the xpos/ypos will + /// stay stable during scrolling relative to the surface. + pub left_click_xpos: f64, + pub left_click_ypos: f64, + + /// The count of clicks to count double and triple clicks and so on. The + /// left click time was the last time the left click was done. This is + /// always set on the first left click. + pub left_click_count: u8, + pub left_click_time: Option, + + /// The last x/y sent for mouse reports. + pub event_point: Option, + + /// The pressure stage for the mouse. This should always be none if the + /// mouse is not pressed. + pub pressure_stage: MousePressureStage, + + /// Pending scroll amounts for high-precision scrolls + pub pending_scroll_x: f64, + pub pending_scroll_y: f64, + + /// True if the mouse is hidden + pub hidden: bool, + + /// True if the mouse position is currently over a link. + pub over_link: bool, + + /// The last x/y in the cursor position for links. We use this to only + /// process link hover events when the mouse actually moves cells. + pub link_point: Option, +} + +impl Default for Mouse { + fn default() -> Self { + Mouse { + click_state: [MouseButtonState::Release; MOUSE_BUTTON_COUNT], + mods: Mods::default(), + left_click_pin: None, + left_click_screen: ScreenKey::Primary, + left_click_xpos: 0.0, + left_click_ypos: 0.0, + left_click_count: 0, + left_click_time: None, + event_point: None, + pressure_stage: MousePressureStage::None, + pending_scroll_x: 0.0, + pending_scroll_y: 0.0, + hidden: false, + over_link: false, + link_point: None, + } + } +} + +/// The type of action to report for a mouse event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MouseReportAction { + Press, + Release, + Motion, +} + +/// The amount to scroll. This structure is always normalized so that +/// negative is down, left and positive is up, right. Note that INTERNALLY, +/// vertical scroll on our terminal uses positive for down. +#[derive(Debug, Clone, Copy, Default)] +struct ScrollAmount { + delta: isize, +} + +impl ScrollAmount { + fn up_right(self) -> bool { + self.delta >= 0 + } + + fn magnitude(self) -> usize { + self.delta.unsigned_abs() + } +} + +/// A link found under the mouse. +#[derive(Debug, Clone, Copy)] +struct Link { + action: LinkAction, + selection: Selection, +} + +/// Work that must happen after the renderer lock is released. +#[derive(Default)] +struct Deferred { + actions: Vec, + io: Vec, + render: bool, +} + +impl Deferred { + fn run(self, s: &Surface) { + for m in self.io { + s.queue_io(m); + } + for a in self.actions { + s.perform(a); + } + if self.render { + s.queue_render(); + } + } +} + +// ---------------------------------------------------------------------- +// Pure selection logic + +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as mouse mods and screen size. +#[must_use] +pub fn mouse_selection( + list: &PageList, + click_pin: Pin, + drag_pin: Pin, + click_x: u32, + drag_x: u32, + mods: Mods, + size: Size, +) -> Option { + // Explanation: + // + // # Normal selections + // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold + // point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold + // point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that entire + // columns are considered rather than individual cells. + + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of 60% + // of the cell width was chosen empirically because it felt good. + let threshold_point = (f64::from(size.cell.width) * 0.6).round() as u32; + + // We use this to clamp the pixel positions below. + let max_x = (u32::from(size.grid().columns) * size.cell.width).saturating_sub(1); + + // We need to know how far across in the cell the drag pos is, so we + // subtract the padding and then take it modulo the cell width. + let drag_x_frac = max_x.min(drag_x.saturating_sub(size.padding.left)) % size.cell.width; + + // We figure out the fractional part of the click x position similarly. + let click_x_frac = max_x.min(click_x.saturating_sub(size.padding.left)) % size.cell.width; + + // Whether or not this is a rectangular selection. + let rectangle_selection = surface_mouse::is_rectangle_select_state(mods); + + // Whether the click pin and drag pin are equal. + let same_pin = drag_pin.eql(click_pin); + + // Whether or not the end point of our selection is before the start + // point. + let end_before_start = if same_pin { + drag_x_frac < click_x_frac + } else if rectangle_selection { + // Special handling for rectangular selections, we only use x + // position. + match drag_pin.x.cmp(&click_pin.x) { + std::cmp::Ordering::Equal => drag_x_frac < click_x_frac, + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Greater => false, + } + } else { + drag_pin.before(list, click_pin) + }; + + // Whether or not the the click pin cell should be included in the + // selection. + let include_click_cell = if end_before_start { + click_x_frac >= threshold_point + } else { + click_x_frac < threshold_point + }; + + // Whether or not the the drag pin cell should be included in the + // selection. + let include_drag_cell = if end_before_start { + drag_x_frac < threshold_point + } else { + drag_x_frac >= threshold_point + }; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + let start_pin = if include_click_cell { + click_pin + } else if end_before_start { + if rectangle_selection { + click_pin.left_clamp(1) + } else { + click_pin.left_wrap(list, 1).unwrap_or(click_pin) + } + } else if rectangle_selection { + click_pin.right_clamp(list, 1) + } else { + click_pin.right_wrap(list, 1).unwrap_or(click_pin) + }; + + // Likewise for the end pin with the drag cell. + let end_pin = if include_drag_cell { + drag_pin + } else if end_before_start { + if rectangle_selection { + drag_pin.right_clamp(list, 1) + } else { + drag_pin.right_wrap(list, 1).unwrap_or(drag_pin) + } + } else if rectangle_selection { + drag_pin.left_clamp(1) + } else { + drag_pin.left_wrap(list, 1).unwrap_or(drag_pin) + }; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not be + // included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + if (!include_click_cell && same_pin) + || (!include_click_cell && rectangle_selection && click_pin.x == drag_pin.x) + || (!include_click_cell && end_pin.eql(click_pin)) + || (!include_click_cell && rectangle_selection && end_pin.x == click_pin.x) + || (!include_drag_cell && start_pin.eql(drag_pin)) + || (!include_drag_cell && rectangle_selection && start_pin.x == drag_pin.x) + { + return None; + } + + // TODO: Clamp selection to the screen area, don't let it extend past the + // last written row. + + Some(Selection::new(start_pin, end_pin, rectangle_selection)) +} + +/// Double-click dragging moves the selection one "word" at a time. Returns +/// `None` when the selection should be cleared. +fn drag_left_click_double_selection( + screen: &Screen, + click_pin: Pin, + drag_pin: Pin, + word_chars: &[u32], +) -> Option { + let list = &screen.pages; + + // Get the word closest to our starting click. + let word_start = screen.select_word_between(click_pin, drag_pin, word_chars)?; + + // Get the word closest to our current point. + let word_current = screen.select_word_between(drag_pin, click_pin, word_chars)?; + + // If our current mouse position is before the starting position, then + // the selection start is the word nearest our current position. + if drag_pin.before(list, click_pin) { + Some(Selection::new( + word_current.start(list), + word_start.end(list), + false, + )) + } else { + Some(Selection::new( + word_start.start(list), + word_current.end(list), + false, + )) + } +} + +/// Triple-click dragging moves the selection one "line" at a time. `None` +/// means do nothing. +fn drag_left_click_triple_selection( + screen: &Screen, + click_pin: Pin, + drag_pin: Pin, +) -> Option { + let list = &screen.pages; + + // Get the line selection under our current drag point. If there isn't a + // line, do nothing. + let line = screen.select_line(SelectLine::new(drag_pin))?; + + // Get the selection under our click point. We first try to trim + // whitespace if we've selected a word. But if no word exists then we + // select the blank line. + let sel = screen.select_line(SelectLine::new(click_pin)).or_else(|| { + screen.select_line(SelectLine { + whitespace: None, + ..SelectLine::new(click_pin) + }) + })?; + + if drag_pin.before(list, click_pin) { + Some(Selection::new( + line.start(list), + sel.end(list), + sel.rectangle, + )) + } else { + Some(Selection::new( + sel.start(list), + line.end(list), + sel.rectangle, + )) + } +} + +/// Resolve a relative file path to an absolute path using `pwd` (Zig +/// `resolvePathForOpening`). Returns `None` for absolute paths (use as-is), +/// when there's no pwd, or when the resolved path doesn't exist. +fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option { + use std::path::{Component, Path, PathBuf}; + + if Path::new(path).is_absolute() { + return None; + } + + let pwd = pwd?; + let joined = Path::new(pwd).join(path); + + // Lexically normalize (std.fs.path.resolve). + let mut out = PathBuf::new(); + for c in joined.components() { + match c { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + + if !out.exists() { + return None; + } + Some(out.to_string_lossy().into_owned()) +} + +impl Surface { + // ------------------------------------------------------------------ + // Helpers + + /// Returns true if mouse reporting is enabled both in the config and + /// the terminal state. + fn is_mouse_reporting(&self, t: &Terminal) -> bool { + self.cfg().mouse_reporting && t.flags.mouse_event != MouseEvents::None + } + + /// Returns true if the shift modifier is allowed to be captured by + /// modifier events. It is up to the caller to still verify it is a + /// situation in which shift capture makes sense (i.e. left button, mouse + /// click, etc.) + pub(crate) fn mouse_shift_capture(&self, t: &Terminal) -> bool { + let cfg = self.cfg(); + + // Handle our never/always case where we don't need the terminal. + match cfg.mouse_shift_capture { + ConfigShiftCapture::Never => return false, + ConfigShiftCapture::Always => return true, + ConfigShiftCapture::False | ConfigShiftCapture::True => {} + } + + // If the terminal explicitly requests it then we always allow it + // since we processed never/always at this point. + match t.flags.mouse_shift_capture { + TerminalShiftCapture::False => return false, + TerminalShiftCapture::True => return true, + TerminalShiftCapture::Null => {} + } + + // Otherwise, go with the user's preference + matches!(cfg.mouse_shift_capture, ConfigShiftCapture::True) + } + + /// Returns true if the mouse is currently captured by the terminal + /// (i.e. reporting events). + pub fn mouse_captured(&self) -> bool { + self.lock_terminal().flags.mouse_event != MouseEvents::None + } + + /// This returns the mouse mods to consider for link highlighting or + /// other purposes taking into account when shift is pressed for + /// releasing the mouse from capture. Takes the renderer lock. + #[allow(dead_code)] + pub(crate) fn mouse_mods_with_capture(&self, mods: Mods) -> Mods { + let t = self.lock_terminal(); + self.mouse_mods_with_capture_locked(&t, mods) + } + + fn mouse_mods_with_capture_locked(&self, t: &Terminal, mods: Mods) -> Mods { + // In any of these scenarios, whatever mods are set (even shift) are + // preserved. + if t.flags.mouse_event == MouseEvents::None { + return mods; + } + if !mods.shift { + return mods; + } + if self.mouse_shift_capture(t) { + return mods; + } + + // We have mouse capture, shift set, and we're not allowed to + // capture shift, so we can clear shift. + let mut final_ = mods; + final_.shift = false; + final_ + } + + /// Call this when modifiers change. This is safe to call even if + /// modifiers match the previous state. + /// + /// The renderer state mutex MUST NOT be held. + pub(crate) fn mods_changed(&self, mods: Mods) { + // The only place we keep track of mods currently is on the mouse. + let new_mods = { + let mut m = self.mouse.borrow_mut(); + if m.mods.equal(mods) { + return; + } + + // The mouse mods only contain binding modifiers since we don't + // want caps/num lock or sided modifiers to affect the mouse. + m.mods = mods.binding(); + m.mods + }; + + // We also need to update the renderer so it knows if it should + // highlight links. Additionally, mark the screen as dirty so that + // the highlight state of all links is properly updated. + { + let mut st = self.lock_state(); + let capture = self.mouse_mods_with_capture_locked(&st.terminal, new_mods); + st.mouse.mods = capture; + + // We use the clear screen dirty flag to force a rebuild of all + // rows because changing mouse mods can affect the highlight + // state of a link. If there is no link this seems very wasteful + // but its really only one frame so it's not so bad. + st.terminal.flags.dirty.clear = true; + } + + self.queue_render(); + } + + // ------------------------------------------------------------------ + // Mouse reporting + + /// Build the bytes for a mouse report (Zig `mouseReport`). Returns + /// `None` if nothing should be sent. The caller holds the terminal + /// lock and `m` is the (borrowed) mouse state. + fn mouse_report( + &self, + t: &Terminal, + m: &mut Mouse, + button: Option, + action: MouseReportAction, + mods: Mods, + pos: CursorPos, + ) -> Option> { + // Mouse reporting must be enabled by both config and terminal state + debug_assert!(self.cfg().mouse_reporting); + debug_assert_ne!(t.flags.mouse_event, MouseEvents::None); + + // Depending on the event, we may do nothing at all. + match t.flags.mouse_event { + MouseEvents::None => return None, + + // X10 only reports clicks with mouse button 1, 2, 3. We verify + // the button later. + MouseEvents::X10 => { + if action != MouseReportAction::Press + || !matches!( + button, + Some(MouseButton::Left | MouseButton::Right | MouseButton::Middle) + ) + { + return None; + } + } + + // Doesn't report motion + MouseEvents::Normal => { + if action == MouseReportAction::Motion { + return None; + } + } + + // Button must be pressed + MouseEvents::Button => { + button?; + } + + // Everything + MouseEvents::Any => {} + } + + let size = self.size.get(); + + // Handle scenarios where the mouse position is outside the + // viewport. We always report release events no matter where they + // happen. + if action != MouseReportAction::Release { + let max_x = size.screen.width as f32; + let max_y = size.screen.height as f32; + let pos_out_viewport = pos.x < 0.0 || pos.y < 0.0 || pos.x > max_x || pos.y > max_y; + if pos_out_viewport { + // If we don't have a motion-tracking event mode, do nothing. + if !t.flags.mouse_event.motion() { + return None; + } + + // If any button is pressed, we still do the report. + // Otherwise, we do not do the report. + if m.click_state + .iter() + .all(|s| *s == MouseButtonState::Release) + { + return None; + } + } + } + + // This format reports X/Y + let viewport_point = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + + // Record our new point. We only want to send a mouse event if the + // cell changed, unless we're tracking raw pixels. + if action == MouseReportAction::Motion + && t.flags.mouse_format != MouseFormat::SgrPixels + && let Some(last) = m.event_point + && last == viewport_point + { + return None; + } + m.event_point = Some(viewport_point); + + let format = t.flags.mouse_format; + + // Get the code we'll actually write + let button_code: u8 = { + // Determine our initial button value + let mut acc: u8 = match button { + // Null button means motion without a button pressed + None => 3, + // Release is 3. It is NOT 3 in SGR mode because SGR can tell + // the application what button was released. + Some(_) + if action == MouseReportAction::Release + && format != MouseFormat::Sgr + && format != MouseFormat::SgrPixels => + { + 3 + } + Some(b) => match b { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + MouseButton::Four => 64, + MouseButton::Five => 65, + MouseButton::Six => 66, + MouseButton::Seven => 67, + MouseButton::Eight => 128, + MouseButton::Nine => 129, + _ => return None, // unsupported + }, + }; + + // X10 doesn't have modifiers + if t.flags.mouse_event != MouseEvents::X10 { + if mods.shift { + acc += 4; + } + if mods.alt { + acc += 8; + } + if mods.ctrl { + acc += 16; + } + } + + // Motion adds another bit + if action == MouseReportAction::Motion { + acc += 32; + } + + acc + }; + + let x = u32::from(viewport_point.x); + let y = viewport_point.y; + + match format { + MouseFormat::X10 => { + if x > 222 || y > 222 { + log::info!("X10 mouse format can only encode X/Y up to 223"); + return None; + } + + // + 1 below is because our x/y is 0-indexed and the protocol + // wants 1 + Some(vec![ + 0x1b, + b'[', + b'M', + 32 + button_code, + 32 + x as u8 + 1, + 32 + y as u8 + 1, + ]) + } + + MouseFormat::Utf8 => { + let mut data = vec![0x1b, b'[', b'M', 32 + button_code]; + + // UTF-8 encode the x/y + let mut buf = [0u8; 4]; + for v in [32 + x + 1, 32 + y + 1] { + let c = char::from_u32(v)?; + data.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + Some(data) + } + + MouseFormat::Sgr => { + // Final character to send in the CSI + let final_ = if action == MouseReportAction::Release { + 'm' + } else { + 'M' + }; + Some(format!("\x1b[<{button_code};{};{}{final_}", x + 1, y + 1).into_bytes()) + } + + MouseFormat::Urxvt => Some( + format!("\x1b[{};{};{}M", 32 + u32::from(button_code), x + 1, y + 1).into_bytes(), + ), + + MouseFormat::SgrPixels => { + // Final character to send in the CSI + let final_ = if action == MouseReportAction::Release { + 'm' + } else { + 'M' + }; + + // The position has to be adjusted to the terminal space. + let coord = SizeCoordinate::Surface(SurfaceCoord { + x: f64::from(pos.x), + y: f64::from(pos.y), + }) + .convert(CoordinateTag::Terminal, size); + let (cx, cy) = match coord { + SizeCoordinate::Terminal(c) => (c.x, c.y), + _ => (0.0, 0.0), + }; + + Some( + format!( + "\x1b[<{button_code};{};{}{final_}", + cx.round() as i32, + cy.round() as i32 + ) + .into_bytes(), + ) + } + } + } + + // ------------------------------------------------------------------ + // Scrolling + + /// Mouse scroll event. Negative is down, left. Positive is up, right. + /// + /// "Natural scrolling" is a macOS term for inverting the scroll + /// direction. This should be handled by the apprt implementation. At + /// this layer, negative is always down, left. + pub fn scroll_callback( + &self, + xoff: f64, + yoff: f64, + scroll_mods: ScrollMods, + ) -> Result<(), CoreError> { + // Always show the mouse again if it is hidden + let hidden = self.mouse.borrow().hidden; + if hidden { + self.show_mouse(); + } + + let cfg = self.cfg(); + let size = self.size.get(); + + let (y, x) = { + let mut m = self.mouse.borrow_mut(); + + let y = if yoff == 0.0 { + ScrollAmount::default() + } else { + // We use cell_size to determine if we have accumulated + // enough to trigger a scroll + let cell_size = f64::from(size.cell.height); + + // If we have precision scroll, yoff is the number of pixels + // to scroll. In non-precision scroll, yoff is the number of + // wheel ticks. Some mice are capable of reporting fractional + // wheel ticks, which don't necessarily get reported as + // precision scrolls. We normalize all scroll events to + // pixels by multiplying the wheel tick value and the cell + // size. This means that a wheel tick of 1 results in single + // scroll event. + let yoff_adjusted = if scroll_mods.precision { + yoff * cfg.mouse_scroll_multiplier.precision + } else { + // Round out the yoff to an absolute minimum of 1. macos + // tries to simulate precision scrolling with non + // precision events by ramping up the magnitude of the + // offsets as it detects faster scrolling. Single click + // (very slow) scrolls are reported with a magnitude of + // 0.1 which would normally require a few clicks before + // we register an actual scroll event (depending on cell + // height and the mouse_scroll_multiplier setting). + let yoff_max = if yoff > 0.0 { + yoff.max(1.0) + } else { + yoff.min(-1.0) + }; + + yoff_max * cell_size * cfg.mouse_scroll_multiplier.discrete + }; + + // Add our previously saved pending amount to the offset to + // get the new offset value. The signs of the pending and + // yoff should match so that we move further away from zero, + // but we don't assert this because in theory a user could + // scroll in the opposite direction and undo a pending + // scroll. + let poff = m.pending_scroll_y + yoff_adjusted; + + // If the new offset is less than a single unit of scroll, + // we save the new pending value and do not scroll yet. + if poff.abs() < cell_size { + m.pending_scroll_y = poff; + ScrollAmount::default() + } else { + // We scroll by the number of rows in the offset and save + // the remainder + let amount = poff / cell_size; + m.pending_scroll_y = poff - (amount * cell_size); + + // Round towards zero. + ScrollAmount { + delta: amount.trunc() as isize, + } + } + }; + + // For detailed comments see the y calculation above. + let x = if xoff == 0.0 { + ScrollAmount::default() + } else if !scroll_mods.precision { + ScrollAmount { + delta: xoff.round() as isize, + } + } else { + let poff = m.pending_scroll_x + xoff; + let cell_size = f64::from(size.cell.width); + if poff.abs() < cell_size { + m.pending_scroll_x = poff; + ScrollAmount::default() + } else { + let amount = poff / cell_size; + m.pending_scroll_x = poff - (amount * cell_size); + ScrollAmount { + delta: amount.trunc() as isize, + } + } + }; + + (y, x) + }; + + // The cursor position is only needed when reporting; fetch it before + // taking the lock. + let pos = self.rt_surface.get_cursor_pos(); + + let mut writes: Vec = Vec::new(); + { + let mut t = self.lock_terminal(); + + // If we have an active mouse reporting mode, clear the + // selection. The selection can occur if the user uses the shift + // mod key to override mouse grabbing from the window. + if self.is_mouse_reporting(&t) { + let _ = self.set_selection(&mut t, None); + } + + // If we're in alternate screen with alternate scroll enabled, + // then we convert to cursor keys. This only happens if we're: + // (1) alt screen (2) no explicit mouse reporting and (3) + // alt scroll mode enabled. + if t.screens.active_key == ScreenKey::Alternate + && t.flags.mouse_event == MouseEvents::None + && t.modes.get(Mode::MouseAlternateScroll) + { + if y.delta != 0 { + // When we send mouse events as cursor keys we always + // clear the selection. + let _ = self.set_selection(&mut t, None); + + let seq: &'static [u8] = if t.modes.get(Mode::CursorKeys) { + // cursor key: application mode + if y.up_right() { b"\x1bOA" } else { b"\x1bOB" } + } else { + // cursor key: normal mode + if y.up_right() { b"\x1b[A" } else { b"\x1b[B" } + }; + for _ in 0..y.magnitude() { + writes.push(IoMessage::write_stable(seq)); + } + } + drop(t); + for w in writes { + self.queue_io(w); + } + return Ok(()); + } + + // We have mouse events, are not in an alternate scroll buffer, + // or have alternate scroll disabled. In this case, we just run + // the normal logic. + + // If we're scrolling up or down, then send a mouse event. + if self.is_mouse_reporting(&t) { + let mods = self.mouse.borrow().mods; + for _ in 0..y.magnitude() { + let button = if y.up_right() { + MouseButton::Four + } else { + MouseButton::Five + }; + let mut m = self.mouse.borrow_mut(); + if let Some(b) = self.mouse_report( + &t, + &mut m, + Some(button), + MouseReportAction::Press, + mods, + pos, + ) { + writes.push(IoMessage::write_req(&b)); + } + } + + for _ in 0..x.magnitude() { + let button = if x.up_right() { + MouseButton::Six + } else { + MouseButton::Seven + }; + let mut m = self.mouse.borrow_mut(); + if let Some(b) = self.mouse_report( + &t, + &mut m, + Some(button), + MouseReportAction::Press, + mods, + pos, + ) { + writes.push(IoMessage::write_req(&b)); + } + } + + // If mouse reporting is on, we do not want to scroll the + // viewport. + drop(t); + for w in writes { + self.queue_io(w); + } + return Ok(()); + } + + if y.delta != 0 { + // Modify our viewport, this requires a lock since it affects + // rendering. We have to switch signs here because our delta + // is negative down but our viewport is positive down. + t.scroll_viewport(ScrollViewport::Delta(-y.delta)); + } + } + + self.queue_render(); + Ok(()) + } + + // ------------------------------------------------------------------ + // Button + + /// Called for mouse button press/release events. This will return true + /// if the mouse event was consumed in some way (i.e. the program is + /// capturing mouse events). If the event was not consumed, then false is + /// returned. + pub fn mouse_button_callback( + &self, + action: MouseButtonState, + button: MouseButton, + mods: Mods, + ) -> Result { + let cfg = self.cfg(); + + // Always record our latest mouse state + let hidden = { + let mut m = self.mouse.borrow_mut(); + let idx = button as usize; + if idx < MOUSE_BUTTON_COUNT { + m.click_state[idx] = action; + } + m.hidden + }; + + // Always show the mouse again if it is hidden + if hidden { + self.show_mouse(); + } + + // Update our modifiers if they changed + self.mods_changed(mods); + + // This is set to true if the terminal is allowed to capture the + // shift modifier. Note we can do this more efficiently probably with + // less locking/unlocking but clicking isn't that frequent enough to + // be a bottleneck. + let shift_capture = { + let t = self.lock_terminal(); + self.mouse_shift_capture(&t) + }; + + // Shift-click continues the previous mouse state if we have a + // selection. cursorPosCallback will also do a mouse report so we + // don't need to do any of the logic below. + if button == MouseButton::Left && action == MouseButtonState::Press { + let click_count = self.mouse.borrow().left_click_count; + if mods.shift && click_count > 0 && !shift_capture && self.has_selection() { + // If we are within the interval that the click would + // register an increment then we do not extend the selection. + let within_interval = self + .mouse + .borrow() + .left_click_time + .is_some_and(|t| t.elapsed().as_nanos() as u64 <= cfg.mouse_interval); + + if !within_interval { + let pos = self.rt_surface.get_cursor_pos(); + self.cursor_pos_callback(pos, None)?; + return Ok(true); + } + } + } + + if button == MouseButton::Left && action == MouseButtonState::Release { + // Stop selection scrolling when releasing the left mouse button + // but only when selection scrolling is active. + if self.selection_scroll_active.get() { + self.queue_io(IoMessage::SelectionScroll(false)); + } + + // The selection clipboard is only updated for left-click drag + // when the left button is released. This is to avoid the + // clipboard being updated on every mouse move which would be + // noisy. + if cfg.copy_on_select != CopyOnSelect::False { + let mut t = self.lock_terminal(); + if let Some(prev) = t.screen().selection { + let list = &t.screen().pages; + let sel = Selection::new(prev.start(list), prev.end(list), prev.rectangle); + self.copy_on_select(&mut t, sel); + } + } + + // Handle link clicking. We want to do this before we do mouse + // reporting or any other mouse handling because a successfully + // clicked link will swallow the event. + let over_link = self.mouse.borrow().over_link; + if over_link { + let pos = self.rt_surface.get_cursor_pos(); + match self.process_links(pos) { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(err) => log::warn!("error processing links err={err}"), + } + } + + // Handle prompt clicking. If we released our mouse on a prompt + // and we support some kind of click events, then we need to move + // to it. + if self.maybe_prompt_click() { + return Ok(true); + } + } + + // Report mouse events if enabled + { + let mut t = self.lock_terminal(); + if self.is_mouse_reporting(&t) { + // If we have shift-pressed and we aren't allowed to capture + // it, then we do not do a mouse report. + if !(mods.shift && !shift_capture) { + // In any other mouse button scenario without shift + // pressed we clear the selection since the underlying + // application can handle that in any way (i.e. + // "scrolling"). + let _ = self.set_selection(&mut t, None); + + // We also set the left click count to 0 so that if mouse + // reporting is disabled in the middle of press (before + // release) we don't suddenly start selecting text. + let pos = self.rt_surface.get_cursor_pos(); + let report_action = match action { + MouseButtonState::Press => MouseReportAction::Press, + MouseButtonState::Release => MouseReportAction::Release, + }; + + let bytes = { + let mut m = self.mouse.borrow_mut(); + m.left_click_count = 0; + let mods = m.mods; + self.mouse_report(&t, &mut m, Some(button), report_action, mods, pos) + }; + drop(t); + if let Some(b) = bytes { + self.queue_io(IoMessage::write_req(&b)); + } + + // If we're doing mouse reporting, we do not support any + // other selection or highlighting. + return Ok(true); + } + } + } + + // For left button clicks we always record some information for + // selection/highlighting purposes. + if button == MouseButton::Left && action == MouseButtonState::Press { + self.left_press(mods, &cfg); + } + + // Middle-click pastes from our selection clipboard + if button == MouseButton::Middle && action == MouseButtonState::Press { + let clipboard = if self.rt_surface.supports_clipboard(Clipboard::Selection) { + Clipboard::Selection + } else { + Clipboard::Standard + }; + self.start_clipboard_request(clipboard, ClipboardRequest::Paste); + } + + // Right-click down selects word for context menus. If the apprt + // doesn't implement context menus this can be a bit weird but they + // are supported by our two main apprts so we always do this. If we + // want to be careful in the future we can add a function to apprts + // that let's us know. + if button == MouseButton::Right + && action == MouseButtonState::Press + && let Some(consumed) = self.right_press(&cfg) + { + return Ok(consumed); + } + + Ok(false) + } + + /// Copy the selection per `copy-on-select` (the clipboard half of Zig + /// `setSelection`) without changing the selection. + fn copy_on_select(&self, t: &mut Terminal, sel: Selection) { + let cfg = self.cfg(); + let format = ghostty_input::binding::CopyToClipboard::Mixed; + match cfg.copy_on_select { + CopyOnSelect::False => {} + CopyOnSelect::Clipboard => self.copy_selection_to_clipboards( + t, + sel, + &[Clipboard::Standard, Clipboard::Selection], + format, + ), + CopyOnSelect::True => { + let clipboard = if self.rt_surface.supports_clipboard(Clipboard::Selection) { + Clipboard::Selection + } else { + Clipboard::Standard + }; + self.copy_selection_to_clipboards(t, sel, &[clipboard], format); + } + } + } + + /// Left button press: record click state and start/extend selection by + /// click count. + fn left_press(&self, mods: Mods, cfg: &super::config::DerivedConfig) { + let pos = self.rt_surface.get_cursor_pos(); + let (xpos, ypos) = (f64::from(pos.x), f64::from(pos.y)); + let size = self.size.get(); + let vp = self.pos_to_viewport(xpos, ypos); + + let mut t = self.lock_terminal(); + let active_key = t.screens.active_key; + + let Some(pin) = t.screen().pages.pin(Point::Viewport(vp)) else { + // Weird... our viewport x/y that we just converted isn't found in + // our pages. This is probably a bug but we don't want to crash + // in releases because its harmless. + debug_assert!(false, "viewport pin not found"); + return; + }; + let Ok(tracked) = t.screen_mut().pages.track_pin(pin) else { + return; + }; + + let count = { + let mut m = self.mouse.borrow_mut(); + + // If we move our cursor too much between clicks then we reset + // the multi-click state. + if m.left_click_count > 0 { + let max_distance = f64::from(size.cell.width); + let distance = ((xpos - m.left_click_xpos).powi(2) + + (ypos - m.left_click_ypos).powi(2)) + .sqrt(); + + if distance > max_distance { + m.left_click_count = 0; + } + } + + if let Some(prev) = m.left_click_pin.take() { + let key = m.left_click_screen; + if let Some(pin_screen) = t.screens.get_mut(key) { + pin_screen.pages.untrack_pin(prev); + } + } + + // Store it + m.left_click_pin = Some(tracked); + m.left_click_screen = active_key; + m.left_click_xpos = xpos; + m.left_click_ypos = ypos; + + // Setup our click counter and timer. If we have mouse clicks, + // then we check if the time elapsed is less than and our + // interval and if so, increase the count. + let now = Instant::now(); + if m.left_click_count > 0 { + let since = m + .left_click_time + .map_or(u64::MAX, |t| now.duration_since(t).as_nanos() as u64); + if since > cfg.mouse_interval { + m.left_click_count = 0; + } + } + + m.left_click_time = Some(now); + m.left_click_count += 1; + + // We only support up to triple-clicks. + if m.left_click_count > 3 { + m.left_click_count = 1; + } + m.left_click_count + }; + + // In all cases below, we set the selection directly rather than use + // `set_selection` because we want to avoid copying the selection to + // the selection clipboard. For left mouse clicks we only set the + // clipboard on release. + match count { + // Single click + 1 => { + // If we have a selection, clear it. This always happens. + if t.screen().selection.is_some() { + let _ = t.screen_mut().select(None); + self.queue_render(); + } + } + + // Double click, select the word under our mouse. First try to + // detect if we're clicking on a URL to select the entire URL. + 2 => { + // Try link detection without requiring modifier keys + let sel = self + .link_at_pin(t.screen(), pin, None) + .map(|l| l.selection) + .or_else(|| t.screen().select_word(pin, &cfg.selection_word_chars)); + if let Some(sel) = sel { + let _ = t.screen_mut().select(Some(sel)); + self.queue_render(); + } + } + + // Triple click, select the line under our mouse + 3 => { + let screen = t.screen(); + let sel = if mods.ctrl_or_super() { + screen.select_output(pin) + } else { + screen.select_line(SelectLine::new(pin)) + }; + if let Some(sel) = sel { + let _ = t.screen_mut().select(Some(sel)); + self.queue_render(); + } + } + + // We should be bounded by 1 to 3 + _ => unreachable!(), + } + } + + /// Right button press. Returns `Some(consumed)` if the event is + /// finished, `None` to fall through. + fn right_press(&self, cfg: &super::config::DerivedConfig) -> Option { + let pos = self.rt_surface.get_cursor_pos(); + let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + + let mut paste = false; + let consumed; + { + let mut t = self.lock_terminal(); + + // Get our viewport pin + let Some(pin) = t.screen().pages.pin(Point::Viewport(vp)) else { + debug_assert!(false, "viewport pin not found"); + return None; + }; + + match cfg.right_click_action { + RightClickAction::Ignore => consumed = true, + RightClickAction::ContextMenu => { + // If we already have a selection and the selection + // contains where we clicked then we don't want to modify + // the selection. + if let Some(prev_sel) = t.screen().selection + && prev_sel.contains(&t.screen().pages, pin) + { + return None; + } + + // The selection doesn't contain our pin, so we + // create a new word selection where we clicked. + + // If there is a link at this position, we want to select + // the link. Otherwise, select the word. + let link = { + let mods = self.mouse.borrow().mods; + let mods = self.mouse_mods_with_capture_locked(&t, mods); + self.link_at_pos(&t, vp, mods) + }; + if let Some(link) = link { + let _ = self.set_selection(&mut t, Some(link.selection)); + } else { + let Some(sel) = t.screen().select_word(pin, &cfg.selection_word_chars) + else { + return None; + }; + let _ = self.set_selection(&mut t, Some(sel)); + } + drop(t); + self.queue_render(); + + // Don't consume so that we show the context menu in + // apprt. + return Some(false); + } + RightClickAction::Copy => { + if let Some(sel) = t.screen().selection { + self.copy_selection_to_clipboards( + &t, + sel, + &[Clipboard::Standard], + ghostty_input::binding::CopyToClipboard::Mixed, + ); + } + + let _ = self.set_selection(&mut t, None); + self.queue_render(); + consumed = true; + } + RightClickAction::CopyOrPaste => { + if let Some(sel) = t.screen().selection { + self.copy_selection_to_clipboards( + &t, + sel, + &[Clipboard::Standard], + ghostty_input::binding::CopyToClipboard::Mixed, + ); + let _ = self.set_selection(&mut t, None); + self.queue_render(); + } else { + // We don't need to clear selection because we didn't + // have one to begin with. + paste = true; + } + consumed = true; + } + RightClickAction::Paste => { + // Before we yield the lock, clear our selection if we + // have one. + let _ = self.set_selection(&mut t, None); + self.queue_render(); + paste = true; + consumed = true; + } + } + } + + // Pasting can trigger a lock grab in complete clipboard request so + // we must not hold the lock. + if paste { + self.start_clipboard_request(Clipboard::Standard, ClipboardRequest::Paste); + } + + // Consume the event such that the context menu is not displayed. + Some(consumed) + } + + /// Handle a click on the prompt (Zig `maybePromptClick`). Returns true + /// if handled. + fn maybe_prompt_click(&self) -> bool { + let cfg = self.cfg(); + let pos = self.rt_surface.get_cursor_pos(); + let pos_vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + + let t = self.lock_terminal(); + let screen = t.screen(); + + // If our screen doesn't handle any prompt clicks, then we never do + // anything. + if screen.semantic_prompt.click == SemanticClick::None { + return false; + } + + // If cursor-click-to-move is disabled, we don't do any prompt + // clicking. + if !cfg.cursor_click_to_move { + return false; + } + + // If our cursor isn't currently at a prompt then we don't handle + // prompt clicks because we can't move if we're not in a prompt! + if !t.cursor_is_at_prompt() { + return false; + } + + // If we have a selection currently, then releasing the mouse + // completes the selection and we don't do prompt moving. + if screen.selection.is_some() { + return false; + } + + // Get the pin for our mouse click. + let Some(click_pin) = screen.pages.pin(Point::Viewport(pos_vp)) else { + debug_assert!(false, "viewport pin not found"); + return false; + }; + + // Get our cursor's most current prompt. + let Some(cursor_pin) = screen.pages.tracked_pin(screen.cursor.page_pin) else { + return false; + }; + let Some(prompt_pin) = cursor_pin + .prompt_iterator(&screen.pages, Direction::LeftUp, None) + .next() + else { + // This shouldn't be possible because we asserted we're at a + // prompt above, so we MUST find some prompt in a left_up search. + log::warn!("cursor is at prompt but no prompt found"); + return false; + }; + + // If our mouse click is before the prompt, we don't move. We DO + // ALLOW clicks AFTER the prompt, specifically with Kitty's + // click_events=1 since we rely on the shell to validate out of + // bounds clicks. This matches Kitty's logic as best I can tell. + if click_pin.before(&screen.pages, prompt_pin) { + return false; + } + + // At this point we've established: + // - Screen supports prompt clicks + // - Cursor is at a prompt + // - Click is at or below our prompt + let mut writes: Vec = Vec::new(); + match screen.semantic_prompt.click { + // Guarded at the start of this function + SemanticClick::None => unreachable!(), + + SemanticClick::ClickEvents => { + // For the event, we always send a left-click press event. + // This matches what Kitty sends. + let resp = format!("\x1b[<0;{};{}M", u32::from(pos_vp.x) + 1, pos_vp.y + 1); + + // Not that noisy since this only happens on prompt clicks. + log::debug!("sending click_events=1 event=ESC{}", &resp[1..]); + writes.push(IoMessage::write_req(resp.as_bytes())); + } + + SemanticClick::Cl(_) => { + let app = t.modes.get(Mode::CursorKeys); + let left_arrow: &'static [u8] = if app { b"\x1bOD" } else { b"\x1b[D" }; + let right_arrow: &'static [u8] = if app { b"\x1bOC" } else { b"\x1b[C" }; + + let mv = screen.prompt_click_move(click_pin); + for _ in 0..mv.left { + writes.push(IoMessage::write_stable(left_arrow)); + } + for _ in 0..mv.right { + writes.push(IoMessage::write_stable(right_arrow)); + } + } + } + drop(t); + for w in writes { + self.queue_io(w); + } + + true + } + + // ------------------------------------------------------------------ + // Links + + /// Returns the link at the given viewport point, if any. Requires the + /// renderer lock (pass the locked terminal) and the capture-adjusted + /// mods. + fn link_at_pos(&self, t: &Terminal, vp: PointCoordinate, mouse_mods: Mods) -> Option { + // Convert our cursor position to a screen point. + let screen = t.screen(); + let Some(mouse_pin) = screen.pages.pin(Point::Viewport(vp)) else { + log::warn!("failed to get pin for clicked point"); + return None; + }; + + // If we have the proper modifiers set then we can check for OSC8 + // links. + if mouse_mods.equal(ghostty_input::ctrl_or_super(Mods::default())) + && let Some((_, cell)) = mouse_pin.row_and_cell(&screen.pages) + && cell.hyperlink() + { + let sel = Selection::new(mouse_pin, mouse_pin, false); + return Some(Link { + action: LinkAction::_OpenOsc8, + selection: sel, + }); + } + + // Fall back to configured links + self.link_at_pin(screen, mouse_pin, Some(mouse_mods)) + } + + /// Detects if a link is present at the given pin. + /// + /// If mouse mods is `None` then mouse mod requirements are ignored (all + /// configured links are checked). + fn link_at_pin( + &self, + screen: &Screen, + mouse_pin: Pin, + mouse_mods: Option, + ) -> Option { + let cfg = self.cfg(); + if cfg.links.is_empty() { + return None; + } + + let line = screen.select_line(SelectLine { + pin: mouse_pin, + whitespace: None, + semantic_prompt_boundary: false, + })?; + + let strmap = screen.selection_string_map(SelectionString { + sel: line, + trim: false, + }); + + for link in &cfg.links { + // Skip highlight/mods check when mouse_mods is None + // (double-click mode) + if let Some(mods) = mouse_mods { + match link.highlight { + Highlight::Always | Highlight::Hover => {} + Highlight::AlwaysMods(v) | Highlight::HoverMods(v) => { + if !v.equal(mods) { + continue; + } + } + } + } + + let mut it = strmap.search_iterator(&link.regex); + while let Some(m) = it.next_match() { + let sel = m.selection(); + if !sel.contains(&screen.pages, mouse_pin) { + continue; + } + return Some(Link { + action: link.action, + selection: sel, + }); + } + } + + None + } + + /// Return the URI for an OSC8 hyperlink at the given position or `None` + /// if there is no hyperlink. + fn osc8_uri(screen: &Screen, pin: Pin) -> Option { + let node = screen.pages.node(pin.node)?; + let page = &node.data; + let idx = page + .cell_index(usize::from(pin.x), usize::from(pin.y)) + .ok()?; + let id = page.lookup_hyperlink(idx)?; + let entry = page.hyperlink_set.get(id)?; + Some(String::from_utf8_lossy(&entry.uri).into_owned()) + } + + /// The URL of the link (`open` regex link or OSC8) under the current + /// cursor position, if the mouse is over a link (Zig + /// `copy_url_to_clipboard`). Takes the renderer lock. + pub(crate) fn link_url_at_cursor(&self) -> Option { + // If the mouse isn't over a link, nothing we can do. + if !self.mouse.borrow().over_link { + return None; + } + let pos = self.rt_surface.get_cursor_pos(); + let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + let trim = self.cfg().clipboard_trim_trailing_spaces; + + let t = self.lock_terminal(); + let mods = self.mouse.borrow().mods; + let mods = self.mouse_mods_with_capture_locked(&t, mods); + let link = self.link_at_pos(&t, vp, mods)?; + let screen = t.screen(); + match link.action { + LinkAction::Open => Some(screen.selection_string(SelectionString { + sel: link.selection, + trim, + })), + LinkAction::_OpenOsc8 => { + let uri = Self::osc8_uri(screen, link.selection.start(&screen.pages)); + if uri.is_none() { + log::warn!("failed to get URI for OSC8 hyperlink"); + } + uri + } + } + } + + /// Attempt to invoke the action of any link that is under the given + /// position. Takes the renderer lock. + fn process_links(&self, pos: CursorPos) -> Result { + let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + let url = { + let t = self.lock_terminal(); + let mods = self.mouse.borrow().mods; + let mods = self.mouse_mods_with_capture_locked(&t, mods); + let Some(link) = self.link_at_pos(&t, vp, mods) else { + return Ok(false); + }; + let screen = t.screen(); + match link.action { + LinkAction::Open => { + let str = screen.selection_string(SelectionString { + sel: link.selection, + trim: false, + }); + let pwd = t.get_pwd().map(|p| String::from_utf8_lossy(p).into_owned()); + resolve_path_for_opening(pwd.as_deref(), &str).unwrap_or(str) + } + + LinkAction::_OpenOsc8 => { + let start = link.selection.start(&screen.pages); + let Some(uri) = Self::osc8_uri(screen, start) else { + log::warn!("failed to get URI for OSC8 hyperlink"); + return Ok(false); + }; + uri + } + } + }; + + self.open_url(OpenUrl { + kind: OpenUrlKind::Unknown, + url, + })?; + Ok(true) + } + + 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(); + if self.perform(RtAction::OpenUrl(action)) { + return Ok(()); + } + + // 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, + }; + ghostty_os::open::open(kind, &url)?; + Ok(()) + } + + /// Call this whenever the mouse moves or mods changed. Takes the + /// renderer lock; must not be called with it held. + pub(crate) fn mouse_refresh_links( + &self, + pos: CursorPos, + pos_vp: PointCoordinate, + over_link: bool, + ) -> Result<(), CoreError> { + let mut deferred = Deferred::default(); + { + let mut st = self.lock_state(); + self.mouse_refresh_links_locked(&mut st, pos, pos_vp, over_link, &mut deferred); + } + deferred.run(self); + Ok(()) + } + + fn mouse_refresh_links_locked( + &self, + st: &mut RenderStateData, + pos: CursorPos, + pos_vp: PointCoordinate, + over_link: bool, + out: &mut Deferred, + ) { + // If the position is outside our viewport, do nothing + if pos.x < 0.0 || pos.y < 0.0 { + return; + } + + let cfg = self.cfg(); + + // Update the last point that we checked for links so we don't + // recheck if the mouse moves some pixels to the same point. + self.mouse.borrow_mut().link_point = Some(pos_vp); + + // Get our link at the current position. This returns None if there + // isn't a link OR if we shouldn't be showing links for some reason + // (see further comments for cases). + let (link, preview): (Option, bool) = 'link: { + // If we clicked and our mouse moved cells then we never + // highlight links until the mouse is unclicked. This follows + // standard macOS and Linux behavior where a click and drag + // cancels mouse actions. + { + let m = self.mouse.borrow(); + if m.click_state[MouseButton::Left as usize] == MouseButtonState::Press + && let Some(h) = m.left_click_pin + { + let screen = st.terminal.screen(); + if let Some(pin) = screen.pages.tracked_pin(h) + && let Some(pt) = screen + .pages + .point_from_pin(ghostty_terminal::point::Tag::Viewport, pin) + && pt.coord() != pos_vp + { + log::debug!("mouse moved while left click held, ignoring link hover"); + break 'link (None, false); + } + } + } + + let mods = self.mouse.borrow().mods; + let mods = self.mouse_mods_with_capture_locked(&st.terminal, mods); + let Some(link) = self.link_at_pos(&st.terminal, pos_vp, mods) else { + break 'link (None, false); + }; + let screen = st.terminal.screen(); + match link.action { + LinkAction::Open => { + let str = screen.selection_string(SelectionString { + sel: link.selection, + trim: false, + }); + ( + Some(MouseOverLink { url: str }), + cfg.link_previews == LinkPreviews::True, + ) + } + + LinkAction::_OpenOsc8 => { + // Show the URL in the status bar + let pin = link.selection.start(&screen.pages); + let Some(uri) = Self::osc8_uri(screen, pin) else { + log::warn!("failed to get URI for OSC8 hyperlink"); + break 'link (None, false); + }; + ( + Some(MouseOverLink { url: uri }), + cfg.link_previews != LinkPreviews::False, + ) + } + } + }; + + // If we found a link, setup our internal state and notify the apprt + // so it can highlight it. + if let Some(link) = link { + st.mouse.point = Some(pos_vp); + self.mouse.borrow_mut().over_link = true; + st.terminal.screen_mut().dirty.hyperlink_hover = true; + out.actions.push(RtAction::MouseShape( + ghostty_vt::mouse_shape::MouseShape::Pointer, + )); + + if preview { + out.actions.push(RtAction::MouseOverLink(link)); + } + + out.render = true; + return; + } + + // No link, if we're previously over a link then we need to clear the + // over-link apprt state. + if over_link { + out.actions + .push(RtAction::MouseShape(st.terminal.mouse_shape)); + out.actions.push(RtAction::MouseOverLink(MouseOverLink { + url: String::new(), + })); + out.render = true; + } + } + + // ------------------------------------------------------------------ + // Pressure + + /// Mouse pressure (force click) callback. + pub fn mouse_pressure_callback(&self, stage: MousePressureStage, _pressure: f64) { + // We don't currently use the pressure value for anything. In the + // future, we could report this to applications using new mouse + // events or utilize it for some custom UI. + + // If the pressure stage is the same as what we already have do + // nothing + let left_pressed = { + let mut m = self.mouse.borrow_mut(); + if m.pressure_stage == stage { + return; + } + + // Update our pressure stage. + m.pressure_stage = stage; + m.click_state[MouseButton::Left as usize] == MouseButtonState::Press + }; + + // If our left mouse button is pressed and we're entering a deep + // click then we want to start a selection. We treat this as a word + // selection since that is typical macOS behavior. + if left_pressed && stage == MousePressureStage::Deep { + let handle = self.mouse.borrow().left_click_pin; + let word_chars = self.cfg().selection_word_chars.clone(); + let mut t = self.lock_terminal(); + + // This should always be set in this state but we don't want to + // handle state inconsistency here. + let Some(h) = handle else { return }; + let Some(pin) = t.screen().pages.tracked_pin(h) else { + return; + }; + let Some(sel) = t.screen().select_word(pin, &word_chars) else { + return; + }; + if t.screen_mut().select(Some(sel)).is_ok() { + self.queue_render(); + } + } + } + + // ------------------------------------------------------------------ + // Cursor position + + /// Cursor position callback. + /// + /// Send negative x or y values to indicate the cursor is outside the + /// viewport. The magnitude of the negative values are meaningless; they + /// are only used to indicate the cursor is outside the viewport. It's + /// important to do this to ensure hover states are cleared. + /// + /// The mods parameter is optional because some apprts do not provide + /// modifier information on cursor position events. If mods is `None` + /// then we'll use the last known mods. + pub fn cursor_pos_callback(&self, pos: CursorPos, mods: Option) -> Result<(), CoreError> { + let mut deferred = Deferred::default(); + + // If the position is negative, it is outside our viewport and we + // need to clear any hover states. + if pos.x < 0.0 || pos.y < 0.0 { + // Reset our hyperlink state + let was_over_link = { + let mut m = self.mouse.borrow_mut(); + m.link_point = None; + let was = m.over_link; + m.over_link = false; + was + }; + if was_over_link { + let shape = self.lock_terminal().mouse_shape; + self.perform(RtAction::MouseShape(shape)); + self.perform(RtAction::MouseOverLink(MouseOverLink { + url: String::new(), + })); + self.queue_render(); + } + + let mut st = self.lock_state(); + + // No mouse point so we don't highlight links + st.mouse.point = None; + + // Mark the link's row as dirty, but continue with updating the + // mouse state below so we can scroll when our position is + // negative. + st.terminal.screen_mut().dirty.hyperlink_hover = true; + } + + // Always show the mouse again if it is hidden + let hidden = self.mouse.borrow().hidden; + if hidden { + self.show_mouse(); + } + + // Update our modifiers if they changed + if let Some(v) = mods { + self.mods_changed(v); + } + + // The mouse position in the viewport + let pos_vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + + // We always reset the over link status because it will be + // reprocessed below. But we need the old value to know if we need to + // undo mouse shape changes. + let over_link = { + let mut m = self.mouse.borrow_mut(); + let o = m.over_link; + m.over_link = false; + o + }; + + let selection_scroll_active = self.selection_scroll_active.get(); + let size = self.size.get(); + + { + // We are reading/writing state for the remainder + let mut st = self.lock_state(); + + // Stop selection scrolling when inside the viewport within a 1px + // buffer for fullscreen windows, but only when selection + // scrolling is active. + if pos.y >= 1.0 && selection_scroll_active { + deferred.io.push(IoMessage::SelectionScroll(false)); + } + + // Update our mouse state. We set this to null initially because + // we only want to set it when we're not selecting or doing any + // other mouse event. + st.mouse.point = None; + + // Handle link hovering. We refresh links when + // 1. we were previously over a link + // OR + // 2. the cursor position has changed (either we have no + // previous state, or the state has changed) + // AND + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the + // terminal + let (link_point, mouse_mods) = { + let m = self.mouse.borrow(); + (m.link_point, m.mods) + }; + if (over_link || link_point.is_none_or(|p| p != pos_vp)) + && (st.terminal.flags.mouse_event == MouseEvents::None + || (mouse_mods.shift && !self.mouse_shift_capture(&st.terminal))) + { + // If we were previously over a link, we always update. We do + // this so that if the text changed underneath us, even if the + // mouse didn't move, we update the URL hints and state + self.mouse_refresh_links_locked(&mut st, pos, pos_vp, over_link, &mut deferred); + } + + // Do a mouse report + if self.is_mouse_reporting(&st.terminal) { + // Shift overrides mouse "grabbing" in the window, taken from + // Kitty. This only applies if there is a mouse button + // pressed so that movement reports are not affected. + let skip = mouse_mods.shift + && !self.mouse_shift_capture(&st.terminal) + && self + .mouse + .borrow() + .click_state + .iter() + .any(|s| *s != MouseButtonState::Release); + + if !skip { + let mut m = self.mouse.borrow_mut(); + + // We use the first mouse button we find pressed in order + // to report since the spec (afaict) does not say... + let button = m + .click_state + .iter() + .position(|s| *s == MouseButtonState::Press) + .and_then(button_from_index); + + let mods = m.mods; + if let Some(b) = self.mouse_report( + &st.terminal, + &mut m, + button, + MouseReportAction::Motion, + mods, + pos, + ) { + deferred.io.push(IoMessage::write_req(&b)); + } + + // If we're doing mouse motion tracking, we do not + // support text selection. + drop(m); + drop(st); + deferred.run(self); + return Ok(()); + } + } + + // Handle cursor position for text selection + let (left_pressed, count, click_screen, handle, click_xpos, sel_mods) = { + let m = self.mouse.borrow(); + ( + m.click_state[MouseButton::Left as usize] == MouseButtonState::Press, + m.left_click_count, + m.left_click_screen, + m.left_click_pin, + m.left_click_xpos, + m.mods, + ) + }; + + // Left click pressed but count zero can happen if mouse + // reporting is on. In this scenario, we mark the click state + // because we need that to properly make some mouse reports, but + // we don't keep track of the count because we don't want to + // handle selection. + // + // If our terminal screen changed then we don't process this. We + // don't invalidate our pin or mouse state because if the screen + // switches back then we can continue our selection. + if left_pressed && count != 0 && click_screen == st.terminal.screens.active_key { + // All roads lead to requiring a re-render at this point. + deferred.render = true; + + // If our y is negative, we're above the window. In this + // case, we scroll up. The amount we scroll up is dependent + // on how negative we are. We allow for a 1 pixel buffer at + // the top and bottom to detect scroll even in full screen + // windows. + let max_y = size.screen.height as f32; + + // If the mouse is outside the viewport and we have the left + // mouse button pressed then we need to start the scroll + // timer. + if (pos.y <= 1.0 || pos.y > max_y - 1.0) && !selection_scroll_active { + deferred.io.push(IoMessage::SelectionScroll(true)); + } + + // Convert to points + let t = &mut st.terminal; + let Some(pin) = t.screen().pages.pin(Point::Viewport(pos_vp)) else { + debug_assert!(false, "viewport pin not found"); + drop(st); + deferred.run(self); + return Ok(()); + }; + + // Handle dragging depending on click count + if let Some(click_pin) = handle.and_then(|h| t.screen().pages.tracked_pin(h)) { + let word_chars = self.cfg().selection_word_chars.clone(); + match count { + 1 => Self::drag_left_click_single( + t, + click_pin, + pin, + click_xpos, + f64::from(pos.x), + sel_mods, + size, + ), + 2 => { + let sel = drag_left_click_double_selection( + t.screen(), + click_pin, + pin, + &word_chars, + ); + match sel { + Some(sel) => { + let _ = t.screen_mut().select(Some(sel)); + } + None => { + let _ = self.set_selection(t, None); + } + } + } + 3 => { + if let Some(sel) = + drag_left_click_triple_selection(t.screen(), click_pin, pin) + { + let _ = t.screen_mut().select(Some(sel)); + } + } + _ => unreachable!(), + } + } + } + } + + deferred.run(self); + Ok(()) + } + + fn drag_left_click_single( + t: &mut Terminal, + click_pin: Pin, + drag_pin: Pin, + click_xpos: f64, + drag_x: f64, + mods: Mods, + size: Size, + ) { + // This logic is in a separate function so that it can be unit + // tested. + let sel = mouse_selection( + &t.screen().pages, + click_pin, + drag_pin, + click_xpos.max(0.0) as u32, + drag_x.max(0.0) as u32, + mods, + size, + ); + let _ = t.screen_mut().select(sel); + } + + /// Selection scrolling tick (Zig `selectionScrollTick`). + pub(crate) fn selection_scroll_tick(&self) -> Result<(), CoreError> { + // If we're no longer active then we don't do anything. + if !self.selection_scroll_active.get() { + return Ok(()); + } + + // If we don't have a left mouse button down then we don't do + // anything. + let (count, screen_key, handle, xpos, mods) = { + let m = self.mouse.borrow(); + ( + m.left_click_count, + m.left_click_screen, + m.left_click_pin, + m.left_click_xpos, + m.mods, + ) + }; + if count == 0 { + return Ok(()); + } + + let pos = self.rt_surface.get_cursor_pos(); + let pos_vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y)); + let delta: isize = if pos.y < 0.0 { -1 } else { 1 }; + let size = self.size.get(); + + let mut t = self.lock_terminal(); + + // If our screen changed while this is happening, we stop our + // selection scroll. + if screen_key != t.screens.active_key { + drop(t); + self.queue_io(IoMessage::SelectionScroll(false)); + return Ok(()); + } + + // Scroll the viewport as required + t.scroll_viewport(ScrollViewport::Delta(delta)); + + // Next, trigger our drag behavior + let Some(pin) = t.screen().pages.pin(Point::Viewport(pos_vp)) else { + debug_assert!(false, "viewport pin not found"); + return Ok(()); + }; + if let Some(click_pin) = handle.and_then(|h| t.screen().pages.tracked_pin(h)) { + Self::drag_left_click_single( + &mut t, + click_pin, + pin, + xpos, + f64::from(pos.x), + mods, + size, + ); + } + + // We modified our viewport and selection so we need to queue a + // render. + drop(t); + self.queue_render(); + Ok(()) + } +} + +/// `@enumFromInt(i)` for the click state index. +fn button_from_index(i: usize) -> Option { + Some(match i { + 0 => MouseButton::Unknown, + 1 => MouseButton::Left, + 2 => MouseButton::Right, + 3 => MouseButton::Middle, + 4 => MouseButton::Four, + 5 => MouseButton::Five, + 6 => MouseButton::Six, + 7 => MouseButton::Seven, + 8 => MouseButton::Eight, + 9 => MouseButton::Nine, + 10 => MouseButton::Ten, + 11 => MouseButton::Eleven, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ghostty_renderer::size::{CellSize, Padding, ScreenSize}; + use ghostty_terminal::screen::Options as ScreenOptions; + + /// Our screen size is 10x5 cells that are 10x20 px, with 5px padding on + /// all sides. + fn test_size() -> Size { + Size { + cell: CellSize { + width: 10, + height: 20, + }, + padding: Padding { + left: 5, + top: 5, + right: 5, + bottom: 5, + }, + screen: ScreenSize { + width: 110, + height: 110, + }, + } + } + + struct Setup { + screen: Screen, + mods: Mods, + click_pin: Pin, + drag_pin: Pin, + click_x_pos: u32, + drag_x_pos: u32, + } + + fn setup(click_x: f64, click_y: u32, drag_x: f64, drag_y: u32, rect: bool) -> Setup { + let size = test_size(); + let screen = Screen::new(ScreenOptions::new(10, 5, 0)).unwrap(); + + // We hold both ctrl and alt for rectangular select so that this + // test is platform agnostic. + let mods = Mods { + ctrl: rect, + alt: rect, + ..Mods::default() + }; + assert_eq!(rect, surface_mouse::is_rectangle_select_state(mods)); + + let vp = |x: f64, y: u32| { + Point::Viewport(PointCoordinate { + x: x.floor() as u16, + y, + }) + }; + let click_pin = screen.pages.pin(vp(click_x, click_y)).unwrap(); + let drag_pin = screen.pages.pin(vp(drag_x, drag_y)).unwrap(); + + let cell_width = f64::from(size.cell.width); + let click_x_pos = (click_x * cell_width).floor() as u32 + size.padding.left; + let drag_x_pos = (drag_x * cell_width).floor() as u32 + size.padding.left; + + Setup { + screen, + mods, + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + } + } + + #[allow(clippy::too_many_arguments)] + fn test_mouse_selection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: u16, + start_y: u32, + end_x: u16, + end_y: u32, + rect: bool, + ) { + let s = setup(click_x, click_y, drag_x, drag_y, rect); + let start_pin = s + .screen + .pages + .pin(Point::Viewport(PointCoordinate { + x: start_x, + y: start_y, + })) + .unwrap(); + let end_pin = s + .screen + .pages + .pin(Point::Viewport(PointCoordinate { x: end_x, y: end_y })) + .unwrap(); + + assert_eq!( + Some(Selection::new(start_pin, end_pin, rect)), + mouse_selection( + &s.screen.pages, + s.click_pin, + s.drag_pin, + s.click_x_pos, + s.drag_x_pos, + s.mods, + test_size(), + ) + ); + } + + /// Like `test_mouse_selection` but checks that the resulting selection + /// is null. + fn test_mouse_selection_is_null( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, + ) { + let s = setup(click_x, click_y, drag_x, drag_y, rect); + assert_eq!( + None, + mouse_selection( + &s.screen.pages, + s.click_pin, + s.drag_pin, + s.click_x_pos, + s.drag_x_pos, + s.mods, + test_size(), + ) + ); + } + + #[test] + fn surface_selection_logic() { + // -- LTR + // single cell selection + test_mouse_selection(3.0, 3, 3.9, 3, 3, 3, 3, 3, false); + // including click and drag pin cells + test_mouse_selection(3.0, 3, 5.9, 3, 3, 3, 5, 3, false); + // including click pin cell but not drag pin cell + test_mouse_selection(3.0, 3, 5.0, 3, 3, 3, 4, 3, false); + // including drag pin cell but not click pin cell + test_mouse_selection(3.9, 3, 5.9, 3, 4, 3, 5, 3, false); + // including neither click nor drag pin cells + test_mouse_selection(3.9, 3, 5.0, 3, 4, 3, 4, 3, false); + // empty selection (single cell on only left half) + test_mouse_selection_is_null(3.0, 3, 3.1, 3, false); + // empty selection (single cell on only right half) + test_mouse_selection_is_null(3.8, 3, 3.9, 3, false); + // empty selection (between two cells, not crossing threshold) + test_mouse_selection_is_null(3.9, 3, 4.0, 3, false); + + // -- RTL + // single cell selection + test_mouse_selection(3.9, 3, 3.0, 3, 3, 3, 3, 3, false); + // including click and drag pin cells + test_mouse_selection(5.9, 3, 3.0, 3, 5, 3, 3, 3, false); + // including click pin cell but not drag pin cell + test_mouse_selection(5.9, 3, 3.9, 3, 5, 3, 4, 3, false); + // including drag pin cell but not click pin cell + test_mouse_selection(5.0, 3, 3.0, 3, 4, 3, 3, 3, false); + // including neither click nor drag pin cells + test_mouse_selection(5.0, 3, 3.9, 3, 4, 3, 4, 3, false); + // empty selection (single cell on only left half) + test_mouse_selection_is_null(3.1, 3, 3.0, 3, false); + // empty selection (single cell on only right half) + test_mouse_selection_is_null(3.9, 3, 3.8, 3, false); + // empty selection (between two cells, not crossing threshold) + test_mouse_selection_is_null(4.0, 3, 3.9, 3, false); + + // -- Wrapping + // LTR, wrap excluded cells + test_mouse_selection(9.9, 2, 0.0, 4, 0, 3, 9, 3, false); + // RTL, wrap excluded cells + test_mouse_selection(0.0, 4, 9.9, 2, 9, 3, 0, 3, false); + } + + #[test] + fn surface_rectangle_selection_logic() { + // -- LTR + // single column selection + test_mouse_selection(3.0, 2, 3.9, 4, 3, 2, 3, 4, true); + // including click and drag pin columns + test_mouse_selection(3.0, 2, 5.9, 4, 3, 2, 5, 4, true); + // including click pin column but not drag pin column + test_mouse_selection(3.0, 2, 5.0, 4, 3, 2, 4, 4, true); + // including drag pin column but not click pin column + test_mouse_selection(3.9, 2, 5.9, 4, 4, 2, 5, 4, true); + // including neither click nor drag pin columns + test_mouse_selection(3.9, 2, 5.0, 4, 4, 2, 4, 4, true); + // empty selection (single column on only left half) + test_mouse_selection_is_null(3.0, 2, 3.1, 4, true); + // empty selection (single column on only right half) + test_mouse_selection_is_null(3.8, 2, 3.9, 4, true); + // empty selection (between two columns, not crossing threshold) + test_mouse_selection_is_null(3.9, 2, 4.0, 4, true); + + // -- RTL + // single column selection + test_mouse_selection(3.9, 2, 3.0, 4, 3, 2, 3, 4, true); + // including click and drag pin columns + test_mouse_selection(5.9, 2, 3.0, 4, 5, 2, 3, 4, true); + // including click pin column but not drag pin column + test_mouse_selection(5.9, 2, 3.9, 4, 5, 2, 4, 4, true); + // including drag pin column but not click pin column + test_mouse_selection(5.0, 2, 3.0, 4, 4, 2, 3, 4, true); + // including neither click nor drag pin columns + test_mouse_selection(5.0, 2, 3.9, 4, 4, 2, 4, 4, true); + // empty selection (single column on only left half) + test_mouse_selection_is_null(3.1, 2, 3.0, 4, true); + // empty selection (single column on only right half) + test_mouse_selection_is_null(3.9, 2, 3.8, 4, true); + // empty selection (between two columns, not crossing threshold) + test_mouse_selection_is_null(4.0, 2, 3.9, 4, true); + + // -- Wrapping + // LTR, do not wrap + test_mouse_selection(9.9, 2, 0.0, 4, 9, 2, 0, 4, true); + // RTL, do not wrap + test_mouse_selection(0.0, 4, 9.9, 2, 0, 4, 9, 2, true); + } + + #[test] + fn resolve_path_for_opening_semantics() { + // Absolute paths are used as-is. + assert_eq!(resolve_path_for_opening(Some("/tmp"), "/etc/passwd"), None); + // No pwd, no resolution. + assert_eq!(resolve_path_for_opening(None, "foo"), None); + // Non-existent relative paths are not resolved. + assert_eq!( + resolve_path_for_opening(Some("/tmp"), "does-not-exist-xyz"), + None + ); + // Existing relative path resolves, including `..`. + assert_eq!( + resolve_path_for_opening(Some("/tmp"), "../etc"), + Some("/etc".to_owned()) + ); + } + + #[test] + fn scroll_amount_direction() { + let a = ScrollAmount { delta: -3 }; + assert!(!a.up_right()); + assert_eq!(a.magnitude(), 3); + assert!(ScrollAmount { delta: 2 }.up_right()); + } +} diff --git a/crates/ghostty-core/src/surface_mouse.rs b/crates/ghostty-core/src/surface_mouse.rs new file mode 100644 index 0000000..b08ae45 --- /dev/null +++ b/crates/ghostty-core/src/surface_mouse.rs @@ -0,0 +1,288 @@ +//! SurfaceMouse represents mouse helper functionality for the core surface +//! (port of `surface_mouse.zig`). +//! +//! It's currently small in scope; its purpose is to isolate mouse logic that +//! has gotten a bit complex (e.g. pointer shape handling for key events). + +use ghostty_input::{Key, Mods}; +use ghostty_terminal::terminal::MouseEvents; +use ghostty_vt::mouse_shape::MouseShape; + +/// See the module docs. +#[derive(Debug, Clone, Copy)] +pub struct SurfaceMouse { + /// For processing key events; the key that was physically pressed on the + /// keyboard. + pub physical_key: Key, + + /// The mouse event tracking mode, if any. + pub mouse_event: MouseEvents, + + /// The current terminal's mouse shape. + pub mouse_shape: MouseShape, + + /// The last mods state when the last mouse button (whatever it was) was + /// pressed or release. + pub mods: Mods, + + /// True if the mouse position is currently over a link. + pub over_link: bool, + + /// True if the mouse pointer is currently hidden. + pub hidden: bool, +} + +impl SurfaceMouse { + /// Translates key state to mouse shape (cursor) state, based on a state + /// machine. + /// + /// There are 4 current states: + /// + /// * text: starting state, displays a text bar. + /// * default: default state when in a mouse tracking mode. (e.g. vim, + /// etc). Displays an arrow pointer. + /// * pointer: default state when over a link. Displays a pointing finger. + /// * crosshair: any above state can transition to this when the + /// rectangle select keys are pressed (ctrl/super+alt). + /// + /// Additionally, default can transition back to text if one of the shift + /// keys are pressed during mouse tracking mode. + /// + /// Any secondary state transitions back to its default state when the + /// appropriate keys are released. + /// + /// `None` is returned when the mouse shape does not need changing. + #[must_use] + pub fn key_to_mouse_shape(&self) -> Option { + // Filter for appropriate key events + if !eligible_mouse_shape_key_event(self.physical_key) { + return None; + } + + // Exceptions: link hover or hidden state overrides any other shape + // processing and does not change state. + if self.over_link || self.hidden { + return None; + } + + if self.mouse_event == MouseEvents::None { + // Default terminal mode, should be text (text bar) + if is_rectangle_select_state(self.mods) { + // Crosshair (rectangle select) + Some(MouseShape::Crosshair) + } else { + Some(MouseShape::Text) + } + } else { + // In mouse tracking mode, should be default (arrow pointer) + if is_mouse_mode_override_state(self.mods) && is_rectangle_select_state(self.mods) { + // Crosshair (rectangle select), only set if we are also + // overriding (e.g. shift+ctrl+alt) + Some(MouseShape::Crosshair) + } else if is_mouse_mode_override_state(self.mods) { + // Normal override state + Some(MouseShape::Text) + } else { + Some(MouseShape::Default) + } + } + } +} + +fn eligible_mouse_shape_key_event(physical_key: Key) -> bool { + physical_key.ctrl_or_super() + || physical_key.left_or_right_shift() + || physical_key.left_or_right_alt() +} + +fn is_mouse_mode_override_state(mods: Mods) -> bool { + mods.shift +} + +/// Returns true if our modifiers put us in a state where dragging should +/// cause a rectangle select. +#[must_use] +pub fn is_rectangle_select_state(mods: Mods) -> bool { + mods.ctrl_or_super() && mods.alt +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m( + physical_key: Key, + mouse_event: MouseEvents, + mouse_shape: MouseShape, + mods: Mods, + over_link: bool, + hidden: bool, + ) -> SurfaceMouse { + SurfaceMouse { + physical_key, + mouse_event, + mouse_shape, + mods, + over_link, + hidden, + } + } + + fn mods(shift: bool, ctrl: bool, alt: bool, super_: bool) -> Mods { + Mods { + shift, + ctrl, + alt, + super_, + ..Mods::default() + } + } + + #[test] + fn key_to_mouse_shape() { + let none = Mods::default(); + + // No specific key pressed + let s = m( + Key::Unidentified, + MouseEvents::None, + MouseShape::Progress, + none, + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), None); + + // Over a link. + let s = m( + Key::ShiftLeft, + MouseEvents::None, + MouseShape::Progress, + none, + true, + false, + ); + assert_eq!(s.key_to_mouse_shape(), None); + + // Mouse is currently hidden + let s = m( + Key::ShiftLeft, + MouseEvents::None, + MouseShape::Progress, + none, + true, + true, + ); + assert_eq!(s.key_to_mouse_shape(), None); + + // default, no mods (mouse tracking) + let s = m( + Key::ShiftLeft, + MouseEvents::X10, + MouseShape::Default, + none, + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Default)); + + // default -> crosshair (mouse tracking) + let s = m( + Key::AltLeft, + MouseEvents::X10, + MouseShape::Default, + mods(true, true, true, true), + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Crosshair)); + + // default -> text (mouse tracking) + let s = m( + Key::ShiftLeft, + MouseEvents::X10, + MouseShape::Default, + mods(true, false, false, false), + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text)); + + // crosshair -> text (mouse tracking) + let s = m( + Key::AltLeft, + MouseEvents::X10, + MouseShape::Crosshair, + mods(true, false, false, false), + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text)); + + // crosshair -> default (mouse tracking) + let s = m( + Key::AltLeft, + MouseEvents::X10, + MouseShape::Crosshair, + none, + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Default)); + + // text -> crosshair (mouse tracking) + let s = m( + Key::AltLeft, + MouseEvents::X10, + MouseShape::Text, + mods(true, true, true, true), + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Crosshair)); + + // text -> default (mouse tracking) + let s = m( + Key::ShiftLeft, + MouseEvents::X10, + MouseShape::Text, + none, + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Default)); + + // text, no mods (no mouse tracking) + let s = m( + Key::ShiftLeft, + MouseEvents::None, + MouseShape::Text, + none, + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text)); + + // text -> crosshair (no mouse tracking) + let s = m( + Key::AltLeft, + MouseEvents::None, + MouseShape::Text, + mods(false, true, true, true), + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Crosshair)); + + // crosshair -> text (no mouse tracking) + let s = m( + Key::AltLeft, + MouseEvents::None, + MouseShape::Crosshair, + none, + false, + false, + ); + assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text)); + } +} diff --git a/crates/ghostty-renderer/src/generic.rs b/crates/ghostty-renderer/src/generic.rs index 55baf3f..db59d20 100644 --- a/crates/ghostty-renderer/src/generic.rs +++ b/crates/ghostty-renderer/src/generic.rs @@ -1886,16 +1886,11 @@ fn update_kitty( terminal: &mut ghostty_terminal::terminal::Terminal, cell_size: CellSize, ) { - // TODO(integration): `Screen.kitty_images` is still the placeholder - // `KittyImages { dirty }` (see docs/rewrite/notes/ghostty-terminal-kitty.md). - // Once it is a `kitty::ImageStorage`, call - // images.kitty_update(&screen.kitty_images, &screen.pages, - // &TerminalInfo::from_terminal(terminal), cell_size); - // and then clear `screen.kitty_images.dirty`. `image::State::kitty_update` - // is implemented and compiled against the real storage API already. - let _ = cell_size; + let info = ghostty_terminal::kitty::graphics_storage::TerminalInfo::from_terminal(terminal); + let screen = terminal.screen_mut(); + images.kitty_update(&screen.kitty_images, &screen.pages, &info, cell_size); images.force_kitty_update = false; - terminal.screen_mut().kitty_images.dirty = false; + screen.kitty_images.dirty = false; } // --------------------------------------------------------------------------- diff --git a/crates/ghostty-terminal/src/search/thread.rs b/crates/ghostty-terminal/src/search/thread.rs index b54506b..9d25adc 100644 --- a/crates/ghostty-terminal/src/search/thread.rs +++ b/crates/ghostty-terminal/src/search/thread.rs @@ -13,7 +13,8 @@ //! based refresh timer. The terminal and its mutex are combined into an //! `Arc>`. -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::ops::DerefMut; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use crossbeam_channel::{Receiver, Sender, TrySendError}; @@ -50,7 +51,7 @@ pub type EventCallback = Arc; pub struct Options { /// The terminal data to search. The mutex must be held while /// reading/writing the terminal. - pub terminal: Arc>, + pub terminal: Arc, /// The callback for events from the search thread. This can be `None` /// if you don't want to receive events, which could be useful for a @@ -146,8 +147,26 @@ impl Handle { } } -fn lock(m: &Mutex) -> MutexGuard<'_, Terminal> { - m.lock().unwrap_or_else(PoisonError::into_inner) +/// A locked terminal (any guard that derefs to a [`Terminal`]). +pub trait TerminalRef: DerefMut {} +impl> TerminalRef for T {} + +/// Something that owns a terminal behind a lock. Implemented for +/// `Mutex`; the core implements it for the shared renderer state +/// (where the terminal lives next to preedit/mouse state). +pub trait TerminalLock: Send + Sync { + /// Lock and borrow the terminal (poisoning is ignored). + fn lock_terminal(&self) -> Box; +} + +impl TerminalLock for Mutex { + fn lock_terminal(&self) -> Box { + Box::new(self.lock().unwrap_or_else(PoisonError::into_inner)) + } +} + +fn lock(m: &Arc) -> Box { + m.lock_terminal() } /// The search thread state. @@ -677,7 +696,7 @@ impl Search { /// This doesn't require any locking as it only reads internal state /// (the terminal mutex is only passed through for API symmetry and is /// not locked). - fn notify(&mut self, _terminal: &Arc>, cb: &EventCallback) { + fn notify(&mut self, _terminal: &Arc, cb: &EventCallback) { let Some(screen_search) = self.screens[key_index(self.last_screen.key)].as_ref() else { return; }; @@ -787,7 +806,7 @@ mod tests { }); let (handle, join) = Thread::spawn(Options { - terminal: Arc::clone(&terminal), + terminal: terminal.clone(), event_cb: Some(cb), }) .unwrap(); @@ -845,7 +864,7 @@ mod tests { }); let (handle, join) = Thread::spawn(Options { - terminal: Arc::clone(&terminal), + terminal: terminal.clone(), event_cb: Some(cb), }) .unwrap(); diff --git a/crates/ghostty-termio/src/backend.rs b/crates/ghostty-termio/src/backend.rs index 79727c7..cace069 100644 --- a/crates/ghostty-termio/src/backend.rs +++ b/crates/ghostty-termio/src/backend.rs @@ -8,7 +8,7 @@ use ghostty_terminal::terminal::Terminal; use crate::exec::{self, Exec}; use crate::sinks::SurfaceMailbox; -use crate::state::RendererState; +use crate::state::SharedState; use crate::termio::Output; /// The kinds of backends. @@ -51,7 +51,7 @@ impl Backend { &mut self, output: Arc, surface_mailbox: Arc, - renderer_state: Arc, + renderer_state: SharedState, ) -> Result { match self { Backend::Exec(e) => Ok(ThreadData::Exec(e.thread_enter( diff --git a/crates/ghostty-termio/src/exec.rs b/crates/ghostty-termio/src/exec.rs index a9b9252..c2e469e 100644 --- a/crates/ghostty-termio/src/exec.rs +++ b/crates/ghostty-termio/src/exec.rs @@ -40,7 +40,7 @@ use crate::command::{ use crate::pty::{self, Mode, Pty, Winsize}; use crate::shell_integration::{self, EnvMap, Shell}; use crate::sinks::SurfaceMailbox; -use crate::state::RendererState; +use crate::state::{SharedState, SharedStateExt}; use crate::termio::Output; use crate::wakeup::Wakeup; use ghostty_apprt::surface::{ChildExited, Message as SurfaceMessage}; @@ -192,7 +192,7 @@ impl Exec { &mut self, output: Arc, surface_mailbox: Arc, - renderer_state: Arc, + renderer_state: SharedState, ) -> Result { // Start our subprocess let (read_fd, write_fd) = self.subprocess.start()?; @@ -382,7 +382,7 @@ pub struct ThreadData { termios_mode: Mode, surface_mailbox: Arc, - renderer_state: Arc, + renderer_state: SharedState, } impl ThreadData { @@ -579,7 +579,7 @@ impl ThreadData { // If our password input state changed on the terminal then // we notify the surface. let changed = { - let t = self.renderer_state.lock(); + let t = self.renderer_state.lock_terminal(); t.flags.password_input != password_input }; diff --git a/crates/ghostty-termio/src/lib.rs b/crates/ghostty-termio/src/lib.rs index e0e7c60..5499889 100644 --- a/crates/ghostty-termio/src/lib.rs +++ b/crates/ghostty-termio/src/lib.rs @@ -42,7 +42,7 @@ pub use message::Message; pub use options::Options; pub use pty::{Pty, Winsize}; pub use sinks::{RendererMailbox, SurfaceMailbox}; -pub use state::RendererState; +pub use state::{SharedState, SharedStateExt, State, TerminalGuard}; pub use stream_handler::StreamHandler; pub use termio::{DerivedConfig, Termio}; pub use thread::Thread; diff --git a/crates/ghostty-termio/src/state.rs b/crates/ghostty-termio/src/state.rs index 32b5463..6604ee6 100644 --- a/crates/ghostty-termio/src/state.rs +++ b/crates/ghostty-termio/src/state.rs @@ -1,37 +1,49 @@ -//! The shared render state (`renderer/State.zig`, the part termio needs). +//! The shared render state (`renderer/State.zig`). //! -//! In Zig the terminal is owned by `Termio` and `renderer.State` holds a -//! mutex plus a pointer to it. In Rust the terminal lives inside the mutex, -//! shared via `Arc` between the IO thread, the pty read thread, the -//! surface (core) thread and the renderer thread. -//! -//! TODO(integration): `renderer/State.zig` is deferred in ghostty-renderer; -//! when it lands, this type should move there (and gain `inspector`, -//! `preedit`, `mouse`). +//! The canonical type lives in `ghostty-renderer` +//! ([`ghostty_renderer::state::State`] behind a mutex, +//! [`SharedState`]). This module only provides a convenience for termio: +//! locking the state and borrowing just the [`Terminal`]. -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::ops::{Deref, DerefMut}; +use std::sync::{MutexGuard, PoisonError}; +pub use ghostty_renderer::state::{SharedState, State}; use ghostty_terminal::terminal::Terminal; -/// The state shared with the renderer. -#[derive(Debug)] -pub struct RendererState { - /// The terminal emulator state. Hold the lock for as short as possible; - /// it is contended by the pty read thread. - pub terminal: Mutex, -} +/// A locked [`SharedState`] that dereferences to its [`Terminal`]. +pub struct TerminalGuard<'a>(MutexGuard<'a, State>); -impl RendererState { - pub fn new(terminal: Terminal) -> Self { - Self { - terminal: Mutex::new(terminal), - } +impl TerminalGuard<'_> { + /// The whole locked state (preedit, mouse, ...). + pub fn state(&mut self) -> &mut State { + &mut self.0 } +} - /// Lock the terminal. Lock poisoning is ignored: a panic on another - /// thread must not take the whole terminal down with it (Zig mutexes - /// don't poison). - pub fn lock(&self) -> MutexGuard<'_, Terminal> { - self.terminal.lock().unwrap_or_else(PoisonError::into_inner) +impl Deref for TerminalGuard<'_> { + type Target = Terminal; + fn deref(&self) -> &Terminal { + &self.0.terminal + } +} + +impl DerefMut for TerminalGuard<'_> { + fn deref_mut(&mut self) -> &mut Terminal { + &mut self.0.terminal + } +} + +/// Lock helpers for [`SharedState`]. +pub trait SharedStateExt { + /// Lock the state and borrow the terminal. Lock poisoning is ignored: a + /// panic on another thread must not take the whole terminal down with + /// it (Zig mutexes don't poison). + fn lock_terminal(&self) -> TerminalGuard<'_>; +} + +impl SharedStateExt for SharedState { + fn lock_terminal(&self) -> TerminalGuard<'_> { + TerminalGuard(self.lock().unwrap_or_else(PoisonError::into_inner)) } } diff --git a/crates/ghostty-termio/src/termio.rs b/crates/ghostty-termio/src/termio.rs index a8e4f57..2f3ee2c 100644 --- a/crates/ghostty-termio/src/termio.rs +++ b/crates/ghostty-termio/src/termio.rs @@ -12,10 +12,10 @@ //! * [`Termio`] is owned by the IO thread (`Thread::thread_main` takes it by //! value) and holds the backend, derived config and cached size. //! * [`Output`] (an `Arc`, reachable through [`Termio::output`]) is the part -//! the pty read thread needs: the shared [`RendererState`], the VT stream and +//! the pty read thread needs: the shared [`SharedState`], the VT stream and //! the renderer/termio notification handles. `Termio::process_output` is //! just `output.process_output`. -//! * The `Terminal` itself lives in [`RendererState`] +//! * The `Terminal` itself lives in [`SharedState`] //! (`Arc>`), shared with the renderer like Zig's //! `renderer.State`. //! @@ -34,12 +34,14 @@ use ghostty_config::types_b::{ClipboardAccess, GraphemeWidthMethod, OSCColorRepo use ghostty_datastruct::blocking_queue::Timeout; use ghostty_renderer::message::Message as RendererMessage; use ghostty_renderer::size::Size; +use ghostty_terminal::kitty::graphics_storage::TerminalInfo; use ghostty_terminal::point::{Coordinate, Point}; use ghostty_terminal::screen::Scroll; use ghostty_terminal::screen_set::Key as ScreenKey; use ghostty_terminal::terminal::{Colors, Options as TerminalOptions, ScrollViewport, Terminal}; use ghostty_vt::color::{DynamicPalette, DynamicRgb, Palette}; use ghostty_vt::csi; +use ghostty_vt::kitty::graphics_command::Delete as KittyDelete; use ghostty_vt::modes::{Mode, ModePacked}; use std::sync::MutexGuard; @@ -49,7 +51,7 @@ use crate::mailbox::Mailbox; use crate::message::{Message, SizeReport}; use crate::options::Options; use crate::sinks::{RendererMailbox, SurfaceMailbox}; -use crate::state::RendererState; +use crate::state::{SharedState, SharedStateExt, State}; use crate::stream_handler::{ HandlerOptions, HandlerState, TerminalStream, cursor_style_from_config, }; @@ -201,7 +203,7 @@ struct OutputInner { /// The part of termio used from the pty read thread: parses the pty output /// and applies it to the shared terminal. pub struct Output { - state: Arc, + state: SharedState, inner: Mutex, renderer_mailbox: Arc, mailbox: Mailbox, @@ -220,7 +222,7 @@ impl Output { // We are modifying terminal state from here on out and we need // the lock to grab our read data. let outbox = { - let mut term = self.state.lock(); + let mut term = self.state.lock_terminal(); let mut inner = self.inner(); self.process_output_locked(&mut term, &mut inner, buf); @@ -275,7 +277,7 @@ pub struct Termio { config: DerivedConfig, /// The shared render state (contains the terminal). - renderer_state: Arc, + renderer_state: SharedState, /// The part shared with the pty read thread. output: Arc, @@ -309,7 +311,7 @@ pub struct Termio { /// the termio thread. As such, a lock is not necessary. pub struct ThreadData { /// The shared render state - pub renderer_state: Arc, + pub renderer_state: SharedState, /// Mailboxes for different threads pub surface_mailbox: Arc, @@ -371,8 +373,7 @@ impl Termio { })?; // Set the image size limits - // TODO(integration): `screen.kitty_images.setLimit(config.image_storage_limit)` - // once ghostty-terminal ports kitty `ImageStorage` (currently a stub). + set_image_limits(&mut term, config.image_storage_limit); // Set our default cursor style term.screen_mut().cursor.cursor_style = cursor_style_from_config(config.cursor_style); @@ -399,7 +400,7 @@ impl Termio { let thread_enter_state = ThreadEnterState::create(full_config); - let renderer_state = Arc::new(RendererState::new(term)); + let renderer_state = State::new(term).into_shared(); let output = Arc::new(Output { state: Arc::clone(&renderer_state), inner: Mutex::new(OutputInner { @@ -427,7 +428,7 @@ impl Termio { /// The shared render state (it holds the terminal). Hand this to the /// renderer. - pub fn renderer_state(&self) -> &Arc { + pub fn renderer_state(&self) -> &SharedState { &self.renderer_state } @@ -522,7 +523,7 @@ impl Termio { // The remainder of this function is modifying terminal state or // the read thread data, all of which requires holding the renderer // state lock. - let mut term = self.renderer_state.lock(); + let mut term = self.renderer_state.lock_terminal(); // Update our stream handler. The stream handler uses the same // renderer mutex so this is safe to do despite being executed @@ -546,7 +547,7 @@ impl Termio { term.colors.cursor.default = config.cursor_color.and_then(TerminalColor::to_terminal_rgb); // Set the image size limits - // TODO(integration): kitty_images.setLimit (see `init`). + set_image_limits(&mut term, config.image_storage_limit); self.config = config; } @@ -562,7 +563,7 @@ impl Termio { // Enter the critical area that we want to keep small { let state = Arc::clone(&self.renderer_state); - let mut term = state.lock(); + let mut term = state.lock_terminal(); self.output().inner().stream.state.size = size; // Update the size of our terminal state @@ -594,7 +595,7 @@ impl Termio { /// Make a size report. pub fn size_report(&mut self, td: &mut ThreadData, style: SizeReport) { let state = Arc::clone(&self.renderer_state); - let _term = state.lock(); + let _term = state.lock_terminal(); self.size_report_locked(td, style); } @@ -625,7 +626,7 @@ impl Termio { /// Reset the synchronized output mode. This is usually called by timer /// expiration from the termio thread. pub fn reset_synchronized_output(&mut self) { - let mut term = self.renderer_state.lock(); + let mut term = self.renderer_state.lock_terminal(); term.modes.set(Mode::SynchronizedOutput, false); if let Err(err) = self.renderer_wakeup.notify() { log::warn!("failed to notify renderer err={err}"); @@ -635,7 +636,7 @@ impl Termio { /// Clear the screen. pub fn clear_screen(&mut self, td: &mut ThreadData, history: bool) { { - let mut term = self.renderer_state.lock(); + let mut term = self.renderer_state.lock_terminal(); // If we're on the alternate screen, we do not clear. Since this is an // emulator-level screen clear, this messes up the running programs @@ -672,9 +673,11 @@ impl Termio { // graphics that are placed above the cursor or if it deletes // all of them. We delete all of them for now but if this behavior // isn't fully correct we should fix this later. - // TODO(integration): `kitty_images.delete(.{ .all = true })` - // once kitty image storage is ported; only mark dirty for now. - term.screen_mut().kitty_images.dirty = true; + let info = TerminalInfo::from_terminal(&term); + let screen = term.screen_mut(); + screen + .kitty_images + .delete(&mut screen.pages, &info, KittyDelete::All(true)); return; } @@ -690,13 +693,13 @@ impl Termio { /// Scroll the viewport pub fn scroll_viewport(&mut self, scroll: ScrollViewport) { - self.renderer_state.lock().scroll_viewport(scroll); + self.renderer_state.lock_terminal().scroll_viewport(scroll); } /// Jump the viewport to the prompt. pub fn jump_to_prompt(&mut self, delta: isize) { { - let mut term = self.renderer_state.lock(); + let mut term = self.renderer_state.lock_terminal(); term.screen_mut().scroll(Scroll::DeltaPrompt(delta)); } @@ -707,7 +710,11 @@ impl Termio { /// Called when focus is gained or lost (when focus events are enabled) pub fn focus_gained(&mut self, td: &mut ThreadData, focused: bool) { - let focus_event = self.renderer_state.lock().modes.get(Mode::FocusEvent); + let focus_event = self + .renderer_state + .lock_terminal() + .modes + .get(Mode::FocusEvent); // If we have focus events enabled, we send the focus event. if focus_event { @@ -727,7 +734,7 @@ impl Termio { /// Sends a DSR response for the current color scheme to the pty. pub fn color_scheme_report(&mut self, td: &mut ThreadData, force: bool) { let state = Arc::clone(&self.renderer_state); - let term = state.lock(); + let term = state.lock_terminal(); self.color_scheme_report_locked(&term, td, force); } @@ -747,3 +754,14 @@ impl Termio { self.queue_write(td, output, false); } } + +/// Apply `image-storage-limit` to every initialized screen. +fn set_image_limits(term: &mut Terminal, limit: usize) { + for key in [ScreenKey::Primary, ScreenKey::Alternate] { + if let Some(screen) = term.screens.get_mut(key) + && let Err(err) = screen.kitty_images.set_limit(&mut screen.pages, limit) + { + log::warn!("failed to set kitty image storage limit: {err}"); + } + } +} diff --git a/crates/ghostty-termio/src/thread.rs b/crates/ghostty-termio/src/thread.rs index 710b3d8..be664d6 100644 --- a/crates/ghostty-termio/src/thread.rs +++ b/crates/ghostty-termio/src/thread.rs @@ -31,6 +31,7 @@ use rustix::event::{PollFd, PollFlags}; use crate::backend::ThreadData as BackendThreadData; use crate::exec; use crate::message::Message; +use crate::state::SharedStateExt; use crate::termio::{Error, Termio, ThreadData}; use crate::wakeup::Wakeup; @@ -376,7 +377,7 @@ impl Thread { /// in some way but this works for now. Without this, the user would /// just see a blank terminal window. fn report_start_error(io: &Termio, err: &Error) { - let mut t = io.renderer_state().lock(); + let mut t = io.renderer_state().lock_terminal(); // Hide the cursor t.modes.set(Mode::CursorVisible, false); diff --git a/crates/ghostty-termio/src/wakeup.rs b/crates/ghostty-termio/src/wakeup.rs index 5110081..03a04ab 100644 --- a/crates/ghostty-termio/src/wakeup.rs +++ b/crates/ghostty-termio/src/wakeup.rs @@ -12,17 +12,35 @@ use std::sync::Arc; use rustix::event::{EventfdFlags, eventfd}; /// See the module docs. Cloning shares the same underlying eventfd. -#[derive(Clone, Debug)] -pub struct Wakeup(Arc); +#[derive(Clone)] +pub struct Wakeup(Arc, Option>); + +impl std::fmt::Debug for Wakeup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Wakeup").field(&self.0).finish() + } +} impl Wakeup { pub fn new() -> io::Result { let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?; - Ok(Wakeup(Arc::new(fd))) + Ok(Wakeup(Arc::new(fd), None)) + } + + /// A wakeup that calls `hook` instead of signalling an eventfd. Used to + /// bridge into waiters that are not `poll(2)`-based (the renderer + /// thread's channel). `as_fd`/`drain` are meaningless for these. + pub fn with_hook(hook: Arc) -> io::Result { + let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?; + Ok(Wakeup(Arc::new(fd), Some(hook))) } /// Wake up the waiter. Multiple notifications coalesce. pub fn notify(&self) -> io::Result<()> { + if let Some(hook) = &self.1 { + hook(); + return Ok(()); + } loop { match rustix::io::write(&*self.0, &1u64.to_ne_bytes()) { Ok(_) => return Ok(()), diff --git a/crates/ghostty-termio/tests/exec_integration.rs b/crates/ghostty-termio/tests/exec_integration.rs index 22da94f..0d70f2f 100644 --- a/crates/ghostty-termio/tests/exec_integration.rs +++ b/crates/ghostty-termio/tests/exec_integration.rs @@ -18,12 +18,13 @@ use ghostty_config::types_b::{LinuxCgroup, ShellIntegration}; use ghostty_datastruct::blocking_queue::Timeout; use ghostty_renderer::message::Message as RendererMessage; use ghostty_renderer::size::{CellSize, Padding, ScreenSize, Size}; +use ghostty_termio::SharedStateExt; use ghostty_termio::backend::{self, Backend}; use ghostty_termio::exec; use ghostty_termio::message::Message; use ghostty_termio::termio::DerivedConfig; use ghostty_termio::{ - Mailbox, Options, RendererMailbox, RendererState, SurfaceMailbox, Termio, Thread, Wakeup, + Mailbox, Options, RendererMailbox, SharedState, SurfaceMailbox, Termio, Thread, Wakeup, }; struct SurfaceRec(Sender); @@ -69,7 +70,7 @@ fn size(cols: u32, rows: u32) -> Size { } struct Fixture { - state: Arc, + state: SharedState, mailbox: Mailbox, stop: Wakeup, thread: Option>, @@ -136,7 +137,7 @@ fn sh(script: &str) -> ConfigCommand { impl Fixture { fn text(&self) -> String { - self.state.lock().plain_string().unwrap() + self.state.lock_terminal().plain_string().unwrap() } fn send(&self, msg: Message) { @@ -218,7 +219,7 @@ fn printf_output_reaches_terminal_and_exit_is_reported() { // The SGR was applied: the cursor pen is red (palette 1). { - let t = f.state.lock(); + let t = f.state.lock_terminal(); let style = t.screen().cursor.style; assert!( !matches!(style.fg_color, ghostty_terminal::style::Color::None), @@ -308,7 +309,7 @@ fn linefeed_mode_translates_cr() { f.wait_text("a^M"); // cat -v got "a\r\n", so it printed a newline after the ^M. f.wait_for("cursor on second row", |f| { - (f.state.lock().screen().cursor.y == 1).then_some(()) + (f.state.lock_terminal().screen().cursor.y == 1).then_some(()) }); f.stop(); } @@ -346,7 +347,7 @@ fn missing_input_file_shows_error_and_thread_keeps_draining() { f.wait_text("A configured `input` path was not found"); assert!( !f.state - .lock() + .lock_terminal() .modes .get(ghostty_vt::modes::Mode::CursorVisible) ); @@ -366,7 +367,7 @@ fn resize_messages_are_coalesced_and_applied_to_the_pty() { f.send(Message::Resize(size(100, 40))); f.wait_text("40 100"); { - let t = f.state.lock(); + let t = f.state.lock_terminal(); assert_eq!((t.cols, t.rows), (100, 40)); assert_eq!(t.width_px, 1000); assert_eq!(t.height_px, 800); @@ -385,7 +386,7 @@ fn synchronized_output_is_force_reset_by_timer() { let mut f = start(sh("printf '\\033[?2026h'; sleep 5"), |_| {}); f.wait_for("sync output on", |f| { f.state - .lock() + .lock_terminal() .modes .get(ghostty_vt::modes::Mode::SynchronizedOutput) .then_some(()) @@ -393,7 +394,7 @@ fn synchronized_output_is_force_reset_by_timer() { let started = Instant::now(); f.wait_for("sync output reset", |f| { (!f.state - .lock() + .lock_terminal() .modes .get(ghostty_vt::modes::Mode::SynchronizedOutput)) .then_some(()) @@ -462,7 +463,7 @@ fn focus_events_are_sent_when_enabled() { ); f.wait_for("focus mode", |f| { f.state - .lock() + .lock_terminal() .modes .get(ghostty_vt::modes::Mode::FocusEvent) .then_some(()) @@ -529,7 +530,7 @@ fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() { f.send(Message::Focused(true)); f.send(Message::Resize(size(90, 25))); f.wait_for("resize after exit", |f| { - (f.state.lock().cols == 90).then_some(()) + (f.state.lock_terminal().cols == 90).then_some(()) }); f.stop(); } @@ -542,11 +543,11 @@ fn change_config_message_updates_palette_and_colors() { cfg.enquiry_response = "x".into(); f.send(Message::ChangeConfig(Box::new(cfg))); f.wait_for("background change", |f| { - let t = f.state.lock(); + let t = f.state.lock_terminal(); (t.colors.background.default == Some(ghostty_vt::color::Rgb { r: 1, g: 2, b: 3 })) .then_some(()) }); - assert!(f.state.lock().flags.dirty.palette); + assert!(f.state.lock_terminal().flags.dirty.palette); f.stop(); } @@ -571,7 +572,11 @@ fn process_output_manual_api() { termio.process_output(b"manual\x1b[31m!"); assert_eq!( "manual!", - termio.renderer_state().lock().plain_string().unwrap() + termio + .renderer_state() + .lock_terminal() + .plain_string() + .unwrap() ); termio.process_output(b"\x1b[5n"); let m = termio.mailbox().pop().unwrap(); diff --git a/docs/rewrite/INTEGRATION_TODO.md b/docs/rewrite/INTEGRATION_TODO.md index 82a6812..d1f8086 100644 --- a/docs/rewrite/INTEGRATION_TODO.md +++ b/docs/rewrite/INTEGRATION_TODO.md @@ -3,25 +3,26 @@ Collected from agent reports; resolve during integration phases. - [ ] vt `osc/parsers/semantic_prompt.rs`: local printf-q / percent decoders → share with `ghostty-os::string_encoding` (vt can't depend on os; maybe move into vt and have os re-export). -- [ ] os `locale::ensure_locale` no-op → call setlocale from apprt-gtk (unsafe allowed) at startup, as Ghostty's global.zig does. +- [x] os `locale::ensure_locale` no-op → call setlocale from apprt-gtk (unsafe allowed) at startup, as Ghostty's global.zig does. (resolved: apprt-gtk Application calls setlocale at startup; the core/root binary do not need unsafe) - [ ] os `passwd` Flatpak branch needs termio Pty. -- [ ] os `open::OpenUrlKind` → replace with core apprt action type. +- [x] os `open::OpenUrlKind` → replace with core apprt action type. - [x] terminal crate: re-add deps on unicode/datastruct once those land. -- [ ] input: key_encode::Options / paste::Options from terminal modes (Zig `fromTerminal`) — in terminal or core. +- [x] input: key_encode::Options / paste::Options from terminal modes (Zig `fromTerminal`) — in terminal or core. (resolved: `ghostty_core::surface::keyboard::encode_options_from_terminal`, `clipboard::paste_options_from_terminal`) - [ ] input: `binding::set::EntryFormatter` must be implemented by config formatter. - [ ] config: clipboard_codepoint_map::Entry and CursorStyle are local copies → unify with terminal/formatter (font types are unified: canonical in ghostty-config, re-exported by ghostty-font). - [ ] config: url::REGEX needs onig (lookbehind) in renderer/core link matching. - [ ] config: theme::open needs resources dir from ghostty_os. - NOTE: Ghostty 1.3.1 is installed at /usr/bin/ghostty — use it as reference oracle (e.g. +show-config, +list-actions, terminfo). - [ ] termio: Pty closes slave on drop (Zig didn't) — verify Exec semantics; cgroup/rlimit/D-Bus scope wiring; local option stand-ins → config types. -- [ ] cli: wire list-fonts/show-face (font discovery) and new-window (GTK IPC); call build_info::set from root/apprt. +- [x] cli: wire list-fonts/show-face (font discovery) and new-window (`set_ipc_handler`, root must register it). +- [x] cli: call build_info::set from root (done in src/main.rs; build versions are the cargo feature levels 4.14/1.5). - [ ] input: ActionKey::docs() has extra trailing blank line for 4 actions (cli works around locally). - [ ] vt: x11_color lacks name listing with original case (cli re-parses rgb.txt). -- [ ] apprt: Scrollbar/RendererHealth stand-ins → terminal/renderer types; core implements AppMailbox. +- [x] apprt: Scrollbar/RendererHealth stand-ins → terminal/renderer types; core implements AppMailbox. (resolved: apprt re-exports the real types; core implements AppMailbox) - [x] terminal (after T4/T5/K1): Codex round 3 on notes/ghostty-terminal-pagelist-requests.md items 1,2,3,4,5,7 + append_grapheme dirty + clone_partial_row_between; then remove Screen/Terminal workarounds. Items 6 and the two T3 test items are resolved by the parity pass. - [ ] tmux viewer wiring into termio stream handler (see notes/ghostty-terminal-search-tmux.md); renderer sets search_viewport_dirty. - [ ] apprt-gtk: stubs toggle_visibility/float_window/secure_input/close_all_windows; inspector no-op; cgroup wait loop duplicated with termio; GLArea framebuffer id must be queried by renderer; layer-shell lib load order. -- [ ] RendererState defined in termio::state; renderer R2 ports State.zig → unify (canonical in renderer, termio depends on renderer). -- [ ] termio: hook kitty graphics exec/replies (terminal now has it) and image_storage_limit; tmux viewer. -- [ ] termio: Flatpak stop path needs ghostty-os FlatpakHostCommand change; apprt-gtk create_scope; core restore_rlimit. -- [ ] renderer: generic::update_kitty → call image::State::kitty_update now that Screen has ImageStorage; apprt-gtk must set GL viewport before draw. +- [x] RendererState defined in termio::state; renderer R2 ports State.zig → unify (canonical in renderer, termio depends on renderer). (resolved: termio uses `ghostty_renderer::state::{State, SharedState}`) +- [x] termio: hook kitty graphics exec/replies (terminal now has it) and image_storage_limit; tmux viewer. (resolved: APC exec already in stream handler; image-storage-limit applied at init/config change, clear-screen deletes images; tmux viewer still open) +- [x] termio: Flatpak stop path needs ghostty-os FlatpakHostCommand change; apprt-gtk create_scope; core restore_rlimit. (partially: core supplies restore_rlimit; apprt-gtk supplies create_scope via RtSurface::create_scope_fn; Flatpak stop unchanged) +- [x] renderer: generic::update_kitty → call image::State::kitty_update now that Screen has ImageStorage; apprt-gtk must set GL viewport before draw. (resolved; apprt-gtk resolves GL via epoxy_* pointers; the renderer queries GL_VIEWPORT) diff --git a/docs/rewrite/notes/ghostty-cli.md b/docs/rewrite/notes/ghostty-cli.md index db419af..b5e8ee8 100644 --- a/docs/rewrite/notes/ghostty-cli.md +++ b/docs/rewrite/notes/ghostty-cli.md @@ -85,3 +85,13 @@ new-window arg hook). * crash-report only lists (sentry is not ported). `ssh-cache` locking uses `File::try_lock`; like Zig, a lock held by another process is ignored (the boolean result is unused upstream). * `Config::load` and `finalize` are called twice in `validate-config` (as in Zig). + +## Integration pass: list-fonts / show-face / new-window (wired) +* `list_fonts.rs`, `show_face.rs` now use `ghostty-font` (fontconfig `Discover`, `SharedGridSet`, `SharedGrid::get_index`, + `Collection::get_face`); ghostty-cli depends on ghostty-font and ghostty-apprt. Output is byte-identical to + /usr/bin/ghostty for `+list-fonts` (plain, `--family`, `--bold`) and `+show-face` (`--cp`, `--string`, `--presentation`). + Like Zig, show-face writes its "stderr" messages to stdout. +* `new_window.rs`: `set_ipc_handler(fn(&ipc::Target, &ipc::Action) -> Result)` (OnceLock). The root binary must + register it (map `ghostty_apprt_gtk::ipc::perform_ipc`'s `Err(IpcFailed)` to `Err(new_window::IPC_FAILED.into())`). + Without a handler: prints "+new-window is not supported on this platform." and exits 1 (apprt none behaviour). +* Still open: `build_info::set`. diff --git a/docs/rewrite/notes/ghostty-core.md b/docs/rewrite/notes/ghostty-core.md new file mode 100644 index 0000000..084eff1 --- /dev/null +++ b/docs/rewrite/notes/ghostty-core.md @@ -0,0 +1,47 @@ +# ghostty-core notes + +## Keyboard (fork K) - crates/ghostty-core/src/surface/keyboard.rs +Ported: Keyboard state, keyEventIsBinding, keyCallback, maybeHandleBinding (sequences/leader, key tables incl. `once`, +catch_all ignore, chained actions, performable, consumed/global/all, last_trigger release suppression), endKeySequence, +encodeKey (+ `encode_options_from_terminal` = `key_encode.Options.fromTerminal`), key-table helpers +(`activate_key_table`, `deactivate_key_table`, `deactivate_all_key_tables`), `release_pressed_key_on_unfocus`, +`SurfaceMouse.keyToMouseShape` (private copy, because it is only used here at the moment). +Deviations: sequences/tables hold paths/names resolved against the current config (no raw pointers); +global/all chained actions are re-implemented locally (`perform_all_chained_action`) as the surface has no `App` handle; +no inspector; macOS option-as-alt skipped; `once` table deactivation calls `deactivate_key_table` directly. +Needs from the mouse module: `Mouse.{mods: Mods, over_link: bool, hidden: bool}`, `mods_changed(Mods)`, +`mouse_shift_capture(bool) -> bool`, `mouse_refresh_links(CursorPos, Coordinate, bool) -> Result<(), CoreError>`. +Tests: 8 (Zig has none for this code): OwnedKeyEvent round trip, closing actions, sequence path resolution, table lookup, +leader path, encode options from terminal modes, mouse shape for keys. + +## Binding actions (fork B) - surface/binding.rs + +- Ported: `Search` (+ search-thread event callback), `Text`/`dump_text`/`dump_text_locked` (viewport math in free fn `text_viewport`), `perform_binding_action` for every action, `closing_action`, `writeScreenFile` (free fns `screen_file_contents` / `write_screen_temp_file`; the temp dir is deliberately left on disk like Zig), `adjust_selection` (free fn on `&mut Terminal`). +- App-scoped actions are mapped locally (`perform_app_action`) because `App::perform_action` needs an `App`, not the shared `AppState`; `new_window` from a surface targets the surface. +- `toggle_mouse_reporting` rebuilds the `DerivedConfig` with a cloned `Config` (Zig mutated in place). +- Inspector action only forwards `RtAction::Inspector` (inspector is a no-op). +- Cross-crate change: `ghostty_terminal::search::thread::Options.terminal` is now `Arc` (trait implemented for `Mutex`; core implements it for the shared render state) because the search thread must lock the terminal inside `ghostty_renderer::state::State`. +- Requires from the mouse module: `link_url_at_cursor(&self) -> Option` (returns the link text under the cursor incl. clipboard trimming and OSC8 URI; None when not over a link); also `mods_changed`, `mouse_shift_capture`, `mouse_refresh_links` used by keyboard.rs. +- Tests: 7 (no Zig tests in this range). + +## Mouse (fork M) +Files: `crates/ghostty-core/src/surface/mouse.rs` (Mouse state, mouse reporting x10/utf8/sgr/urxvt/sgr-pixels, button/cursor/scroll/pressure callbacks, click-to-move, multi-click select, right-click actions, middle-click paste, links incl. OSC8, selection scrolling) and `src/surface_mouse.rs`. +- Tests: `key_to_mouse_shape`, `surface_selection_logic`, `surface_rectangle_selection_logic` ported 1:1 (selection math is the pure `mouse_selection(&PageList, ..)`); 3 extra. +- Deviations: mouse reports/IO messages/runtime actions are collected and run after the renderer lock is released (Zig held the lock and used `.locked` sends). `process_links` takes the lock itself (Zig called it unlocked). Left-release copy-on-select copies via `copy_on_select` directly: Zig's `setSelection(prev-equal)` early-returns on `eql`, which would never copy. +- Extra API for binding fork: `link_url_at_cursor()`. + +## Core overview (integration, agent CORE) +Done: trait boundary moved to `ghostty_apprt::runtime_api` (apprt now depends on terminal+renderer and re-exports the real `Scrollbar`/`RendererHealth`); +termio uses `ghostty_renderer::state::{State,SharedState}` (`SharedStateExt::lock_terminal`); renderer `update_kitty` calls `image::State::kitty_update`; +termio applies `image-storage-limit` (init + config change) and deletes kitty images on clear-screen; `app.rs` (App.zig, `CoreApp` impl, app mailbox), +`global.rs` (logging in Zig format, GHOSTTY_LOG, rlimits, resources dir, i18n), `surface/mod.rs` (init/teardown with renderer+IO threads, message handler, +config updates, sizing, fonts, focus, preedit, selection, `CoreSurface` impl), `surface/{clipboard,keyboard,mouse,binding,config}.rs`, `surface_mouse.rs`; root `src/main.rs`. +Wiring details: renderer->app via `Host` (scrollbar/health/redraw as app mailbox messages); termio->renderer via `RendererLink` + `Wakeup::with_hook`; +apprt-gtk got `RtSurface::gl_proc_address` (libepoxy `epoxy_*` pointer variables) and `create_scope_fn` (cgroup scope over D-Bus from the IO thread). +Deviations/stubs: inspector no-op; locale only via GTK's setlocale (no unsafe in core/root); crash reporting (sentry) not ported; `Rust version` row of `+version` empty; +default DPI 96 (Zig linux). +How to run: `GSETTINGS_BACKEND=memory GHOSTTY_RESOURCES_DIR=/usr/share/ghostty cargo run -- -e sh -c '...'` (debug builds only find resources via the env var here). +Dev screenshot: `GHOSTTY_DEBUG_SCREENSHOT=/abs/out.png [GHOSTTY_DEBUG_SCREENSHOT_MS=2500]` dumps the renderer's last frame (compositor screenshots under niri come out black / are blocked). +Verified manually: window opens on Wayland and X11, shell prompt and SGR colored output render (target/screenshots/frame.png, shell.png), child exit closes window, clean shutdown. +NOT verified: keyboard input end-to-end (no way to inject keys into the window here; xdotool/niri did not deliver), mouse, clipboard, IME, splits/tabs. +Known warnings at runtime: `unimplemented action=CellSize` (same as Zig GTK), cgroup scope creation fails with UnixProcessIdUnknown in this sandbox (non-fatal). diff --git a/src/main.rs b/src/main.rs index e7a11a9..d78f838 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,106 @@ -fn main() { - println!("Hello, world!"); +//! The main entrypoint for the `ghostty` application (port of +//! `main_ghostty.zig` + `build_config.zig`). +//! +//! If the command line contains a `+action` it is dispatched to +//! `ghostty-cli`; otherwise the configuration is loaded by the GTK runtime +//! and the terminal application is run with the real core. + +use std::process::ExitCode; +use std::rc::Rc; + +use ghostty_apprt::ipc; +use ghostty_apprt_gtk::{adw_version, gtk_version}; +use ghostty_cli::build_info::{self, BuildInfo, Version}; +use ghostty_core::global::{self, InitOptions}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + + // We first start by initializing our global state. This sets up + // process-level state we need to run the terminal. We detect whether a + // CLI action was requested so logging can be silenced for it. + let action_requested = !matches!(ghostty_cli::ghostty::detect(&args), Ok(None)); + global::init(InitOptions { + action: action_requested, + }); + build_info::set(build_info()); + + // Execute our action if we have one. + if action_requested { + ghostty_cli::new_window::set_ipc_handler(new_window_ipc); + if let Some(code) = ghostty_cli::run_from_args(&args) { + return ExitCode::from(code); + } + } + + if cfg!(debug_assertions) { + log::warn!("This is a debug build. Performance will be very poor."); + log::warn!("You should only use a debug build for developing Ghostty."); + log::warn!("Otherwise, please rebuild in a release mode."); + } + + run_app() +} + +/// Create the core app and the GTK runtime and run the event loop. +fn run_app() -> ExitCode { + // The waker lets core threads (renderer, IO) wake the GTK main loop. + let waker = ghostty_apprt_gtk::app::waker(); + + // Create our app state + let core = match ghostty_core::App::new(waker) { + Ok(core) => Rc::new(core), + Err(err) => { + log::error!("failed to create the app err={err}"); + return ExitCode::FAILURE; + } + }; + + // Create our runtime app + let app = ghostty_apprt_gtk::app::App::new(core); + + // Run the GUI event loop + let result = app.run(); + app.terminate(); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + log::error!("app runtime failed err={err:?}"); + ExitCode::FAILURE + } + } +} + +/// `+new-window`: send the IPC through the GTK runtime. +fn new_window_ipc(target: &ipc::Target, action: &ipc::Action) -> Result { + match ghostty_apprt_gtk::app::App::perform_ipc(target, action) { + Ok(v) => Ok(v), + Err(ipc::Error::IpcFailed) => Err(ghostty_cli::new_window::IPC_FAILED.to_owned()), + } +} + +/// Build information for `+version` (Zig: `build_config.zig`). +fn build_info() -> BuildInfo { + let version = |(major, minor, micro): (u32, u32, u32)| Version { + major, + minor, + micro, + }; + let gtk = gtk_version::get_runtime_version(); + let adw = adw_version::get_runtime_version(); + BuildInfo { + gtk_build: Some(Version { + major: 4, + minor: 14, + micro: 0, + }), + gtk_runtime: Some(version((gtk.0, gtk.1, gtk.2))), + adw_build: Some(Version { + major: 1, + minor: 5, + micro: 0, + }), + adw_runtime: Some(version((adw.0, adw.1, adw.2))), + ..BuildInfo::default() + } }