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=<png> 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
This commit is contained in:
ddidderr committed 2026-10-12 01:33:44 +02:00
1 parent 9c3eeeba50
commit 3266108c10
42 files changed
+9301 -179

No files matched your search

+6 -7
View File
@@ -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(())
}
+13 -5
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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)]
+26 -3
View File
@@ -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<ghostty_apprt::runtime_api::CreateScopeFn> {
// 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();
+16 -8
View File
@@ -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)
}
}
-1
View File
@@ -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;
+2
View File
@@ -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]
+1
View File
@@ -7,6 +7,7 @@
pub mod action;
pub mod ipc;
pub mod runtime;
pub mod runtime_api;
pub mod structs;
pub mod surface;
@@ -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<CreateScopeFn> {
None
}
}
/// `fn(pid, memory_high, tasks_max)`: `None` if there is no D-Bus
/// connection, `Some(Err(msg))` on failure.
pub type CreateScopeFn =
Arc<dyn Fn(u32, Option<u64>, Option<u64>) -> Option<Result<(), String>> + 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 {
+4 -22
View File
@@ -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 {
+2
View File
@@ -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"
+69 -5
View File
@@ -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<u8, Error> {
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<String> = Vec::new();
let mut map: HashMap<String, Vec<String>> = 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)
}
+71 -7
View File
@@ -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<bool, String>;
/// Error string for `error.IPCFailed`: the runtime already reported it.
pub const IPC_FAILED: &str = "IPCFailed";
static IPC_HANDLER: OnceLock<IpcHandler> = 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<u8, Error> {
.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<bool, String> {
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));
}
}
+150 -6
View File
@@ -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<u8, Error> {
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<u8, Error> {
// 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<font::Presentation>,
cp: u32,
) -> Result<Option<u8>, 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)
}
+15 -6
View File
@@ -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
+634
View File
@@ -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<dyn CoreSurface>`).
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<SurfaceId>,
},
/// 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<Message>,
waker: Arc<dyn Waker>,
}
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<SurfaceId> 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<Vec<(SurfaceId, Weak<Surface>)>>,
/// 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<bool>,
/// The last focused surface. This surface may not be valid; you must
/// always validate it with [`AppState::focused_surface`].
focused_surface: Cell<Option<SurfaceId>>,
/// The mailbox other threads use to send this thread messages.
pub mailbox: Mailbox,
mailbox_rx: Receiver<Message>,
/// The set of font grids shared by surfaces with the same font
/// configuration.
pub font_grid_set: Arc<SharedGridSet>,
/// Used to rate limit desktop notifications.
pub last_notification_time: Cell<Option<Instant>>,
pub last_notification_digest: Cell<u64>,
/// 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<conditional::State>,
/// The runtime app, remembered from the first surface so the quit timer
/// can be started when the last surface goes away.
rt_app: RefCell<Option<Rc<dyn RtApp>>>,
/// 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<bool>,
}
impl AppState {
/// Ids and strong references of all live surfaces.
pub fn surfaces(&self) -> Vec<Rc<Surface>> {
self.surfaces
.borrow()
.iter()
.filter_map(|(_, w)| w.upgrade())
.collect()
}
/// Look up a live surface.
pub fn surface(&self, id: SurfaceId) -> Option<Rc<Surface>> {
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<Rc<Surface>> {
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<AppState>,
}
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<dyn Waker>) -> Result<App, ghostty_font::shared_grid_set::InitError> {
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<AppState> {
&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<SurfaceId>) {
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<Surface>, rt: &Rc<dyn RtApp>) {
*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<Config>) -> 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<dyn RtApp>,
rt_surface: Rc<dyn RtSurface>,
) -> Result<Rc<dyn CoreSurface>, 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<SurfaceId> {
self.state
.surfaces
.borrow()
.iter()
.map(|(i, _)| *i)
.collect()
}
fn cell_size(&self, config: &Config, font_size: DesiredFontSize) -> Option<RtCellSize> {
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,
})
}
}
+230
View File
@@ -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<Rlimit>,
}
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<GlobalState> = 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());
}
}
+33 -1
View File
@@ -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;
+1327
View File
@@ -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<JoinHandle<()>>,
}
/// Lets the search thread lock the terminal inside the shared render state.
struct StateLock(SharedState);
impl TerminalLock for StateLock {
fn lock_terminal(&self) -> Box<dyn TerminalRef + '_> {
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<Search, CoreError> {
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<SurfaceId, AppMailbox>,
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<TextViewport>,
}
/// 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<TextViewport> {
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<Option<String>, 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 `<tempdir>/<loc>.<ext>` (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<String, CoreError> {
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<String, CoreError> {
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<Text, CoreError> {
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<Text, CoreError> {
// 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<bool, CoreError> {
// 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);
}
}
@@ -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<formatter::CodepointMap> = cfg
.clipboard_codepoint_map
.map
.list
.iter()
.map(|e| formatter::CodepointMap {
range: e.range,
replacement: match &e.replacement {
ghostty_config::clipboard_codepoint_map::Replacement::Codepoint(c) => {
formatter::Replacement::Codepoint(*c)
}
ghostty_config::clipboard_codepoint_map::Replacement::String(s) => {
formatter::Replacement::String(s.clone())
}
},
})
.collect();
let 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<String> {
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<u8>;
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)
}
}
+87
View File
@@ -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<Config>,
/// `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<u32>,
/// The compiled `link` entries.
pub links: Vec<Link>,
}
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<Config>) -> Result<DerivedConfig, onig::Error> {
// 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),
}
}
}
+1138
View File
@@ -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<Trigger>,
}
/// 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<SequencePath>,
/// 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<IoMessage>,
/// 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<TableEntry>,
/// 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<u64>,
}
/// 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<BindingAction>,
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<MouseShape> {
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<Flags> {
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<InputEffect, CoreError> {
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<InputEffect> {
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<bool, CoreError> {
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<IoMessage> {
// Build our encoding options, which requires the lock.
let opts = {
let t = self.lock_terminal();
encode_options_from_terminal(&t)
};
let mut data: Vec<u8> = 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)
);
}
}
+1941
View File
@@ -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<SurfaceId, Mailbox>;
/// 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<RendererHandle>,
}
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<AppState>,
/// The windowing system surface and app.
pub(crate) rt_app: Rc<dyn RtApp>,
pub(crate) rt_surface: Rc<dyn RtSurface>,
/// The font structures.
pub(crate) font: RefCell<FontState>,
/// 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<Renderer>,
/// 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<Option<RendererRunning>>,
/// The IO thread and the mailbox used to send it messages.
pub(crate) io_mailbox: IoMailbox,
io_stop: Wakeup,
io_thread: RefCell<Option<JoinHandle<()>>>,
/// The command arguments the IO backend was started with (for the
/// abnormal exit message).
command_args: Vec<String>,
/// Mouse state.
pub(crate) mouse: RefCell<mouse::Mouse>,
/// Keyboard input state.
pub(crate) keyboard: RefCell<keyboard::Keyboard>,
/// 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<Option<keyboard::OwnedKeyEvent>>,
/// All our sizing information.
pub(crate) size: Cell<Size>,
/// The configuration derived from the main config.
config: RefCell<Rc<DerivedConfig>>,
/// The conditional state of the configuration.
pub(crate) config_conditional_state: Cell<conditional::State>,
/// This is set to true if our IO thread notifies us our child exited.
pub(crate) child_exited: Cell<bool>,
/// We maintain our focus state and assume we're focused by default.
pub(crate) focused: Cell<bool>,
/// Used to determine whether to continuously scroll.
pub(crate) selection_scroll_active: Cell<bool>,
/// 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<bool>,
/// Used to send notifications that long running commands have finished.
command_timer: Cell<Option<Instant>>,
/// Search state.
pub(crate) search: RefCell<Option<binding::Search>>,
/// Used to rate limit BEL handling.
last_bell_time: Cell<Option<Instant>>,
/// Debug screenshot state (see `debug_screenshot`).
created: Instant,
debug_shot_done: Cell<bool>,
}
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<AppState>,
config_original: Arc<Config>,
rt_app: Rc<dyn RtApp>,
rt_surface: Rc<dyn RtSurface>,
) -> Result<Rc<Surface>, 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<Config> =
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<dyn RendererHost> = 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<dyn ghostty_termio::SurfaceMailbox> = 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<dyn RendererMailbox>,
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<DerivedConfig> {
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<Config>) -> 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<Config> =
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<String> {
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<String> {
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<Selection>,
) -> 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<PreeditCodepoint> = 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=<path.png>` 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::<u64>().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<dyn std::error::Error>> {
let file = std::io::BufWriter::new(std::fs::File::create(&path)?);
let mut enc = png::Encoder::new(file, w as u32, h as u32);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
enc.write_header()?.write_image_data(&flipped)?;
Ok(())
};
match write() {
Ok(()) => log::info!("wrote debug screenshot to {}", path.to_string_lossy()),
Err(err) => log::warn!("debug screenshot failed: {err}"),
}
}
/// 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<String, String> {
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<Config> {
Arc::clone(&self.cfg().config)
}
fn key_callback(&self, event: ghostty_input::KeyEvent<'_>) -> Result<InputEffect, CoreError> {
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<bool, CoreError> {
Surface::mouse_button_callback(self, state, button, mods)
}
fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> 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<Config>) -> Result<(), CoreError> {
Surface::update_config(self, config)
}
fn perform_binding_action(
&self,
action: &ghostty_input::binding::Action,
) -> Result<bool, CoreError> {
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<SurfaceId> {
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,
}
}
}
+2472
View File
@@ -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<TrackedPin>,
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<Instant>,
/// The last x/y sent for mouse reports.
pub event_point: Option<PointCoordinate>,
/// 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<PointCoordinate>,
}
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<RtAction>,
io: Vec<IoMessage>,
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<Selection> {
// 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<Selection> {
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<Selection> {
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<String> {
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<MouseButton>,
action: MouseReportAction,
mods: Mods,
pos: CursorPos,
) -> Option<Vec<u8>> {
// 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<IoMessage> = 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<bool, CoreError> {
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<bool> {
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<IoMessage> = 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<Link> {
// 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<Mods>,
) -> Option<Link> {
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<String> {
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<String> {
// 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<bool, CoreError> {
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<MouseOverLink>, 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<Mods>) -> 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<MouseButton> {
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());
}
}
+288
View File
@@ -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<MouseShape> {
// 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));
}
}
+4 -9
View File
@@ -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;
}
// ---------------------------------------------------------------------------
+26 -7
View File
@@ -13,7 +13,8 @@
//! based refresh timer. The terminal and its mutex are combined into an
//! `Arc<Mutex<Terminal>>`.
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<dyn Fn(Event) + Send + Sync>;
pub struct Options {
/// The terminal data to search. The mutex must be held while
/// reading/writing the terminal.
pub terminal: Arc<Mutex<Terminal>>,
pub terminal: Arc<dyn TerminalLock>,
/// 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<Terminal>) -> MutexGuard<'_, Terminal> {
m.lock().unwrap_or_else(PoisonError::into_inner)
/// A locked terminal (any guard that derefs to a [`Terminal`]).
pub trait TerminalRef: DerefMut<Target = Terminal> {}
impl<T: DerefMut<Target = Terminal>> TerminalRef for T {}
/// Something that owns a terminal behind a lock. Implemented for
/// `Mutex<Terminal>`; 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<dyn TerminalRef + '_>;
}
impl TerminalLock for Mutex<Terminal> {
fn lock_terminal(&self) -> Box<dyn TerminalRef + '_> {
Box::new(self.lock().unwrap_or_else(PoisonError::into_inner))
}
}
fn lock(m: &Arc<dyn TerminalLock>) -> Box<dyn TerminalRef + '_> {
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<Mutex<Terminal>>, cb: &EventCallback) {
fn notify(&mut self, _terminal: &Arc<dyn TerminalLock>, 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();
+2 -2
View File
@@ -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<Output>,
surface_mailbox: Arc<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
renderer_state: SharedState,
) -> Result<ThreadData, exec::Error> {
match self {
Backend::Exec(e) => Ok(ThreadData::Exec(e.thread_enter(
+4 -4
View File
@@ -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<Output>,
surface_mailbox: Arc<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
renderer_state: SharedState,
) -> Result<ThreadData, Error> {
// Start our subprocess
let (read_fd, write_fd) = self.subprocess.start()?;
@@ -382,7 +382,7 @@ pub struct ThreadData {
termios_mode: Mode,
surface_mailbox: Arc<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
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
};
+1 -1
View File
@@ -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;
+39 -27
View File
@@ -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<Terminal>,
}
/// 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))
}
}
+42 -24
View File
@@ -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<Mutex<Terminal>>`), 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<RendererState>,
state: SharedState,
inner: Mutex<OutputInner>,
renderer_mailbox: Arc<dyn RendererMailbox>,
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<RendererState>,
renderer_state: SharedState,
/// The part shared with the pty read thread.
output: Arc<Output>,
@@ -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<RendererState>,
pub renderer_state: SharedState,
/// Mailboxes for different threads
pub surface_mailbox: Arc<dyn SurfaceMailbox>,
@@ -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<RendererState> {
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}");
}
}
}
+2 -1
View File
@@ -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);
+21 -3
View File
@@ -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<OwnedFd>);
#[derive(Clone)]
pub struct Wakeup(Arc<OwnedFd>, Option<Arc<dyn Fn() + Send + Sync>>);
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<Wakeup> {
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<dyn Fn() + Send + Sync>) -> io::Result<Wakeup> {
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(()),
+19 -14
View File
@@ -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<SurfaceMessage>);
@@ -69,7 +70,7 @@ fn size(cols: u32, rows: u32) -> Size {
}
struct Fixture {
state: Arc<RendererState>,
state: SharedState,
mailbox: Mailbox,
stop: Wakeup,
thread: Option<JoinHandle<()>>,
@@ -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();