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:
1 parent
9c3eeeba50
commit
3266108c10
42 files changed
+9301
-179
No files matched your search
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(()),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user