diff --git a/Cargo.lock b/Cargo.lock index b6648bc..aa06a2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,6 +571,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1339,11 +1358,20 @@ name = "ghostty-renderer" version = "0.1.0" dependencies = [ "bytemuck", + "crossbeam-channel", + "ghostty-config", "ghostty-font", + "ghostty-input", + "ghostty-terminal", + "ghostty-unicode", + "ghostty-vt", "glow", + "jpeg-decoder", "khronos-egl", "log", "naga", + "onig", + "png", "thiserror 2.0.21", ] @@ -1838,6 +1866,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -2777,6 +2814,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/Cargo.toml b/Cargo.toml index 0822470..144a8a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,8 @@ ghostty-os = { path = "crates/ghostty-os" } ghostty-datastruct = { path = "crates/ghostty-datastruct" } ghostty-core = { path = "crates/ghostty-core" } ghostty-apprt-gtk = { path = "crates/ghostty-apprt-gtk" } +ghostty-apprt = { path = "crates/ghostty-apprt" } +ghostty-cli = { path = "crates/ghostty-cli" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/ghostty-apprt-gtk/Cargo.toml b/crates/ghostty-apprt-gtk/Cargo.toml index 14f8b80..f87b682 100644 --- a/crates/ghostty-apprt-gtk/Cargo.toml +++ b/crates/ghostty-apprt-gtk/Cargo.toml @@ -3,11 +3,64 @@ name = "ghostty-apprt-gtk" version.workspace = true edition.workspace = true license.workspace = true +build = "build.rs" [dependencies] -ghostty-core.workspace = true +ghostty-apprt.workspace = true ghostty-config.workspace = true ghostty-input.workspace = true +ghostty-datastruct.workspace = true +ghostty-os.workspace = true +ghostty-vt.workspace = true +log = "0.4" +thiserror = "2" +bitflags = "2" +gtk = { package = "gtk4", version = "0.11", features = ["v4_14", "gnome_44"] } +adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] } +gtk4-layer-shell = "0.8" +gdk4-x11 = { version = "0.11.5", features = ["x11", "xlib"] } +gdk4-wayland = { version = "0.11.5", features = ["wayland_crate"] } +wayland-client = "0.31.15" +wayland-protocols-plasma = { version = "0.3.12", features = ["client"] } +wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } +wayland-backend = { version = "0.3.17", features = ["client_system"] } +half = "2" +libc = "0.2.190" +signal-hook = "0.4.5" +libloading = "0.9.0" -[lints] -workspace = true +[build-dependencies] +glib-build-tools = "0.22" + +[dev-dependencies] +env_logger = "0.11" +glow = "0.18" + +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +todo = "warn" +unwrap_used = "warn" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +module_name_repetitions = "allow" +too_many_lines = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +fn_params_excessive_bools = "allow" +match_same_arms = "allow" +unreadable_literal = "allow" +many_single_char_names = "allow" +doc_markdown = "allow" +must_use_candidate = "allow" +return_self_not_must_use = "allow" +used_underscore_binding = "allow" +large_stack_arrays = "allow" +inline_always = "allow" diff --git a/crates/ghostty-apprt-gtk/build.rs b/crates/ghostty-apprt-gtk/build.rs new file mode 100644 index 0000000..ce94ff3 --- /dev/null +++ b/crates/ghostty-apprt-gtk/build.rs @@ -0,0 +1,111 @@ +//! Compiles the blueprint UI files with `blueprint-compiler` and bundles them +//! (plus CSS and icons) into a GResource, mirroring +//! ZIG/src/apprt/gtk/build/{blueprint,gresource}.zig. + +#![allow(clippy::unwrap_used)] +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const PREFIX: &str = "/com/mitchellh/ghostty"; +const APP_ID: &str = "com.mitchellh.ghostty"; +const ICON_SIZES: &[u32] = &[16, 32, 128, 256, 512, 1024]; +const CSS: &[&str] = &[ + "style.css", + "style-dark.css", + "style-hc.css", + "style-hc-dark.css", +]; +// (major, minor, name). The inspector / imgui blueprints are deferred. +const BLUEPRINTS: &[(u32, u32, &str)] = &[ + (1, 0, "clipboard-confirmation-dialog"), + (1, 4, "clipboard-confirmation-dialog"), + (1, 2, "close-confirmation-dialog"), + (1, 2, "config-errors-dialog"), + (1, 2, "debug-warning"), + (1, 3, "debug-warning"), + (1, 2, "resize-overlay"), + (1, 2, "search-overlay"), + (1, 2, "key-state-overlay"), + (1, 5, "split-tree"), + (1, 5, "split-tree-split"), + (1, 2, "surface"), + (1, 5, "surface-scrolled-window"), + (1, 3, "surface-child-exited"), + (1, 5, "tab"), + (1, 5, "title-dialog"), + (1, 5, "window"), + (1, 5, "command-palette"), +]; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let res = manifest.join("resources"); + println!("cargo:rerun-if-changed=resources"); + println!("cargo:rerun-if-changed=build.rs"); + + let mut xml = String::new(); + xml.push_str("\n\n"); + + let _ = writeln!(xml, " "); + for name in CSS { + let _ = writeln!( + xml, + " css/{name}" + ); + } + xml.push_str(" \n"); + + let _ = writeln!(xml, " "); + for &s in ICON_SIZES { + let _ = writeln!( + xml, + " icons/{s}.png" + ); + let _ = writeln!( + xml, + " icons/{}.png", + s * 2 + ); + } + xml.push_str(" \n"); + + let _ = writeln!(xml, " "); + for &(major, minor, name) in BLUEPRINTS { + let rel = format!("{major}.{minor}/{name}"); + let input = res.join(format!("ui/{rel}.blp")); + let output = out.join(format!("ui/{rel}.ui")); + compile_blueprint(&input, &output); + let _ = writeln!(xml, " ui/{rel}.ui"); + } + xml.push_str(" \n\n"); + + let xml_path = out.join("ghostty.gresource.xml"); + std::fs::write(&xml_path, xml).unwrap(); + + glib_build_tools::compile_resources( + &[out.as_path(), res.as_path()], + xml_path.to_str().unwrap(), + "ghostty.gresource", + ); +} + +fn compile_blueprint(input: &Path, output: &Path) { + std::fs::create_dir_all(output.parent().unwrap()).unwrap(); + let st = Command::new("blueprint-compiler") + .arg("compile") + .arg("--output") + .arg(output) + .arg(input) + .output() + .unwrap_or_else(|e| { + panic!("`blueprint-compiler` (>= 0.16) is required to build ghostty-apprt-gtk: {e}") + }); + assert!( + st.status.success(), + "blueprint-compiler failed for {}:\n{}", + input.display(), + String::from_utf8_lossy(&st.stderr) + ); +} diff --git a/crates/ghostty-apprt-gtk/examples/demo.rs b/crates/ghostty-apprt-gtk/examples/demo.rs new file mode 100644 index 0000000..fff865c --- /dev/null +++ b/crates/ghostty-apprt-gtk/examples/demo.rs @@ -0,0 +1,347 @@ +//! Demo for the GTK app runtime with a dummy core. +//! +//! The dummy `CoreApp`/`CoreSurface` clear the `GLArea` with the configured +//! background color and implement just enough key handling (keybindings from +//! the user's config) to exercise windows, tabs, splits, dialogs, the command +//! palette, fullscreen, etc. +//! +//! Environment: +//! * `DEMO_QUIT_AFTER_MS=`: quit automatically after n milliseconds. +//! * `DEMO_CONFIRM=1`: make every surface report "needs confirm quit". +//! +//! Run: `cargo run -p ghostty-apprt-gtk --example demo` + +#![allow(unsafe_code)] // GL calls through glow + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use ghostty_apprt::action::{self, Action, Target}; +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}; +use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods}; +use glow::HasContext; + +fn confirm() -> bool { + std::env::var("DEMO_CONFIRM").is_ok_and(|v| v == "1") +} + +/// Map the subset of keybinding actions the demo understands to apprt actions. +fn binding_to_action(b: &Binding) -> Option { + use binding::action::{CloseTabMode, SplitDirection, SplitFocusDirection}; + Some(match b { + Binding::NewWindow => Action::NewWindow, + Binding::NewTab => Action::NewTab, + Binding::PreviousTab => Action::GotoTab(action::GotoTab::Previous), + Binding::NextTab => Action::GotoTab(action::GotoTab::Next), + Binding::LastTab => Action::GotoTab(action::GotoTab::Last), + Binding::GotoTab(n) => Action::GotoTab(action::GotoTab::Index(*n as i32)), + Binding::MoveTab(n) => Action::MoveTab(action::MoveTab { amount: *n }), + Binding::ToggleTabOverview => Action::ToggleTabOverview, + Binding::PromptSurfaceTitle => Action::PromptTitle(action::PromptTitle::Surface), + Binding::PromptTabTitle => Action::PromptTitle(action::PromptTitle::Tab), + Binding::NewSplit(d) => Action::NewSplit(match d { + SplitDirection::Right | SplitDirection::Auto => action::SplitDirection::Right, + SplitDirection::Down => action::SplitDirection::Down, + SplitDirection::Left => action::SplitDirection::Left, + SplitDirection::Up => action::SplitDirection::Up, + }), + Binding::GotoSplit(d) => Action::GotoSplit(match d { + SplitFocusDirection::Previous => action::GotoSplit::Previous, + SplitFocusDirection::Next => action::GotoSplit::Next, + SplitFocusDirection::Up => action::GotoSplit::Up, + SplitFocusDirection::Left => action::GotoSplit::Left, + SplitFocusDirection::Down => action::GotoSplit::Down, + SplitFocusDirection::Right => action::GotoSplit::Right, + }), + Binding::ToggleSplitZoom => Action::ToggleSplitZoom, + Binding::EqualizeSplits => Action::EqualizeSplits, + Binding::OpenConfig => Action::OpenConfig, + Binding::ReloadConfig => Action::ReloadConfig(action::ReloadConfig::default()), + Binding::CloseTab(m) => Action::CloseTab(match m { + CloseTabMode::This => action::CloseTabMode::This, + CloseTabMode::Other => action::CloseTabMode::Other, + CloseTabMode::Right => action::CloseTabMode::Right, + }), + Binding::CloseWindow | Binding::CloseSurface => Action::CloseWindow, + Binding::ToggleMaximize => Action::ToggleMaximize, + Binding::ToggleFullscreen => Action::ToggleFullscreen(action::Fullscreen::Native), + Binding::ToggleWindowDecorations => Action::ToggleWindowDecorations, + Binding::ToggleCommandPalette => Action::ToggleCommandPalette, + Binding::ToggleQuickTerminal => Action::ToggleQuickTerminal, + Binding::ShowGtkInspector => Action::ShowGtkInspector, + Binding::Quit => Action::Quit, + _ => return None, + }) +} + +// --------------------------------------------------------------------- + +struct DemoSurface { + id: SurfaceId, + config: RefCell>, + rt_app: Rc, + _rt_surface: Rc, + gl: RefCell>, + focused: Cell, +} + +impl DemoSurface { + fn perform(&self, a: Action) -> bool { + self.rt_app.perform_action(Target::Surface(self.id), a) + } +} + +impl CoreSurface for DemoSurface { + fn config(&self) -> Arc { + self.config.borrow().clone() + } + + fn key_callback(&self, event: KeyEvent<'_>) -> Result { + if event.action == ghostty_input::Action::Release { + return Ok(InputEffect::Ignored); + } + let config = self.config(); + let Some((_, value)) = config.keybind.set.get_event(&event) else { + return Ok(InputEffect::Ignored); + }; + let BindingValue::Leaf(leaf) = value else { + return Ok(InputEffect::Ignored); + }; + match binding_to_action(&leaf.action) { + Some(a) => { + let closes = matches!(a, Action::CloseWindow | Action::CloseTab(_)); + self.perform(a); + Ok(if closes { + InputEffect::Closed + } else { + InputEffect::Consumed + }) + } + None => Ok(InputEffect::Ignored), + } + } + + fn preedit_callback(&self, _text: Option<&str>) -> Result<(), CoreError> { + Ok(()) + } + fn focus_callback(&self, focused: bool) -> Result<(), CoreError> { + self.focused.set(focused); + Ok(()) + } + fn mouse_button_callback( + &self, + _state: MouseButtonState, + _button: MouseButton, + _mods: Mods, + ) -> Result { + Ok(false) + } + fn cursor_pos_callback(&self, _pos: CursorPos, _mods: Option) -> Result<(), CoreError> { + Ok(()) + } + fn scroll_callback(&self, _x: f64, _y: f64, _mods: ScrollMods) -> Result<(), CoreError> { + Ok(()) + } + fn size_callback(&self, _size: SurfaceSize) -> Result<(), CoreError> { + Ok(()) + } + fn content_scale_callback(&self, _scale: ContentScale) -> Result<(), CoreError> { + Ok(()) + } + fn ime_point(&self) -> ImePos { + ImePos { + x: 0.0, + y: 0.0, + width: 10.0, + height: 20.0, + } + } + fn has_selection(&self) -> bool { + false + } + fn color_scheme_callback(&self, _scheme: ColorScheme) -> Result<(), CoreError> { + Ok(()) + } + fn update_config(&self, config: &Arc) -> Result<(), CoreError> { + *self.config.borrow_mut() = config.clone(); + self.perform(Action::ConfigChange(action::ConfigChange { + config: config.clone(), + })); + Ok(()) + } + fn perform_binding_action(&self, a: &Binding) -> Result { + Ok(binding_to_action(a).is_some_and(|a| self.perform(a))) + } + fn complete_clipboard_request( + &self, + _req: ClipboardRequest, + data: &str, + _confirmed: bool, + ) -> Result<(), ClipboardError> { + log::info!("demo: paste of {} bytes", data.len()); + Ok(()) + } + fn set_clipboard_read_allowed(&self, _allow: bool) {} + fn set_clipboard_write_allowed(&self, _allow: bool) {} + fn needs_confirm_quit(&self) -> bool { + confirm() + } + fn child_exited(&self) -> bool { + false + } + 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) + }; + *self.gl.borrow_mut() = Some(gl); + Ok(()) + } + fn renderer_unrealized(&self) { + *self.gl.borrow_mut() = None; + } + fn draw_frame(&self, default_framebuffer: u32) -> Result<(), CoreError> { + let gl = self.gl.borrow(); + let Some(gl) = gl.as_ref() else { + return Ok(()); + }; + let config = self.config(); + let bg = config.background; + let a = config.background_opacity as f32; + let (r, g, b) = ( + f32::from(bg.r) / 255.0, + f32::from(bg.g) / 255.0, + f32::from(bg.b) / 255.0, + ); + // SAFETY: GL context is current (GLArea render signal). + unsafe { + gl.bind_framebuffer( + glow::FRAMEBUFFER, + std::num::NonZeroU32::new(default_framebuffer).map(glow::NativeFramebuffer), + ); + // Premultiplied alpha. + gl.clear_color(r * a, g * a, b * a, a); + gl.clear(glow::COLOR_BUFFER_BIT); + } + Ok(()) + } +} + +// --------------------------------------------------------------------- + +#[derive(Default)] +struct DemoApp { + surfaces: RefCell>, + mailbox: RefCell>, +} + +impl CoreApp for DemoApp { + fn tick(&self, rt: &dyn RtApp) -> Result<(), CoreError> { + let msgs: Vec = self.mailbox.borrow_mut().drain(..).collect(); + for m in msgs { + match m { + AppMessage::NewWindow => { + rt.perform_action(Target::App, Action::NewWindow); + } + AppMessage::OpenConfig => { + rt.perform_action(Target::App, Action::OpenConfig); + } + AppMessage::PresentSurface(id) => { + if self.surfaces.borrow().contains(&id) { + rt.perform_action(Target::Surface(id), Action::PresentTerminal); + } + } + } + } + Ok(()) + } + + fn update_config(&self, rt: &dyn RtApp, config: &Arc) -> Result<(), CoreError> { + rt.perform_action( + Target::App, + Action::ConfigChange(action::ConfigChange { + config: config.clone(), + }), + ); + Ok(()) + } + fn needs_confirm_quit(&self) -> bool { + confirm() && !self.surfaces.borrow().is_empty() + } + fn color_scheme_event(&self, _rt: &dyn RtApp, scheme: ColorScheme) { + log::info!("demo: color scheme {scheme:?}"); + } + fn perform_all_action(&self, rt: &dyn RtApp, a: &Binding) -> Result<(), CoreError> { + if let Some(a) = binding_to_action(a) { + rt.perform_action(Target::App, a); + } + Ok(()) + } + fn push_message(&self, msg: AppMessage) { + self.mailbox.borrow_mut().push(msg); + } + fn new_surface( + &self, + id: SurfaceId, + config: Config, + rt_app: Rc, + rt_surface: Rc, + ) -> Result, CoreError> { + self.surfaces.borrow_mut().push(id); + Ok(Rc::new(DemoSurface { + id, + config: RefCell::new(Arc::new(config)), + rt_app, + _rt_surface: rt_surface, + gl: RefCell::new(None), + focused: Cell::new(false), + })) + } + fn delete_surface(&self, id: SurfaceId) { + self.surfaces.borrow_mut().retain(|s| *s != id); + } + fn surface_ids(&self) -> Vec { + self.surfaces.borrow().clone() + } + fn cell_size(&self, _config: &Config, _font_size: DesiredFontSize) -> Option { + Some(CellSize { + width: 9, + height: 18, + }) + } +} + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let core = Rc::new(DemoApp::default()); + let app = App::new(core); + + if let Some(ms) = std::env::var("DEMO_QUIT_AFTER_MS") + .ok() + .and_then(|v| v.parse::().ok()) + { + gtk::glib::timeout_add_local_once(Duration::from_millis(ms), || { + ghostty_apprt_gtk::class::application::Application::default().quit(); + }); + } + + if let Err(err) = app.run() { + eprintln!("error: {err}"); + std::process::exit(1); + } + app.terminate(); +} diff --git a/crates/ghostty-apprt-gtk/resources/css/style-dark.css b/crates/ghostty-apprt-gtk/resources/css/style-dark.css new file mode 100644 index 0000000..f13b4f4 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/css/style-dark.css @@ -0,0 +1,8 @@ +.transparent { + background-color: transparent; +} + +.window .split paned > separator { + background-color: rgba(36, 36, 36, 1); + background-clip: content-box; +} diff --git a/crates/ghostty-apprt-gtk/resources/css/style-hc-dark.css b/crates/ghostty-apprt-gtk/resources/css/style-hc-dark.css new file mode 100644 index 0000000..a9aa2dc --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/css/style-hc-dark.css @@ -0,0 +1,3 @@ +.transparent { + background-color: transparent; +} diff --git a/crates/ghostty-apprt-gtk/resources/css/style-hc.css b/crates/ghostty-apprt-gtk/resources/css/style-hc.css new file mode 100644 index 0000000..a9aa2dc --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/css/style-hc.css @@ -0,0 +1,3 @@ +.transparent { + background-color: transparent; +} diff --git a/crates/ghostty-apprt-gtk/resources/css/style.css b/crates/ghostty-apprt-gtk/resources/css/style.css new file mode 100644 index 0000000..9c0f115 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/css/style.css @@ -0,0 +1,183 @@ +/* Application CSS that applies to the entire application. + * + * This is automatically loaded by AdwApplication: + * https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.3/styles-and-appearance.html#custom-styles + */ + +window.ssd.no-border-radius { + /* Without clearing the border radius, at least on Mutter with + * gtk-titlebar=true and gtk-adwaita=false, there is some window artifacting + * that this will mitigate. + */ + border-radius: 0 0; +} + +/* + * GhosttySurface URL overlay + */ +label.url-overlay { + padding: 4px 8px 4px 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +label.url-overlay:hover { + opacity: 0; +} + +label.url-overlay.left { + border-radius: 0px 6px 0px 0px; +} + +label.url-overlay.right { + border-radius: 6px 0px 0px 0px; +} + +/* + * GhosttySurface search overlay + */ +.search-overlay { + padding: 6px 8px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +/* + * GhosttySurface key state overlay + */ +.key-state-overlay { + padding: 6px 10px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +/* + * GhosttySurface resize overlay + */ +label.resize-overlay { + padding: 4px 8px 4px 8px; + border-radius: 6px 6px 6px 6px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +/* + * GhosttyClipboardConfirmationDialog + * + * Based on boxed-list-separate: + * https://gitlab.gnome.org/GNOME/libadwaita/-/blob/ad446167acf3e6d1ee693f98ca636268be8592a1/src/stylesheet/widgets/_lists.scss#L548 + */ +.clipboard-confirmation-dialog list { + background: none; +} + +.clipboard-confirmation-dialog list > row { + border: none; + margin-bottom: 12px; +} + +.clipboard-confirmation-dialog list > row:last-child { + margin-bottom: 0; +} + +.clipboard-confirmation-dialog .clipboard-overlay { + border-radius: 10px; +} + +.clipboard-confirmation-dialog .clipboard-contents { + filter: blur(0px); + transition: filter 0.3s ease; + border-radius: 10px; +} + +.clipboard-confirmation-dialog .clipboard-contents.blurred { + filter: blur(5px); +} + +/* + * Child Exited Overlay + */ +.child-exited.normal revealer widget { + background-color: rgba(38, 162, 105, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--success-bg-color), transparent 50%); */ +} + +.child-exited.abnormal revealer widget { + background-color: rgba(192, 28, 40, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ +} + +/* + * Surface + */ +.surface progressbar.error trough progress { + background-color: rgba(192, 28, 40, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ +} + +.surface .bell-overlay { + border-color: rgba(58, 148, 74, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--accent-color), transparent 50%); */ + border-width: 3px; + border-style: solid; +} + +.surface .readonly_overlay { + /* Should be the equivalent of the following SwiftUI color: */ + /* Color(hue: 0.08, saturation: 0.5, brightness: 0.8) */ + color: hsl(25 50 75); + padding: 8px 8px 8px 8px; + margin: 8px 8px 8px 8px; + border-radius: 6px 6px 6px 6px; + outline-style: solid; + outline-width: 1px; +} +/* + * Command Palette + */ +.command-palette-search > image:first-child { + margin-left: 8px; + margin-right: 4px; +} + +.command-palette-search > image:last-child { + margin-left: 4px; + margin-right: 8px; +} + +/* + * Splits + */ + +.window .split paned > separator { + background-color: rgba(250, 250, 250, 1); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--window-bg-color), transparent 0%); */ + background-clip: content-box; + + /* This works around the oversized drag area for the right side of GtkPaned. + * + * Upstream Gtk issue: + * https://gitlab.gnome.org/GNOME/gtk/-/issues/4484#note_2362002 + * + * Ghostty issue: + * https://github.com/ghostty-org/ghostty/issues/3020 + * + * Without this, it's not possible to select the first character on the + * right-hand side of a split. + */ + margin: 0; + padding: 0; +} diff --git a/crates/ghostty-apprt-gtk/resources/icons/1024.png b/crates/ghostty-apprt-gtk/resources/icons/1024.png new file mode 100644 index 0000000..8accee3 Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/1024.png differ diff --git a/crates/ghostty-apprt-gtk/resources/icons/128.png b/crates/ghostty-apprt-gtk/resources/icons/128.png new file mode 100644 index 0000000..cab4dcf Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/128.png differ diff --git a/crates/ghostty-apprt-gtk/resources/icons/16.png b/crates/ghostty-apprt-gtk/resources/icons/16.png new file mode 100644 index 0000000..0212014 Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/16.png differ diff --git a/crates/ghostty-apprt-gtk/resources/icons/2048.png b/crates/ghostty-apprt-gtk/resources/icons/2048.png new file mode 100644 index 0000000..1327d46 Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/2048.png differ diff --git a/crates/ghostty-apprt-gtk/resources/icons/256.png b/crates/ghostty-apprt-gtk/resources/icons/256.png new file mode 100644 index 0000000..423465c Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/256.png differ diff --git a/crates/ghostty-apprt-gtk/resources/icons/32.png b/crates/ghostty-apprt-gtk/resources/icons/32.png new file mode 100644 index 0000000..2889131 Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/32.png differ diff --git a/crates/ghostty-apprt-gtk/resources/icons/512.png b/crates/ghostty-apprt-gtk/resources/icons/512.png new file mode 100644 index 0000000..2045204 Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/512.png differ diff --git a/crates/ghostty-apprt-gtk/resources/icons/64.png b/crates/ghostty-apprt-gtk/resources/icons/64.png new file mode 100644 index 0000000..fae92b4 Binary files /dev/null and b/crates/ghostty-apprt-gtk/resources/icons/64.png differ diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.0/clipboard-confirmation-dialog.blp b/crates/ghostty-apprt-gtk/resources/ui/1.0/clipboard-confirmation-dialog.blp new file mode 100644 index 0000000..5f29981 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.0/clipboard-confirmation-dialog.blp @@ -0,0 +1,86 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { + styles [ + "clipboard-confirmation-dialog", + ] + + notify::blur => $notify_blur(); + notify::request => $notify_request(); + heading: _("Authorize Clipboard Access"); + // Not localized because this is a placeholder users never see. + body: "If you see this text, there is a bug in Ghostty. Please report it."; + + responses [ + cancel: _("Deny") suggested, + ok: _("Allow") destructive, + ] + + default-response: "cancel"; + close-response: "cancel"; + + extra-child: ListBox { + selection-mode: none; + + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] + + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + styles [ + "clipboard-contents", + ] + + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + buffer: bind template.clipboard-contents; + } + } + + [overlay] + Button reveal_button { + clicked => $reveal_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + clicked => $hide_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + styles [ + "opaque", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + }; +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.2/close-confirmation-dialog.blp b/crates/ghostty-apprt-gtk/resources/ui/1.2/close-confirmation-dialog.blp new file mode 100644 index 0000000..f58ef52 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.2/close-confirmation-dialog.blp @@ -0,0 +1,12 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyCloseConfirmationDialog: $GhosttyDialog { + responses [ + cancel: _("Cancel"), + close: _("Close") destructive, + ] + + close-response: "cancel"; +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.2/config-errors-dialog.blp b/crates/ghostty-apprt-gtk/resources/ui/1.2/config-errors-dialog.blp new file mode 100644 index 0000000..845909e --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.2/config-errors-dialog.blp @@ -0,0 +1,28 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyConfigErrorsDialog: $GhosttyDialog { + heading: _("Configuration Errors"); + body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); + + responses [ + ignore: _("Ignore"), + reload: _("Reload Configuration") suggested, + ] + + extra-child: ScrolledWindow { + min-content-width: 500; + min-content-height: 100; + + TextView { + editable: false; + cursor-visible: false; + top-margin: 8; + bottom-margin: 8; + left-margin: 8; + right-margin: 8; + buffer: bind (template.config as <$GhosttyConfig>).diagnostics-buffer; + } + }; +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.2/debug-warning.blp b/crates/ghostty-apprt-gtk/resources/ui/1.2/debug-warning.blp new file mode 100644 index 0000000..34411ce --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.2/debug-warning.blp @@ -0,0 +1,11 @@ +using Gtk 4.0; + +template $GhosttyDebugWarning: Gtk.Box { + orientation: vertical; + + Gtk.Label { + label: _("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); + margin-top: 10; + margin-bottom: 10; + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.2/key-state-overlay.blp b/crates/ghostty-apprt-gtk/resources/ui/1.2/key-state-overlay.blp new file mode 100644 index 0000000..c8654bf --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.2/key-state-overlay.blp @@ -0,0 +1,58 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyKeyStateOverlay: Adw.Bin { + visible: bind $has_state(template.has-tables, template.has-sequence) as ; + valign-target: end; + halign: center; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "key-state-overlay", + ] + + orientation: horizontal; + spacing: 6; + + Image { + icon-name: "input-keyboard-symbolic"; + pixel-size: 16; + } + + Label tables_label { + visible: bind template.has-tables; + label: bind $tables_text(template.tables) as ; + xalign: 0.0; + } + + Label chevron_label { + visible: bind $show_chevron(template.has-tables, template.has-sequence) as ; + label: "›"; + + styles [ + "dim-label", + ] + } + + Label sequence_label { + visible: bind template.has-sequence; + label: bind $sequence_text(template.sequence) as ; + xalign: 0.0; + } + + Spinner pending_spinner { + visible: bind template.has-sequence; + spinning: bind template.has-sequence; + } + } + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.2/resize-overlay.blp b/crates/ghostty-apprt-gtk/resources/ui/1.2/resize-overlay.blp new file mode 100644 index 0000000..5c4a94a --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.2/resize-overlay.blp @@ -0,0 +1,27 @@ +using Gtk 4.0; +using Adw 1; +// We can't inherit directly from Label because its an opaque +// type in zig-gobject. +template $GhosttyResizeOverlay: Adw.Bin { + visible: false; + duration: 750; + first-delay: 250; + overlay-halign: center; + overlay-valign: center; + // See surface.blp for why we need to wrap this. + Adw.Bin { + Label label { + styles [ + "background", + "resize-overlay", + ] + + focusable: false; + focus-on-click: false; + justify: center; + selectable: false; + halign: bind template.overlay-halign; + valign: bind template.overlay-valign; + } + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.2/search-overlay.blp b/crates/ghostty-apprt-gtk/resources/ui/1.2/search-overlay.blp new file mode 100644 index 0000000..6523d41 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.2/search-overlay.blp @@ -0,0 +1,94 @@ +using Gtk 4.0; +using Gdk 4.0; +using Adw 1; + +template $GhosttySearchOverlay: Adw.Bin { + visible: bind template.active; + halign-target: end; + valign-target: start; + halign: bind template.halign-target; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "search-overlay", + ] + + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + placeholder-text: _("Find…"); + width-chars: 20; + hexpand: true; + stop-search => $stop_search(); + search-changed => $search_changed(); + next-match => $next_match(); + previous-match => $previous_match(); + + EventControllerKey { + // We need this so we capture before the SearchEntry. + propagation-phase: capture; + key-pressed => $search_entry_key_pressed(); + } + } + + Label { + styles [ + "dim-label", + ] + + label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; + width-chars: 6; + xalign: 1.0; + } + + Box button_box { + orientation: horizontal; + spacing: 1; + + styles [ + "linked", + ] + + Button prev_button { + icon-name: "go-up-symbolic"; + tooltip-text: _("Previous Match"); + clicked => $next_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + + Button next_button { + icon-name: "go-down-symbolic"; + tooltip-text: _("Next Match"); + clicked => $previous_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + + Button close_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Close"); + clicked => $stop_search(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.2/surface.blp b/crates/ghostty-apprt-gtk/resources/ui/1.2/surface.blp new file mode 100644 index 0000000..794ea18 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.2/surface.blp @@ -0,0 +1,371 @@ +using Gtk 4.0; +using Adw 1; + +Adw.StatusPage error_page { + icon-name: "computer-fail-symbolic"; + title: _("Oh, no."); + description: _("Unable to acquire an OpenGL context for rendering."); + + child: LinkButton { + label: "https://ghostty.org/docs/help/gtk-opengl-context"; + uri: "https://ghostty.org/docs/help/gtk-opengl-context"; + }; +} + +Overlay terminal_page { + focusable: false; + focus-on-click: false; + + child: Box { + hexpand: true; + vexpand: true; + + GLArea gl_area { + realize => $gl_realize(); + unrealize => $gl_unrealize(); + render => $gl_render(); + resize => $gl_resize(); + hexpand: true; + vexpand: true; + focusable: true; + focus-on-click: true; + has-stencil-buffer: false; + has-depth-buffer: false; + allowed-apis: gl; + } + + PopoverMenu context_menu { + closed => $context_menu_closed(); + menu-model: context_menu_model; + flags: nested; + halign: start; + has-arrow: false; + } + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerScroll { + scroll => $scroll_vertical(); + scroll-begin => $scroll_vertical_begin(); + scroll-end => $scroll_vertical_end(); + flags: vertical; + } + + EventControllerScroll { + scroll => $scroll_horizontal(); + flags: horizontal; + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } + }; + + [overlay] + Revealer { + reveal-child: bind template.readonly; + transition-type: crossfade; + transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; + + Box readonly_overlay { + styles [ + "readonly_overlay", + ] + + // TODO: the tooltip doesn't actually work, but keep it here for now so + // that we can get the tooltip text translated. + has-tooltip: true; + tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application."); + halign: end; + valign: start; + spacing: 6; + + Image { + icon-name: "changes-prevent-symbolic"; + } + + Label { + label: _("Read-only"); + } + } + } + + [overlay] + ProgressBar progress_bar_overlay { + styles [ + "osd", + ] + + visible: false; + halign: fill; + valign: start; + } + + [overlay] + // The "border" bell feature is implemented here as an overlay rather than + // just adding a border to the GLArea or other widget for two reasons. + // First, adding a border to an existing widget causes a resize of the + // widget which undesirable side effects. Second, we can make it reactive + // here in the blueprint with relatively little code. + Revealer { + reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; + transition-type: crossfade; + transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; + + Box bell_overlay { + styles [ + "bell-overlay", + ] + + halign: fill; + valign: fill; + } + } + + [overlay] + $GhosttySurfaceChildExited child_exited_overlay { + visible: bind template.child-exited; + close-request => $child_exited_close(); + } + + [overlay] + $GhosttyResizeOverlay resize_overlay {} + + [overlay] + Label url_left { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: start; + valign: end; + label: bind template.mouse-hover-url; + + EventControllerMotion url_ec_motion { + enter => $url_mouse_enter(); + leave => $url_mouse_leave(); + } + } + + [overlay] + Label url_right { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: end; + valign: end; + label: bind template.mouse-hover-url; + } + + [overlay] + $GhosttySearchOverlay search_overlay { + stop-search => $search_stop(); + search-changed => $search_changed(); + next-match => $search_next_match(); + previous-match => $search_previous_match(); + } + + [overlay] + $GhosttyKeyStateOverlay key_state_overlay { + tables: bind template.key-table; + sequence: bind template.key-sequence; + } + + [overlay] + // Apply unfocused-split-fill and unfocused-split-opacity to current surface + // this is only applied when a tab has more than one surface + Revealer { + reveal-child: bind $should_unfocused_split_be_shown(search_overlay.active, template.focused, template.is-split) as ; + transition-duration: 0; + // This is all necessary so that the Revealer itself doesn't override + // any input events from the other overlays. Namely, if you don't have + // these then the search overlay won't get mouse events. + can-focus: false; + can-target: false; + focusable: false; + + DrawingArea { + styles [ + "unfocused-split", + ] + } + } + + DropTarget drop_target { + drop => $drop(); + actions: copy | move; + } +} + +template $GhosttySurface: Adw.Bin { + styles [ + "surface", + ] + + notify::config => $notify_config(); + notify::error => $notify_error(); + notify::mouse-hover-url => $notify_mouse_hover_url(); + notify::mouse-hidden => $notify_mouse_hidden(); + notify::mouse-shape => $notify_mouse_shape(); + notify::vadjustment => $notify_vadjustment(); + // Some history: we used to use a Stack here and swap between the + // terminal and error pages as needed. But a Stack doesn't play nice + // with our SplitTree and Gtk.Paned usage[^1]. Replacing this with + // a manual programmatic child swap fixed this. So if you ever change + // this, be sure to test many splits! + // + // [^1]: https://github.com/ghostty-org/ghostty/issues/8533 + child: terminal_page; +} + +IMMulticontext im_context { + input-purpose: terminal; + preedit-start => $im_preedit_start(); + preedit-changed => $im_preedit_changed(); + preedit-end => $im_preedit_end(); + commit => $im_commit(); +} + +menu context_menu_model { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + + item { + label: _("Notify on Next Command Finish"); + action: "surface.notify-on-next-command-finish"; + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "surface.prompt-title"; + } + + item { + label: _("Split Up"); + action: "split-tree.new-split"; + target: "up"; + } + + item { + label: _("Split Down"); + action: "split-tree.new-split"; + target: "down"; + } + + item { + label: _("Split Left"); + action: "split-tree.new-split"; + target: "left"; + } + + item { + label: _("Split Right"); + action: "split-tree.new-split"; + target: "right"; + } + } + + submenu { + label: _("Tab"); + + item { + label: _("Change Tab Title…"); + action: "tab.prompt-tab-title"; + } + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + } + + submenu { + label: _("Window"); + + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + } + + section { + submenu { + label: _("Config"); + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.3/debug-warning.blp b/crates/ghostty-apprt-gtk/resources/ui/1.3/debug-warning.blp new file mode 100644 index 0000000..ad7246a --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.3/debug-warning.blp @@ -0,0 +1,9 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyDebugWarning: Adw.Bin { + Adw.Banner { + title: _("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); + revealed: true; + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.3/surface-child-exited.blp b/crates/ghostty-apprt-gtk/resources/ui/1.3/surface-child-exited.blp new file mode 100644 index 0000000..6745f0c --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.3/surface-child-exited.blp @@ -0,0 +1,22 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySurfaceChildExited: Adw.Bin { + styles [ + "child-exited", + ] + + notify::data => $notify_data(); + + Adw.Bin { + Adw.Banner banner { + button-clicked => $clicked(); + revealed: true; + // Not localized on purpose because it should never be seen. + title: "This is a bug in Ghostty. Please report it."; + button-label: _("Close"); + halign: fill; + valign: end; + } + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.4/clipboard-confirmation-dialog.blp b/crates/ghostty-apprt-gtk/resources/ui/1.4/clipboard-confirmation-dialog.blp new file mode 100644 index 0000000..144e3a3 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.4/clipboard-confirmation-dialog.blp @@ -0,0 +1,96 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { + styles [ + "clipboard-confirmation-dialog", + ] + + notify::blur => $notify_blur(); + notify::request => $notify_request(); + heading: _("Authorize Clipboard Access"); + // Not localized because this is a placeholder users never see. + body: "If you see this text, there is a bug in Ghostty. Please report it."; + + responses [ + cancel: _("Deny") suggested, + ok: _("Allow") destructive, + ] + + default-response: "cancel"; + close-response: "cancel"; + + extra-child: ListBox { + selection-mode: none; + + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] + + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + styles [ + "clipboard-contents", + ] + + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + buffer: bind template.clipboard-contents; + } + } + + [overlay] + Button reveal_button { + clicked => $reveal_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + clicked => $hide_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + styles [ + "opaque", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + + Adw.SwitchRow remember_choice { + styles [ + "card", + ] + + visible: bind template.can-remember; + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); + } + }; +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/command-palette.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/command-palette.blp new file mode 100644 index 0000000..473fb1f --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/command-palette.blp @@ -0,0 +1,110 @@ +using Gtk 4.0; +using Gio 2.0; +using Adw 1; + +Adw.Dialog dialog { + content-width: 700; + closed => $closed(); + + Adw.ToolbarView { + top-bar-style: flat; + + [top] + Adw.HeaderBar { + [title] + Gtk.SearchEntry search { + hexpand: true; + placeholder-text: _("Execute a command…"); + stop-search => $search_stopped(); + activate => $search_activated(); + + styles [ + "command-palette-search", + ] + } + } + + Gtk.ScrolledWindow { + min-content-height: 300; + + Gtk.ListView view { + show-separators: true; + single-click-activate: true; + activate => $row_activated(); + + model: Gtk.SingleSelection model { + model: Gtk.FilterListModel { + incremental: true; + + filter: Gtk.AnyFilter { + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.title; + search: bind search.text; + } + + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.action-key; + search: bind search.text; + } + }; + + model: Gio.ListStore source { + item-type: typeof<$GhosttyCommand>; + }; + }; + }; + + styles [ + "rich-list", + ] + + factory: Gtk.BuilderListItemFactory { + template Gtk.ListItem { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + tooltip-text: bind template.item as <$GhosttyCommand>.description; + + Gtk.Box { + orientation: vertical; + hexpand: true; + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "title", + ] + + label: bind template.item as <$GhosttyCommand>.title; + } + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "subtitle", + "monospace", + ] + + label: bind template.item as <$GhosttyCommand>.action-key; + } + } + + Gtk.ShortcutLabel { + accelerator: bind template.item as <$GhosttyCommand>.action; + valign: center; + } + }; + } + }; + } + } + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/split-tree-split.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/split-tree-split.blp new file mode 100644 index 0000000..182919f --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/split-tree-split.blp @@ -0,0 +1,20 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTreeSplit: Adw.Bin { + styles [ + "split", + ] + + // The double-nesting is required due to a GTK bug where you can't + // bind the first child of a builder layout. If you do, you get a double + // dispose. Easiest way to see that is simply remove this and see the + // GTK critical errors (and sometimes crashes). + Adw.Bin { + Paned paned { + notify::max-position => $notify_max_position(); + notify::min-position => $notify_min_position(); + notify::position => $notify_position(); + } + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/split-tree.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/split-tree.blp new file mode 100644 index 0000000..e8c53b6 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/split-tree.blp @@ -0,0 +1,25 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTree: Box { + notify::tree => $notify_tree(); + orientation: vertical; + + Adw.Bin tree_bin { + visible: bind template.has-surfaces; + hexpand: true; + vexpand: true; + } + + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.has-surfaces inverted; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/surface-scrolled-window.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/surface-scrolled-window.blp new file mode 100644 index 0000000..722c442 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/surface-scrolled-window.blp @@ -0,0 +1,11 @@ +using Gtk 4.0; +using Adw 1; + +template $GhostttySurfaceScrolledWindow: Adw.Bin { + notify::surface => $notify_surface(); + + Gtk.ScrolledWindow { + hscrollbar-policy: never; + vscrollbar-policy: bind $scrollbar_policy(template.config) as ; + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/tab.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/tab.blp new file mode 100644 index 0000000..55f2e7e --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/tab.blp @@ -0,0 +1,18 @@ +using Gtk 4.0; + +template $GhosttyTab: Box { + styles [ + "tab", + ] + + orientation: vertical; + hexpand: true; + vexpand: true; + title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; + tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; + + $GhosttySplitTree split_tree { + notify::active-surface => $notify_active_surface(); + notify::tree => $notify_tree(); + } +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/title-dialog.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/title-dialog.blp new file mode 100644 index 0000000..737a92b --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/title-dialog.blp @@ -0,0 +1,18 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyTitleDialog: Adw.AlertDialog { + body: _("Leave blank to restore the default title."); + + responses [ + cancel: _("Cancel"), + ok: _("OK") suggested, + ] + + default-response: "ok"; + focus-widget: entry; + + extra-child: Entry entry { + activates-default: true; + }; +} diff --git a/crates/ghostty-apprt-gtk/resources/ui/1.5/window.blp b/crates/ghostty-apprt-gtk/resources/ui/1.5/window.blp new file mode 100644 index 0000000..b66a930 --- /dev/null +++ b/crates/ghostty-apprt-gtk/resources/ui/1.5/window.blp @@ -0,0 +1,323 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyWindow: Adw.ApplicationWindow { + styles [ + "window", + ] + + close-request => $close_request(); + realize => $realize(); + notify::config => $notify_config(); + notify::fullscreened => $notify_fullscreened(); + notify::is-active => $notify_is_active(); + notify::maximized => $notify_maximized(); + notify::quick-terminal => $notify_quick_terminal(); + notify::scale-factor => $notify_scale_factor(); + default-width: 800; + default-height: 600; + // GTK4 grabs F10 input by default to focus the menubar icon. We want + // to disable this so that terminal programs can capture F10 (such as htop) + handle-menubar-accel: false; + + content: Adw.TabOverview tab_overview { + create-tab => $overview_create_tab(); + notify::open => $overview_notify_open(); + view: tab_view; + enable-new-tab: true; + // Disable the title buttons (close, maximize, minimize, ...) + // *inside* the tab overview if CSDs are disabled. + // We do spare the search button, though. + show-start-title-buttons: bind template.decorated; + show-end-title-buttons: bind template.decorated; + + Adw.ToolbarView toolbar { + top-bar-style: bind template.toolbar-style; + bottom-bar-style: bind template.toolbar-style; + + [top] + Adw.HeaderBar { + visible: bind template.headerbar-visible; + + title-widget: Adw.WindowTitle { + title: bind template.title; + // Blueprint auto-formatter won't let me split this into multiple + // lines. Let me explain myself. All parameters to a closure are used + // as notifications to recompute the value of the closure. All + // elements of a property chain are also subscribed to for changes. + // This one long, ugly line saves us from manually building up this + // massive notify chain in code. + subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as ; + }; + + [start] + Adw.SplitButton { + clicked => $new_tab(); + icon-name: "tab-new-symbolic"; + tooltip-text: _("New Tab"); + dropdown-tooltip: _("New Split"); + menu-model: split_menu; + can-focus: false; + focus-on-click: false; + } + + [end] + Gtk.Box { + Gtk.ToggleButton { + icon-name: "view-grid-symbolic"; + tooltip-text: _("View Open Tabs"); + active: bind tab_overview.open bidirectional; + can-focus: false; + focus-on-click: false; + } + + Gtk.MenuButton { + notify::active => $notify_menu_active(); + icon-name: "open-menu-symbolic"; + menu-model: main_menu; + tooltip-text: _("Main Menu"); + can-focus: false; + } + } + } + + [top] + Adw.TabBar tab_bar { + autohide: bind template.tabs-autohide; + expand-tabs: bind template.tabs-wide; + view: tab_view; + visible: bind template.tabs-visible; + + [start] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; + + Gtk.WindowControls { + side: start; + } + + Adw.SplitButton { + styles [ + "flat", + ] + + clicked => $new_tab(); + icon-name: "tab-new-symbolic"; + tooltip-text: _("New Tab"); + dropdown-tooltip: _("New Split"); + menu-model: split_menu; + can-focus: false; + focus-on-click: false; + } + } + + [end] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; + + Gtk.ToggleButton { + styles [ + "flat", + ] + + icon-name: "view-grid-symbolic"; + tooltip-text: _("View Open Tabs"); + active: bind tab_overview.open bidirectional; + can-focus: false; + focus-on-click: false; + } + + Gtk.MenuButton { + styles [ + "flat", + ] + + notify::active => $notify_menu_active(); + icon-name: "open-menu-symbolic"; + menu-model: main_menu; + tooltip-text: _("Main Menu"); + can-focus: false; + } + + Gtk.WindowControls { + side: end; + } + } + } + + Box { + orientation: vertical; + + $GhosttyDebugWarning { + visible: bind template.debug; + } + + Adw.ToastOverlay toast_overlay { + Adw.TabView tab_view { + notify::n-pages => $notify_n_pages(); + notify::selected-page => $notify_selected_page(); + close-page => $close_page(); + page-attached => $page_attached(); + page-detached => $page_detached(); + create-window => $tab_create_window(); + setup-menu => $setup_tab_menu(); + menu-model: tab_context_menu; + shortcuts: none; + } + } + } + } + }; +} + +menu split_menu { + item { + label: _("Split Up"); + action: "win.split-up"; + } + + item { + label: _("Split Down"); + action: "win.split-down"; + } + + item { + label: _("Split Left"); + action: "win.split-left"; + } + + item { + label: _("Split Right"); + action: "win.split-right"; + } +} + +menu main_menu { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + } + + section { + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + + section { + item { + label: _("Change Tab Title…"); + action: "win.prompt-tab-title"; + } + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "win.close-tab"; + target: "this"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "win.prompt-surface-title"; + } + + item { + label: _("Split Up"); + action: "win.split-up"; + } + + item { + label: _("Split Down"); + action: "win.split-down"; + } + + item { + label: _("Split Left"); + action: "win.split-left"; + } + + item { + label: _("Split Right"); + action: "win.split-right"; + } + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + item { + label: _("Command Palette"); + action: "win.toggle-command-palette"; + } + + item { + label: _("Terminal Inspector"); + action: "win.toggle-inspector"; + } + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + + section { + item { + label: _("About Ghostty"); + action: "win.about"; + } + + item { + label: _("Quit"); + action: "app.quit"; + } + } +} + +menu tab_context_menu { + item { + label: _("Change Tab Title…"); + action: "win.prompt-context-tab-title"; + } +} diff --git a/crates/ghostty-apprt-gtk/src/adw_version.rs b/crates/ghostty-apprt-gtk/src/adw_version.rs new file mode 100644 index 0000000..9c8313f --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/adw_version.rs @@ -0,0 +1,79 @@ +//! libadwaita version helpers (port of `apprt/gtk/adw_version.zig`). + +use crate::gtk_version::Version; + +/// The libadwaita feature level this crate is compiled against (`v1_5`). +pub const COMPTIME_VERSION: Version = (1, 5, 0); + +/// Returns the runtime libadwaita version. +pub fn get_runtime_version() -> Version { + ( + adw::major_version(), + adw::minor_version(), + adw::micro_version(), + ) +} + +/// Logs the build and runtime libadwaita version. +pub fn log_version() { + let (a, b, c) = COMPTIME_VERSION; + let (x, y, z) = get_runtime_version(); + log::info!("libadwaita version build={a}.{b}.{c} runtime={x}.{y}.{z}"); +} + +/// Verifies that the libadwaita version at runtime is at least the given +/// version (callers needing newer API must also gate on a cargo feature). +pub fn at_least(major: u32, minor: u32, micro: u32) -> bool { + runtime_at_least(major, minor, micro) +} + +/// Verifies that the libadwaita runtime version is at least the given version. +pub fn runtime_at_least(major: u32, minor: u32, micro: u32) -> bool { + get_runtime_version() >= (major, minor, micro) +} + +/// Whether AdwDialog, AdwAlertDialog, etc. are supported (1.5+). +pub fn supports_dialogs() -> bool { + at_least(1, 5, 0) +} + +pub fn supports_tab_overview() -> bool { + at_least(1, 4, 0) +} + +pub fn supports_switch_row() -> bool { + at_least(1, 4, 0) +} + +pub fn supports_toolbar_view() -> bool { + at_least(1, 4, 0) +} + +pub fn supports_banner() -> bool { + at_least(1, 3, 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_at_least() { + // libadwaita requires GTK init, which only works on the main thread + // with a display; skip otherwise. + if adw::init().is_err() { + return; + } + let (a, b, c) = get_runtime_version(); + for f in [at_least, runtime_at_least] { + assert!(f(a, b, c)); + assert!(!f(a, b, c + 1)); + assert!(!f(a, b + 1, c)); + assert!(!f(a + 1, b, c)); + assert!(f(a - 1, b, c)); + assert!(f(a - 1, b + 1, c)); + assert!(f(a - 1, b, c + 1)); + assert!(f(a, b - 1, c + 1)); + } + } +} diff --git a/crates/ghostty-apprt-gtk/src/app.rs b/crates/ghostty-apprt-gtk/src/app.rs new file mode 100644 index 0000000..9f0bf1d --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/app.rs @@ -0,0 +1,89 @@ +//! Port of `apprt/gtk/App.zig`: the apprt `App` handed to the core. It is a +//! thin wrapper around the [`Application`] GObject. +//! +//! ```ignore +//! let app = App::new(core_app); // loads config, creates GtkApplication +//! app.run()?; // main loop (ticks core_app) +//! app.terminate(); +//! ``` + +use std::rc::Rc; +use std::sync::Arc; + +use ghostty_apprt::action::{Action, Target}; +use ghostty_apprt::ipc; + +use crate::class::application::{Application, RunError}; +use crate::core::{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 +/// the app thread, because GTK's `GLArea` does not support drawing from a +/// different thread. +pub const MUST_DRAW_FROM_APP_THREAD: bool = true; + +/// GTK application ID (Zig: `App.application_id`). +pub use crate::APPLICATION_ID as application_id; +/// GTK object path (Zig: `App.object_path`). +pub use crate::OBJECT_PATH as object_path; + +/// The [`Waker`] given to core threads: wakes the GLib main context so the +/// app loop runs `CoreApp::tick`. +#[derive(Debug, Default, Clone, Copy)] +pub struct GlibWaker; + +impl Waker for GlibWaker { + fn wake(&self) { + gtk::glib::MainContext::default().wakeup(); + } +} + +/// A new thread-safe waker (create it before the core app, hand it to core). +#[must_use] +pub fn waker() -> Arc { + Arc::new(GlibWaker) +} + +/// The apprt App. +pub struct App { + /// The GObject Application instance. + pub app: Application, +} + +impl App { + /// `App.init`: creates the GTK application (loads the configuration, + /// initializes GTK/libadwaita). + #[must_use] + pub fn new(core_app: Rc) -> Self { + Self { + app: Application::new(core_app), + } + } + + /// `App.run`: run until quit. + pub fn run(&self) -> Result<(), RunError> { + self.app.run_loop() + } + + /// `App.terminate`: force deinitialize the app (other things tend to + /// still hold references, so this forces the disposal now). + pub fn terminate(&self) { + self.app.deinit(); + } + + /// `App.wakeup`: wake up the event loop (thread safe). + pub fn wakeup(&self) { + self.app.wakeup(); + } + + /// `App.performAction`. + pub fn perform_action(&self, target: Target, action: Action) -> bool { + self.app.perform_action(target, action) + } + + /// `App.performIpc`: send the IPC to a running Ghostty. Static: it's + /// called from a CLI process without a full-featured apprt App. + pub fn perform_ipc(target: &ipc::Target, action: &ipc::Action) -> Result { + crate::ipc::perform_ipc(target, action) + } +} diff --git a/crates/ghostty-apprt-gtk/src/cgroup.rs b/crates/ghostty-apprt-gtk/src/cgroup.rs new file mode 100644 index 0000000..da1d27e --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/cgroup.rs @@ -0,0 +1,86 @@ +//! Transient systemd scope creation over D-Bus (port of `apprt/gtk/cgroup.zig`). + +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; + +/// Properties for the transient scope. +#[derive(Debug, Clone, Copy, Default)] +pub struct Options { + /// `MemoryHigh` in bytes. + pub memory_high: Option, + /// `TasksMax`. + pub tasks_max: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("D-Bus call failed")] + DbusCallFailed, +} + +/// The scope unit name for the given PID. +pub fn fmt_scope(pid: u32) -> String { + format!("app-ghostty-surface-transient-{pid}.scope") +} + +/// Create a transient systemd scope containing `pid` via +/// `org.freedesktop.systemd1.Manager.StartTransientUnit`. +pub fn create_scope(dbus: &gio::DBusConnection, pid: u32, options: Options) -> Result<(), Error> { + // The unit name needs to be unique. We use the PID for this. + let name = fmt_scope(pid); + + // Properties + let mut properties: Vec<(String, glib::Variant)> = Vec::new(); + if let Some(v) = options.memory_high { + properties.push(("MemoryHigh".into(), v.to_variant())); + } + if let Some(v) = options.tasks_max { + properties.push(("TasksMax".into(), v.to_variant())); + } + // https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html + properties.push(("ManagedOOMMemoryPressure".into(), "kill".to_variant())); + // PID to move into the unit + properties.push(("PIDs".into(), vec![pid].to_variant())); + + // Aux - unused but must be present + let aux: Vec<(String, Vec<(String, glib::Variant)>)> = Vec::new(); + + let params = (name, "fail".to_owned(), properties, aux).to_variant(); + + dbus.call_sync( + Some("org.freedesktop.systemd1"), + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "StartTransientUnit", + Some(¶ms), + Some(glib::VariantTy::new("(o)").map_err(|_| Error::DbusCallFailed)?), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) + .map(|_| ()) + .map_err(|e| { + log::error!("creating transient cgroup scope failed err={e}"); + Error::DbusCallFailed + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fmt_scope_test() { + assert_eq!(fmt_scope(42), "app-ghostty-surface-transient-42.scope"); + assert!(fmt_scope(u32::MAX).len() < 256); + } + + #[test] + fn scope_params_signature() { + let props: Vec<(String, glib::Variant)> = vec![("PIDs".into(), vec![1u32].to_variant())]; + let aux: Vec<(String, Vec<(String, glib::Variant)>)> = Vec::new(); + let v = ("n".to_owned(), "fail".to_owned(), props, aux).to_variant(); + assert_eq!(v.type_().as_str(), "(ssa(sv)a(sa(sv)))"); + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs new file mode 100644 index 0000000..d905ff2 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -0,0 +1,1841 @@ +//! `GhosttyApplication` (ZIG/src/apprt/gtk/class/application.zig): the +//! primary entrypoint of the GTK runtime. Wraps an `AdwApplication`, owns the +//! config, windowing-protocol state, CSS providers and global shortcuts, and +//! implements the apprt action switch ([`Application::perform_action`]). +//! +//! Public API used by the other classes: +//! * [`Application::default`] -> the running application +//! * [`Application::core`] / [`Application::rt`] -> `Rc` / `Rc` +//! * [`Application::get_config`] -> `class::config::Config` object (property `config`) +//! * [`Application::core_config`] -> `Arc` +//! * [`Application::winproto`] / [`Application::winproto_mut`] +//! * [`Application::saved_language`], [`Application::wakeup`], [`Application::quit`] + +#![allow( + clippy::needless_pass_by_value, + clippy::unused_self, + clippy::type_complexity, + clippy::should_implement_trait +)] + +use std::cell::{Cell, OnceCell, Ref, RefCell, RefMut}; +use std::rc::Rc; +use std::sync::Arc; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_apprt::action::{self, Action, Target}; +use ghostty_apprt::structs::ColorScheme; +use ghostty_config::cli_args::ConfigField; +use ghostty_config::command::Command; +use ghostty_config::config::Config as CoreConfig; +use ghostty_config::types_b::{GtkSingleInstance, WindowTheme}; +use ghostty_input::binding::Action as BindingAction; +use gtk::{gdk, gio, glib}; + +use crate::class::close_confirmation_dialog::{self, CloseConfirmationDialog}; +use crate::class::config::Config; +use crate::class::config_errors_dialog::ConfigErrorsDialog; +use crate::class::global_shortcuts::GlobalShortcuts; +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; + +/// Errors from [`Application::run_loop`]. +#[derive(Debug, thiserror::Error)] +pub enum RunError { + #[error("unable to acquire the default main context")] + ContextAcquireFailed, + #[error("unable to register the application")] + ApplicationRegisterFailed, + #[error("core app tick failed: {0}")] + Tick(String), +} + +/// State of the quit-after-last-window-closed timer. +#[derive(Debug, Default)] +pub enum QuitTimer { + #[default] + Off, + Active(glib::SourceId), + Expired, +} + +/// Overrides for a new window (Zig: `newWindow` overrides struct). +#[derive(Debug, Default, Clone)] +pub struct WindowOverrides { + pub command: Option, + pub working_directory: Option, + pub title: Option, +} + +// --------------------------------------------------------------------- +// Process level helpers + +/// Set an environment variable. +#[allow(unsafe_code)] +fn set_env(key: &str, value: &str) { + // SAFETY: only called during application construction on the main + // thread, before GTK is initialized and before we spawn any child + // processes; no other thread in this crate reads the environment. + unsafe { std::env::set_var(key, value) } +} + +/// Funnel GLib/GObject/GTK log messages into the `log` crate rather than +/// having them dumped directly to stderr. +fn glib_log_writer(level: glib::LogLevel, fields: &[glib::LogField<'_>]) -> glib::LogWriterOutput { + let mut message = None; + let mut domain = None; + for f in fields { + match f.key() { + "MESSAGE" => message = f.value_str(), + "GLIB_DOMAIN" => domain = f.value_str(), + _ => {} + } + } + let Some(message) = message else { + return glib::LogWriterOutput::Unhandled; + }; + let domain = domain.unwrap_or("«unknown»"); + match level { + glib::LogLevel::Error => log::error!(target: "glib", "ERROR: {domain}: {message}"), + glib::LogLevel::Critical => log::error!(target: "glib", "CRITICAL: {domain}: {message}"), + glib::LogLevel::Warning => log::warn!(target: "glib", "WARNING: {domain}: {message}"), + glib::LogLevel::Message => log::info!(target: "glib", "MESSAGE: {domain}: {message}"), + glib::LogLevel::Info => log::info!(target: "glib", "INFO: {domain}: {message}"), + glib::LogLevel::Debug => log::debug!(target: "glib", "DEBUG: {domain}: {message}"), + } + glib::LogWriterOutput::Handled +} + +fn runtime_at_least(major: u32, minor: u32, micro: u32) -> bool { + ( + gtk::major_version(), + gtk::minor_version(), + gtk::micro_version(), + ) >= (major, minor, micro) +} + +/// Sets various GTK-related environment variables as necessary given the +/// runtime environment or configuration. Must be called BEFORE GTK +/// initialization. +fn set_gtk_env(config: &CoreConfig) { + // gdk_debug flags + let mut opengl = config.gtk_opengl_debug; + let mut gl_disable_gles = false; + let mut gl_no_fractional = false; + let mut vulkan_disable = false; + + // gdk_disable flags + let mut gles_api = false; + // current gtk implementation for color management is not good enough. + // see: https://bugs.kde.org/show_bug.cgi?id=495647 + let mut color_mgmt = true; + let mut vulkan = false; + + 'environment: { + if runtime_at_least(4, 18, 0) { + color_mgmt = false; + } + + if runtime_at_least(4, 16, 0) { + // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. + gles_api = true; + vulkan = true; + break 'environment; + } + if runtime_at_least(4, 14, 0) { + // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. + // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 + gl_disable_gles = true; + vulkan_disable = true; + if !runtime_at_least(4, 17, 5) { + // Removed at GTK v4.17.5 + gl_no_fractional = true; + } + break 'environment; + } + // Versions prior to 4.14 are a bit of an unknown for Ghostty. + vulkan_disable = true; + } + // `gtk-opengl-debug` dumps logs directly to stderr. + opengl = opengl && std::io::IsTerminal::is_terminal(&std::io::stderr()); + + let join = |flags: &[(&str, bool)]| { + flags + .iter() + .filter(|(_, on)| *on) + .map(|(name, _)| *name) + .collect::>() + .join(",") + }; + + let gdk_debug = join(&[ + ("opengl", opengl), + ("gl-disable-gles", gl_disable_gles), + ("gl-no-fractional", gl_no_fractional), + ("vulkan-disable", vulkan_disable), + ]); + log::warn!("setting GDK_DEBUG={gdk_debug}"); + set_env("GDK_DEBUG", &gdk_debug); + + let gdk_disable = join(&[ + ("gles-api", gles_api), + ("color-mgmt", color_mgmt), + ("vulkan", vulkan), + ]); + log::warn!("setting GDK_DISABLE={gdk_disable}"); + set_env("GDK_DISABLE", &gdk_disable); +} + +/// Call `setlocale(LC_ALL, "")` like Ghostty's `global.zig` does at startup +/// (see docs/rewrite/INTEGRATION_TODO.md). Idempotent. +#[allow(unsafe_code)] +pub fn init_locale() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + // SAFETY: called with a valid NUL-terminated empty string at startup + // before other threads depend on the locale. + unsafe { + libc::setlocale(libc::LC_ALL, c"".as_ptr()); + } + }); +} + +// --------------------------------------------------------------------- +// RtApp + +/// The `RtApp` handed to core. Stateless; forwards to the default +/// application. Core must only call it from the main thread. +#[derive(Debug, Default)] +pub struct AppRt; + +impl RtApp for AppRt { + fn perform_action(&self, target: Target, action: Action) -> bool { + Application::default().perform_action(target, action) + } +} + +mod imp { + use super::{ + AppMessage, Cell, Config, ConfigErrorsDialog, CoreApp, CssProviders, GlobalShortcuts, + OnceCell, QuitTimer, Rc, RefCell, RtApp, glib, winproto, + }; + use adw::prelude::*; + use adw::subclass::prelude::*; + use gtk::gio; + + #[derive(Default)] + pub struct Application { + /// The libghostty App instance. + pub core_app: OnceCell>, + pub rt_app: OnceCell>, + + /// The configuration for the application. + pub config: RefCell>, + + /// State and logic for the underlying windowing protocol. + pub winproto: RefCell>, + + /// The global shortcut logic. + pub global_shortcuts: OnceCell, + + /// Set to true so long as we request a window exactly once. This + /// prevents quitting the app before we've shown one window. + pub requested_window: Cell, + + /// Set to false internally when the event loop should exit. + pub running: Cell, + + pub quit_timer: RefCell, + + /// The config errors dialog (weak: it can close on its own). + pub config_errors_dialog: glib::WeakRef, + + pub signal_thread_started: Cell, + + /// CSS providers (runtime + custom). + pub css: RefCell>, + + /// Copy of the LANG variable provided by the system, if any. + pub saved_language: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for Application { + const NAME: &'static str = "GhosttyApplication"; + type Type = super::Application; + type ParentType = adw::Application; + } + + impl ObjectImpl for Application { + fn properties() -> &'static [glib::ParamSpec] { + static PROPS: std::sync::OnceLock> = std::sync::OnceLock::new(); + PROPS.get_or_init(|| { + vec![ + glib::ParamSpecObject::builder::("config") + .read_only() + .build(), + ] + }) + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "config" => self.config.borrow().to_value(), + name => unimplemented!("unknown property {name}"), + } + } + + fn dispose(&self) { + if let Some(diag) = self.config_errors_dialog.upgrade() { + diag.close(); + } + } + } + + impl ApplicationImpl for Application { + fn startup(&self) { + log::debug!("startup"); + self.parent_startup(); + let obj = self.obj(); + + // Set ourselves as the default application. + gio::Application::set_default(obj.upcast_ref::()); + + // Setup our style manager (light/dark mode) + obj.startup_style_manager(); + // Setup some signal handlers + obj.startup_signals(); + // Setup our action map + obj.startup_action_map(); + // Setup our global shortcuts + obj.startup_global_shortcuts(); + + // If we have any config diagnostics from loading, then we show + // the diagnostics dialog. We show this one as a general modal + // (not to any specific window) because we don't even know if the + // window will load. + obj.show_config_errors_dialog(); + } + + fn activate(&self) { + log::debug!("activate"); + // Queue a new window + if let Some(core) = self.core_app.get() { + core.push_message(AppMessage::NewWindow); + } + self.parent_activate(); + } + } + + impl GtkApplicationImpl for Application {} + impl AdwApplicationImpl for Application {} +} + +glib::wrapper! { + pub struct Application(ObjectSubclass) + @extends adw::Application, gtk::Application, gio::Application, + @implements gio::ActionGroup, gio::ActionMap; +} + +impl Application { + /// Get the default (running) application. + /// + /// Panics if there is no default application or it is not a + /// GhosttyApplication. + pub fn default() -> Self { + gio::Application::default() + .and_then(|a| a.downcast::().ok()) + .expect("no default GhosttyApplication") + } + + /// Creates a new Application instance. This does a lot more work than a + /// typical class instantiation, because we expect that this is the main + /// program entrypoint. Every error is mapped to some degraded state + /// where we can at least show a window with an error. + pub fn new(core_app: Rc) -> Self { + init_locale(); + + // Capture GLib/GObject/GTK log messages. + glib::log_set_writer_func(glib_log_writer); + + // Load our configuration. + let mut config = match CoreConfig::load() { + Ok(c) => c, + Err(err) => { + // If we fail to load the configuration, then we should log + // the error in the diagnostics so it can be shown to the user. + let mut def = CoreConfig::new_default(); + def.add_diagnostic(format!("error loading user configuration: {err}")); + def + } + }; + + let saved_language = std::env::var("LANG").ok(); + if let Some(language) = &config.language { + set_env("LANG", language); + } + + // Set gettext global domain to be our app so that our unqualified + // translations map to our translations. + if let Err(err) = ghostty_os::i18n::init_global_domain() { + // Failures shouldn't stop application startup. + log::warn!("i18n initialization failed error={err:?}"); + } + + // Setup our GTK init env vars + set_gtk_env(&config); + if let Err(err) = gtk::init() { + log::error!("failed to initialize GTK: {err}"); + std::process::exit(1); + } + if let Err(err) = adw::init() { + log::error!("failed to initialize libadwaita: {err}"); + } + + // Log our GTK versions (libadwaita requires GTK to be initialized + // first in the Rust bindings). + crate::gtk_version::log_version(); + crate::adw_version::log_version(); + + let single_instance = match config.gtk_single_instance { + GtkSingleInstance::True => true, + GtkSingleInstance::False => false, + // This should have been resolved to true/false during config loading. + GtkSingleInstance::Detect => { + log::warn!("gtk-single-instance=detect was not resolved, assuming true"); + true + } + }; + + // Setup the flags for our application. + let mut flags = gio::ApplicationFlags::default(); + if !single_instance { + flags |= gio::ApplicationFlags::NON_UNIQUE; + } + + // Our app ID determines uniqueness and maps to our desktop file. + let app_id: String = match &config.class { + Some(class) if gio::Application::id_is_valid(class) => class.clone(), + Some(_) => { + log::warn!("invalid 'class' in config, ignoring"); + crate::APPLICATION_ID.to_owned() + } + None => crate::APPLICATION_ID.to_owned(), + }; + + let Some(display) = gdk::Display::default() else { + // I'm unsure of any scenario where this happens. + log::warn!("gdk display is null, exiting"); + std::process::exit(1); + }; + + // Setup our windowing protocol logic + let wp = match winproto::App::init(&display, &app_id, &config) { + Ok(wp) => wp, + Err(err) => { + // If we fail to detect or setup the windowing protocol + // specifics, we fallback to a noop implementation. + log::warn!("error initializing windowing protocol err={err}"); + winproto::App::None(winproto::noop::App) + } + }; + log::debug!("windowing protocol={:?}", wp.protocol()); + + log::debug!("creating GTK application id={app_id} single-instance={single_instance}"); + + // Wrap our configuration in a GObject. + let _ = &mut config; + let config_obj = Config::new(&config); + + // Internally, GTK ensures that only one instance of this provider + // exists in the provider list for the display. + let css = CssProviders::new(&display); + + let obj: Self = glib::Object::builder() + .property("application-id", app_id) + .property("flags", flags) + // Force the resource path to a known value so it doesn't depend + // on the app id and force it to load in compiled resources. + .property("resource-base-path", crate::RESOURCE_PREFIX) + .build(); + + let imp = obj.imp(); + let _ = imp.core_app.set(core_app); + let _ = imp.rt_app.set(Rc::new(AppRt)); + *imp.config.borrow_mut() = Some(config_obj); + *imp.winproto.borrow_mut() = Some(wp); + *imp.css.borrow_mut() = Some(css); + let _ = imp.global_shortcuts.set(GlobalShortcuts::new()); + *imp.saved_language.borrow_mut() = saved_language; + + // Signals + obj.connect_notify_local(Some("config"), |this, _| this.prop_config()); + + // Trigger initial config changes + obj.notify("config"); + + obj + } + + /// Force deinitialize the application (Zig: `deinit`). Applications are + /// never fully unreferenced so this ensures our state is released. + pub fn deinit(&self) { + let imp = self.imp(); + *imp.css.borrow_mut() = None; + *imp.winproto.borrow_mut() = None; + *imp.config.borrow_mut() = None; + if let Some(gs) = imp.global_shortcuts.get() { + gs.set_connection(None); + } + *imp.saved_language.borrow_mut() = None; + } + + // ----------------------------------------------------------------- + // Accessors + + /// The core app. Not reference counted in Zig; here a cheap `Rc` clone. + pub fn core(&self) -> Rc { + self.imp().core_app.get().expect("core app set").clone() + } + + /// The `RtApp` implementation to hand to core. + pub fn rt(&self) -> Rc { + self.imp().rt_app.get().expect("rt app set").clone() + } + + /// The configuration object (property `config`). Increments its refcount. + pub fn get_config(&self) -> Config { + self.imp() + .config + .borrow() + .clone() + .expect("application config") + } + + /// The configuration, as a shared core config. + pub fn core_config(&self) -> Arc { + self.get_config().get() + } + + /// The windowing protocol state. Panics if called after `deinit`. + pub fn winproto(&self) -> Ref<'_, winproto::App> { + Ref::map(self.imp().winproto.borrow(), |v| { + v.as_ref().expect("winproto available") + }) + } + + /// Mutable windowing protocol state (needed by `init_quick_terminal`). + pub fn winproto_mut(&self) -> RefMut<'_, winproto::App> { + RefMut::map(self.imp().winproto.borrow_mut(), |v| { + v.as_mut().expect("winproto available") + }) + } + + /// The original `LANG` Ghostty was launched with (None if unset). + pub fn saved_language(&self) -> Option { + self.imp().saved_language.borrow().clone() + } + + /// All live surfaces (Zig: `core_app.surfaces.items` mapped to GObjects). + pub fn surfaces(&self) -> Vec { + self.core() + .surface_ids() + .into_iter() + .filter_map(Surface::from_id) + .collect() + } + + /// Wake the main loop (Zig: `wakeup`). Thread safe. + pub fn wakeup(&self) { + glib::MainContext::default().wakeup(); + } + + /// Mark that a window was requested at least once. + pub fn mark_requested_window(&self) { + self.imp().requested_window.set(true); + } + + // ----------------------------------------------------------------- + // Run loop + + /// Run the application. Replacement for `gio::Application::run` because + /// we want tighter control over the event loop to integrate with core. + pub fn run_loop(&self) -> Result<(), RunError> { + // Based on the actual `gio.Application.run` implementation. + let ctx = glib::MainContext::default(); + let _guard = ctx.acquire().map_err(|_| RunError::ContextAcquireFailed)?; + + let result = self.run_loop_inner(&ctx); + + // The final cleanup that is always required at the end of running. + self.stop_quit_timer(); + gio::Settings::sync(); + // Clear out the event loop, don't block. + while ctx.iteration(false) {} + result + } + + fn run_loop_inner(&self, ctx: &glib::MainContext) -> Result<(), RunError> { + let gapp = self.upcast_ref::(); + + // Register the application + if let Err(err) = gapp.register(gio::Cancellable::NONE) { + log::warn!("error registering application: {}", err.message()); + return Err(RunError::ApplicationRegisterFailed); + } + + // This just calls the `activate` signal but its part of the normal + // startup routine, but only if the config allows it. + if self.core_config().initial_window { + gapp.activate(); + } + + // If we are NOT the primary instance, then we never want to run. + if gapp.is_remote() { + log::debug!("application is remote, exiting run loop after activation"); + return Ok(()); + } + + // Tell systemd that we are ready. + ghostty_os::systemd::notify::ready(); + + log::debug!("entering runloop"); + let imp = self.imp(); + imp.running.set(true); + let core = self.core(); + let rt = self.rt(); + let mut result = Ok(()); + while imp.running.get() { + ctx.iteration(true); + + // Tick the core Ghostty terminal app + if let Err(err) = core.tick(&*rt) { + log::error!("core tick failed: {err}"); + result = Err(RunError::Tick(err.to_string())); + break; + } + + // Check if we must quit based on the current state. + let must_quit = { + let config = self.core_config(); + if !config.quit_after_last_window_closed { + // If we are configured to always stay running, don't quit. + false + } else if matches!(*imp.quit_timer.borrow(), QuitTimer::Expired) { + // If the quit timer has expired, quit. + log::debug!("must_quit due to quit timer expired"); + true + } else if config.quit_after_last_window_closed_delay.is_none() + && imp.requested_window.get() + && self.windows().is_empty() + { + // No windows attached to our app. Only if we don't have + // the closed delay set. + log::debug!("must_quit due to no app windows"); + true + } else { + false + } + }; + + if must_quit { + // All must quit scenarios do not need confirmation. + self.quit_now(); + } + } + log::debug!("exiting runloop"); + result + } + + /// Quit the application. This starts the process to stop the run loop. + /// It will not exit the process. + pub fn quit(&self) { + let imp = self.imp(); + + // If our run loop has already exited then we are done. + if !imp.running.get() { + return; + } + + // If our core app doesn't need to confirm quit then we can exit now. + if !self.core().needs_confirm_quit() { + self.quit_now(); + return; + } + + // Get the parent for our dialog + let parent = gtk::Window::list_toplevels().into_iter().find(|w| { + w.downcast_ref::() + .is_some_and(gtk::Window::is_active) + }); + + // Show a confirmation dialog + let dialog = CloseConfirmationDialog::new(close_confirmation_dialog::Target::App); + let this = self.downgrade(); + dialog.connect_close_request(move |_| { + if let Some(this) = this.upgrade() { + this.quit_now(); + } + }); + + // Show it + dialog.present(parent.as_ref()); + } + + fn quit_now(&self) { + // Get all our windows and destroy them, forcing them to free. We + // only want to destroy our windows (they own every other type of + // window) so this triggers a proper shutdown sequence. + for w in gtk::Window::list_toplevels() { + if let Ok(window) = w.downcast::() { + window.destroy(); + } + } + + // Trigger our runloop exit. + self.imp().running.set(false); + } + + // ----------------------------------------------------------------- + // Quit timer + + /// Called when there are no more open surfaces. + fn start_quit_timer(&self) { + let config = self.core_config(); + + // Cancel any previous timer. + self.stop_quit_timer(); + + // This is a no-op unless we are configured to quit after last window closed. + if !config.quit_after_last_window_closed { + return; + } + + // If a delay is configured, set a timeout function to quit after the delay. + let imp = self.imp(); + if let Some(v) = &config.quit_after_last_window_closed_delay { + let this = self.downgrade(); + let id = glib::timeout_add_local( + std::time::Duration::from_millis(u64::from(v.as_milliseconds())), + move || { + if let Some(this) = this.upgrade() { + *this.imp().quit_timer.borrow_mut() = QuitTimer::Expired; + } + glib::ControlFlow::Break + }, + ); + *imp.quit_timer.borrow_mut() = QuitTimer::Active(id); + } else { + // If no delay is configured, treat it as expired. + *imp.quit_timer.borrow_mut() = QuitTimer::Expired; + } + } + + /// Called when a new surface gets opened. + fn stop_quit_timer(&self) { + let old = std::mem::take(&mut *self.imp().quit_timer.borrow_mut()); + if let QuitTimer::Active(id) = old { + id.remove(); + } + } + + // ----------------------------------------------------------------- + // Config / CSS / accelerators + + /// Set the configuration for this application. If the config has errors + /// this may show the config errors dialog. + fn set_config(&self, config: &Config) { + *self.imp().config.borrow_mut() = Some(config.clone()); + self.notify("config"); + + // Show our errors if we have any + self.show_config_errors_dialog(); + } + + fn prop_config(&self) { + // Sync our accelerators for menu items. + self.sync_action_accelerators(); + + // Load our runtime and custom CSS. + let config = self.core_config(); + if let Some(css) = self.imp().css.borrow_mut().as_mut() { + css.load_all(&config); + } + + // Keep global shortcuts in sync with the config (property binding). + if let Some(gs) = self.imp().global_shortcuts.get() { + gs.set_config(self.imp().config.borrow().as_ref()); + } + } + + fn sync_action_accelerators(&self) { + use ghostty_input::binding::action::{ + CloseTabMode, CopyToClipboard, InspectorMode, SplitDirection, + }; + let a = |name: &str, action: BindingAction| self.sync_action_accelerator(name, &action); + a("app.quit", BindingAction::Quit); + a("app.open-config", BindingAction::OpenConfig); + a("app.reload-config", BindingAction::ReloadConfig); + a( + "win.toggle-inspector", + BindingAction::Inspector(InspectorMode::Toggle), + ); + a("app.show-gtk-inspector", BindingAction::ShowGtkInspector); + a( + "win.toggle-command-palette", + BindingAction::ToggleCommandPalette, + ); + a("win.close", BindingAction::CloseWindow); + a("win.new-window", BindingAction::NewWindow); + a("win.new-tab", BindingAction::NewTab); + a( + "win.close-tab::this", + BindingAction::CloseTab(CloseTabMode::This), + ); + a( + "tab.close::this", + BindingAction::CloseTab(CloseTabMode::This), + ); + a( + "win.split-right", + BindingAction::NewSplit(SplitDirection::Right), + ); + a( + "win.split-down", + BindingAction::NewSplit(SplitDirection::Down), + ); + a( + "win.split-left", + BindingAction::NewSplit(SplitDirection::Left), + ); + a("win.split-up", BindingAction::NewSplit(SplitDirection::Up)); + a( + "win.copy", + BindingAction::CopyToClipboard(CopyToClipboard::Mixed), + ); + a("win.paste", BindingAction::PasteFromClipboard); + a("win.reset", BindingAction::Reset); + a("win.clear", BindingAction::ClearScreen); + a("win.prompt-title", BindingAction::PromptSurfaceTitle); + a( + "split-tree.new-split::left", + BindingAction::NewSplit(SplitDirection::Left), + ); + a( + "split-tree.new-split::right", + BindingAction::NewSplit(SplitDirection::Right), + ); + a( + "split-tree.new-split::up", + BindingAction::NewSplit(SplitDirection::Up), + ); + a( + "split-tree.new-split::down", + BindingAction::NewSplit(SplitDirection::Down), + ); + } + + fn sync_action_accelerator(&self, gtk_action: &str, action: &BindingAction) { + // Reset it initially + self.set_accels_for_action(gtk_action, &[]); + + let config = self.core_config(); + let Some(trigger) = config.keybind.set.get_trigger(action) else { + return; + }; + let Some(accel) = crate::key::accel_from_trigger(trigger) else { + return; + }; + self.set_accels_for_action(gtk_action, &[accel.as_str()]); + } + + // ----------------------------------------------------------------- + // Startup + + /// Setup the style manager on startup: initial light/dark mode based on + /// the configuration, plus change listeners. + fn startup_style_manager(&self) { + let config = self.core_config(); + + // Setup our initial light/dark + let style = self.style_manager(); + style.set_color_scheme(match config.window_theme { + WindowTheme::Auto | WindowTheme::Ghostty => { + let lum = config.background.to_terminal_rgb().perceived_luminance(); + if lum > 0.5 { + adw::ColorScheme::PreferLight + } else { + adw::ColorScheme::PreferDark + } + } + WindowTheme::System => adw::ColorScheme::PreferLight, + WindowTheme::Dark => adw::ColorScheme::ForceDark, + WindowTheme::Light => adw::ColorScheme::ForceLight, + }); + + // Setup color change notifications + let this = self.downgrade(); + style.connect_dark_notify(move |style| { + if let Some(this) = this.upgrade() { + this.handle_style_manager_dark(style); + } + }); + + // Do an initial color scheme sync. This is idempotent and does + // nothing if our current theme matches what libghostty has. + self.handle_style_manager_dark(&style); + } + + fn handle_style_manager_dark(&self, style: &adw::StyleManager) { + let scheme = if style.is_dark() { + ColorScheme::Dark + } else { + ColorScheme::Light + }; + log::debug!("style manager changed scheme={scheme:?}"); + + let core = self.core(); + core.color_scheme_event(&*self.rt(), scheme); + for id in core.surface_ids() { + let Some(surface) = Surface::from_id(id) else { + continue; + }; + let Some(core_surface) = surface.core() else { + continue; + }; + if let Err(err) = core_surface.color_scheme_callback(scheme) { + log::warn!("unable to tell surface about color scheme change err={err}"); + } + } + + if let Some(css) = self.imp().css.borrow_mut().as_mut() { + css.set_color_scheme(scheme == ColorScheme::Dark); + } + } + + /// Setup signal handlers (SIGUSR2 reloads the config). + fn startup_signals(&self) { + let imp = self.imp(); + if imp.signal_thread_started.replace(true) { + return; + } + // glib-rs doesn't bind `g_unix_signal_add`, so a helper thread waits + // for SIGUSR2 and hops to the main context. + let mut signals = match signal_hook::iterator::Signals::new([libc::SIGUSR2]) { + Ok(s) => s, + Err(err) => { + log::warn!("unable to register SIGUSR2 handler: {err}"); + return; + } + }; + std::thread::spawn(move || { + for _ in signals.forever() { + glib::MainContext::default().invoke(|| { + log::info!("received SIGUSR2, reloading configuration"); + Application::default() + .reload_config(Target::App, action::ReloadConfig::default()); + }); + } + }); + } + + /// Setup our action map. + fn startup_action_map(&self) { + let add = |name: &str, + param: Option<&glib::VariantTy>, + f: Box)>| { + let action = gio::SimpleAction::new(name, param); + let this = self.downgrade(); + action.connect_activate(move |_, p| { + if let Some(this) = this.upgrade() { + f(&this, p); + } + }); + self.add_action(&action); + }; + + let as_ty = glib::VariantTy::new("as").expect("valid variant type"); + add("new-window", None, Box::new(Self::action_new_window)); + add( + "new-window-command", + Some(as_ty), + Box::new(Self::action_new_window), + ); + add( + "open-config", + None, + Box::new(|this, _| this.action_open_config()), + ); + add( + "present-surface", + Some(glib::VariantTy::UINT64), + Box::new(Self::action_present_surface), + ); + add( + "quit", + None, + Box::new(|this, _| { + this.perform_action(Target::App, Action::Quit); + }), + ); + add( + "reload-config", + None, + Box::new(|this, _| { + this.perform_action( + Target::App, + Action::ReloadConfig(action::ReloadConfig::default()), + ); + }), + ); + } + + /// Setup our global shortcuts. + fn startup_global_shortcuts(&self) { + let Some(gs) = self.imp().global_shortcuts.get() else { + return; + }; + + // On startup, our dbus connection should be available. + gs.set_connection(self.dbus_connection().as_ref()); + + // Keep the shortcut config in sync with the app. + gs.set_config(self.imp().config.borrow().as_ref()); + + // Setup the signal handler for global shortcut triggers + let this = self.downgrade(); + gs.connect_trigger(move |_, action| { + let Some(this) = this.upgrade() else { return }; + if let Err(err) = this.core().perform_all_action(&*this.rt(), action) { + log::warn!("failed to perform action={err}"); + } + }); + } + + /// Show the config errors dialog if the config on our application has + /// diagnostics. + fn show_config_errors_dialog(&self) { + let imp = self.imp(); + let config = self.get_config(); + + // If we already have a dialog, just update the config. + if let Some(diag) = imp.config_errors_dialog.upgrade() { + diag.set_property("config", &config); + if config.has_diagnostics() { + diag.present(None::<>k::Widget>); + } else { + diag.close(); + } + return; + } + + // No diagnostics, do nothing. + if !config.has_diagnostics() { + return; + } + + // No dialog yet, initialize a new one. + let dialog = ConfigErrorsDialog::new(&config); + imp.config_errors_dialog.set(Some(&dialog)); + + // Connect to the reload signal so we know to reload our config. + let this = self.downgrade(); + dialog.connect_reload_config(move |_| { + let Some(this) = this.upgrade() else { return }; + // We clear our dialog reference because it's going to close + // after response handling and we don't want to reuse it. + this.imp() + .config_errors_dialog + .set(None::<&ConfigErrorsDialog>); + // Reload our config as if the app reloaded. + this.reload_config(Target::App, action::ReloadConfig::default()); + }); + + dialog.present(None::<>k::Widget>); + } + + // ----------------------------------------------------------------- + // GActions + + /// `app.new-window` and `app.new-window-command`. + fn action_new_window(&self, parameter: Option<&glib::Variant>) { + log::debug!("received new window action"); + + let mut working_directory: Option = None; + let mut title: Option = None; + let mut command: Option = None; + let mut args: Vec = Vec::new(); + + 'overrides: { + // were we given a parameter? + let Some(parameter) = parameter else { + break 'overrides; + }; + + // ensure that the supplied parameter is an array of strings + let Some(values) = parameter.get::>() else { + log::warn!("parameter is of type '{}', not 'as'", parameter.type_()); + break 'overrides; + }; + + let mut e_seen = false; + for (i, s) in values.iter().enumerate() { + log::debug!("new-window argument: {i} {s}"); + + if e_seen { + args.push(s.clone()); + continue; + } + if s == "-e" { + e_seen = true; + continue; + } + if let Some(v) = s.strip_prefix("--command=") { + let mut cmd = Command::default(); + if let Err(err) = cmd.parse_cli(Some(v)) { + log::warn!("unable to parse command: {err:?}"); + continue; + } + command = Some(cmd); + continue; + } + if let Some(v) = s.strip_prefix("--working-directory=") { + working_directory = Some(v.trim().to_owned()); + continue; + } + if let Some(v) = s.strip_prefix("--title=") { + title = Some(v.trim().to_owned()); + } + } + } + + if !args.is_empty() { + command = Some(Command::Direct(args)); + } + + self.new_window( + None, + WindowOverrides { + command, + working_directory, + title, + }, + ); + } + + fn action_open_config(&self) { + self.core().push_message(AppMessage::OpenConfig); + } + + fn action_present_surface(&self, parameter: Option<&glib::Variant>) { + let Some(parameter) = parameter else { return }; + + // Make sure that we've received a u64 from the system. + let Some(id) = parameter.get::() else { + return; + }; + + // A value of zero means that there was no target surface for the + // notification so we don't focus any surface. Send the id through + // the core mailbox so core can validate it (a notification may + // outlive a Ghostty instance). + if id == 0 { + return; + } + self.core().push_message(AppMessage::PresentSurface(id)); + } + + // ----------------------------------------------------------------- + // Action implementations + + /// apprt API to perform an action. Returns whether it was performed. + pub fn perform_action(&self, target: Target, action: Action) -> bool { + // Resolve the surface target (Zig deref'd `*CoreSurface` directly). + let surface: Option = match target { + Target::App => None, + Target::Surface(id) => { + let s = Surface::from_id(id); + if s.is_none() { + log::warn!("action for unknown surface id={id}"); + } + s + } + }; + let is_surface_target = matches!(target, Target::Surface(_)); + // `surface` as the Zig `target` union. + let tgt = Tgt { + is_surface: is_surface_target, + surface, + }; + + match action { + Action::CloseTab(mode) => tgt.with_surface_bool(|s| { + let value = match mode { + action::CloseTabMode::This => "this", + action::CloseTabMode::Other => "other", + action::CloseTabMode::Right => "right", + }; + s.activate_action("tab.close", Some(&value.to_variant())) + .is_ok() + }), + Action::CloseWindow => { + tgt.with_surface_bool(|s| s.activate_action("win.close", None).is_ok()) + } + Action::CopyTitleToClipboard => tgt.with_surface_bool(Surface::copy_title_to_clipboard), + Action::ConfigChange(change) => { + // Wrap our config in a GObject (shares the Arc). + let config_obj = Config::from_arc(change.config); + match &tgt.surface { + Some(s) => s.set_config(&config_obj), + None => self.set_config(&config_obj), + } + true + } + Action::DesktopNotification(n) => { + self.desktop_notification(&tgt, &n); + true + } + Action::EqualizeSplits => { + if !tgt.is_surface { + log::warn!("equalize splits to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| s.activate_action("split-tree.equalize", None).is_ok()) + } + Action::GotoSplit(to) => tgt.with_surface_bool(|s| Self::goto_split(s, to)), + Action::GotoWindow(dir) => Self::goto_window(dir), + Action::GotoTab(tab) => tgt.with_surface_bool(|s| { + let Some(window) = ancestor::(s) else { + log::warn!("surface is not in a window, ignoring goto_tab"); + return false; + }; + window.select_tab(match tab { + action::GotoTab::Previous => SelectTab::Previous, + action::GotoTab::Next => SelectTab::Next, + action::GotoTab::Last => SelectTab::Last, + action::GotoTab::Index(n) if n >= 0 => SelectTab::N(n as usize), + action::GotoTab::Index(_) => return false, + }) + }), + Action::InitialSize(v) => tgt.with_surface_bool(|s| { + s.set_default_size(Size { + width: v.width, + height: v.height, + }); + true + }), + Action::Inspector(v) => tgt.with_surface_bool(|s| s.control_inspector(v)), + Action::KeySequence(v) => { + if !tgt.is_surface { + log::warn!("key_sequence action to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| { + s.key_sequence_action(&v); + true + }) + } + Action::KeyTable(v) => { + if !tgt.is_surface { + log::warn!("key_table action to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| { + s.key_table_action(&v); + true + }) + } + Action::MouseOverLink(v) => { + tgt.with_surface_void("mouse over link", |s| { + s.set_mouse_hover_url(if v.url.is_empty() { None } else { Some(&v.url) }); + }); + true + } + Action::MouseShape(shape) => { + tgt.with_surface_void("mouse shape", |s| s.set_mouse_shape(shape)); + true + } + Action::MouseVisibility(v) => { + tgt.with_surface_void("mouse visibility", |s| { + s.set_mouse_hidden(matches!(v, action::MouseVisibility::Hidden)); + }); + true + } + Action::MoveTab(v) => tgt.with_surface_bool(|s| { + let Some(window) = ancestor::(s) else { + log::warn!("surface is not in a window, ignoring move_tab"); + return false; + }; + window.move_tab(s, v.amount) + }), + Action::NewSplit(dir) => { + if !tgt.is_surface { + log::warn!("new split to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| { + let name = match dir { + action::SplitDirection::Right => "right", + action::SplitDirection::Down => "down", + action::SplitDirection::Left => "left", + action::SplitDirection::Up => "up", + }; + s.activate_action("split-tree.new-split", Some(&name.to_variant())) + .is_ok() + }) + } + Action::NewTab => { + if !tgt.is_surface { + log::warn!("new tab to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| { + // Get the window ancestor of the surface. Surfaces + // shouldn't be aware they might be in windows but at the + // app level we can do this. + let Some(window) = ancestor::(s) else { + log::warn!("surface is not in a window, ignoring new_tab"); + return false; + }; + window.new_tab(Some(s)); + true + }) + } + Action::NewWindow => { + self.new_window(tgt.surface.as_ref(), WindowOverrides::default()); + true + } + Action::OpenConfig => self.open_config(), + Action::OpenUrl(v) => { + self.open_url(&v); + true + } + Action::Pwd(v) => { + tgt.with_surface_void("pwd", |s| s.set_pwd(Some(&v.pwd))); + true + } + Action::PresentTerminal => tgt.with_surface_bool(|s| { + s.present(); + true + }), + Action::ProgressReport(v) => tgt.with_surface_bool(|s| { + s.set_progress_report(v); + true + }), + Action::PromptTitle(v) => match v { + action::PromptTitle::Surface => tgt.with_surface_bool(|s| { + s.prompt_title(); + true + }), + action::PromptTitle::Tab => tgt.with_surface_bool(|s| { + let Some(tab) = ancestor::(s) else { + log::warn!("surface is not in a tab, ignoring prompt_tab_title"); + return false; + }; + tab.prompt_tab_title(); + true + }), + }, + Action::Quit => { + self.quit(); + true + } + Action::QuitTimer(mode) => { + match mode { + action::QuitTimer::Start => self.start_quit_timer(), + action::QuitTimer::Stop => self.stop_quit_timer(), + } + true + } + Action::ReloadConfig(opts) => { + self.reload_config(target, opts); + true + } + Action::Render => { + if let Some(s) = &tgt.surface { + s.redraw(); + } + true + } + Action::ResizeSplit(v) => { + if !tgt.is_surface { + log::warn!("resize_split to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| { + let Some(tree) = ancestor::(s) else { + log::warn!("surface is not in a split tree, ignoring resize_split"); + return false; + }; + // If the tree has no splits (only one leaf), this action + // is not performable. This allows the key event to pass + // through to the terminal. + if !tree.get_is_split() { + return false; + } + let dir = match v.direction { + action::ResizeSplitDirection::Up => { + ghostty_datastruct::split_tree::Direction::Up + } + action::ResizeSplitDirection::Down => { + ghostty_datastruct::split_tree::Direction::Down + } + action::ResizeSplitDirection::Left => { + ghostty_datastruct::split_tree::Direction::Left + } + action::ResizeSplitDirection::Right => { + ghostty_datastruct::split_tree::Direction::Right + } + }; + match tree.resize(dir, v.amount) { + Ok(b) => b, + Err(err) => { + log::warn!("unable to resize split: {err:?}"); + false + } + } + }) + } + Action::RingBell => { + if let Some(s) = &tgt.surface { + s.set_bell_ringing(true); + } + true + } + Action::Scrollbar(v) => { + if let Some(s) = &tgt.surface { + s.set_scrollbar(v); + } + true + } + Action::SetTitle(v) => { + tgt.with_surface_void("set_title", |s| s.set_title(Some(&v.title))); + true + } + Action::SetTabTitle(v) => { + if !tgt.is_surface { + log::warn!("set_tab_title to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| { + let Some(tab) = ancestor::(s) else { + log::warn!("surface is not in a tab, ignoring set_tab_title"); + return false; + }; + tab.set_title_override(if v.title.is_empty() { + None + } else { + Some(&v.title) + }); + true + }) + } + Action::ShowChildExited(v) => tgt.with_surface_bool(|s| s.child_exited(&v)), + Action::ShowGtkInspector => { + gtk::Window::set_interactive_debugging(true); + true + } + Action::SizeLimit(v) => tgt.with_surface_bool(|s| { + // Note: we ignore the max size currently because we have + // no mechanism to enforce it. + s.set_min_size(Size { + width: v.min_width, + height: v.min_height, + }); + true + }), + Action::ToggleMaximize => { + if let Some(s) = &tgt.surface { + s.toggle_maximize(); + } + true + } + Action::ToggleFullscreen(_) => { + if let Some(s) = &tgt.surface { + s.toggle_fullscreen(); + } + true + } + Action::ToggleQuickTerminal => self.toggle_quick_terminal(), + Action::ToggleTabOverview => tgt.with_surface_bool(|s| { + let Some(window) = ancestor::(s) else { + log::warn!("surface is not in a window, ignoring toggle_tab_overview"); + return false; + }; + window.toggle_tab_overview(); + true + }), + Action::ToggleWindowDecorations => tgt.with_surface_bool(|s| { + let Some(window) = ancestor::(s) else { + log::warn!("surface is not in a window, ignoring toggle_window_decorations"); + return false; + }; + window.toggle_window_decorations(); + true + }), + Action::ToggleCommandPalette => tgt.with_surface_bool(Surface::toggle_command_palette), + Action::ToggleSplitZoom => { + if !tgt.is_surface { + log::warn!("toggle_split_zoom to app is unexpected"); + return false; + } + tgt.with_surface_bool(|s| { + let Some(tree) = ancestor::(s) else { + log::warn!("surface is not in a split tree, ignoring toggle_split_zoom"); + return false; + }; + // If the tree has no splits (only one leaf), this action + // is not performable. + if !tree.get_is_split() { + return false; + } + s.activate_action("split-tree.zoom", None).is_ok() + }) + } + Action::ShowOnScreenKeyboard => { + if !tgt.is_surface { + log::warn!("show_on_screen_keyboard to app is unexpected"); + return false; + } + tgt.with_surface_bool(Surface::show_on_screen_keyboard) + } + Action::CommandFinished(v) => tgt.with_surface_bool(|s| s.command_finished(&v)), + Action::Readonly(v) => tgt.with_surface_bool(|s| s.set_readonly(v)), + Action::StartSearch(v) => { + if let Some(s) = &tgt.surface { + s.set_search_active(true, &v.needle); + } + true + } + Action::EndSearch => { + if let Some(s) = &tgt.surface { + s.set_search_active(false, ""); + } + true + } + Action::SearchTotal(v) => { + if let Some(s) = &tgt.surface { + s.set_search_total(v.total); + } + true + } + Action::SearchSelected(v) => { + if let Some(s) = &tgt.surface { + s.set_search_selected(v.selected); + } + true + } + + // Unimplemented + Action::SecureInput(_) + | Action::CloseAllWindows + | Action::FloatWindow(_) + | Action::ToggleVisibility + | Action::ToggleBackgroundOpacity + | Action::CellSize(_) + | Action::RenderInspector + | Action::RendererHealth(_) + | Action::ColorChange(_) + | Action::ResetWindowSize + | Action::CheckForUpdates + | Action::Undo + | Action::Redo => { + log::warn!("unimplemented action={:?}", action.key()); + false + } + } + } + + fn desktop_notification(&self, tgt: &Tgt, n: &action::DesktopNotification) { + if let Some(s) = &tgt.surface { + s.send_desktop_notification(&n.title, &n.body); + return; + } + + // Set a default title if we don't already have one + let title = if n.title.is_empty() { + "Ghostty" + } else { + n.title.as_str() + }; + + let notification = gio::Notification::new(title); + notification.set_body(Some(&n.body)); + notification.set_icon(&gio::ThemedIcon::new("com.mitchellh.ghostty")); + notification + .set_default_action_and_target_value("app.present-surface", Some(&0u64.to_variant())); + + // We set the notification ID to the body content. If the content is + // the same, this notification may replace a previous notification. + self.send_notification(Some(&n.body), ¬ification); + } + + fn goto_split(surface: &Surface, to: action::GotoSplit) -> bool { + use ghostty_datastruct::split_tree::{Direction, Goto}; + // Design note: we can't use widget actions here because we need to + // know whether there is a goto target for returning the proper + // perform result (boolean). + let Some(tree) = ancestor::(surface) else { + log::warn!("surface is not in a split tree, ignoring goto_split"); + return false; + }; + tree.goto(match to { + action::GotoSplit::Previous => Goto::PreviousWrapped, + action::GotoSplit::Next => Goto::NextWrapped, + action::GotoSplit::Up => Goto::Spatial(Direction::Up), + action::GotoSplit::Down => Goto::Spatial(Direction::Down), + action::GotoSplit::Left => Goto::Spatial(Direction::Left), + action::GotoSplit::Right => Goto::Spatial(Direction::Right), + }) + } + + fn goto_window(direction: action::GotoWindow) -> bool { + let list: Vec = gtk::Window::list_toplevels() + .into_iter() + .filter_map(|w| w.downcast::().ok()) + .collect(); + if list.is_empty() { + return false; + } + + // The window we're starting from is typically our active window. + let start = list.iter().position(gtk::Window::is_active).unwrap_or(0); + + // Go forward or backwards in the list until we find a valid window + // that is visible, wrapping around once. + let n = list.len(); + for step in 0..n { + let idx = match direction { + action::GotoWindow::Next => (start + step) % n, + action::GotoWindow::Previous => (start + n - step) % n, + }; + if Self::goto_window_maybe(&list[idx]) { + return true; + } + } + false + } + + fn goto_window_maybe(gtk_window: >k::Window) -> bool { + // If it is already active skip it. + if gtk_window.is_active() { + return false; + } + // If it is hidden, skip it. + if !gtk_window.is_visible() { + return false; + } + // If it isn't a Ghostty window, skip it. + let Some(window) = gtk_window.downcast_ref::() else { + return false; + }; + + // Focus our active surface + let Some(surface) = window.get_active_surface() else { + return false; + }; + gtk_window.present(); + surface.grab_focus(); + true + } + + /// Create a new window (Zig: `Action.newWindow`). + pub fn new_window(&self, parent: Option<&Surface>, overrides: WindowOverrides) { + // Note that we've requested a window at least once. This is used to + // trigger quit on no windows. + self.mark_requested_window(); + + let win = Window::new(self, overrides.title.as_deref()); + self.init_and_show_window(&win, parent, &overrides); + } + + fn init_and_show_window( + &self, + win: &Window, + parent: Option<&Surface>, + overrides: &WindowOverrides, + ) { + // Setup a binding so that whenever our config changes so does the + // window. There's never a time when the window config should be out + // of sync with the application config. + self.bind_property("config", win, "config").build(); + + // Create a new tab with window context (first tab in new window) + win.new_tab_for_window( + parent, + TabOverrides { + command: overrides.command.clone(), + working_directory: overrides.working_directory.clone(), + title: overrides.title.clone(), + }, + ); + + // Estimate the initial window size before presenting so the window + // manager can position it correctly. + if let Some(surface) = win.get_active_surface() { + surface.estimate_initial_size(); + if let Some(size) = surface.get_default_size() { + win.set_default_size(size.width as i32, size.height as i32); + } + } + + // Show the window + win.present(); + } + + fn open_config(&self) -> bool { + // Get the config file path + let path = match ghostty_config::edit::open_path() { + Ok(p) => p, + Err(err) => { + log::warn!("error getting config file path: {err}"); + return false; + } + }; + + // Open it using open_url. "path" isn't actually a URL but at the + // time of writing that works just fine for GTK. + self.open_url(&action::OpenUrl { + kind: action::OpenUrlKind::Text, + url: path, + }); + true + } + + fn open_url(&self, value: &action::OpenUrl) { + // TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html + + // Fallback to the minimal cross-platform way of opening a URL. + let kind = match value.kind { + action::OpenUrlKind::Unknown => ghostty_os::open::OpenUrlKind::Unknown, + action::OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text, + action::OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html, + }; + if let Err(err) = ghostty_os::open::open(kind, &value.url) { + log::warn!("unable to open url: {err}"); + } + } + + /// Reload the configuration for the application and propagate it across + /// the entire application and all terminals. + fn reload_config(&self, target: Target, opts: action::ReloadConfig) { + // Tell systemd that reloading has started; when we exit this + // function tell it that reloading has finished. + ghostty_os::systemd::notify::reloading(); + let _ready = scopeguard_ready(); + + // Get our config object. + let config: Arc = if opts.soft { + // Soft-reloading applies conditional logic to the existing + // loaded config so we return that as-is. + self.core_config() + } else { + // Hard reload, load a new config completely. + match CoreConfig::load() { + Ok(c) => Arc::new(c), + Err(err) => { + log::warn!("error reloading config: {err}"); + return; + } + } + }; + + // Update the proper target. This will trigger a `config_change` + // apprt action which will propagate the config properly to our + // property system. + match target { + Target::App => { + if let Err(err) = self.core().update_config(&*self.rt(), &config) { + log::warn!("error reloading config: {err}"); + } + } + Target::Surface(id) => { + let Some(core) = Surface::from_id(id).and_then(|s| s.core()) else { + return; + }; + if let Err(err) = core.update_config(&config) { + log::warn!("error reloading config: {err}"); + } + } + } + } + + fn toggle_quick_terminal(&self) -> bool { + // If we already have a quick terminal window, we just toggle the + // visibility of it. + if let Some(win) = Self::quick_terminal_window() { + win.toggle_visibility(); + return true; + } + + // If we don't support quick terminals then we do nothing. + if !self.winproto().supports_quick_terminal() { + return false; + } + + // Create our new window as a quick terminal + crate::register_resources(); + let win: Window = glib::Object::builder() + .property("application", self) + .property("quick-terminal", true) + .build(); + debug_assert!(win.is_quick_terminal()); + self.init_and_show_window(&win, None, &WindowOverrides::default()); + true + } + + fn quick_terminal_window() -> Option { + gtk::Window::list_toplevels() + .into_iter() + .filter_map(|w| w.downcast::().ok()) + .find(Window::is_quick_terminal) + } +} + +/// The Zig `apprt.Target` as seen by action handlers. +struct Tgt { + is_surface: bool, + surface: Option, +} + +impl Tgt { + /// Run `f` for surface targets; app targets (or unknown surfaces) are + /// `false` (Zig: `.app => return false`). + fn with_surface_bool(&self, f: impl FnOnce(&Surface) -> bool) -> bool { + match &self.surface { + Some(s) => f(s), + None => false, + } + } + + /// Like Zig's void handlers that log a warning for app targets. + fn with_surface_void(&self, what: &str, f: impl FnOnce(&Surface)) { + match &self.surface { + Some(s) => f(s), + None if !self.is_surface => log::warn!("{what} to app is unexpected"), + None => {} + } + } +} + +/// Find an ancestor widget of type `T` (Zig: `ext.getAncestor`). +fn ancestor>(widget: &impl IsA) -> Option { + widget.ancestor(T::static_type()).and_downcast::() +} + +/// Guard that tells systemd we're ready again on drop. +struct ReadyGuard; +impl Drop for ReadyGuard { + fn drop(&mut self) { + ghostty_os::systemd::notify::ready(); + } +} +fn scopeguard_ready() -> ReadyGuard { + ReadyGuard +} diff --git a/crates/ghostty-apprt-gtk/src/class/clipboard_confirmation_dialog.rs b/crates/ghostty-apprt-gtk/src/class/clipboard_confirmation_dialog.rs new file mode 100644 index 0000000..7f65613 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/clipboard_confirmation_dialog.rs @@ -0,0 +1,245 @@ +//! Port of apprt/gtk/class/clipboard_confirmation_dialog.zig +//! (`GhosttyClipboardConfirmationDialog`). +//! +//! Properties: `can-remember`, `request` (boxed [`ClipboardRequestBoxed`]), +//! `clipboard-contents` (GtkTextBuffer), `blur`. Signals `confirm(bool)` and +//! `deny(bool)` (bool = "remember choice"). + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_apprt::structs::ClipboardRequest; +use ghostty_os::i18n::tr; +use gtk::glib; +use gtk::glib::subclass::Signal; +use std::cell::{Cell, RefCell}; +use std::sync::OnceLock; + +use super::dialog::{Dialog, DialogImpl}; + +/// GBoxed wrapper for [`ClipboardRequest`] (Zig: boxed `apprt.ClipboardRequest`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, glib::Boxed)] +#[boxed_type(name = "GhosttyClipboardRequest", nullable)] +pub struct ClipboardRequestBoxed(pub ClipboardRequest); + +mod imp { + use super::{ + AdwAlertDialogImpl, AdwDialogImpl, Cell, ClipboardRequest, ClipboardRequestBoxed, Dialog, + DialogImpl, ObjectImpl, ObjectSubclass, OnceLock, RefCell, Signal, WidgetImpl, tr, + }; + use adw::prelude::*; + use gtk::glib; + use gtk::subclass::prelude::*; + + #[derive(Default, glib::Properties, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::ClipboardConfirmationDialog)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.4/clipboard-confirmation-dialog.ui")] + pub struct ClipboardConfirmationDialog { + /// The request that this dialog is for. + #[property(get, set, nullable)] + pub request: RefCell>, + + /// The clipboard contents being read/written. + #[property(get, set, nullable, name = "clipboard-contents")] + pub clipboard_contents: RefCell>, + + /// Whether the contents should be blurred. + #[property(get, set)] + pub blur: Cell, + + /// Whether the user can remember the choice. + #[property(get, set, name = "can-remember")] + pub can_remember: Cell, + + #[template_child] + pub text_view_scroll: TemplateChild, + #[template_child] + pub text_view: TemplateChild, + #[template_child] + pub reveal_button: TemplateChild, + #[template_child] + pub hide_button: TemplateChild, + #[template_child] + pub remember_choice: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ClipboardConfirmationDialog { + const NAME: &'static str = "GhosttyClipboardConfirmationDialog"; + type Type = super::ClipboardConfirmationDialog; + type ParentType = Dialog; + + fn class_init(klass: &mut Self::Class) { + Dialog::ensure_type(); + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[gtk::template_callbacks] + impl ClipboardConfirmationDialog { + fn set_blurred(&self, blurred: bool, reveal_visible: bool, hide_visible: bool) { + self.text_view_scroll.set_sensitive(!blurred); + if blurred { + self.text_view.add_css_class("blurred"); + } else { + self.text_view.remove_css_class("blurred"); + } + self.reveal_button.set_visible(reveal_visible); + self.hide_button.set_visible(hide_visible); + } + + #[template_callback] + fn notify_blur(&self, _pspec: &glib::ParamSpec) { + if self.blur.get() { + self.set_blurred(true, true, false); + } else { + self.set_blurred(false, false, false); + } + } + + #[template_callback] + fn notify_request(&self, _pspec: &glib::ParamSpec) { + self.update_request(); + } + + pub fn update_request(&self) { + let Some(ClipboardRequestBoxed(req)) = *self.request.borrow() else { + return; + }; + let obj = self.obj(); + match req { + ClipboardRequest::Osc52Write(_) => { + obj.set_heading(Some(&tr("Authorize Clipboard Access"))); + obj.set_body(&tr("An application is attempting to write to the clipboard. The current clipboard contents are shown below.")); + } + ClipboardRequest::Osc52Read(_) => { + obj.set_heading(Some(&tr("Authorize Clipboard Access"))); + obj.set_body(&tr("An application is attempting to read from the clipboard. The current clipboard contents are shown below.")); + } + ClipboardRequest::Paste => { + obj.set_heading(Some(&tr("Warning: Potentially Unsafe Paste"))); + obj.set_body(&tr("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.")); + } + } + } + + #[template_callback] + fn reveal_clicked(&self, _button: >k::Button) { + self.set_blurred(false, false, true); + } + + #[template_callback] + fn hide_clicked(&self, _button: >k::Button) { + self.set_blurred(true, true, false); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ClipboardConfirmationDialog { + fn constructed(&self) { + self.parent_constructed(); + // Mirrors `self.propBlur(..); self.propRequest(..)` in init. + self.notify_blur(&Self::type_pspec_dummy()); + self.update_request(); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("deny") + .param_types([bool::static_type()]) + .build(), + Signal::builder("confirm") + .param_types([bool::static_type()]) + .build(), + ] + }) + } + + fn dispose(&self) { + self.clipboard_contents.replace(None); + self.dispose_template(); + } + } + + impl ClipboardConfirmationDialog { + fn type_pspec_dummy() -> glib::ParamSpec { + glib::ParamSpecBoolean::builder("blur").build() + } + } + + impl WidgetImpl for ClipboardConfirmationDialog {} + impl AdwDialogImpl for ClipboardConfirmationDialog {} + impl AdwAlertDialogImpl for ClipboardConfirmationDialog { + fn response(&self, response: &str) { + let remember = self.remember_choice.is_active(); + let obj = self.obj(); + if response == "cancel" { + obj.emit_by_name::<()>("deny", &[&remember]); + } else if response == "ok" { + obj.emit_by_name::<()>("confirm", &[&remember]); + } + } + } + impl DialogImpl for ClipboardConfirmationDialog {} +} + +glib::wrapper! { + pub struct ClipboardConfirmationDialog(ObjectSubclass) + @extends Dialog, adw::AlertDialog, adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::ShortcutManager; +} + +impl ClipboardConfirmationDialog { + pub fn new() -> Self { + crate::register_resources(); + glib::Object::new() + } + + pub fn present(&self, parent: Option<&impl IsA>) { + self.upcast_ref::().present(parent); + } + + /// Get the clipboard request (`getRequest`). + pub fn get_request(&self) -> Option { + self.imp().request.borrow().map(|b| b.0) + } + + /// Get the clipboard contents (`getClipboardContents`). + pub fn get_clipboard_contents(&self) -> Option { + self.imp().clipboard_contents.borrow().clone() + } + + pub fn set_request_value(&self, req: ClipboardRequest) { + self.set_request(Some(ClipboardRequestBoxed(req))); + } + + /// `confirm(remember)`. + pub fn connect_confirm(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "confirm", + false, + glib::closure_local!(move |obj: &Self, remember: bool| f(obj, remember)), + ) + } + + /// `deny(remember)`. + pub fn connect_deny(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "deny", + false, + glib::closure_local!(move |obj: &Self, remember: bool| f(obj, remember)), + ) + } +} + +impl Default for ClipboardConfirmationDialog { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/close_confirmation_dialog.rs b/crates/ghostty-apprt-gtk/src/class/close_confirmation_dialog.rs new file mode 100644 index 0000000..ae7d484 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/close_confirmation_dialog.rs @@ -0,0 +1,152 @@ +//! Port of apprt/gtk/class/close_confirmation_dialog.zig +//! (`GhosttyCloseConfirmationDialog`). Property `target`; signals +//! `close-request` and `cancel`. + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_os::i18n::tr; +use gtk::glib; +use gtk::glib::subclass::Signal; +use std::cell::Cell; +use std::sync::OnceLock; + +use super::dialog::{Dialog, DialogImpl}; + +/// The target of a close dialog (`GhosttyCloseConfirmationDialogTarget`). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)] +#[enum_type(name = "GhosttyCloseConfirmationDialogTarget")] +#[repr(i32)] +pub enum Target { + #[default] + App, + Tab, + Window, + Surface, +} + +impl Target { + pub fn title(self) -> String { + match self { + Target::App => tr("Quit Ghostty?"), + Target::Tab => tr("Close Tab?"), + Target::Window => tr("Close Window?"), + Target::Surface => tr("Close Split?"), + } + } + + pub fn body(self) -> String { + match self { + Target::App => tr("All terminal sessions will be terminated."), + Target::Tab => tr("All terminal sessions in this tab will be terminated."), + Target::Window => tr("All terminal sessions in this window will be terminated."), + Target::Surface => { + tr("The currently running process in this split will be terminated.") + } + } + } +} + +mod imp { + use super::{ + AdwAlertDialogImpl, AdwDialogImpl, Cell, Dialog, DialogImpl, ObjectImpl, ObjectSubclass, + OnceLock, Signal, Target, WidgetImpl, + }; + use gtk::glib; + use gtk::glib::prelude::*; + use gtk::subclass::prelude::*; + + #[derive(Default, glib::Properties, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::CloseConfirmationDialog)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.2/close-confirmation-dialog.ui")] + pub struct CloseConfirmationDialog { + #[property(get, set, builder(Target::App))] + pub target: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CloseConfirmationDialog { + const NAME: &'static str = "GhosttyCloseConfirmationDialog"; + type Type = super::CloseConfirmationDialog; + type ParentType = Dialog; + + fn class_init(klass: &mut Self::Class) { + Dialog::ensure_type(); + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for CloseConfirmationDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("close-request").build(), + Signal::builder("cancel").build(), + ] + }) + } + + fn dispose(&self) { + self.dispose_template(); + } + } + impl WidgetImpl for CloseConfirmationDialog {} + impl AdwDialogImpl for CloseConfirmationDialog {} + impl AdwAlertDialogImpl for CloseConfirmationDialog { + fn response(&self, response: &str) { + let name = if response == "close" { + "close-request" + } else { + "cancel" + }; + self.obj().emit_by_name::<()>(name, &[]); + } + } + impl DialogImpl for CloseConfirmationDialog {} +} + +glib::wrapper! { + pub struct CloseConfirmationDialog(ObjectSubclass) + @extends Dialog, adw::AlertDialog, adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::ShortcutManager; +} + +impl CloseConfirmationDialog { + pub fn new(target: Target) -> Self { + crate::register_resources(); + glib::Object::builder().property("target", target).build() + } + + /// Sets the heading/body from the target and presents over `parent`. + pub fn present(&self, parent: Option<&impl IsA>) { + let target = self.target(); + self.set_heading(Some(&target.title())); + self.set_body(&target.body()); + self.upcast_ref::().present(parent); + } + + pub fn close(&self) { + self.upcast_ref::().close(); + } + + pub fn connect_close_request(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "close-request", + false, + glib::closure_local!(move |obj: &Self| f(obj)), + ) + } + + pub fn connect_cancel(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "cancel", + false, + glib::closure_local!(move |obj: &Self| f(obj)), + ) + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/command_palette.rs b/crates/ghostty-apprt-gtk/src/class/command_palette.rs new file mode 100644 index 0000000..4d742aa --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/command_palette.rs @@ -0,0 +1,560 @@ +//! Port of apprt/gtk/class/command_palette.zig (`GhosttyCommandPalette` and +//! the private `GhosttyCommand` list item wrapper). +//! +//! Property `config`; signal `trigger(action: BindingAction)`. The palette +//! keeps itself alive (a self-reference) from `new()` until its dialog is +//! closed, like the Zig `refSink`/`unref` dance. + +use std::cell::RefCell; +use std::cmp::Ordering; +use std::sync::OnceLock; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_input::binding::Action as BindingAction; +use ghostty_input::command::Command as InputCommand; +use gtk::glib; +use gtk::glib::subclass::Signal; +use gtk::{CompositeTemplate, TemplateChild, gio}; + +use super::application::Application; +use super::config::Config; +use super::surface::Surface; +use super::window::Window; + +/// Boxed wrapper so a binding action can travel through the `trigger` +/// signal (Zig passes `*const input.Binding.Action`). +#[derive(Debug, Clone, glib::Boxed)] +#[boxed_type(name = "GhosttyBindingAction")] +pub struct BoxedAction(pub BindingAction); + +// --------------------------------------------------------------------- +// Command (list item) + +mod command_imp { + use super::{ + BindingAction, Config, InputCommand, RefCell, Surface, ghostty_command_title_for_jump, glib, + }; + use adw::prelude::*; + use gtk::glib::subclass::prelude::*; + + pub enum CommandData { + Regular { + command: InputCommand, + }, + Jump { + surface: glib::WeakRef, + title: Option, + description: Option, + sort_key: usize, + }, + } + + impl Default for CommandData { + fn default() -> Self { + CommandData::Regular { + command: InputCommand::new(BindingAction::Ignore, "", ""), + } + } + } + + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::Command)] + pub struct Command { + #[property(get, set, nullable)] + pub config: RefCell>, + + pub data: RefCell, + + #[property(name = "action-key", get = Self::prop_get_action_key, type = Option)] + #[property(name = "action", get = Self::prop_get_action, type = Option)] + #[property(name = "title", get = Self::prop_get_title, type = Option)] + #[property(name = "description", get = Self::prop_get_description, type = Option)] + _props: (), + } + + #[glib::object_subclass] + impl ObjectSubclass for Command { + const NAME: &'static str = "GhosttyCommand"; + type Type = super::Command; + type ParentType = glib::Object; + } + + #[glib::derived_properties] + impl ObjectImpl for Command { + fn dispose(&self) { + self.config.replace(None); + if let CommandData::Jump { surface, .. } = &*self.data.borrow() { + surface.set(None); + } + } + } + + impl Command { + fn prop_get_action_key(&self) -> Option { + match &*self.data.borrow() { + CommandData::Regular { command } => Some(command.action.to_string()), + CommandData::Jump { .. } => None, + } + } + + fn prop_get_action(&self) -> Option { + let data = self.data.borrow(); + let CommandData::Regular { command } = &*data else { + return None; + }; + let config = self.config.borrow(); + let cfg = config.as_ref()?.get(); + let trigger = cfg.keybind.set.get_trigger(&command.action)?; + crate::key::accel_from_trigger(trigger) + } + + fn prop_get_title(&self) -> Option { + let mut data = self.data.borrow_mut(); + match &mut *data { + CommandData::Regular { command } => Some(command.title.clone()), + CommandData::Jump { surface, title, .. } => { + if let Some(t) = title { + return Some(t.clone()); + } + let surface = surface.upgrade()?; + *title = Some(ghostty_command_title_for_jump(&surface)); + title.clone() + } + } + } + + fn prop_get_description(&self) -> Option { + let mut data = self.data.borrow_mut(); + match &mut *data { + CommandData::Regular { command } => Some(command.description.clone()), + CommandData::Jump { + surface, + description, + .. + } => { + if let Some(d) = description { + return Some(d.clone()); + } + let surface = surface.upgrade()?; + let title = surface + .get_effective_title() + .unwrap_or_else(|| "Untitled".to_string()); + if let Some(pwd) = surface.get_pwd() + && !title.contains(&pwd) + { + *description = Some(pwd); + } + description.clone() + } + } + } + } +} + +/// "Focus: {title}" for jump commands. +fn ghostty_command_title_for_jump(surface: &Surface) -> String { + let t = surface + .get_effective_title() + .unwrap_or_else(|| "Untitled".to_string()); + format!("Focus: {t}") +} + +glib::wrapper! { + /// Object wrapping a command so GTK list models can consume it. + pub struct Command(ObjectSubclass); +} + +impl Command { + /// Create a regular command. + pub fn new(config: &Config, command: &InputCommand) -> Self { + let obj: Self = glib::Object::builder().property("config", config).build(); + obj.imp().data.replace(command_imp::CommandData::Regular { + command: command.clone(), + }); + obj + } + + /// Create a new jump command that focuses a specific surface. + pub fn new_jump(config: &Config, surface: &Surface) -> Self { + let obj: Self = glib::Object::builder().property("config", config).build(); + obj.imp().data.replace(command_imp::CommandData::Jump { + surface: surface.downgrade(), + title: None, + description: None, + // TODO: Replace with surface id whenever Ghostty adds one + sort_key: surface.as_ptr() as usize, + }); + obj + } + + /// The action (None for jump commands). + pub fn get_action(&self) -> Option { + match &*self.imp().data.borrow() { + command_imp::CommandData::Regular { command } => Some(command.action.clone()), + command_imp::CommandData::Jump { .. } => None, + } + } + + pub fn is_jump(&self) -> bool { + matches!( + &*self.imp().data.borrow(), + command_imp::CommandData::Jump { .. } + ) + } + + /// Strong reference to the jump surface, if it still exists. + pub fn get_jump_surface(&self) -> Option { + match &*self.imp().data.borrow() { + command_imp::CommandData::Regular { .. } => None, + command_imp::CommandData::Jump { surface, .. } => surface.upgrade(), + } + } + + fn sort_key(&self) -> Option { + match &*self.imp().data.borrow() { + command_imp::CommandData::Regular { .. } => None, + command_imp::CommandData::Jump { sort_key, .. } => Some(*sort_key), + } + } +} + +/// Compare two commands for sorting (`compareCommands`). Sorts +/// alphabetically by title (case-insensitive), with colon normalization so +/// "Foo:" sorts before "Foo Bar:". Uses sort_key as tie-breaker. Returns +/// true if `a` sorts before `b`. +fn compare_commands(a: &Command, b: &Command) -> bool { + let Some(a_title) = a.property::>("title") else { + return false; + }; + let Some(b_title) = b.property::>("title") else { + return true; + }; + title_less_than(&a_title, &b_title).unwrap_or_else(|| { + // Titles are equal - use sort_key as tie-breaker if both are jump + // commands. + match (a.sort_key(), b.sort_key()) { + (Some(ka), Some(kb)) => ka < kb, + _ => false, + } + }) +} + +/// Returns Some(ordering result) if the titles differ, None if equal. +fn title_less_than(a_title: &str, b_title: &str) -> Option { + let a = a_title.as_bytes(); + let b = b_title.as_bytes(); + for i in 0..a.len().min(b.len()) { + let norm = |c: u8| if c == b':' { b'\t' } else { c }.to_ascii_lowercase(); + let (al, bl) = (norm(a[i]), norm(b[i])); + if al != bl { + return Some(al < bl); + } + } + // If one title is a prefix of the other, shorter one comes first + if a.len() != b.len() { + return Some(a.len() < b.len()); + } + None +} + +/// Check if an action is supported on GTK. +fn is_action_supported_on_gtk(action: &BindingAction) -> bool { + !matches!( + action, + BindingAction::CloseAllWindows + | BindingAction::ToggleSecureInput + | BindingAction::CheckForUpdates + | BindingAction::Redo + | BindingAction::Undo + | BindingAction::ResetWindowSize + | BindingAction::ToggleWindowFloatOnTop + ) +} + +// --------------------------------------------------------------------- +// CommandPalette + +mod imp { + use super::{ + Application, BoxedAction, Command, CompositeTemplate, Config, OnceLock, Ordering, RefCell, + Signal, TemplateChild, compare_commands, is_action_supported_on_gtk, + }; + use adw::prelude::*; + use adw::subclass::prelude::*; + use gtk::gio; + use gtk::glib; + + #[derive(Default, glib::Properties, CompositeTemplate)] + #[properties(wrapper_type = super::CommandPalette)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/command-palette.ui")] + pub struct CommandPalette { + /// The configuration that this command palette is using. + #[property(get, set, nullable)] + pub config: RefCell>, + + /// Self reference held while the palette is alive (until the dialog + /// closes), see `new()`. + pub self_ref: RefCell>, + + #[template_child] + pub dialog: TemplateChild, + #[template_child] + pub search: TemplateChild, + #[template_child] + pub view: TemplateChild, + #[template_child] + pub model: TemplateChild, + #[template_child] + pub source: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for CommandPalette { + const NAME: &'static str = "GhosttyCommandPalette"; + type Type = super::CommandPalette; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Command::ensure_type(); + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for CommandPalette { + fn constructed(&self) { + self.parent_constructed(); + // Listen for any changes to our config. + self.obj() + .connect_config_notify(super::CommandPalette::prop_config); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("trigger") + .param_types([BoxedAction::static_type()]) + .build(), + ] + }) + } + + fn dispose(&self) { + self.source.remove_all(); + self.config.replace(None); + self.dispose_template(); + } + } + + impl WidgetImpl for CommandPalette {} + impl BinImpl for CommandPalette {} + + impl CommandPalette { + pub(super) fn collect_jump_commands(config: &Config, out: &mut Vec) { + let app = Application::default(); + for surface in app.surfaces() { + out.push(Command::new_jump(config, &surface)); + } + } + + pub(super) fn collect_regular_commands(config: &Config, out: &mut Vec) { + let cfg = config.get(); + for command in &cfg.command_palette_entry.value { + // Filter out actions that are not implemented or don't make + // sense for GTK. + if !is_action_supported_on_gtk(&command.action) { + continue; + } + out.push(Command::new(config, command)); + } + } + + pub(super) fn sort(commands: &mut [Command]) { + // std.mem.sort is stable; sort_by is stable too. + commands.sort_by(|a, b| { + if compare_commands(a, b) { + Ordering::Less + } else if compare_commands(b, a) { + Ordering::Greater + } else { + Ordering::Equal + } + }); + } + } + + #[gtk::template_callbacks] + impl CommandPalette { + #[template_callback] + fn closed(_dialog: &adw::Dialog, palette: &super::CommandPalette) { + // Drop the self reference taken in `new()`. + palette.imp().self_ref.replace(None); + } + + #[template_callback] + fn search_stopped(_entry: >k::SearchEntry, palette: &super::CommandPalette) { + // ESC was pressed - close the palette + palette.close(); + } + + #[template_callback] + fn search_activated(_entry: >k::SearchEntry, palette: &super::CommandPalette) { + // If Enter is pressed, activate the selected entry + let pos = palette.imp().model.selected(); + palette.activated(pos); + } + + #[template_callback] + fn row_activated(_view: >k::ListView, pos: u32, palette: &super::CommandPalette) { + palette.activated(pos); + } + } +} + +glib::wrapper! { + pub struct CommandPalette(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl CommandPalette { + /// Create a new instance of the command palette. The palette keeps + /// itself alive until its dialog is closed (the caller's reference is + /// independent). + pub fn new() -> Self { + crate::register_resources(); + let obj: Self = glib::Object::new(); + // Sink ourselves so that we aren't floating anymore. We'll drop + // this reference when the palette is closed or an action is + // activated. + obj.imp().self_ref.replace(Some(obj.clone())); + obj + } + + fn prop_config(&self) { + let imp = self.imp(); + let Some(config) = self.config() else { + log::warn!("command palette does not have a config!"); + return; + }; + + // Clear existing binds + imp.source.remove_all(); + + let mut commands: Vec = Vec::new(); + imp::CommandPalette::collect_jump_commands(&config, &mut commands); + imp::CommandPalette::collect_regular_commands(&config, &mut commands); + imp::CommandPalette::sort(&mut commands); + + for cmd in &commands { + imp.source.append(cmd); + } + } + + fn close(&self) { + self.imp().dialog.close(); + } + + /// Show or hide the command palette dialog. If the dialog is shown it + /// will be modal over the given window. + pub fn toggle(&self, window: &Window) { + let imp = self.imp(); + + // If the dialog has been shown, close it. + if imp.dialog.is_realized() { + self.close(); + return; + } + + // Show the dialog + imp.dialog.present(Some(window)); + + // Focus on the search bar when opening the dialog + imp.search.grab_focus(); + } + + /// Send the `trigger` signal for the item at `pos` of the filtered model. + fn activated(&self, pos: u32) { + let imp = self.imp(); + + // Use the model and not the source here to use the list of + // *visible* results + let object = imp.model.upcast_ref::().item(pos); + + // Close before running the action in order to avoid being replaced + // by another dialog (such as the change title dialog). If that + // occurs then the command palette dialog won't be counted as having + // closed properly and cannot receive focus when reopened. + self.close(); + + let Some(cmd) = object.and_then(|o| o.downcast::().ok()) else { + return; + }; + + // Handle jump commands differently + if cmd.is_jump() { + if let Some(surface) = cmd.get_jump_surface() { + surface.present(); + } + return; + } + + // Regular command - emit trigger signal + let Some(action) = cmd.get_action() else { + return; + }; + self.emit_by_name::<()>("trigger", &[&BoxedAction(action)]); + } + + /// Connect to the `trigger` signal, emitted when a command from the + /// palette is activated. + pub fn connect_trigger( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "trigger", + false, + glib::closure_local!(move |obj: &Self, action: BoxedAction| f(obj, &action.0)), + ) + } +} + +impl Default for CommandPalette { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn title_ordering_colon_normalization() { + // "Foo:" sorts before "Foo Bar:" + assert_eq!(title_less_than("Foo:", "Foo Bar:"), Some(true)); + assert_eq!(title_less_than("Foo Bar:", "Foo:"), Some(false)); + } + + #[test] + fn title_ordering_case_insensitive_and_prefix() { + assert_eq!(title_less_than("abc", "ABD"), Some(true)); + assert_eq!(title_less_than("abc", "ABC"), None); + assert_eq!(title_less_than("ab", "abc"), Some(true)); + } + + #[test] + fn unsupported_actions_filtered() { + assert!(!is_action_supported_on_gtk(&BindingAction::Undo)); + assert!(!is_action_supported_on_gtk(&BindingAction::CloseAllWindows)); + assert!(is_action_supported_on_gtk(&BindingAction::NewTab)); + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/config.rs b/crates/ghostty-apprt-gtk/src/class/config.rs new file mode 100644 index 0000000..08687e7 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/config.rs @@ -0,0 +1,136 @@ +//! Port of apprt/gtk/class/config.zig (`GhosttyConfig`). +//! +//! Wraps a core config in a GObject so it can be reference counted and bound +//! from blueprints (`diagnostics-buffer`, `has-diagnostics`). + +use std::cell::RefCell; +use std::sync::Arc; + +use ghostty_config::config::Config as CoreConfig; +use gtk::glib; +use gtk::glib::subclass::prelude::*; + +mod imp { + use super::{Arc, CoreConfig, RefCell}; + use gtk::glib; + use gtk::glib::prelude::*; + use gtk::glib::subclass::prelude::*; + use gtk::prelude::TextBufferExt; + + #[derive(glib::Properties)] + #[properties(wrapper_type = super::Config)] + pub struct Config { + pub config: RefCell>, + + #[property(name = "diagnostics-buffer", get = Self::diagnostics_buffer, type = Option)] + #[property(name = "has-diagnostics", get = Self::has_diagnostics, type = bool)] + _props: (), + } + + impl Default for Config { + fn default() -> Self { + Self { + config: RefCell::new(Arc::new(CoreConfig::new_default())), + _props: (), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for Config { + const NAME: &'static str = "GhosttyConfig"; + type Type = super::Config; + type ParentType = glib::Object; + } + + #[glib::derived_properties] + impl ObjectImpl for Config {} + + impl Config { + fn has_diagnostics(&self) -> bool { + !self.config.borrow().diagnostics.empty() + } + + fn diagnostics_buffer(&self) -> Option { + let config = self.config.borrow(); + if config.diagnostics.empty() { + return None; + } + let buf = gtk::TextBuffer::new(None); + let mut text = String::new(); + for diag in config.diagnostics.items() { + if let Err(err) = diag.format(&mut text) { + log::warn!("error writing diagnostic to buffer err={err}"); + continue; + } + text.push('\n'); + } + buf.set_text(&text); + Some(buf) + } + } +} + +glib::wrapper! { + /// `GhosttyConfig`: reference counted wrapper around a core config. + pub struct Config(ObjectSubclass); +} + +impl Config { + /// Create a new GhosttyConfig from a loaded configuration. This clones + /// the given configuration. + pub fn new(config: &CoreConfig) -> Self { + let obj: Self = glib::Object::new(); + *obj.imp().config.borrow_mut() = Arc::new(config.clone()); + obj + } + + /// Create a GhosttyConfig that takes ownership of an existing `Arc`. + pub fn from_arc(config: Arc) -> Self { + let obj: Self = glib::Object::new(); + *obj.imp().config.borrow_mut() = config; + obj + } + + /// Create a GhosttyConfig holding the default configuration. + pub fn new_default() -> Self { + Self::new(&CoreConfig::new_default()) + } + + /// Get the wrapped configuration (shared; cheap). + pub fn get(&self) -> Arc { + self.imp().config.borrow().clone() + } + + /// Run `f` with mutable access to the config (clones on write if the + /// `Arc` is shared). Changes aren't propagated to other holders. + pub fn get_mut(&self, f: impl FnOnce(&mut CoreConfig) -> R) -> R { + let mut guard = self.imp().config.borrow_mut(); + f(Arc::make_mut(&mut guard)) + } + + /// A deep copy of the wrapped configuration. + pub fn clone_config(&self) -> CoreConfig { + (*self.get()).clone() + } +} + +impl Default for Config { + fn default() -> Self { + Self::new_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ghostty_config() { + // No display needed; GObject only. + let obj = Config::new_default(); + assert!(!obj.has_diagnostics()); + assert!(obj.diagnostics_buffer().is_none()); + drop(obj); + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/config_errors_dialog.rs b/crates/ghostty-apprt-gtk/src/class/config_errors_dialog.rs new file mode 100644 index 0000000..d4aca5c --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/config_errors_dialog.rs @@ -0,0 +1,103 @@ +//! Port of apprt/gtk/class/config_errors_dialog.zig (`GhosttyConfigErrorsDialog`). +//! +//! Property `config` (GhosttyConfig); signal `reload-config`. + +use std::cell::RefCell; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; +use gtk::glib::subclass::Signal; +use std::sync::OnceLock; + +use super::config::Config; +use super::dialog::{Dialog, DialogImpl}; + +mod imp { + use super::{ + AdwAlertDialogImpl, AdwDialogImpl, Config, Dialog, DialogImpl, ObjectImpl, ObjectSubclass, + ObjectSubclassExt, OnceLock, RefCell, Signal, WidgetImpl, + }; + use gtk::glib; + use gtk::glib::prelude::*; + use gtk::subclass::prelude::*; + + #[derive(Default, glib::Properties, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::ConfigErrorsDialog)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.2/config-errors-dialog.ui")] + pub struct ConfigErrorsDialog { + #[property(get, set, nullable)] + pub config: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ConfigErrorsDialog { + const NAME: &'static str = "GhosttyConfigErrorsDialog"; + type Type = super::ConfigErrorsDialog; + type ParentType = Dialog; + + fn class_init(klass: &mut Self::Class) { + // Types referenced by the template must be registered first. + Config::ensure_type(); + Dialog::ensure_type(); + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ConfigErrorsDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("reload-config").build()]) + } + + fn dispose(&self) { + self.config.replace(None); + self.dispose_template(); + } + } + impl WidgetImpl for ConfigErrorsDialog {} + impl AdwDialogImpl for ConfigErrorsDialog {} + impl AdwAlertDialogImpl for ConfigErrorsDialog { + fn response(&self, response: &str) { + if response != "reload" { + return; + } + self.obj().emit_by_name::<()>("reload-config", &[]); + } + } + impl DialogImpl for ConfigErrorsDialog {} +} + +glib::wrapper! { + pub struct ConfigErrorsDialog(ObjectSubclass) + @extends Dialog, adw::AlertDialog, adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::ShortcutManager; +} + +impl ConfigErrorsDialog { + pub fn new(config: &Config) -> Self { + crate::register_resources(); + glib::Object::builder().property("config", config).build() + } + + pub fn present(&self, parent: Option<&impl IsA>) { + self.upcast_ref::().present(parent); + } + + pub fn close(&self) { + self.upcast_ref::().close(); + } + + pub fn connect_reload_config(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "reload-config", + false, + glib::closure_local!(move |obj: &Self| f(obj)), + ) + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/debug_warning.rs b/crates/ghostty-apprt-gtk/src/class/debug_warning.rs new file mode 100644 index 0000000..6051377 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/debug_warning.rs @@ -0,0 +1,55 @@ +//! Port of apprt/gtk/class/debug_warning.zig (`GhosttyDebugWarning`). +//! Uses the `adw::Banner` variant (libadwaita >= 1.3). + +use gtk::glib; + +mod imp { + use gtk::glib; + use gtk::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.3/debug-warning.ui")] + pub struct DebugWarning; + + #[glib::object_subclass] + impl ObjectSubclass for DebugWarning { + const NAME: &'static str = "GhosttyDebugWarning"; + type Type = super::DebugWarning; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for DebugWarning { + fn dispose(&self) { + self.dispose_template(); + } + } + impl WidgetImpl for DebugWarning {} + impl adw::subclass::prelude::BinImpl for DebugWarning {} +} + +glib::wrapper! { + pub struct DebugWarning(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl DebugWarning { + pub fn new() -> Self { + crate::register_resources(); + glib::Object::new() + } +} + +impl Default for DebugWarning { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/dialog.rs b/crates/ghostty-apprt-gtk/src/class/dialog.rs new file mode 100644 index 0000000..c48685e --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/dialog.rs @@ -0,0 +1,64 @@ +//! Port of apprt/gtk/class/dialog.zig (`GhosttyDialog`). +//! +//! Thin `adw::AlertDialog` subclass (we require libadwaita >= 1.5 so the +//! `adw::MessageDialog` fallback is not needed). Subclasses implement +//! [`DialogImpl`] and may override `response` via `AlertDialogImpl`. + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; + +mod imp { + use adw::subclass::prelude::*; + use gtk::glib; + + #[derive(Default)] + pub struct Dialog; + + #[glib::object_subclass] + impl ObjectSubclass for Dialog { + const NAME: &'static str = "GhosttyDialog"; + type Type = super::Dialog; + type ParentType = adw::AlertDialog; + } + + impl ObjectImpl for Dialog {} + impl WidgetImpl for Dialog {} + impl AdwDialogImpl for Dialog {} + impl AdwAlertDialogImpl for Dialog {} +} + +glib::wrapper! { + pub struct Dialog(ObjectSubclass) + @extends adw::AlertDialog, adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::ShortcutManager; +} + +/// Implemented by subclasses of [`Dialog`]. +pub trait DialogImpl: AdwAlertDialogImpl {} + +#[allow(unsafe_code)] +// SAFETY: `Dialog` adds no class/instance data of its own; we simply forward +// to the parent (`adw::AlertDialog`) class/instance initialization, which is +// valid for any `T: DialogImpl` (a subtype of `AdwAlertDialogImpl`). +unsafe impl IsSubclassable for Dialog { + fn class_init(class: &mut glib::Class) { + >::class_init(class.upcast_ref_mut()); + } + + fn instance_init(instance: &mut glib::subclass::InitializingObject) { + >::instance_init(instance); + } +} + +impl Dialog { + /// Present the dialog over `parent`. + pub fn present(&self, parent: Option<&impl IsA>) { + adw::prelude::AdwDialogExt::present(self, parent); + } + + /// Close the dialog without emitting `close-attempt`. + pub fn close(&self) { + self.force_close(); + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs b/crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs new file mode 100644 index 0000000..7bccd62 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs @@ -0,0 +1,454 @@ +//! `GhosttyGlobalShortcuts` (ZIG/src/apprt/gtk/class/global_shortcuts.zig): +//! registers `global:` keybinds with the XDG GlobalShortcuts portal over +//! D-Bus and emits `trigger` when one is activated. +//! +//! The `trigger` signal carries the binding action serialized in its keybind +//! syntax (a `String`); use [`GlobalShortcuts::connect_trigger`] to receive +//! the parsed [`ghostty_input::binding::Action`]. + +#![allow(deprecated)] // gio D-Bus signal_subscribe: replacement API is awkward for portal use + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::sync::OnceLock; + +use ghostty_input::binding::Action as BindingAction; +use ghostty_input::binding::set::Value as BindingValue; +use gtk::gio::{self, prelude::*}; +use gtk::glib::{self, subclass::Signal, subclass::prelude::*, variant::ObjectPath}; + +use crate::class::config::Config; + +mod imp { + use super::{BindingAction, Cell, Config, OnceLock, RefCell, Signal, gio, glib}; + use glib::prelude::*; + use glib::subclass::prelude::*; + + #[derive(glib::Properties, Default)] + #[properties(wrapper_type = super::GlobalShortcuts)] + pub struct GlobalShortcuts { + /// The configuration that this is using. + #[property(get, set = Self::set_config, explicit_notify, nullable)] + pub config: RefCell>, + + /// The dbus connection. + #[property(get, set = Self::set_dbus_connection, explicit_notify, nullable)] + pub dbus_connection: RefCell>, + + /// Unique ID (the xdg shortcut string) -> action. + pub map: RefCell>, + + /// The current portal session handle (D-Bus object path). + pub handle: RefCell>, + + pub response_subscription: RefCell>, + pub activate_subscription: RefCell>, + + /// Counter making sure async callbacks of stale sessions are ignored. + pub generation: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for GlobalShortcuts { + const NAME: &'static str = "GhosttyGlobalShortcuts"; + type Type = super::GlobalShortcuts; + type ParentType = glib::Object; + } + + impl GlobalShortcuts { + fn set_config(&self, config: Option) { + *self.config.borrow_mut() = config; + self.obj().notify_config(); + // propConfig + if let Err(err) = self.obj().refresh() { + log::warn!("error refreshing global shortcuts: {err}"); + } + } + + fn set_dbus_connection(&self, conn: Option) { + let obj = self.obj(); + // If we have a prior dbus connection we need to close our prior + // registrations first. + if self.dbus_connection.borrow().is_some() { + obj.close(); + *self.dbus_connection.borrow_mut() = None; + } + let has = conn.is_some(); + *self.dbus_connection.borrow_mut() = conn; + if has && let Err(err) = obj.refresh() { + log::warn!("error refreshing global shortcuts: {err}"); + } + obj.notify_dbus_connection(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for GlobalShortcuts { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + // Emitted whenever a global shortcut is triggered. The + // parameter is the action in keybind syntax. + Signal::builder("trigger") + .param_types([String::static_type()]) + .build(), + ] + }) + } + + fn dispose(&self) { + // Since we drop references here we may lose access to things like + // dbus connections, so we need to close all our connections right + // away instead of in finalize. + self.obj().close(); + *self.config.borrow_mut() = None; + *self.dbus_connection.borrow_mut() = None; + } + } +} + +glib::wrapper! { + pub struct GlobalShortcuts(ObjectSubclass); +} + +impl Default for GlobalShortcuts { + fn default() -> Self { + Self::new() + } +} + +/// The two portal methods we call. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Method { + CreateSession, + BindShortcuts, +} + +impl Method { + fn name(self) -> &'static str { + match self { + Method::CreateSession => "CreateSession", + Method::BindShortcuts => "BindShortcuts", + } + } +} + +type Token = String; + +/// Generate a random token suitable for use in requests +/// (`ghostty_` + 7 hex digits). +fn generate_token() -> Token { + format!("ghostty_{:07x}", glib::random_int_range(0, 1 << 28)) +} + +fn vardict(entries: Vec<(&str, glib::Variant)>) -> glib::Variant { + let map: HashMap = entries + .into_iter() + .map(|(k, v)| (k.to_owned(), v)) + .collect(); + map.to_variant() +} + +impl GlobalShortcuts { + pub fn new() -> Self { + glib::Object::new() + } + + /// Set the D-Bus connection (`setDbusConnection`). + pub fn set_connection(&self, conn: Option<&gio::DBusConnection>) { + self.set_dbus_connection(conn); + } + + /// Connect to the `trigger` signal. + pub fn connect_trigger( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "trigger", + false, + glib::closure_local!(move |this: &Self, action: String| { + match BindingAction::parse(&action) { + Ok(a) => f(this, &a), + Err(err) => log::warn!("invalid global shortcut action={action}: {err:?}"), + } + }), + ) + } + + fn close(&self) { + let imp = self.imp(); + let Some(dbus) = imp.dbus_connection.borrow().clone() else { + return; + }; + + if let Some(id) = imp.response_subscription.borrow_mut().take() { + dbus.signal_unsubscribe(id); + } + if let Some(id) = imp.activate_subscription.borrow_mut().take() { + dbus.signal_unsubscribe(id); + } + + if let Some(handle) = imp.handle.borrow_mut().take() { + // Close existing session + dbus.call( + Some("org.freedesktop.portal.Desktop"), + &handle, + "org.freedesktop.portal.Session", + "Close", + None, + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + |_| {}, + ); + } + + imp.generation.set(imp.generation.get() + 1); + imp.map.borrow_mut().clear(); + } + + #[allow(clippy::unnecessary_wraps)] + fn refresh(&self) -> Result<(), glib::Error> { + // Always close our previous state first. + self.close(); + + let imp = self.imp(); + + // We need configuration to proceed. + let Some(config) = imp.config.borrow().as_ref().map(Config::get) else { + return Ok(()); + }; + + // Update map + let mut map: Vec<(String, BindingAction)> = Vec::new(); + for (trigger, value) in &config.keybind.set.bindings { + let actions: &[BindingAction] = match value { + BindingValue::Leader(_) => continue, + BindingValue::Leaf(l) => { + if !l.flags.global { + continue; + } + std::slice::from_ref(&l.action) + } + BindingValue::LeafChained(l) => { + if !l.flags.global { + continue; + } + &l.actions + } + }; + + // We only allow global keybinds that map to exactly a single + // action for now. TODO: remove this restriction + if actions.len() != 1 { + continue; + } + + let Some(shortcut) = crate::key::xdg_shortcut_from_trigger(trigger.0) else { + continue; + }; + + if let Some(slot) = map.iter_mut().find(|(k, _)| *k == shortcut) { + slot.1 = actions[0].clone(); + } else { + map.push((shortcut, actions[0].clone())); + } + } + + let has = !map.is_empty(); + *imp.map.borrow_mut() = map; + + // Create our session if we have global shortcuts. + if has { + self.request(Method::CreateSession); + } + Ok(()) + } + + /// Build the payload for a portal method. + fn make_payload(&self, method: Method, request_token: &str) -> Option { + match method { + // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession + Method::CreateSession => { + let opts = vardict(vec![ + ("handle_token", request_token.to_variant().to_variant()), + ( + "session_handle_token", + generate_token().to_variant().to_variant(), + ), + ]); + Some((opts,).to_variant()) + } + // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts + Method::BindShortcuts => { + let imp = self.imp(); + let handle = imp.handle.borrow().clone()?; + let handle = ObjectPath::try_from(handle).ok()?; + + let binds: Vec<(String, glib::Variant)> = imp + .map + .borrow() + .iter() + .map(|(trigger, action)| { + let opts = vardict(vec![ + ("description", action.to_string().to_variant().to_variant()), + ("preferred_trigger", trigger.to_variant().to_variant()), + ]); + (trigger.clone(), opts) + }) + .collect(); + let opts = vardict(vec![( + "handle_token", + request_token.to_variant().to_variant(), + )]); + Some((handle, binds, String::new(), opts).to_variant()) + } + } + } + + fn on_response(&self, method: Method, dict: &HashMap) { + match method { + Method::CreateSession => { + let Some(handle) = dict.get("session_handle").and_then(|v| v.str()) else { + log::warn!("session handle not found in response={dict:?}"); + return; + }; + let imp = self.imp(); + let Some(dbus) = imp.dbus_connection.borrow().clone() else { + return; + }; + *imp.handle.borrow_mut() = Some(handle.to_owned()); + log::debug!("session_handle={handle}"); + + // Subscribe to keybind activations + let this = self.downgrade(); + let id = dbus.signal_subscribe( + None, + Some("org.freedesktop.portal.GlobalShortcuts"), + Some("Activated"), + Some("/org/freedesktop/portal/desktop"), + Some(handle), + gio::DBusSignalFlags::MATCH_ARG0_PATH, + move |_, _, _, _, _, params| { + if let Some(this) = this.upgrade() { + this.shortcut_activated(params); + } + }, + ); + *imp.activate_subscription.borrow_mut() = Some(id); + + self.request(Method::BindShortcuts); + } + Method::BindShortcuts => {} + } + } + + /// Submit a request to the global shortcuts portal. + /// + /// XDG portals require subscribing to the Request's `Response` signal at + /// a manually constructed object path *before* making the call (see the + /// long rant in the Zig source). + fn request(&self, method: Method) { + let imp = self.imp(); + let request_token = generate_token(); + let Some(payload) = self.make_payload(method, &request_token) else { + return; + }; + let Some(dbus) = imp.dbus_connection.borrow().clone() else { + return; + }; + let Some(request_path) = self.request_path(&dbus, &request_token) else { + return; + }; + + let generation = imp.generation.get(); + let this = self.downgrade(); + let id = dbus.signal_subscribe( + None, + Some("org.freedesktop.portal.Request"), + Some("Response"), + Some(&request_path), + None, + gio::DBusSignalFlags::NONE, + move |dbus, _, _, _, _, params| { + let Some(this) = this.upgrade() else { return }; + let imp = this.imp(); + if imp.generation.get() != generation { + return; + } + + // Unsubscribe from the response signal + if let Some(id) = imp.response_subscription.borrow_mut().take() { + dbus.signal_unsubscribe(id); + } + + let Some((response, dict)) = params.get::<(u32, HashMap)>() + else { + log::warn!("unexpected response payload type={}", params.type_()); + return; + }; + match response { + 0 => { + log::debug!("request successful"); + this.on_response(method, &dict); + } + 1 => log::debug!("request was cancelled by user"), + 2 => log::warn!("request ended unexpectedly"), + other => log::warn!("unrecognized response code={other}"), + } + }, + ); + // Zig asserts there is no prior subscription. + if let Some(old) = imp.response_subscription.borrow_mut().replace(id) { + dbus.signal_unsubscribe(old); + } + + dbus.call( + Some("org.freedesktop.portal.Desktop"), + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + method.name(), + Some(&payload), + None, + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + |res| { + if let Err(err) = res { + log::warn!("request failed={} ({:?})", err.message(), err); + } + // TODO(upstream): XDG recommends updating the signal + // subscription if the actual returned request path differs. + }, + ); + } + + /// The XDG portal request path for the current Ghostty instance. + #[allow(clippy::unused_self)] + fn request_path(&self, dbus: &gio::DBusConnection, token: &str) -> Option { + let unique = dbus.unique_name()?; + let unique = unique.strip_prefix(':').unwrap_or(&unique); + // Replace every `.` with `_`: `:1.192` -> `1_192`. + let unique = unique.replace('.', "_"); + Some(format!( + "/org/freedesktop/portal/desktop/request/{unique}/{token}" + )) + } + + fn shortcut_activated(&self, params: &glib::Variant) { + // 2nd value in the tuple is the activated shortcut ID. + let child = params.child_value(1); + let Some(id) = child.str() else { return }; + log::debug!("activated={id}"); + + let action = { + let map = self.imp().map.borrow(); + map.iter().find(|(k, _)| k == id).map(|(_, a)| a.clone()) + }; + let Some(action) = action else { return }; + self.emit_by_name::<()>("trigger", &[&action.to_string()]); + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/key_state_overlay.rs b/crates/ghostty-apprt-gtk/src/class/key_state_overlay.rs new file mode 100644 index 0000000..2a70aaa --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/key_state_overlay.rs @@ -0,0 +1,186 @@ +//! Port of apprt/gtk/class/key_state_overlay.zig. +//! +//! An overlay that displays the current key table stack and pending key +//! sequence. This helps users understand what key bindings are active and +//! what keys they've pressed in a multi-key sequence. +//! +//! Deviation: Zig's `ext.StringList` boxed type is replaced by `GStrv` +//! (`Vec`) for the `tables` and `sequence` properties. + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; + +glib::wrapper! { + pub struct KeyStateOverlay(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for KeyStateOverlay { + fn default() -> Self { + Self::new() + } +} + +impl KeyStateOverlay { + #[must_use] + pub fn new() -> Self { + glib::Object::new() + } +} + +mod imp { + use std::cell::{Cell, RefCell}; + + #[allow(clippy::wildcard_imports)] + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties)] + #[properties(wrapper_type = super::KeyStateOverlay)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.2/key-state-overlay.ui")] + pub struct KeyStateOverlay { + /// The key table stack. + #[property(get, set = Self::set_tables)] + pub tables: RefCell>, + + /// The key sequence. + #[property(get, set = Self::set_sequence)] + pub sequence: RefCell>, + + #[property(get = Self::has_tables, type = bool, name = "has-tables")] + _has_tables: (), + #[property(get = Self::has_sequence, type = bool, name = "has-sequence")] + _has_sequence: (), + + /// Target vertical alignment for the overlay. + #[property(get, set, name = "valign-target", builder(gtk::Align::End))] + pub valign_target: Cell, + } + + impl Default for KeyStateOverlay { + fn default() -> Self { + Self { + tables: RefCell::default(), + sequence: RefCell::default(), + _has_tables: (), + _has_sequence: (), + valign_target: Cell::new(gtk::Align::End), + } + } + } + + impl KeyStateOverlay { + fn set_tables(&self, v: Vec) { + self.tables.replace(v); + // `tables` itself is notified by the derive; has-tables is ours. + self.obj().notify_has_tables(); + } + + fn set_sequence(&self, v: Vec) { + self.sequence.replace(v); + self.obj().notify_has_sequence(); + } + + fn has_tables(&self) -> bool { + !self.tables.borrow().is_empty() + } + + fn has_sequence(&self) -> bool { + !self.sequence.borrow().is_empty() + } + } + + #[gtk::template_callbacks] + impl KeyStateOverlay { + #[template_callback(function)] + fn show_chevron(has_tables: bool, has_sequence: bool) -> bool { + has_tables && has_sequence + } + + #[template_callback(function)] + fn has_state(has_tables: bool, has_sequence: bool) -> bool { + has_tables || has_sequence + } + + #[template_callback(function)] + #[allow(clippy::needless_pass_by_value)] + fn tables_text(tables: Vec) -> Option { + if tables.is_empty() { + return None; + } + Some(tables.join(" > ")) + } + + #[template_callback(function)] + #[allow(clippy::needless_pass_by_value)] + fn sequence_text(sequence: Vec) -> Option { + if sequence.is_empty() { + return None; + } + Some(sequence.join(" ")) + } + + #[template_callback] + fn on_drag_end(gesture: >k::GestureDrag, _offset_x: f64, offset_y: f64) { + // Key state overlay only moves between top-center and + // bottom-center. Horizontal alignment is always center. + let Some(widget) = gesture.widget() else { + return; + }; + let Some(this) = widget.downcast_ref::() else { + return; + }; + let Some(parent) = widget.parent() else { + return; + }; + + let parent_height = f64::from(parent.height()); + let self_height = f64::from(widget.height()); + + let self_y = if this.valign_target() == gtk::Align::Start { + 0.0 + } else { + parent_height - self_height + }; + let new_y = self_y + offset_y + (self_height / 2.0); + + let new_valign = if new_y > parent_height / 2.0 { + gtk::Align::End + } else { + gtk::Align::Start + }; + + if new_valign != this.valign_target() { + this.set_valign_target(new_valign); + widget.queue_resize(); + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for KeyStateOverlay { + const NAME: &'static str = "GhosttyKeyStateOverlay"; + type Type = super::KeyStateOverlay; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for KeyStateOverlay { + fn dispose(&self) { + self.dispose_template(); + } + } + + impl WidgetImpl for KeyStateOverlay {} + impl BinImpl for KeyStateOverlay {} +} diff --git a/crates/ghostty-apprt-gtk/src/class/mod.rs b/crates/ghostty-apprt-gtk/src/class/mod.rs new file mode 100644 index 0000000..abff38b --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/mod.rs @@ -0,0 +1,21 @@ +//! GObject classes (ZIG/src/apprt/gtk/class.zig and class/*.zig). + +pub mod application; +pub mod clipboard_confirmation_dialog; +pub mod close_confirmation_dialog; +pub mod command_palette; +pub mod config; +pub mod config_errors_dialog; +pub mod debug_warning; +pub mod dialog; +pub mod global_shortcuts; +pub mod key_state_overlay; +pub mod resize_overlay; +pub mod search_overlay; +pub mod split_tree; +pub mod surface; +pub mod surface_child_exited; +pub mod surface_scrolled_window; +pub mod tab; +pub mod title_dialog; +pub mod window; diff --git a/crates/ghostty-apprt-gtk/src/class/resize_overlay.rs b/crates/ghostty-apprt-gtk/src/class/resize_overlay.rs new file mode 100644 index 0000000..0fd178b --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/resize_overlay.rs @@ -0,0 +1,196 @@ +//! Port of apprt/gtk/class/resize_overlay.zig. +//! +//! The overlay that shows the current size while a surface is resizing. +//! This can be used generically to show pretty much anything with a +//! disappearing overlay. +//! +//! General usage: +//! +//! 1. Add it to an overlay +//! 2. Set the label with `set_label` +//! 3. Schedule to show it with `schedule` + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; + +glib::wrapper! { + pub struct ResizeOverlay(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for ResizeOverlay { + fn default() -> Self { + Self::new() + } +} + +impl ResizeOverlay { + #[must_use] + pub fn new() -> Self { + glib::Object::new() + } + + /// Schedule the overlay to be shown. To avoid flickering during + /// resizes we schedule the overlay to be shown on the next idle tick. + pub fn schedule(&self) { + let imp = self.imp(); + + // If we have a delay timer then we're not showing anything yet. + if imp.delay_timer.borrow().is_some() { + return; + } + + // Wait until GTK is idle (we're called in the middle of resizing). + if imp.idler.borrow().is_some() { + return; + } + let weak = self.downgrade(); + let id = glib::idle_add_local_once(move || { + if let Some(this) = weak.upgrade() { + this.on_idle(); + } + }); + imp.idler.replace(Some(id)); + } + + fn on_idle(&self) { + let imp = self.imp(); + // The idler is complete no matter what. + imp.idler.replace(None); + + // Cancel our previous show timer no matter what. + if let Some(t) = imp.timer.take() { + t.remove(); + } + + // If we have a label to show, show ourselves; otherwise hide. + let Some(text) = self.label() else { + self.set_visible(false); + return; + }; + + imp.label.set_label(&text); + self.set_visible(true); + + // Setup the new timer to hide ourselves after the delay. + let weak = self.downgrade(); + let id = glib::timeout_add_local_once( + std::time::Duration::from_millis(u64::from(self.duration())), + move || { + if let Some(this) = weak.upgrade() { + this.imp().timer.replace(None); + this.set_visible(false); + } + }, + ); + imp.timer.replace(Some(id)); + } +} + +mod imp { + use std::cell::{Cell, RefCell}; + + #[allow(clippy::wildcard_imports)] + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties)] + #[properties(wrapper_type = super::ResizeOverlay)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.2/resize-overlay.ui")] + pub struct ResizeOverlay { + /// The label with the text + #[template_child] + pub label: TemplateChild, + + /// The time that the overlay appears (ms). + #[property(get, set, minimum = 250, default = 750)] + pub duration: Cell, + + /// The first delay before any overlay is shown (ms). + #[property(get, set, minimum = 250, default = 250, name = "first-delay")] + pub first_delay: Cell, + + /// The text to set on the label when scheduled. + #[property(get, set, name = "label", nullable)] + pub label_text: RefCell>, + + /// The alignment of the label + #[property(get, set, name = "overlay-halign", builder(gtk::Align::Center))] + pub halign: Cell, + #[property(get, set, name = "overlay-valign", builder(gtk::Align::Center))] + pub valign: Cell, + + pub idler: RefCell>, + pub timer: RefCell>, + pub delay_timer: RefCell>, + } + + impl Default for ResizeOverlay { + fn default() -> Self { + Self { + label: TemplateChild::default(), + duration: Cell::new(750), + first_delay: Cell::new(250), + label_text: RefCell::new(None), + halign: Cell::new(gtk::Align::Center), + valign: Cell::new(gtk::Align::Center), + idler: RefCell::new(None), + timer: RefCell::new(None), + delay_timer: RefCell::new(None), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for ResizeOverlay { + const NAME: &'static str = "GhosttyResizeOverlay"; + type Type = super::ResizeOverlay; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ResizeOverlay { + fn constructed(&self) { + self.parent_constructed(); + // The template sets `first-delay`; start the initial delay timer. + let delay = self.first_delay.get(); + if delay > 0 { + let weak = self.obj().downgrade(); + let id = glib::timeout_add_local_once( + std::time::Duration::from_millis(u64::from(delay)), + move || { + if let Some(this) = weak.upgrade() { + this.imp().delay_timer.replace(None); + } + }, + ); + self.delay_timer.replace(Some(id)); + } + } + + fn dispose(&self) { + if let Some(v) = self.idler.take() { + v.remove(); + } + if let Some(v) = self.timer.take() { + v.remove(); + } + if let Some(v) = self.delay_timer.take() { + v.remove(); + } + self.dispose_template(); + } + } + + impl WidgetImpl for ResizeOverlay {} + impl BinImpl for ResizeOverlay {} +} diff --git a/crates/ghostty-apprt-gtk/src/class/search_overlay.rs b/crates/ghostty-apprt-gtk/src/class/search_overlay.rs new file mode 100644 index 0000000..d052f9f --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/search_overlay.rs @@ -0,0 +1,371 @@ +//! Port of apprt/gtk/class/search_overlay.zig. +//! +//! The terminal search bar overlay. Emits `stop-search`, `search-changed` +//! (with the entry text, may be `None`), `next-match` and `previous-match`. + +use std::sync::OnceLock; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib::subclass::Signal; +use gtk::{gdk, glib}; + +glib::wrapper! { + pub struct SearchOverlay(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for SearchOverlay { + fn default() -> Self { + Self::new() + } +} + +impl SearchOverlay { + #[must_use] + pub fn new() -> Self { + glib::Object::new() + } + + /// Grab focus on the search entry and select all text. + pub fn grab_focus_entry(&self) { + let entry = &self.imp().search_entry; + entry.grab_focus(); + // -1 is distance from the end, causing the entire text to be selected. + entry.select_region(0, -1); + } + + /// Set contents of search and emit `search-changed`. + pub fn set_search_contents(&self, content: &str) { + self.imp().search_entry.set_text(content); + self.emit_by_name::<()>("search-changed", &[&Some(content.to_owned())]); + } + + /// Set the total number of search matches. + pub fn set_search_total(&self, total: Option) { + let imp = self.imp(); + let had_total = imp.search_total.get().is_some(); + if imp.search_total.get() == total { + return; + } + imp.search_total.set(total); + self.notify_search_total(); + if had_total != total.is_some() { + self.notify_has_search_total(); + } + } + + /// Set the currently selected match index. + pub fn set_search_selected(&self, selected: Option) { + let imp = self.imp(); + let had_selected = imp.search_selected.get().is_some(); + if imp.search_selected.get() == selected { + return; + } + imp.search_selected.set(selected); + self.notify_search_selected(); + if had_selected != selected.is_some() { + self.notify_has_search_selected(); + } + } + + pub fn connect_stop_search(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "stop-search", + false, + glib::closure_local!(move |this: &Self| f(this)), + ) + } + + pub fn connect_search_changed) + 'static>( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "search-changed", + false, + glib::closure_local!(move |this: &Self, text: Option| f(this, text)), + ) + } + + pub fn connect_next_match(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "next-match", + false, + glib::closure_local!(move |this: &Self| f(this)), + ) + } + + pub fn connect_previous_match(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "previous-match", + false, + glib::closure_local!(move |this: &Self| f(this)), + ) + } +} + +mod imp { + use std::cell::Cell; + + #[allow(clippy::wildcard_imports)] + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties)] + #[properties(wrapper_type = super::SearchOverlay)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.2/search-overlay.ui")] + pub struct SearchOverlay { + /// The search entry widget. + #[template_child] + pub search_entry: TemplateChild, + + /// True when a search is active, meaning we should show the overlay. + #[property(get, set = Self::set_active)] + pub active: Cell, + + /// Total number of search matches (None means unknown/none). + pub search_total: Cell>, + + /// Currently selected match index (None means none selected). + pub search_selected: Cell>, + + #[property(get = Self::get_search_total, type = u64, name = "search-total")] + _p_total: (), + #[property(get = Self::get_has_search_total, type = bool, name = "has-search-total")] + _p_has_total: (), + #[property(get = Self::get_search_selected, type = u64, name = "search-selected")] + _p_selected: (), + #[property(get = Self::get_has_search_selected, type = bool, name = "has-search-selected")] + _p_has_selected: (), + + /// Target horizontal alignment for the overlay. + #[property(get, set, name = "halign-target", builder(gtk::Align::End))] + pub halign_target: Cell, + + /// Target vertical alignment for the overlay. + #[property(get, set, name = "valign-target", builder(gtk::Align::Start))] + pub valign_target: Cell, + } + + impl Default for SearchOverlay { + fn default() -> Self { + Self { + search_entry: TemplateChild::default(), + active: Cell::new(false), + search_total: Cell::new(None), + search_selected: Cell::new(None), + _p_total: (), + _p_has_total: (), + _p_selected: (), + _p_has_selected: (), + halign_target: Cell::new(gtk::Align::End), + valign_target: Cell::new(gtk::Align::Start), + } + } + } + + impl SearchOverlay { + // Set active status, and update search on activation + fn set_active(&self, active: bool) { + if !self.active.get() && active { + let text = self.search_entry.text().to_string(); + self.obj() + .emit_by_name::<()>("search-changed", &[&Some(text)]); + } + self.active.set(active); + } + + fn get_search_total(&self) -> u64 { + self.search_total.get().unwrap_or(0) as u64 + } + + fn get_has_search_total(&self) -> bool { + self.search_total.get().is_some() + } + + fn get_search_selected(&self) -> u64 { + self.search_selected.get().unwrap_or(0) as u64 + } + + fn get_has_search_selected(&self) -> bool { + self.search_selected.get().is_some() + } + } + + /// Find the overlay that contains `w` (the Zig callbacks receive it as + /// user data). + fn overlay_of(w: &impl IsA) -> Option { + w.ancestor(super::SearchOverlay::static_type()) + .and_downcast() + } + + #[gtk::template_callbacks] + impl SearchOverlay { + #[template_callback(function)] + fn match_label_closure( + has_selected: bool, + selected: u64, + has_total: bool, + total: u64, + ) -> String { + if !has_total || total == 0 { + return "0/0".to_owned(); + } + format!("{}/{}", if has_selected { selected + 1 } else { 0 }, total) + } + + #[template_callback] + fn search_changed(entry: >k::SearchEntry) { + let Some(this) = overlay_of(entry) else { + return; + }; + let text = entry.text().to_string(); + this.emit_by_name::<()>("search-changed", &[&Some(text)]); + } + + // NOTE: shared with multiple widgets in the template. + #[template_callback] + fn stop_search(w: >k::Widget) { + if let Some(this) = overlay_of(w) { + this.emit_by_name::<()>("stop-search", &[]); + } + } + + #[template_callback] + fn next_match(w: >k::Widget) { + if let Some(this) = overlay_of(w) { + this.emit_by_name::<()>("next-match", &[]); + } + } + + #[template_callback] + fn previous_match(w: >k::Widget) { + if let Some(this) = overlay_of(w) { + this.emit_by_name::<()>("previous-match", &[]); + } + } + + #[template_callback] + fn search_entry_key_pressed( + ctrl: >k::EventControllerKey, + keyval: gdk::Key, + _keycode: u32, + mods: gdk::ModifierType, + ) -> bool { + let Some(this) = ctrl.widget().and_then(|w| overlay_of(&w)) else { + return false; + }; + if keyval == gdk::Key::Return || keyval == gdk::Key::KP_Enter { + if mods.contains(gdk::ModifierType::SHIFT_MASK) { + this.emit_by_name::<()>("previous-match", &[]); + } else { + this.emit_by_name::<()>("next-match", &[]); + } + return true; + } + false + } + + #[template_callback] + fn on_drag_end(gesture: >k::GestureDrag, offset_x: f64, offset_y: f64) { + // On drag end, move halign/valign if we crossed the midpoint on + // either axis. This lets the overlay be moved to different + // corners of the parent container. + let Some(widget) = gesture.widget() else { + return; + }; + let Some(this) = widget.downcast_ref::() else { + return; + }; + let Some(parent) = widget.parent() else { + return; + }; + + let parent_width = f64::from(parent.width()); + let parent_height = f64::from(parent.height()); + let self_width = f64::from(widget.width()); + let self_height = f64::from(widget.height()); + + let self_x = if this.halign_target() == gtk::Align::Start { + 0.0 + } else { + parent_width - self_width + }; + let self_y = if this.valign_target() == gtk::Align::Start { + 0.0 + } else { + parent_height - self_height + }; + + let new_x = self_x + offset_x + (self_width / 2.0); + let new_y = self_y + offset_y + (self_height / 2.0); + + let new_halign = if new_x > parent_width / 2.0 { + gtk::Align::End + } else { + gtk::Align::Start + }; + let new_valign = if new_y > parent_height / 2.0 { + gtk::Align::End + } else { + gtk::Align::Start + }; + + let mut changed = false; + if new_halign != this.halign_target() { + this.set_halign_target(new_halign); + changed = true; + } + if new_valign != this.valign_target() { + this.set_valign_target(new_valign); + changed = true; + } + if changed { + widget.queue_resize(); + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for SearchOverlay { + const NAME: &'static str = "GhosttySearchOverlay"; + type Type = super::SearchOverlay; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for SearchOverlay { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + // Emitted when the search is stopped (e.g., Escape pressed). + Signal::builder("stop-search").build(), + // Emitted when the search text changes. + Signal::builder("search-changed") + .param_types([Option::::static_type()]) + .build(), + Signal::builder("next-match").build(), + Signal::builder("previous-match").build(), + ] + }) + } + + fn dispose(&self) { + self.dispose_template(); + } + } + + impl WidgetImpl for SearchOverlay {} + impl BinImpl for SearchOverlay {} +} diff --git a/crates/ghostty-apprt-gtk/src/class/split_tree.rs b/crates/ghostty-apprt-gtk/src/class/split_tree.rs new file mode 100644 index 0000000..31f74ab --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/split_tree.rs @@ -0,0 +1,969 @@ +//! Port of apprt/gtk/class/split_tree.zig: `GhosttySplitTree` and the +//! internal `GhosttySplitTreeSplit` widget. +//! +//! The Zig `tree` property is a boxed pointer that is mutated in place for +//! zoom/resize. Here the tree lives in a `RefCell>` inside the +//! widget and is exposed as a [`SurfaceTree`] boxed value (a clone) through +//! the `tree` property; in-place mutation goes through private helpers. + +#![allow(clippy::wildcard_imports)] + +use std::cell::{Cell, RefCell}; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_datastruct::split_tree::{ + Direction, Goto, Handle, Layout, Node, SplitError, SplitTree as DsSplitTree, SplitTreeView, +}; +use gtk::gio; +use gtk::glib::{self, clone, subclass::Signal}; +use half::f16; + +use super::application::Application; +use super::close_confirmation_dialog::{CloseConfirmationDialog, Target as CloseTarget}; +use super::surface::{Surface, SurfaceOverrides}; +use super::surface_scrolled_window::SurfaceScrolledWindow; + +/// The split tree of surfaces (Zig: `Surface.Tree`). +pub type Tree = DsSplitTree; + +/// Boxed GObject wrapper for [`Tree`] (Zig: `Surface.Tree.getGObjectType`). +#[derive(Clone, Debug, glib::Boxed)] +#[boxed_type(name = "GhosttySurfaceTree", nullable)] +pub struct SurfaceTree(pub Tree); + +impl SplitTreeView for Surface {} + +/// Find the first ancestor of `widget` of type `T` (Zig: `ext.getAncestor`). +fn get_ancestor>(widget: &impl IsA) -> Option { + widget + .ancestor(T::static_type()) + .and_then(|w| w.downcast::().ok()) +} + +glib::wrapper! { + /// A tree of surfaces rendered as nested `GtkPaned`s. + pub struct SplitTree(ObjectSubclass) + @extends gtk::Box, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +impl Default for SplitTree { + fn default() -> Self { + glib::Object::new() + } +} + +impl SplitTree { + pub fn new() -> Self { + Self::default() + } + + /// Create a new split in the given direction from the currently active + /// surface. If the tree is empty this creates a new tree with a new + /// surface and ignores the direction. `parent` is used to inherit + /// surface properties regardless of whether it is in this tree. + pub fn new_split( + &self, + direction: Direction, + parent: Option<&Surface>, + overrides: SurfaceOverrides, + ) -> Result<(), SplitError> { + let imp = self.imp(); + + // Create our new surface. + let surface = Surface::new(overrides); + + // Inherit properly if we were asked to. + if let Some(p) = parent { + surface.set_parent(p, ghostty_apprt::surface::NewSurfaceContext::Split); + } + + // Bind is-split property for new surface + self.bind_property("is-split", &surface, "is-split") + .sync_create() + .build(); + + let single_tree = Tree::new(&surface); + + // We want to move our focus to the new surface no matter what. + // But we need to be careful to restore state if we fail. + let old_last_focused = imp.last_focused.upgrade(); + imp.last_focused.set(Some(&surface)); + + // If we have no tree yet, then this becomes our tree and we're done. + let Some(old_tree) = self.get_tree() else { + self.set_tree(Some(&single_tree)); + return Ok(()); + }; + + // The handle we create the split relative to. Today this is the + // active surface but this might be the handle of the given parent. + let handle = self.get_active_surface_handle().unwrap_or(Handle::ROOT); + + let new_tree = match old_tree.split(handle, direction, f16::from_f32(0.5), &single_tree) { + Ok(t) => t, + Err(e) => { + imp.last_focused.set(old_last_focused.as_ref()); + return Err(e); + } + }; + log::debug!( + "new split at={handle:?} direction={direction:?} old_tree={old_tree} new_tree={new_tree}" + ); + + self.set_tree(Some(&new_tree)); + Ok(()) + } + + /// Resize the active surface's split by `amount` pixels in `direction`. + pub fn resize(&self, direction: Direction, amount: u16) -> Result { + // Avoid useless work + if amount == 0 { + return Ok(false); + } + + let Some(old_tree) = self.get_tree() else { + return Ok(false); + }; + let Some(active) = self.get_active_surface_handle() else { + return Ok(false); + }; + + let bin = self.imp().tree_bin.get(); + let width = bin.width(); + let height = bin.height(); + if width == 0 || height == 0 { + return Ok(false); + } + let width_f64 = f64::from(width); + let height_f64 = f64::from(height); + let amount_f64 = f64::from(amount); + + let ratio = match direction { + Direction::Right => amount_f64 / width_f64, + Direction::Left => -(amount_f64 / width_f64), + Direction::Down => amount_f64 / height_f64, + Direction::Up => -(amount_f64 / height_f64), + }; + let layout = match direction { + Direction::Left | Direction::Right => Layout::Horizontal, + Direction::Up | Direction::Down => Layout::Vertical, + }; + + let new_tree = old_tree.resize(active, layout, f16::from_f64(ratio)); + self.set_tree(Some(&new_tree)); + Ok(true) + } + + /// Move focus from the currently focused surface in the given + /// direction. Returns true if focus switched to a new surface. + pub fn goto(&self, to: Goto) -> bool { + let imp = self.imp(); + let Some(tree) = self.get_tree() else { + return false; + }; + let Some(active) = self.get_active_surface_handle() else { + return false; + }; + let Some(target) = tree.goto(active, to) else { + return false; + }; + + // If we aren't changing targets then we did nothing. + if active == target { + return false; + } + + // Get the surface at the target location and grab focus. + let Node::Leaf(surface) = &tree.nodes()[target.idx()] else { + return false; + }; + surface.grab_focus(); + + // We also need to setup our last_focused to this because if we + // trigger a tree change like below, the grab focus above never + // actually triggers in time to set this and this ensures we + // grab focus to the right thing. + imp.last_focused.set(Some(surface)); + + if tree.zoomed().is_some() { + let config_obj = Application::default().get_config(); + let config = config_obj.get(); + + { + let mut guard = imp.tree.borrow_mut(); + if let Some(t) = guard.as_mut() { + if config.split_preserve_zoom.navigation { + t.zoom(Some(target)); + } else { + t.zoom(None); + } + } + } + + // When the zoom state changes our tree state changes and we + // need to send the proper notifications to trigger relayout. + self.notify("tree"); + self.notify("is-zoomed"); + } + + true + } + + /// Returns true if this split tree needs confirmation before quitting + /// based on the various Ghostty configurations. + pub fn get_needs_confirm_quit(&self) -> bool { + let Some(tree) = self.get_tree() else { + return false; + }; + tree.iterator() + .any(|e| e.view.core().is_some_and(|c| c.needs_confirm_quit())) + } + + /// Get the currently active surface. See the "active-surface" property. + pub fn get_active_surface(&self) -> Option { + let tree = self.get_tree()?; + let handle = self.get_active_surface_handle()?; + match &tree.nodes()[handle.idx()] { + Node::Leaf(s) => Some(s.clone()), + Node::Split(_) => None, + } + } + + fn get_active_surface_handle(&self) -> Option { + let imp = self.imp(); + let guard = imp.tree.borrow(); + let tree = guard.as_ref()?; + if let Some(e) = tree.iterator().find(|e| e.view.property::("focused")) { + return Some(e.handle); + } + + // If none are currently focused, the most previously focused + // surface (if it exists) is our active surface. This lets things + // like apprt actions and bell ringing continue to work in the + // background. + let last = imp.last_focused.upgrade()?; + tree.iterator().find(|e| *e.view == last).map(|e| e.handle) + } + + /// Returns the last focused surface in the tree. + pub fn get_last_focused_surface(&self) -> Option { + self.imp().last_focused.upgrade() + } + + pub fn get_has_surfaces(&self) -> bool { + self.imp() + .tree + .borrow() + .as_ref() + .is_some_and(|t| !t.is_empty()) + } + + pub fn get_is_zoomed(&self) -> bool { + self.imp() + .tree + .borrow() + .as_ref() + .is_some_and(|t| t.zoomed().is_some()) + } + + pub fn get_is_split(&self) -> bool { + self.imp() + .tree + .borrow() + .as_ref() + .is_some_and(Tree::is_split) + } + + /// Get the tree data model shown in this widget. This returns a clone + /// (views are reference counted); mutations must go through + /// [`SplitTree::set_tree`]. + pub fn get_tree(&self) -> Option { + self.imp().tree.borrow().clone() + } + + /// Set the tree data model shown in this widget (clones the tree). + /// Empty trees are normalized to `None`. + pub fn set_tree(&self, tree: Option<&Tree>) { + let imp = self.imp(); + + let tree = tree.filter(|t| !t.is_empty()); + + // Emit the signal so that handlers can witness both the before and + // after values of the tree. + let old = imp.tree.borrow().clone().map(SurfaceTree); + let new = tree.cloned().map(SurfaceTree); + self.emit_by_name::<()>("changed", &[&old, &new]); + + if imp.tree.borrow().is_some() { + self.disconnect_surface_handlers(); + imp.tree.replace(None); + } + + if let Some(new_tree) = tree { + debug_assert!(imp.tree.borrow().is_none()); + imp.tree.replace(Some(new_tree.clone())); + self.connect_surface_handlers(); + } + + self.notify("tree"); + } + + /// Update a split ratio in place (used by `SplitTreeSplit`). + fn resize_in_place(&self, handle: Handle, ratio: f16) { + if let Some(t) = self.imp().tree.borrow_mut().as_mut() { + t.resize_in_place(handle, ratio); + } + } + + fn disconnect_surface_handlers(&self) { + for (surface, ids) in self.imp().handlers.take() { + if let Some(s) = surface.upgrade() { + for id in ids { + s.disconnect(id); + } + } + } + } + + fn connect_surface_handlers(&self) { + let Some(tree) = self.get_tree() else { return }; + let mut out = Vec::new(); + for entry in tree.iterator() { + let surface = entry.view; + let id1 = surface.connect_local( + "close-request", + false, + clone!( + #[weak(rename_to = this)] + self, + #[weak] + surface, + #[upgrade_or] + None, + move |_| { + this.surface_close_request(&surface); + None + } + ), + ); + let id2 = surface.connect_notify_local( + Some("focused"), + clone!( + #[weak(rename_to = this)] + self, + move |surface, _| this.prop_surface_focused(surface) + ), + ); + out.push((surface.downgrade(), vec![id1, id2])); + } + self.imp().handlers.replace(out); + } + + //--------------------------------------------------------------- + // Signal handlers + + fn surface_close_request(&self, surface: &Surface) { + let Some(core) = surface.core() else { return }; + let imp = self.imp(); + + // Reset our pending close state + imp.pending_close.set(None); + + // Find the surface in the tree to verify this is valid and set + // our pending close handle. + let handle = { + let guard = imp.tree.borrow(); + let Some(tree) = guard.as_ref() else { return }; + let Some(e) = tree.iterator().find(|e| e.view == surface) else { + return; + }; + e.handle + }; + imp.pending_close.set(Some(handle)); + + // If we don't need to confirm then just close immediately. + if !core.needs_confirm_quit() { + self.close_confirmation_close(); + return; + } + + // Show a confirmation dialog + let dialog = CloseConfirmationDialog::new(CloseTarget::Surface); + dialog.connect_close_request(clone!( + #[weak(rename_to = this)] + self, + move |_| this.close_confirmation_close() + )); + dialog.present(Some(self.upcast_ref::())); + } + + fn close_confirmation_close(&self) { + // Get the handle we're closing + let imp = self.imp(); + let Some(handle) = imp.pending_close.take() else { + return; + }; + + // Figure out our next focus target. The next focus target is + // always the "previous" surface unless we're the leftmost then + // its the next. + let Some(old_tree) = self.get_tree() else { + return; + }; + let next_focus: Option = (|| { + let next_handle = old_tree + .goto(handle, Goto::Previous) + .or_else(|| old_tree.goto(handle, Goto::Next))?; + if next_handle == handle { + return None; + } + match &old_tree.nodes()[next_handle.idx()] { + Node::Leaf(s) => Some(s.clone()), + Node::Split(_) => None, + } + })(); + + // Remove it from the tree. + let new_tree = old_tree.remove(handle); + self.set_tree(Some(&new_tree)); + + // Grab focus. We have to set this on the "last focused" because our + // focus will be set when the tree is redrawn. + if let Some(v) = next_focus { + imp.last_focused.set(Some(&v)); + } + } + + fn prop_surface_focused(&self, surface: &Surface) { + // We never CLEAR our last_focused because the property is + // specifically the last focused surface. The weakref clears itself + // when the surface is destroyed. + if !surface.property::("focused") { + return; + } + self.imp().last_focused.set(Some(surface)); + + // Our active surface probably changed + self.notify("active-surface"); + } + + fn prop_tree(&self) { + let imp = self.imp(); + + // No matter what we notify + let _freeze = self.freeze_notify(); + self.notify("has-surfaces"); + self.notify("is-zoomed"); + + // If we were planning a rebuild, always remove that so we can + // start from a clean slate. + if let Some(id) = imp.rebuild_source.take() { + id.remove(); + } + + // If we transitioned to an empty tree, clear immediately instead of + // waiting for an idle callback. + if imp.tree.borrow().is_none() { + imp.tree_bin.set_child(gtk::Widget::NONE); + return; + } + + // Build on an idle callback so rapid tree changes are debounced. + // We keep the existing tree attached until the rebuild runs, + // which avoids transient empty frames. + let id = glib::idle_add_local_full( + glib::Priority::DEFAULT_IDLE, + clone!( + #[weak(rename_to = this)] + self, + #[upgrade_or] + glib::ControlFlow::Break, + move || { + this.on_rebuild(); + glib::ControlFlow::Break + } + ), + ); + imp.rebuild_source.replace(Some(id)); + } + + fn on_rebuild(&self) { + let imp = self.imp(); + + // Always mark our rebuild source as null since we're done. (The + // source is removed automatically by returning Break; dropping the + // id without removing it is fine.) + imp.rebuild_source.take(); + + // Rebuild our tree + match self.get_tree() { + None => imp.tree_bin.set_child(gtk::Widget::NONE), + Some(tree) => { + let widget = Self::build_tree(&tree, tree.zoomed().unwrap_or(Handle::ROOT)); + imp.tree_bin.set_child(Some(&widget)); + } + } + + // Replacing our tree widget hierarchy can reset focus state. + // If we have a last-focused surface, restore focus to it. + if let Some(v) = imp.last_focused.upgrade() { + v.grab_focus(); + } + + // Our split status may have changed + self.notify("is-split"); + + // Our active surface may have changed + self.notify("active-surface"); + } + + /// Builds the widget tree associated with a surface split tree. + /// Returned widgets are expected to be attached to a parent by the + /// caller. Reused widgets are detached from their old parent (the + /// returned strong reference keeps them alive meanwhile). + fn build_tree(tree: &Tree, current: Handle) -> gtk::Widget { + match &tree.nodes()[current.idx()] { + Node::Leaf(v) => match get_ancestor::(v) { + // The surface isn't in a window already so we don't have to + // worry about reuse. + None => glib::Object::builder::() + .property("surface", v) + .build() + .upcast(), + Some(window) => { + let w: gtk::Widget = window.upcast(); + // Our strong ref (`w`) keeps it alive while we detach it + // from the old tree and adopt it into the new one. + detach_widget(&w); + w + } + }, + Node::Split(s) => { + let left = Self::build_tree(tree, s.left); + let right = Self::build_tree(tree, s.right); + SplitTreeSplit::new(current, s.layout, &left, &right).upcast() + } + } + } + + //--------------------------------------------------------------- + // Actions + + fn init_action_map(&self) { + let group = gio::SimpleActionGroup::new(); + + let new_split = gio::SimpleAction::new("new-split", Some(glib::VariantTy::STRING)); + new_split.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, param| { + let Some(dir) = param.and_then(|p| p.str()) else { + log::warn!("split-tree.new-split called without a parameter"); + return; + }; + let direction = match dir { + "left" => Direction::Left, + "right" => Direction::Right, + "down" => Direction::Down, + "up" => Direction::Up, + _ => { + // Need to be defensive here since actions can be + // triggered externally. + log::warn!("invalid split direction for split-tree.new-split: {dir}"); + return; + } + }; + if let Err(e) = this.new_split( + direction, + this.get_active_surface().as_ref(), + SurfaceOverrides::default(), + ) { + log::warn!("new split failed error={e}"); + } + } + )); + group.add_action(&new_split); + + let equalize = gio::SimpleAction::new("equalize", None); + equalize.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, _| { + let Some(old_tree) = this.get_tree() else { + return; + }; + let new_tree = old_tree.equalize(); + this.set_tree(Some(&new_tree)); + } + )); + group.add_action(&equalize); + + let zoom = gio::SimpleAction::new("zoom", None); + zoom.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, _| this.action_zoom() + )); + group.add_action(&zoom); + + self.insert_action_group("split-tree", Some(&group)); + } + + fn action_zoom(&self) { + let imp = self.imp(); + if imp.tree.borrow().is_none() { + return; + } + if self.get_is_zoomed() { + if let Some(t) = imp.tree.borrow_mut().as_mut() { + t.zoom(None); + } + } else { + let Some(active) = self.get_active_surface_handle() else { + return; + }; + if let Some(t) = imp.tree.borrow_mut().as_mut() { + if t.zoomed() == Some(active) { + return; + } + t.zoom(Some(active)); + } + } + self.notify("tree"); + } +} + +/// Detach a widget from its current parent. +/// +/// We intentionally use parent-specific child APIs when possible +/// (`GtkPaned.set_start_child/set_end_child`, `AdwBin.set_child`) instead of +/// calling `unparent` directly, since those keep container state and +/// notifications in sync. +fn detach_widget(widget: >k::Widget) { + let Some(parent) = widget.parent() else { + return; + }; + + // Surface will be in a paned when it is split. + if let Some(paned) = parent.downcast_ref::() { + if paned.start_child().as_ref() == Some(widget) { + paned.set_start_child(gtk::Widget::NONE); + return; + } + if paned.end_child().as_ref() == Some(widget) { + paned.set_end_child(gtk::Widget::NONE); + return; + } + } + + // Surface will be in a bin when it is not split. + if let Some(bin) = parent.downcast_ref::() + && bin.child().as_ref() == Some(widget) + { + bin.set_child(gtk::Widget::NONE); + return; + } + + // Fallback for unexpected parents. + widget.unparent(); +} + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/split-tree.ui")] + pub struct SplitTree { + /// The tree datastructure containing all of our surface views. + pub tree: RefCell>, + + #[template_child] + pub tree_bin: TemplateChild, + + /// Last focused surface in the tree. + pub last_focused: glib::WeakRef, + + /// The source used to rebuild the tree (also debounces updates). + pub rebuild_source: RefCell>, + + /// State about a pending surface close for the close dialog. + pub pending_close: Cell>, + + /// Handlers connected on surfaces in the tree. + pub handlers: RefCell, Vec)>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for SplitTree { + const NAME: &'static str = "GhosttySplitTree"; + type Type = super::SplitTree; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + Surface::ensure_type(); + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[gtk::template_callbacks] + impl SplitTree { + #[template_callback] + fn notify_tree(&self, _pspec: glib::ParamSpec) { + self.obj().prop_tree(); + } + } + + impl ObjectImpl for SplitTree { + fn properties() -> &'static [glib::ParamSpec] { + static PROPS: std::sync::OnceLock> = std::sync::OnceLock::new(); + PROPS.get_or_init(|| { + vec![ + // The active surface is the surface that should be + // receiving all surface-targeted actions. + glib::ParamSpecObject::builder::("active-surface") + .read_only() + .build(), + glib::ParamSpecBoolean::builder("has-surfaces") + .default_value(false) + .read_only() + .build(), + glib::ParamSpecBoolean::builder("is-zoomed") + .default_value(false) + .read_only() + .build(), + glib::ParamSpecBoxed::builder::("tree").build(), + glib::ParamSpecBoolean::builder("is-split") + .default_value(false) + .read_only() + .build(), + ] + }) + } + + fn signals() -> &'static [Signal] { + static SIGNALS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + // Emitted whenever the tree property has changed, with + // access to the previous and new values. + Signal::builder("changed") + .param_types([SurfaceTree::static_type(), SurfaceTree::static_type()]) + .build(), + ] + }) + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + match pspec.name() { + "active-surface" => obj.get_active_surface().to_value(), + "has-surfaces" => obj.get_has_surfaces().to_value(), + "is-zoomed" => obj.get_is_zoomed().to_value(), + "is-split" => obj.get_is_split().to_value(), + "tree" => obj.get_tree().map(SurfaceTree).to_value(), + _ => unreachable!(), + } + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "tree" => { + let t = value.get::>().ok().flatten(); + self.obj().set_tree(t.as_ref().map(|t| &t.0)); + } + _ => unreachable!(), + } + } + + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + obj.init_action_map(); + } + + fn dispose(&self) { + self.last_focused.set(None); + if let Some(id) = self.rebuild_source.take() { + id.remove(); + } + self.dispose_template(); + } + } + + impl WidgetImpl for SplitTree {} + impl BoxImpl for SplitTree {} +} + +glib::wrapper! { + /// Internal widget representing a split: a `GtkPaned` wrapper handling + /// ratio (0 to 1) based positioning and writing user adjustments back + /// to the split tree. Immutable after creation. + pub struct SplitTreeSplit(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl SplitTreeSplit { + fn new(handle: Handle, layout: Layout, start: >k::Widget, end: >k::Widget) -> Self { + let this: Self = glib::Object::new(); + let imp = this.imp(); + imp.handle.set(Some(handle)); + + let paned = imp.paned.get(); + paned.set_start_child(Some(start)); + paned.set_end_child(Some(end)); + paned.set_orientation(match layout { + Layout::Horizontal => gtk::Orientation::Horizontal, + Layout::Vertical => gtk::Orientation::Vertical, + }); + this + } + + fn refresh(&self) { + let imp = self.imp(); + if imp.idle.borrow().is_none() { + let id = glib::idle_add_local_full( + glib::Priority::DEFAULT_IDLE, + clone!( + #[weak(rename_to = this)] + self, + #[upgrade_or] + glib::ControlFlow::Break, + move || { + this.on_idle(); + glib::ControlFlow::Break + } + ), + ); + imp.idle.replace(Some(id)); + } + } + + fn on_idle(&self) { + let imp = self.imp(); + let paned = imp.paned.get(); + + // Our idle source is always over + imp.idle.take(); + + // Get our split. We assume that this widget is always a child of a + // SplitTree, that our handle is valid, and that it is a split node. + let Some(split_tree) = get_ancestor::(self) else { + return; + }; + let Some(handle) = imp.handle.get() else { + return; + }; + let desired_ratio = { + let guard = split_tree.imp().tree.borrow(); + let Some(tree) = guard.as_ref() else { return }; + match &tree.nodes()[handle.idx()] { + Node::Split(s) => f64::from(s.ratio), + Node::Leaf(_) => return, + } + }; + + // Current, min, and max positions as pixels. + let pos = paned.position(); + let min: i32 = paned.property("min-position"); + let max: i32 = paned.property("max-position"); + let pos_set: bool = paned.property("position-set"); + + // We don't actually use min, but we don't expect it to be non-zero. + debug_assert_eq!(min, 0); + + // If our max is zero then we can't do any math. + if max == 0 { + return; + } + + let current_ratio = f64::from(pos) / f64::from(max); + + // If our ratio is close enough to our desired ratio, then we ignore + // the update. This avoids constant split updates for lossy math. + if (current_ratio - desired_ratio).abs() <= 0.001 { + return; + } + + // If we've never set the position, then we set it to the desired. + if !pos_set { + let desired_pos = (f64::from(max) * desired_ratio).round() as i32; + paned.set_position(desired_pos); + return; + } + + // If we've set the position, then this is a manual human update and + // we need to write our update back to the tree. + split_tree.resize_in_place(handle, f16::from_f64(current_ratio)); + } +} + +mod split_imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/split-tree-split.ui")] + pub struct SplitTreeSplit { + /// The handle of the node in the tree that this split represents. + pub handle: Cell>, + + /// Source to handle repositioning the split when properties change. + pub idle: RefCell>, + + #[template_child] + pub paned: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SplitTreeSplit { + const NAME: &'static str = "GhosttySplitTreeSplit"; + type Type = super::SplitTreeSplit; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[gtk::template_callbacks] + impl SplitTreeSplit { + #[template_callback] + fn notify_max_position(&self, _pspec: glib::ParamSpec) { + self.obj().refresh(); + } + + #[template_callback] + fn notify_min_position(&self, _pspec: glib::ParamSpec) { + self.obj().refresh(); + } + + #[template_callback] + fn notify_position(&self, _pspec: glib::ParamSpec) { + self.obj().refresh(); + } + } + + impl ObjectImpl for SplitTreeSplit { + fn dispose(&self) { + if let Some(id) = self.idle.take() { + id.remove(); + } + self.dispose_template(); + } + } + + impl WidgetImpl for SplitTreeSplit {} + impl BinImpl for SplitTreeSplit {} +} diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs new file mode 100644 index 0000000..4afc13d --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -0,0 +1,3422 @@ +//! Port of apprt/gtk/class/surface.zig and apprt/gtk/Surface.zig. +//! +//! `GhosttySurface`: the GTK widget that hosts a terminal surface. It is a +//! `AdwBin` subclass implementing `GtkScrollable` (so it can live in a +//! 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`] +//! and holds only a weak reference to the widget. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::io::Write as _; +use std::rc::Rc; +use std::sync::OnceLock; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_apprt::action::{CommandFinished, Inspector, KeySequence, KeyTable, Readonly}; +use ghostty_apprt::structs::{ + Clipboard, ClipboardRequest, ContentScale, CursorPos, ImePos, SurfaceSize, +}; +use ghostty_apprt::surface::{ChildExited, NewSurfaceContext, Scrollbar}; +use ghostty_config::command::Command; +use ghostty_config::config::Config as CoreConfig; +use ghostty_config::path::Path as ConfigPath; +use ghostty_config::types_a::WorkingDirectory; +use ghostty_config::types_b::{ + BellFeatures, NotifyOnCommandFinish, ResizeOverlay as ResizeOverlayMode, ResizeOverlayPosition, +}; +use ghostty_input::{Action as KeyAction, Key, KeyEvent, Mods, MouseButton, MouseButtonState}; +use ghostty_vt::mouse_shape::MouseShape; +use ghostty_vt::osc::{ProgressReport, ProgressState}; +use gtk::glib::subclass::Signal; +use gtk::{gdk, gio, glib}; + +use crate::class::application::Application; +use crate::class::clipboard_confirmation_dialog::ClipboardConfirmationDialog; +use crate::class::config::Config; +use crate::class::key_state_overlay::KeyStateOverlay; +use crate::class::resize_overlay::ResizeOverlay; +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::{ + 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`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, glib::Boxed)] +#[boxed_type(name = "GhosttySurfaceSize", nullable)] +pub struct Size { + pub width: u32, + pub height: u32, +} + +/// Boxed `font.face.DesiredSize` for the `font-size-request` property. +#[derive(Debug, Clone, Copy, PartialEq, glib::Boxed)] +#[boxed_type(name = "GhosttyFontDesiredSize", nullable)] +pub struct FontSizeRequest(pub DesiredFontSize); + +/// The state of the key event while we're doing IM composition. +/// See `Surface::key_event` for detailed descriptions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum IMKeyEvent { + /// Not in a key event. + #[default] + False, + /// In a key event but `im_composing` was true prior to IME processing. + Composing, + /// In a key event but `im_composing` was false prior to IME processing. + NotComposing, +} + +/// Overrides applied on top of the config when the core surface is created +/// (Zig: the anonymous struct taking by `Surface.new`). +#[derive(Debug, Clone, Default)] +pub struct SurfaceOverrides { + pub command: Option, + pub working_directory: Option, + pub title: Option, +} + +thread_local! { + /// Registry of live surfaces: id -> weak ref. Used to map + /// `Target::Surface(id)` coming from the core back to widgets. + static SURFACES: RefCell>> = + RefCell::new(HashMap::new()); + static NEXT_ID: Cell = const { Cell::new(1) }; +} + +/// Seconds before a stalled progress bar is removed. +const PROGRESS_BAR_TIMEOUT_SECONDS: u32 = 15; + +/// `font.face.default_dpi` on Linux. +const DEFAULT_DPI: f32 = 96.0; + +fn app() -> Application { + Application::default() +} + +glib::wrapper! { + pub struct Surface(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Scrollable, gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +// Marker wrapper over the same GType: `glib::Properties` generates property +// accessors (`title()`, `set_title()`, `notify_title()`, ...) on its +// `wrapper_type`. We want Zig-shaped hand-written accessors on `Surface` +// instead, so the generated ones land on this private alias type. +glib::wrapper! { + #[allow(dead_code)] + pub(self) struct SurfaceProps(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Scrollable, gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for Surface { + fn default() -> Self { + Self::new(SurfaceOverrides::default()) + } +} + +// --------------------------------------------------------------------- +// Construction / lookup + +impl Surface { + /// Create a new surface widget (Zig: `Surface.new`). + pub fn new(overrides: SurfaceOverrides) -> Self { + crate::register_resources(); + let title = overrides.title.clone(); + let obj: Self = glib::Object::builder() + .property("title-override", title) + .build(); + obj.imp().overrides.replace(SurfaceOverrides { + title: None, + ..overrides + }); + obj + } + + /// The surface id handed to the core (`Target::Surface(id)`). + pub fn id(&self) -> SurfaceId { + self.imp().id.get() + } + + /// Look up a live surface by id. + pub fn from_id(id: SurfaceId) -> Option { + SURFACES.with(|m| m.borrow().get(&id).and_then(glib::WeakRef::upgrade)) + } + + /// All live surfaces. + pub fn all() -> Vec { + SURFACES.with(|m| { + m.borrow() + .values() + .filter_map(glib::WeakRef::upgrade) + .collect() + }) + } + + /// The core surface, if initialized (Zig: `core()`). + pub fn core(&self) -> Option> { + self.imp().core_surface.borrow().clone() + } + + /// The apprt surface handle (Zig: `rt()`). + pub fn rt(&self) -> Rc { + let existing = self.imp().rt_surface.borrow().clone(); + existing.unwrap_or_else(|| { + let h = Rc::new(RtSurfaceHandle { + surface: self.downgrade(), + }); + self.imp().rt_surface.replace(Some(h.clone())); + h + }) + } + + /// Set the parent of this surface. This will extract the information + /// required to initialize this surface with the proper values but doesn't + /// retain any memory. + /// + /// If the surface is already realized this does nothing. + /// + /// (Zig took the parent `*CoreSurface` and went through its rt surface; + /// here the parent widget is passed directly.) + pub fn set_parent(&self, parent: &Surface, context: NewSurfaceContext) { + let imp = self.imp(); + + // This is a mistake! We can only set a parent before surface + // realization. We log this because this is probably a logic error. + if imp.core_surface.borrow().is_some() { + log::warn!("setParent called after surface is already realized"); + return; + } + + // Store the context so init_surface can use it + imp.context.set(context); + + // Setup our font size + if let Some(core) = parent.core() { + imp.font_size_request + .replace(Some(FontSizeRequest(core.font_size()))); + self.notify("font-size-request"); + } + + // Remainder needs a config. If there is no config we just assume + // we aren't inheriting any of these values. + let config = imp.config.borrow().clone(); + if let Some(config_obj) = config { + // Setup our cwd if configured to inherit + if ghostty_apprt::surface::should_inherit_working_directory(context, &config_obj.get()) + && let Some(pwd) = parent.get_pwd() + { + imp.pwd.replace(Some(pwd)); + self.notify("pwd"); + } + } + } + + /// Force the surface to redraw itself. Ghostty often will only redraw + /// the terminal in reaction to internal changes. If there are external + /// events that invalidate the surface, such as the widget moving parents, + /// then we should force a redraw. + pub fn redraw(&self) { + self.imp().gl_area.queue_render(); + } + + pub fn toggle_fullscreen(&self) { + self.emit_by_name::<()>("toggle-fullscreen", &[]); + } + + pub fn toggle_maximize(&self) { + self.emit_by_name::<()>("toggle-maximize", &[]); + } + + pub fn toggle_command_palette(&self) -> bool { + // TODO: pass the surface with the action + self.activate_action("win.toggle-command-palette", None) + .is_ok() + } + + /// Show/hide the inspector. + /// + /// TODO(inspector): the inspector window is deferred; this is a no-op + /// that reports success like Zig does after (not) creating a window. + pub fn control_inspector(&self, value: Inspector) -> bool { + log::debug!("inspector action {value:?} ignored (inspector deferred)"); + true + } + + /// Redraw our inspector, if there is one associated with this surface. + /// TODO(inspector): deferred. + pub fn redraw_inspector(&self) {} + + /// Handle a key sequence action from the apprt. + pub fn key_sequence_action(&self, value: &KeySequence) { + let imp = self.imp(); + let _guard = self.freeze_notify(); + self.notify("key-sequence"); + + match value { + KeySequence::Trigger(trigger) => { + // Convert the trigger to a human-readable label + if let Some(label) = gtk_key::label_from_trigger(*trigger) { + imp.key_sequence.borrow_mut().push(label); + } + } + KeySequence::End => imp.key_sequence.borrow_mut().clear(), + } + } + + /// Handle a key table action from the apprt. + pub fn key_table_action(&self, value: &KeyTable) { + let imp = self.imp(); + let _guard = self.freeze_notify(); + self.notify("key-table"); + + match value { + KeyTable::Activate(name) => imp.key_tables.borrow_mut().push(name.clone()), + KeyTable::Deactivate => { + imp.key_tables.borrow_mut().pop(); + } + KeyTable::DeactivateAll => imp.key_tables.borrow_mut().clear(), + } + } + + /// `showOnScreenKeyboard(null)`: used by the `show_on_screen_keyboard` + /// action. + pub fn show_on_screen_keyboard(&self) -> bool { + self.show_on_screen_keyboard_event(None) + } + + /// `showOnScreenKeyboard(event)`. + pub fn show_on_screen_keyboard_event(&self, event: Option<&gdk::Event>) -> bool { + self.imp().im_context.activate_osk(event) + } + + /// Set the scrollbar state for this surface. This will setup the + /// properties for our `Gtk.Scrollable` interface properly. + pub fn set_scrollbar(&self, scrollbar: Scrollbar) { + // Update existing adjustment in-place. If we don't have an + // adjustment then we do nothing because we're not part of a + // scrolled window. + let Some(vadj) = self.get_vadjustment() else { + return; + }; + + // Check if values match existing adjustment and skip update if so + let value = scrollbar.offset as f64; + let upper = scrollbar.total as f64; + let page_size = scrollbar.len as f64; + + if (vadj.value() - value).abs() < 0.001 + && (vadj.upper() - upper).abs() < 0.001 + && (vadj.page_size() - page_size).abs() < 0.001 + { + return; + } + + // During manual scrollbar changes from Ghostty core we don't + // want to emit value-changed signals so we block them. This would + // cause a waste of resources at best and infinite loops at worst. + let handler = self.imp().vadj_handler.borrow(); + if let Some((a, id)) = handler.as_ref() { + a.block_signal(id); + } + vadj.configure( + value, // value: current scroll position + 0.0, // lower: minimum value + upper, // upper: maximum value (total scrollable area) + 1.0, // step_increment: amount to scroll on arrow click + page_size, // page_increment: amount to scroll on page up/down + page_size, // page_size: size of visible area + ); + if let Some((a, id)) = handler.as_ref() { + a.unblock_signal(id); + } + } + + /// Set the current progress report state. + pub fn set_progress_report(&self, value: ProgressReport) { + let imp = self.imp(); + + // No matter what, we stop the timer because if we're removing + // then we're done and otherwise we restart it. + if let Some(timer) = imp.progress_bar_timer.take() { + timer.remove(); + } + + let config = imp.config.borrow().clone(); + if let Some(config) = config + && !config.get().progress_style + { + log::debug!("progress_report action blocked by config"); + imp.progress_bar_overlay.set_visible(false); + return; + } + + let progress_bar = &*imp.progress_bar_overlay; + match value.state { + // Remove the progress bar + ProgressState::Remove => { + progress_bar.set_visible(false); + return; + } + + // Set the progress bar to a fixed value if one was provided, + // otherwise pulse. Remove the `error` CSS class so that the + // progress bar shows as normal. + ProgressState::Set => { + progress_bar.remove_css_class("error"); + match value.progress { + Some(p) => progress_bar.set_fraction(compute_fraction(p)), + None => progress_bar.pulse(), + } + } + + // Same, with the `error` CSS class set. + ProgressState::Error => { + progress_bar.add_css_class("error"); + match value.progress { + Some(p) => progress_bar.set_fraction(compute_fraction(p)), + None => progress_bar.pulse(), + } + } + + // The state of progress is unknown, so pulse the progress bar to + // indicate that things are still happening. + ProgressState::Indeterminate => progress_bar.pulse(), + + // If a progress value was provided, set the progress bar to that + // value. Don't pulse the progress bar as that would indicate that + // things were happening. Otherwise this is mainly used to keep + // the progress bar on screen instead of timing out. + ProgressState::Pause => { + if let Some(p) = value.progress { + progress_bar.set_fraction(compute_fraction(p)); + } + } + } + + // Assume all states lead to visibility + progress_bar.set_visible(true); + + // Start our timer to remove bad actor programs that stall + // the progress bar. + let weak = self.downgrade(); + let id = glib::timeout_add_seconds_local(PROGRESS_BAR_TIMEOUT_SECONDS, move || { + if let Some(this) = weak.upgrade() { + // The source is removed by returning Break; forget the id. + let _ = this.imp().progress_bar_timer.take(); + this.set_progress_report(ProgressReport { + state: ProgressState::Remove, + progress: None, + }); + } + glib::ControlFlow::Break + }); + imp.progress_bar_timer.set(Some(id)); + } + + /// Request that this terminal come to the front and become focused. + /// It is up to the embedding widget to react to this. + pub fn present(&self) { + self.emit_by_name::<()>("present-request", &[]); + } + + pub fn command_finished(&self, value: &CommandFinished) -> bool { + let imp = self.imp(); + + let notify_next_command_finish = 'notify: { + let Some(group) = imp.action_group.borrow().clone() else { + break 'notify false; + }; + let Some(state) = group.action_state("notify-on-next-command-finish") else { + break 'notify false; + }; + let Some(notify) = state.get::() else { + break 'notify false; + }; + group.change_action_state("notify-on-next-command-finish", &false.to_variant()); + notify + }; + + let Some(config_obj) = imp.config.borrow().clone() else { + return false; + }; + let cfg = config_obj.get(); + + if !notify_next_command_finish { + if cfg.notify_on_command_finish == NotifyOnCommandFinish::Never { + return true; + } + if cfg.notify_on_command_finish == NotifyOnCommandFinish::Unfocused + && self.get_focused() + { + return true; + } + } + + if value.duration.lte(cfg.notify_on_command_finish_after) { + return true; + } + + let action = cfg.notify_on_command_finish_action; + + if action.bell { + self.set_bell_ringing(true); + } + + if action.notify { + use ghostty_os::i18n::tr; + let title = match value.exit_code { + None => tr("Command Finished"), + Some(0) => tr("Command Succeeded"), + Some(_) => tr("Command Failed"), + }; + let rounded = value.duration.round(1_000_000); + let body = match value.exit_code { + None => format!("Command took {rounded}."), + Some(code) => format!("Command took {rounded} and exited with code {code}."), + }; + self.send_desktop_notification(&title, &body); + } + + true + } + + /// Get the readonly state from the core surface. + pub fn get_readonly(&self) -> bool { + self.core().is_some_and(|c| c.readonly()) + } + + /// Notify anyone interested that the readonly status has changed. + pub fn set_readonly(&self, _value: Readonly) -> bool { + self.notify("readonly"); + true + } + + /// Key press event (press or release). + /// + /// At a high level, we want to construct an `input.KeyEvent` and + /// pass that to `key_callback`. At a low level, this is more complicated + /// than it appears because we need to construct all of this information + /// and its not given to us. + /// + /// For all events, we run the GdkEvent through the input method context. + /// This allows the input method to capture the event and trigger + /// callbacks such as preedit, commit, etc. + /// + /// There are a couple important aspects to the prior paragraph: we must + /// send ALL events through the input method context. This is because + /// input methods use both key press and key release events to determine + /// the state of the input method. For example, fcitx uses key release + /// events on modifiers (i.e. ctrl+shift) to switch the input method. + /// + /// We set some state to note we're in a key event (`in_keyevent`) + /// because some of the input method callbacks change behavior based on + /// this state. For example, we don't want to send character events + /// like "a" via the input "commit" event if we're actively processing + /// a keypress because we'd lose access to the keycode information. + /// However, a "commit" event may still happen outside of a keypress + /// event from e.g. a tablet or on-screen keyboard. + /// + /// Finally, we take all of the information in order to determine if we have + /// a unicode character or if we have to map the keyval to a code to + /// get the underlying logical key, etc. + /// + /// Then we can emit the `key_callback`. + pub fn key_event( + &self, + action: KeyAction, + ec_key: >k::EventControllerKey, + keyval: u32, + keycode: u32, + gtk_mods: gdk::ModifierType, + ) -> bool { + let imp = self.imp(); + let Some(event) = ec_key.current_event() else { + return false; + }; + let Ok(key_event) = event.clone().downcast::() else { + return false; + }; + + // The block below is all related to input method handling. See the + // function comment for some high level details and then the comments + // within the block for more specifics. + { + // This can trigger an input method so we need to notify the im + // context where the cursor is so it can render the dropdowns in + // the correct place. + if let Some(surface) = self.core() { + let p: ImePos = surface.ime_point(); + imp.im_context + .set_cursor_location(&gdk::Rectangle::new(p.x as i32, p.y as i32, 1, 1)); + } + + // We note that we're in a keypress because we want some logic to + // depend on this. For example, we don't want to send character + // events like "a" via the input "commit" event if we're actively + // processing a keypress because we'd lose access to the keycode + // information. + // + // We have to maintain some additional state here of whether we + // were composing because different input methods call the + // callbacks in different orders. For example, ibus calls commit + // THEN preedit end but simple calls preedit end THEN commit. + imp.in_keyevent.set(if imp.im_composing.get() { + IMKeyEvent::Composing + } else { + IMKeyEvent::NotComposing + }); + let handled = imp.im_context.filter_keypress(&event); + let in_keyevent = imp.in_keyevent.replace(IMKeyEvent::False); + + // Pass the event through the input method which returns true if + // handled. Confusingly, not all events handled by the input + // method result in this returning true so we have to maintain + // some additional state about whether we were composing or not + // to determine if we should proceed with key encoding. + // + // Cases where the input method does not mark the event as + // handled: + // + // - If we change the input method via keypress while we have + // preedit text, the input method will commit the pending text + // but will not mark it as handled. We use the `Composing` + // state to detect this case. + // + // - If we switch input methods (i.e. via ctrl+shift with fcitx), + // the input method will handle the key release event but will + // not mark it as handled. I don't know any way to detect this + // case so it will result in a key event being sent to the key + // callback. For Kitty text encoding, this will result in + // modifiers being triggered despite being technically + // consumed. At the time of writing, both Kitty and Alacritty + // have the same behavior. I know of no way to fix this. + // + // If the input method handled the event, you would think we would + // never proceed with key encoding for Ghostty but that is not the + // case. Input methods will handle basic character encoding like + // typing "a" and we want to associate that with the key event. + // So we have to check additional state to determine if we exit. + if handled { + // If we are composing then we're in a preedit state and do + // not want to encode any keys. For example: type a deadkey + // such as single quote on a US international keyboard layout. + if imp.im_composing.get() { + return true; + } + + // If we were composing and now we're not, it means that we + // committed the text. We also don't want to encode a key + // event for this. Example: enable Japanese input method, + // press "konn" and then press enter. The final enter should + // not be encoded and "konn" (in hiragana) should be written + // as "こん". + if in_keyevent == IMKeyEvent::Composing { + return true; + } + + // Not composing and our input method buffer is empty. This + // could mean that the input method reacted to this event by + // activating an onscreen keyboard or something equivalent. + // We don't know. But the input method handled it and didn't + // give us text so we will just assume we should not encode + // this. This handles a real scenario when ibus starts the + // emoji input method (super+.). + if imp.im_buf.borrow().is_empty() { + return true; + } + } + + // At this point, for the sake of explanation of internal state: + // it is possible that im_len > 0 and im_composing == false. This + // means that we received a commit event from the input method + // that we want associated with the key event. This is common: its + // how basic character translation for simple inputs like "a" + // work. + } + + // We always reset the length of the im buffer. There's only one + // scenario we reach this point with im_len > 0 and that's if we + // received a commit event from the input method. We don't want to + // keep that state around since we've handled it here. + let result = self.key_event_encode(action, &event, &key_event, keyval, keycode, gtk_mods); + imp.im_buf.borrow_mut().clear(); + result + } + + fn key_event_encode( + &self, + action: KeyAction, + event: &gdk::Event, + key_event: &gdk::KeyEvent, + keyval: u32, + keycode: u32, + gtk_mods: gdk::ModifierType, + ) -> bool { + let imp = self.imp(); + + // Get the keyvals for this event. + let keyval_unicode: Option = key_event.keyval().to_unicode(); + let keyval_unicode_unshifted = + gtk_key::keyval_unicode_unshifted(&*imp.gl_area, key_event, keycode); + + // We want to get the physical unmapped key to process physical + // keybinds. (These are keybinds explicitly marked as requesting + // physical mapping). + let physical_key: Key = { + let w3c_key = ghostty_input::keycodes::entries() + .iter() + .find(|e| e.native == keycode) + .map_or(Key::Unidentified, |e| e.key); + + // Consult the pre-remapped XKB keyval/keysym to get the + // (possibly) remapped key. If the W3C key or the remapped key is + // eligible for remapping, we use it. + // + // See the docs for `should_be_remappable` for why we even have + // to do this in the first place. + match gtk_key::key_from_keyval(keyval) { + Some(remapped) + if w3c_key.should_be_remappable() || remapped.should_be_remappable() => + { + remapped + } + // Return the original physical key + _ => w3c_key, + } + }; + + // Get our modifier for the event + let application = app(); + let mods: Mods = gtk_key::event_mods(event, physical_key, gtk_mods, action, |dev, m| { + application.winproto().event_mods(dev, m) + }); + + // Get our consumed modifiers + let consumed_mods: Mods = + gtk_key::translate_mods(key_event.consumed_modifiers() & gdk::MODIFIER_MASK); + + // If we have no UTF-8 text, we try to convert our keyval to + // a text value. We have to do this because GTK will not process + // "Ctrl+Shift+1" (on US keyboards) as "Ctrl+!" but instead as "". + // But the keyval is set correctly so we can at least extract that. + if imp.im_buf.borrow().is_empty() + && let Some(ch) = keyval_unicode + { + // We don't want to send control characters as IM text. + // Control characters are handled already by the encoder + // directly. + if (ch as u32) >= 0x20 { + imp.im_buf.borrow_mut().push(ch); + } + } + + // Invoke the core Ghostty logic to handle this input. + let Some(surface) = self.core() else { + return false; + }; + let utf8 = imp.im_buf.borrow().clone(); + let effect = match surface.key_callback(KeyEvent { + action, + key: physical_key, + mods, + consumed_mods, + composing: imp.im_composing.get(), + utf8: &utf8, + unshifted_codepoint: keyval_unicode_unshifted, + }) { + Ok(e) => e, + Err(err) => { + log::error!("error in key callback err={err}"); + return false; + } + }; + + match effect { + InputEffect::Closed => return true, + InputEffect::Ignored => {} + InputEffect::Consumed => { + if action == KeyAction::Press || action == KeyAction::Repeat { + // If we were in the composing state then we reset our + // context. We do NOT want to reset if we're not in the + // composing state because there is other IME state that + // we want to preserve, such as quotation mark ordering + // for Chinese input. + if imp.im_composing.get() { + imp.im_context.reset(); + let _ = surface.preedit_callback(None); + } + + // Bell stops ringing when any key is pressed that is + // used by the core in any way. + self.set_bell_ringing(false); + + return true; + } + } + } + + false + } + + /// Prompt for a manual title change for the surface. + pub fn prompt_title(&self) { + let imp = self.imp(); + let initial = imp + .title_override + .borrow() + .clone() + .or_else(|| imp.title.borrow().clone()); + let dialog = TitleDialog::new(title_dialog::Target::Surface, initial.as_deref()); + let weak = self.downgrade(); + dialog.connect_set(move |_, title| { + if let Some(this) = weak.upgrade() { + this.set_title_override(if title.is_empty() { None } else { Some(title) }); + } + }); + dialog.present(self); + } + + /// Scale x/y by the GDK device scale. + fn scaled_coordinates(&self, x: f64, y: f64) -> (f64, f64) { + let scale_factor = f64::from(self.imp().gl_area.scale_factor()); + (x * scale_factor, y * scale_factor) + } + + // ----------------------------------------------------------------- + // Libghostty callbacks + + /// Emit `close-request` (Zig: `close`). Does not confirm. + pub fn close(&self) { + self.emit_by_name::<()>("close-request", &[]); + } + + pub fn child_exited(&self, data: &ChildExited) -> bool { + // Even if we don't support the overlay, we still keep our property + // up to date for anyone listening. + self.imp().child_exited.set(true); + self.notify("child-exited"); + + self.imp().child_exited_overlay.set_child_exited(Some(data)); + true + } + + pub fn get_content_scale(&self) -> ContentScale { + let gtk_scale: f32 = { + // Future: detect GTK version 4.12+ and use gdk_surface_get_scale + // so we can support fractional scaling. + let scale = self.imp().gl_area.scale_factor(); + if scale <= 0 { + log::warn!("gtk_widget_get_scale_factor returned a non-positive number: {scale}"); + 1.0 + } else { + scale as f32 + } + }; + + // Also scale using font-specific DPI, which is often exposed to the + // user via DE accessibility settings (see + // https://docs.gtk.org/gtk4/class.Settings.html). + let xft_dpi_scale: f32 = 'xft: { + // gtk-xft-dpi is font DPI multiplied by 1024. + let Some(gtk_xft_dpi) = crate::gsettings::get_int(crate::gsettings::Key::GtkXftDpi) + else { + log::warn!("gtk-xft-dpi was not set, using default value"); + break 'xft 1.0; + }; + + // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 + if gtk_xft_dpi <= 0 { + log::warn!("gtk-xft-dpi has invalid value ({gtk_xft_dpi}), using default"); + break 'xft 1.0; + } + + // As noted above gtk-xft-dpi is multiplied by 1024, so we divide + // by 1024, then divide by the default value (96) to derive a + // scale. Note gtk-xft-dpi can be fractional, so we use floating + // point math here. + let xft_dpi = gtk_xft_dpi as f32 / 1024.0; + xft_dpi / 96.0 + }; + + let scale = gtk_scale * xft_dpi_scale; + ContentScale { x: scale, y: scale } + } + + pub fn get_size(&self) -> SurfaceSize { + // By the time this is called, we should be in a widget tree. This + // should not be called before that. We ensure this by initializing + // the surface in `gl_resize`. This is VERY important because it + // avoids the pty having an incorrect initial size. + self.imp().size.get() + } + + pub fn get_cursor_pos(&self) -> CursorPos { + self.imp().cursor_pos.get() + } + + pub fn default_termio_env(&self) -> Vec<(String, String)> { + let application = app(); + let mut env: Vec<(String, String)> = std::env::vars().collect(); + let remove = |env: &mut Vec<(String, String)>, k: &str| env.retain(|(n, _)| n != k); + + remove(&mut env, "LANG"); + if let Some(language) = application.saved_language() { + env.push(("LANG".to_owned(), language)); + } + + // Don't leak these GTK environment variables to child processes. + remove(&mut env, "GDK_DEBUG"); + remove(&mut env, "GDK_DISABLE"); + remove(&mut env, "GSK_RENDERER"); + + // Remove some environment variables that are set when Ghostty is + // launched from a `.desktop` file, by D-Bus activation, or systemd. + remove(&mut env, "GIO_LAUNCHED_DESKTOP_FILE"); + remove(&mut env, "GIO_LAUNCHED_DESKTOP_FILE_PID"); + remove(&mut env, "DBUS_STARTER_ADDRESS"); + remove(&mut env, "DBUS_STARTER_BUS_TYPE"); + remove(&mut env, "INVOCATION_ID"); + remove(&mut env, "JOURNAL_STREAM"); + remove(&mut env, "NOTIFY_SOCKET"); + + // Snap path filtering (`build_config.snap`) is a build-time option + // of the Zig build and is not supported here. + + // This is a hack because it ties ourselves (optionally) to the + // Window class. The right solution we should do is emit a signal + // here where the handler can modify our env, but boxing the + // env is a bit annoying so I'm punting it. + if let Some(window) = self + .ancestor(Window::static_type()) + .and_then(|w| w.downcast::().ok()) + { + if let Err(e) = window.winproto().add_subprocess_env(&mut env) { + log::warn!("winproto add_subprocess_env failed: {e}"); + } + if window.is_quick_terminal() { + env.retain(|(n, _)| n != "GHOSTTY_QUICK_TERMINAL"); + env.push(("GHOSTTY_QUICK_TERMINAL".to_owned(), "1".to_owned())); + } + } + + env + } + + pub fn clipboard_request(&self, clipboard_type: Clipboard, state: ClipboardRequest) -> bool { + clipboard::request(self, clipboard_type, state) + } + + pub fn set_clipboard( + &self, + clipboard_type: Clipboard, + contents: &[(String, String)], + confirm: bool, + ) { + clipboard::set(self, clipboard_type, contents, confirm); + } + + /// Paste explicit text directly into the surface, regardless of the + /// actual clipboard contents (Zig: `Clipboard.paste`). + pub fn paste(&self, text: &str) { + clipboard::paste(self, text); + } + + /// Focus this surface. This properly focuses the input part of + /// our surface. (Shadows `WidgetExt::grab_focus` on purpose, like Zig.) + pub fn grab_focus(&self) { + self.imp().gl_area.grab_focus(); + } + + pub fn send_desktop_notification(&self, title: &str, body: &str) { + let application = app(); + + if self.core().is_none() { + log::warn!("can't send notification because there is no core surface"); + return; + } + + let t = if title.is_empty() { "Ghostty" } else { title }; + + let notification = gio::Notification::new(t); + notification.set_body(Some(body)); + + let icon = gio::ThemedIcon::new("com.mitchellh.ghostty"); + notification.set_icon(&icon); + + // The "pointer" is our surface id; the app validates it against the + // core's surface list via a mailbox message. + notification.set_default_action_and_target_value( + "app.present-surface", + Some(&self.id().to_variant()), + ); + + // We set the notification ID to the body content. If the content is + // the same, this notification may replace a previous notification + application + .upcast_ref::() + .send_notification(Some(body), ¬ification); + } + + // ----------------------------------------------------------------- + // Properties + + /// Returns the title property. + pub fn get_title(&self) -> Option { + self.imp().title.borrow().clone() + } + + /// Returns the effective title: the user-overridden title if set, + /// otherwise the terminal-set title. + pub fn get_effective_title(&self) -> Option { + let imp = self.imp(); + imp.title_override + .borrow() + .clone() + .or_else(|| imp.title.borrow().clone()) + } + + /// Copies the effective title to the clipboard. + pub fn copy_title_to_clipboard(&self) -> bool { + let Some(title) = self.get_effective_title() else { + return false; + }; + if title.is_empty() { + return false; + } + self.set_clipboard( + Clipboard::Standard, + &[("text/plain".to_owned(), title)], + false, + ); + true + } + + /// Set the title for this surface, copies the value. This should always + /// be the title as set by the terminal program, not any manually set + /// title. For manually set titles see `set_title_override`. + pub fn set_title(&self, title: Option<&str>) { + self.imp().title.replace(title.map(str::to_owned)); + self.notify("title"); + } + + /// Overridden title. This will be generally be shown over the title + /// unless this is unset (None). + pub fn set_title_override(&self, title: Option<&str>) { + self.imp().title_override.replace(title.map(str::to_owned)); + self.notify("title-override"); + } + + pub fn get_title_override(&self) -> Option { + self.imp().title_override.borrow().clone() + } + + /// Returns the pwd property. + pub fn get_pwd(&self) -> Option { + self.imp().pwd.borrow().clone() + } + + /// Set the pwd for this surface, copies the value. + pub fn set_pwd(&self, pwd: Option<&str>) { + self.imp().pwd.replace(pwd.map(str::to_owned)); + self.notify("pwd"); + } + + /// Returns the focus state of this surface. + pub fn get_focused(&self) -> bool { + self.imp().focused.get() + } + + /// The surface config object (Zig: `priv.config`). + pub fn get_config(&self) -> Option { + self.imp().config.borrow().clone() + } + + /// Change the configuration for this surface. + pub fn set_config(&self, config: &Config) { + self.imp().config.replace(Some(config.clone())); + self.notify("config"); + } + + /// Return the default size, if set. + pub fn get_default_size(&self) -> Option { + *self.imp().default_size.borrow() + } + + /// Set the default size for a window that contains this surface. + /// This is up to the embedding widget to respect this. Generally, only + /// the first surface in a window respects this. + pub fn set_default_size(&self, size: Size) { + self.imp().default_size.replace(Some(size)); + self.notify("default-size"); + } + + /// Estimate and set the initial window size from config and font metrics. + /// This can be called before the core surface exists to set up the window + /// size before presenting. This is an estimate because it does not take + /// into account any padding that may need to be added to the window. + pub fn estimate_initial_size(&self) { + let Some(config_obj) = self.imp().config.borrow().clone() else { + return; + }; + let config = config_obj.get(); + + // Both dimensions must be configured + if config.window_height == 0 || config.window_width == 0 { + return; + } + + // Get content scale and compute DPI + let content_scale = self.get_content_scale(); + let x_dpi = content_scale.x * DEFAULT_DPI; + let y_dpi = content_scale.y * DEFAULT_DPI; + + let font_size = DesiredFontSize { + points: config.font_size, + xdpi: x_dpi as u16, + ydpi: y_dpi as u16, + }; + + // Get font grid for cell metrics + let Some(cell) = app().core().cell_size(&config, font_size) else { + return; + }; + + let width = MIN_WINDOW_WIDTH_CELLS.max(config.window_width) * cell.width; + let height = MIN_WINDOW_HEIGHT_CELLS.max(config.window_height) * cell.height; + + let final_width = (width as f32 / content_scale.x).ceil() as u32; + let final_height = (height as f32 / content_scale.y).ceil() as u32; + + self.set_default_size(Size { + width: final_width, + height: final_height, + }); + } + + /// Return the min size, if set. + pub fn get_min_size(&self) -> Option { + *self.imp().min_size.borrow() + } + + /// Set the min size for a window that contains this surface. + /// This is up to the embedding widget to respect this. Generally, only + /// the first surface in a window respects this. + pub fn set_min_size(&self, size: Size) { + self.imp().min_size.replace(Some(size)); + self.notify("min-size"); + } + + pub fn get_font_size_request(&self) -> Option { + self.imp().font_size_request.borrow().map(|v| v.0) + } + + pub fn get_mouse_shape(&self) -> MouseShape { + self.imp().mouse_shape.get() + } + + pub fn set_mouse_shape(&self, shape: MouseShape) { + self.imp().mouse_shape.set(shape); + self.notify("mouse-shape"); + } + + pub fn get_mouse_hidden(&self) -> bool { + self.imp().mouse_hidden.get() + } + + pub fn set_mouse_hidden(&self, hidden: bool) { + self.imp().mouse_hidden.set(hidden); + self.notify("mouse-hidden"); + } + + pub fn set_mouse_hover_url(&self, url: Option<&str>) { + self.imp().mouse_hover_url.replace(url.map(str::to_owned)); + self.notify("mouse-hover-url"); + } + + pub fn set_bell_ringing(&self, ringing: bool) { + self.imp().set_bell_ringing_prop(ringing); + } + + pub fn get_bell_ringing(&self) -> bool { + self.imp().bell_ringing.get() + } + + pub fn set_error(&self, v: bool) { + self.imp().error.set(v); + self.notify("error"); + } + + pub fn get_zoom(&self) -> bool { + self.imp().zoom.get() + } + + pub fn get_is_split(&self) -> bool { + self.imp().is_split.get() + } + + pub fn get_child_exited(&self) -> bool { + self.imp().child_exited.get() + } + + pub fn set_search_active(&self, active: bool, needle: &str) { + let imp = self.imp(); + imp.search_overlay.set_property("active", active); + + if !needle.is_empty() { + imp.search_overlay.set_search_contents(needle); + } + + if active { + imp.search_overlay.grab_focus_entry(); + } + } + + pub fn set_search_total(&self, total: Option) { + self.imp().search_overlay.set_search_total(total); + } + + pub fn set_search_selected(&self, selected: Option) { + self.imp().search_overlay.set_search_selected(selected); + } + + /// Handle bell features that need to happen every time a BEL is received + /// Currently this is audio and system but this could change in the future. + fn ring_bell(&self) { + let imp = self.imp(); + + // Emit the signal + self.emit_by_name::<()>("bell", &[]); + + // Activate actions if they exist + let _ = self.activate_action("tab.ring-bell", None); + let _ = self.activate_action("win.ring-bell", None); + + let Some(config_obj) = imp.config.borrow().clone() else { + return; + }; + let config = config_obj.get(); + + // Do our sound + let BellFeatures { audio, .. } = config.bell_features; + if audio { + let Some(config_path) = config.bell_audio_path.as_ref() else { + return; + }; + let (path, required) = match config_path { + ConfigPath::Optional(p) => (p, false), + ConfigPath::Required(p) => (p, true), + }; + + let volume = config.bell_audio_volume.clamp(0.0, 1.0); + + debug_assert!(std::path::Path::new(path).is_absolute()); + let media_file = gtk::MediaFile::for_filename(path); + + // If the audio file is marked as required, we'll emit an error if + // there was a problem playing it. Otherwise there will be silence. + if required { + media_file.connect_notify_local(Some("error"), media_file_error); + } + + // Watch for the "ended" signal so that we can clean up after + // ourselves: the closure owns a strong ref until playback ends. + let keep = RefCell::new(Some(media_file.clone())); + media_file.connect_notify_local(Some("ended"), move |_, _| { + keep.borrow_mut().take(); + }); + + let media_stream = media_file.upcast_ref::(); + media_stream.set_volume(volume); + media_stream.play(); + } + } + + // ----------------------------------------------------------------- + // Gtk.Scrollable + + pub fn get_hadjustment(&self) -> Option { + self.imp().hadj.borrow().clone() + } + + pub fn set_hadjustment(&self, adj: Option<>k::Adjustment>) { + self.imp().hadj.replace(adj.cloned()); + self.notify("hadjustment"); + } + + pub fn get_vadjustment(&self) -> Option { + self.imp().vadj.borrow().clone() + } + + pub fn set_vadjustment(&self, adj: Option<>k::Adjustment>) { + self.imp().vadj.replace(adj.cloned()); + self.notify("vadjustment"); + } + + // ----------------------------------------------------------------- + // Typed signal connectors (Zig: `Surface.signals.*.connect`) + + /// Emitted whenever the bell event is received. Unlike the + /// `bell-ringing` property, this is emitted every time the event + /// is received and not just on state changes. + pub fn connect_bell(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("bell", false, glib::closure_local!(move |s: &Self| f(s))) + } + + /// Emitted whenever the surface would like to be closed for any reason. + /// + /// The surface view does NOT handle its own close confirmation. If there + /// is a process alive then the parent widget should handle this request. + pub fn connect_close_request(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "close-request", + false, + glib::closure_local!(move |s: &Self| f(s)), + ) + } + + /// Emitted whenever the clipboard has been written. + pub fn connect_clipboard_write( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "clipboard-write", + false, + glib::closure_local!(move |s: &Self, kind: u32, text: &str| { + f(s, clipboard_from_u32(kind), text); + }), + ) + } + + /// Emitted whenever the surface reads the clipboard. + pub fn connect_clipboard_read(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "clipboard-read", + false, + glib::closure_local!(move |s: &Self| f(s)), + ) + } + + /// Emitted after the core surface is initialized. + pub fn connect_init(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("init", false, glib::closure_local!(move |s: &Self| f(s))) + } + + /// Emitted just prior to the context menu appearing. + pub fn connect_menu(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("menu", false, glib::closure_local!(move |s: &Self| f(s))) + } + + /// Emitted when the focus wants to be brought to the top and focused. + pub fn connect_present_request(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "present-request", + false, + glib::closure_local!(move |s: &Self| f(s)), + ) + } + + /// Emitted when this surface requests its container to toggle fullscreen. + pub fn connect_toggle_fullscreen(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toggle-fullscreen", + false, + glib::closure_local!(move |s: &Self| f(s)), + ) + } + + /// Emitted when this surface requests its container to toggle maximize. + pub fn connect_toggle_maximize(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toggle-maximize", + false, + glib::closure_local!(move |s: &Self| f(s)), + ) + } + + // ----------------------------------------------------------------- + // Internal + + fn prop_config(&self) { + let imp = self.imp(); + let Some(config_obj) = imp.config.borrow().clone() else { + return; + }; + let config = config_obj.get(); + + // resize-overlay-duration + imp.resize_overlay + .set_property("duration", config.resize_overlay_duration.as_milliseconds()); + + // resize-overlay-position + let (halign, valign) = match config.resize_overlay_position { + ResizeOverlayPosition::Center => (gtk::Align::Center, gtk::Align::Center), + ResizeOverlayPosition::TopLeft => (gtk::Align::Start, gtk::Align::Start), + ResizeOverlayPosition::TopRight => (gtk::Align::End, gtk::Align::Start), + ResizeOverlayPosition::TopCenter => (gtk::Align::Center, gtk::Align::Start), + ResizeOverlayPosition::BottomLeft => (gtk::Align::Start, gtk::Align::End), + ResizeOverlayPosition::BottomRight => (gtk::Align::End, gtk::Align::End), + ResizeOverlayPosition::BottomCenter => (gtk::Align::Center, gtk::Align::End), + }; + imp.resize_overlay.set_property("overlay-halign", halign); + imp.resize_overlay.set_property("overlay-valign", valign); + } + + fn prop_error(&self) { + let imp = self.imp(); + if imp.error.get() { + // Ensure we have an opaque background. The window will NOT set + // this if we have transparency set and we need an opaque + // background for the error message to be readable. + self.add_css_class("background"); + } else { + // Regardless of transparency setting, we remove the background + // CSS class from this widget. Parent widgets will set it + // appropriately (see window.zig for example). + self.remove_css_class("background"); + } + + // We need to set our child property on an idle tick, because the + // error property can be triggered by signals that are in the middle + // of widget mapping and changing our child during that time + // results in a hard gtk crash. + let pending = imp.idle_rechild.take(); + if pending.is_some() { + imp.idle_rechild.set(pending); + } else { + let weak = self.downgrade(); + let id = glib::idle_add_local(move || { + if let Some(this) = weak.upgrade() { + let imp = this.imp(); + let _ = imp.idle_rechild.take(); + if imp.error.get() { + this.set_child(Some(&*imp.error_page)); + } else { + this.set_child(Some(&*imp.terminal_page)); + } + } + glib::ControlFlow::Break + }); + imp.idle_rechild.set(Some(id)); + } + } + + fn prop_mouse_hover_url(&self) { + let imp = self.imp(); + let visible = imp + .mouse_hover_url + .borrow() + .as_ref() + .is_some_and(|v| !v.is_empty()); + imp.url_left.set_visible(visible); + } + + fn prop_mouse_hidden(&self) { + // If we're hidden we set it to "none" + if self.imp().mouse_hidden.get() { + self.set_cursor_from_name(Some("none")); + return; + } + + // If we're not hidden we just trigger the mouse shape + // prop notification to handle setting the proper mouse shape. + self.prop_mouse_shape(); + } + + fn prop_mouse_shape(&self) { + let imp = self.imp(); + + // If our mouse should be hidden currently then we don't do anything. + if imp.mouse_hidden.get() { + return; + } + + // Set our new cursor. + self.set_cursor_from_name(Some(mouse_shape_name(imp.mouse_shape.get()))); + } + + fn prop_vadjustment(&self) { + let imp = self.imp(); + + // Move our value-changed handler to the new adjustment (Zig uses a + // lazily-created GSignalGroup for this). + if let Some((old, id)) = imp.vadj_handler.take() { + old.disconnect(id); + } + let Some(adj) = imp.vadj.borrow().clone() else { + return; + }; + let weak = self.downgrade(); + let id = adj.connect_value_changed(move |adj| { + if let Some(this) = weak.upgrade() { + this.vadj_value_changed(adj); + } + }); + imp.vadj_handler.replace(Some((adj, id))); + } + + fn vadj_value_changed(&self, adj: >k::Adjustment) { + // This will trigger for every single pixel change in the adjustment, + // but our core surface handles the noise from this so that identical + // rows are cheap. + let Some(core_surface) = self.core() else { + return; + }; + let row = adj.value().round().max(0.0) as usize; + if let Err(err) = + core_surface.perform_binding_action(&ghostty_input::binding::Action::ScrollToRow(row)) + { + log::error!("error performing scroll_to_row action err={err}"); + } + } + + fn init_action_map(&self) { + let group = gio::SimpleActionGroup::new(); + + let prompt_title = gio::SimpleAction::new("prompt-title", None); + let weak = self.downgrade(); + prompt_title.connect_activate(move |_, _| { + let Some(this) = weak.upgrade() else { return }; + let Some(surface) = this.core() else { return }; + if let Err(err) = + surface.perform_binding_action(&ghostty_input::binding::Action::PromptSurfaceTitle) + { + log::warn!("unable to perform prompt title action err={err}"); + } + }); + group.add_action(&prompt_title); + + let notify_next = gio::SimpleAction::new_stateful( + "notify-on-next-command-finish", + None, + &false.to_variant(), + ); + notify_next.connect_activate(|action, _| { + let state = action.state().unwrap_or_else(|| false.to_variant()); + let Some(value) = state.get::() else { + return; + }; + action.set_state(&(!value).to_variant()); + }); + group.add_action(¬ify_next); + + self.insert_action_group("surface", Some(&group)); + self.imp().action_group.replace(Some(group)); + } + + fn resize_overlay_schedule(&self) { + let imp = self.imp(); + let Some(surface) = self.core() else { return }; + + // Only show the resize overlay if its enabled + let Some(config_obj) = imp.config.borrow().clone() else { + return; + }; + match config_obj.get().resize_overlay { + ResizeOverlayMode::Always | ResizeOverlayMode::AfterFirst => {} + ResizeOverlayMode::Never => return, + } + + // If we have resize overlays enabled, setup an idler + // to show that. We do this in an idle tick because doing it + // during the resize results in flickering. + let (columns, rows) = surface.grid_size(); + imp.resize_overlay + .set_label(Some(format!("{columns} x {rows}").as_str())); + imp.resize_overlay.schedule(); + } + + /// The first `focused` surface other than ourselves, used to inherit the + /// working directory like `App.focusedSurface` does in the core. + fn focused_surface_pwd(&self) -> Option { + Surface::all() + .into_iter() + .find(|s| s != self && s.core().is_some() && s.get_focused()) + .and_then(|s| s.get_pwd()) + } + + fn init_surface(&self) -> Result<(), InitError> { + let imp = self.imp(); + debug_assert!(imp.core_surface.borrow().is_none()); + let gl_area = imp.gl_area.clone(); + + // We need to make the context current so we can call GL functions. + // This is required for all surface operations. + gl_area.make_current(); + if let Some(err) = gl_area.error() { + log::warn!("failed to make GL context current: {}", err.message()); + log::warn!("this error is usually due to a driver or gtk bug"); + log::warn!( + "this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950" + ); + return Err(InitError::GlArea); + } + + let application = app(); + let base: std::sync::Arc = imp + .config + .borrow() + .as_ref() + .map(Config::get) + .ok_or(InitError::Surface)?; + + // Initialize our surface configuration. + let mut config = ghostty_apprt::surface::new_config( + &base, + imp.context.get(), + self.focused_surface_pwd(), + ); + + let overrides = imp.overrides.borrow().clone(); + if let Some(c) = overrides.command { + config.command = Some(c); + } + if let Some(wd) = overrides.working_directory { + let mut wd_val = WorkingDirectory::Path(wd); + wd_val.finalize(); + config.working_directory = Some(wd_val); + } + + // Properties that can impact surface init + if let Some(size) = *imp.font_size_request.borrow() { + config.font_size = size.0.points; + } + if let Some(pwd) = imp.pwd.borrow().clone() { + let mut wd_val = WorkingDirectory::Path(pwd); + wd_val.finalize(); + config.working_directory = Some(wd_val); + } + + // Initialize the surface + let core = application + .core() + .new_surface(self.id(), config, application.rt(), self.rt()) + .map_err(|err| { + log::warn!("failed to initialize surface err={err}"); + InitError::Surface + })?; + + // Store it! + imp.core_surface.replace(Some(core)); + + // Emit the signal that we initialized the surface. + self.emit_by_name::<()>("init", &[]); + Ok(()) + } +} + +#[derive(Debug)] +enum InitError { + GlArea, + Surface, +} + +fn clipboard_from_u32(v: u32) -> Clipboard { + match v { + 0 => Clipboard::Standard, + 1 => Clipboard::Selection, + _ => Clipboard::Primary, + } +} + +fn media_file_error(media_file: >k::MediaFile, _: &glib::ParamSpec) { + let path = media_file + .file() + .and_then(|f| f.path()) + .map_or_else(|| "<>".to_owned(), |p| p.display().to_string()); + + let media_stream = media_file.upcast_ref::(); + let Some(err) = media_stream.error() else { + return; + }; + log::warn!( + "error playing bell from {}: {:?} {} {}", + path, + err.domain(), + err.code(), + err.message() + ); +} + +/// Translate a GTK mouse button number to the input mouse button. +fn translate_mouse_button(button: u32) -> MouseButton { + match button { + 1 => MouseButton::Left, + 2 => MouseButton::Middle, + 3 => MouseButton::Right, + 4 => MouseButton::Four, + 5 => MouseButton::Five, + 6 => MouseButton::Six, + 7 => MouseButton::Seven, + 8 => MouseButton::Eight, + 9 => MouseButton::Nine, + 10 => MouseButton::Ten, + 11 => MouseButton::Eleven, + _ => MouseButton::Unknown, + } +} + +/// CSS cursor name for the given mouse shape. +fn mouse_shape_name(shape: MouseShape) -> &'static str { + match shape { + MouseShape::Default => "default", + MouseShape::Help => "help", + MouseShape::Pointer => "pointer", + MouseShape::ContextMenu => "context-menu", + MouseShape::Progress => "progress", + MouseShape::Wait => "wait", + MouseShape::Cell => "cell", + MouseShape::Crosshair => "crosshair", + MouseShape::Text => "text", + MouseShape::VerticalText => "vertical-text", + MouseShape::Alias => "alias", + MouseShape::Copy => "copy", + MouseShape::NoDrop => "no-drop", + MouseShape::Move => "move", + MouseShape::NotAllowed => "not-allowed", + MouseShape::Grab => "grab", + MouseShape::Grabbing => "grabbing", + MouseShape::AllScroll => "all-scroll", + MouseShape::ColResize => "col-resize", + MouseShape::RowResize => "row-resize", + MouseShape::NResize => "n-resize", + MouseShape::EResize => "e-resize", + MouseShape::SResize => "s-resize", + MouseShape::WResize => "w-resize", + MouseShape::NeResize => "ne-resize", + MouseShape::NwResize => "nw-resize", + MouseShape::SeResize => "se-resize", + MouseShape::SwResize => "sw-resize", + MouseShape::EwResize => "ew-resize", + MouseShape::NsResize => "ns-resize", + MouseShape::NeswResize => "nesw-resize", + MouseShape::NwseResize => "nwse-resize", + MouseShape::ZoomIn => "zoom-in", + MouseShape::ZoomOut => "zoom-out", + } +} + +fn mouse_shape_from_i32(v: i32) -> MouseShape { + // Keep in sync with the `MouseShape` discriminants (`repr(i32)`). + const ALL: [MouseShape; 34] = [ + MouseShape::Default, + MouseShape::ContextMenu, + MouseShape::Help, + MouseShape::Pointer, + MouseShape::Progress, + MouseShape::Wait, + MouseShape::Cell, + MouseShape::Crosshair, + MouseShape::Text, + MouseShape::VerticalText, + MouseShape::Alias, + MouseShape::Copy, + MouseShape::Move, + MouseShape::NoDrop, + MouseShape::NotAllowed, + MouseShape::Grab, + MouseShape::Grabbing, + MouseShape::AllScroll, + MouseShape::ColResize, + MouseShape::RowResize, + MouseShape::NResize, + MouseShape::EResize, + MouseShape::SResize, + MouseShape::WResize, + MouseShape::NeResize, + MouseShape::NwResize, + MouseShape::SeResize, + MouseShape::SwResize, + MouseShape::EwResize, + MouseShape::NsResize, + MouseShape::NeswResize, + MouseShape::NwseResize, + MouseShape::ZoomIn, + MouseShape::ZoomOut, + ]; + usize::try_from(v) + .ok() + .and_then(|i| ALL.get(i).copied()) + .unwrap_or_default() +} + +/// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped +/// to [0, 100]. +fn compute_fraction(progress: u8) -> f64 { + f64::from(progress.min(100)) / 100.0 +} + +// --------------------------------------------------------------------- +// Apprt Surface (Surface.zig) + +/// The object handed to the core as its runtime surface. Holds only a weak +/// reference to the widget. +pub struct RtSurfaceHandle { + surface: glib::WeakRef, +} + +impl RtSurfaceHandle { + /// The GObject surface for this apprt surface. + pub fn gobj(&self) -> Option { + self.surface.upgrade() + } +} + +impl RtSurface for RtSurfaceHandle { + fn close(&self, _process_active: bool) { + if let Some(s) = self.gobj() { + s.close(); + } + } + + fn cgroup(&self) -> Option { + // Zig's `Surface.cgroup()` forwards to a method that doesn't exist + // (never instantiated); there is no per-surface cgroup path. + None + } + + fn get_title(&self) -> Option { + self.gobj().and_then(|s| s.get_title()) + } + + fn get_content_scale(&self) -> ContentScale { + self.gobj() + .map_or(ContentScale { x: 1.0, y: 1.0 }, |s| s.get_content_scale()) + } + + fn get_size(&self) -> SurfaceSize { + self.gobj().map_or( + SurfaceSize { + width: 0, + height: 0, + }, + |s| s.get_size(), + ) + } + + fn get_cursor_pos(&self) -> CursorPos { + self.gobj() + .map_or(CursorPos { x: 0.0, y: 0.0 }, |s| s.get_cursor_pos()) + } + + fn supports_clipboard(&self, clipboard: Clipboard) -> bool { + match clipboard { + Clipboard::Standard | Clipboard::Selection | Clipboard::Primary => true, + } + } + + fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool { + self.gobj() + .is_some_and(|s| s.clipboard_request(clipboard, state)) + } + + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) { + if let Some(s) = self.gobj() { + s.set_clipboard(clipboard, contents, confirm); + } + } + + fn default_termio_env(&self) -> Vec<(String, String)> { + self.gobj() + .map(|s| s.default_termio_env()) + .unwrap_or_default() + } + + fn redraw_inspector(&self) { + if let Some(s) = self.gobj() { + s.redraw_inspector(); + } + } +} + +// --------------------------------------------------------------------- +// Clipboard + +/// A namespace for our clipboard-related functions so Surface isn't SO large. +mod clipboard { + use super::{ + Clipboard, ClipboardConfirmationDialog, ClipboardError, ClipboardRequest, Surface, gdk, + gio, glib, + }; + use adw::prelude::*; + use adw::subclass::prelude::ObjectSubclassIsExt; + + /// Set the clipboard contents. + pub(super) fn set( + surface: &Surface, + clipboard_type: Clipboard, + contents: &[(String, String)], + confirm: bool, + ) { + // Grab our plaintext content for use in confirmation dialogs + // and signals. We always expect one to exist. + let Some(text) = contents + .iter() + .find(|(mime, _)| mime == "text/plain") + .map(|(_, data)| data.clone()) + else { + return; + }; + + // If no confirmation is necessary, set the clipboard. + if !confirm { + let clipboard = get(surface.imp().gl_area.upcast_ref(), clipboard_type); + + let providers: Vec = contents + .iter() + .map(|(mime, data)| { + let bytes = glib::Bytes::from(data.as_bytes()); + if mime == "text/plain" { + // Add an explicit UTF-8 encoding parameter to the + // text/plain type. The default charset when there is + // none is ASCII, and lots of things look for UTF-8 + // specifically. The specs are not clear about the + // order here, but some clients apparently pick the + // first match in the order we set here then garble up + // bare 'text/plain' with non-ASCII UTF-8 content, so + // offer UTF-8 first. + // + // Note that under X11, GTK automatically adds the + // UTF8_STRING atom when this is present. + let text_providers = [ + gdk::ContentProvider::for_bytes("text/plain;charset=utf-8", &bytes), + gdk::ContentProvider::for_bytes("text/plain", &bytes), + ]; + gdk::ContentProvider::new_union(&text_providers) + } else { + gdk::ContentProvider::for_bytes(mime, &bytes) + } + }) + .collect(); + + let all = gdk::ContentProvider::new_union(&providers); + if let Err(err) = clipboard.set_content(Some(&all)) { + log::warn!("failed to set clipboard content: {err}"); + } + + surface.emit_by_name::<()>( + "clipboard-write", + &[&clipboard_to_u32(clipboard_type), &text], + ); + + return; + } + + show_clipboard_confirmation(surface, ClipboardRequest::Osc52Write(clipboard_type), &text); + } + + fn clipboard_to_u32(c: Clipboard) -> u32 { + match c { + Clipboard::Standard => 0, + Clipboard::Selection => 1, + Clipboard::Primary => 2, + } + } + + /// Request data from the clipboard (read the clipboard). This + /// completes asynchronously and will call the `complete_clipboard_request` + /// core surface API when done. + /// + /// Returns true if the request was started, false if the clipboard + /// doesn't contain text (allowing performable keybinds to pass through). + pub(super) fn request( + surface: &Surface, + clipboard_type: Clipboard, + state: ClipboardRequest, + ) -> bool { + // Get our requested clipboard + let clipboard = get(surface.imp().gl_area.upcast_ref(), clipboard_type); + + // For paste requests, check if clipboard has text format available. + // This is a synchronous check that allows performable keybinds to + // pass through when the clipboard contains non-text content (e.g., + // images). + if state == ClipboardRequest::Paste + && !clipboard.formats().contains_type(glib::Type::STRING) + { + log::debug!("clipboard has no text format, not starting paste request"); + return false; + } + + // Important: we hold a strong ref to the surface in the callback so + // that we can't free memory while we have an outstanding clipboard + // read. + let this = surface.clone(); + clipboard.read_text_async(None::<&gio::Cancellable>, move |res| { + let text = match res { + Ok(Some(t)) => t, + Ok(None) => return, + Err(err) => { + log::warn!("failed to read clipboard err={}", err.message()); + return; + } + }; + + let Some(core) = this.core() else { return }; + match core.complete_clipboard_request(state, &text, false) { + Ok(()) => {} + Err(ClipboardError::UnsafePaste | ClipboardError::UnauthorizedPaste) => { + show_clipboard_confirmation(&this, state, &text); + return; + } + Err(err) => { + log::warn!("failed to complete clipboard request err={err}"); + return; + } + } + + this.emit_by_name::<()>("clipboard-read", &[]); + }); + + true + } + + /// Paste explicit text directly into the surface, regardless of the + /// actual clipboard contents. + pub(super) fn paste(surface: &Surface, text: &str) { + if text.is_empty() { + return; + } + + let Some(core) = surface.core() else { return }; + match core.complete_clipboard_request(ClipboardRequest::Paste, text, false) { + Ok(()) => {} + Err(ClipboardError::UnsafePaste | ClipboardError::UnauthorizedPaste) => { + show_clipboard_confirmation(surface, ClipboardRequest::Paste, text); + } + Err(err) => log::warn!("failed to complete clipboard request err={err}"), + } + } + + /// Get the specific type of clipboard for a widget. + fn get(widget: >k::Widget, clipboard: Clipboard) -> gdk::Clipboard { + match clipboard { + Clipboard::Standard => widget.clipboard(), + Clipboard::Selection | Clipboard::Primary => widget.primary_clipboard(), + } + } + + fn show_clipboard_confirmation(surface: &Surface, req: ClipboardRequest, text: &str) { + // Build a text buffer for our contents + let contents_buf = gtk::TextBuffer::new(None); + contents_buf.insert_at_cursor(text); + + // Confirm + let dialog = ClipboardConfirmationDialog::new(); + dialog.set_request_value(req); + dialog.set_can_remember(match req { + ClipboardRequest::Osc52Read(_) | ClipboardRequest::Osc52Write(_) => true, + ClipboardRequest::Paste => false, + }); + dialog.set_clipboard_contents(Some(contents_buf.clone())); + + let weak = surface.downgrade(); + let buf = contents_buf.clone(); + dialog.connect_confirm(move |_, remember| { + let Some(this) = weak.upgrade() else { return }; + let Some(core) = this.core() else { return }; + + // Handle remember + if remember { + match req { + ClipboardRequest::Osc52Read(_) => core.set_clipboard_read_allowed(true), + ClipboardRequest::Osc52Write(_) => core.set_clipboard_write_allowed(true), + ClipboardRequest::Paste => {} + } + } + + // Get our text + let text = buf.text(&buf.start_iter(), &buf.end_iter(), false); + if let Err(err) = core.complete_clipboard_request(req, &text, true) { + log::warn!("failed to complete clipboard request: {err}"); + } + }); + + let weak = surface.downgrade(); + dialog.connect_deny(move |_, remember| { + let Some(this) = weak.upgrade() else { return }; + let Some(core) = this.core() else { return }; + + // Handle remember + if remember { + match req { + ClipboardRequest::Osc52Read(_) => core.set_clipboard_read_allowed(false), + ClipboardRequest::Osc52Write(_) => core.set_clipboard_write_allowed(false), + ClipboardRequest::Paste => { + unreachable!("paste should not be able to be remembered") + } + } + } + }); + + dialog.present(Some(surface)); + } +} + +// --------------------------------------------------------------------- +// GObject implementation + +mod imp { + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties)] + #[properties(wrapper_type = super::SurfaceProps)] + #[allow(clippy::struct_field_names)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.2/surface.ui")] + pub struct Surface { + /// This property is set to true when the bell is ringing. Note that + /// this property will only emit a changed signal when there is a + /// full state change. If a bell is ringing and another bell event + /// comes through, the change notification will NOT be emitted. + /// + /// If you need to know every scenario the bell is triggered, + /// listen to the `bell` signal instead. + #[property(get, set = Self::set_bell_ringing_prop, explicit_notify, name = "bell-ringing")] + pub bell_ringing: Cell, + + /// The configuration that this surface is using. + #[property(get, set, nullable)] + pub config: RefCell>, + + /// True when the child has exited. + #[property(get, set, name = "child-exited")] + pub child_exited: Cell, + + /// The default size for a window that embeds this surface. + #[property(get, set, nullable, name = "default-size")] + pub default_size: RefCell>, + + /// True if this surface is in an error state. This is currently + /// a simple boolean with no additional information on WHAT the + /// error state is, because we don't yet need it or use it. For now, + /// if this is true, then it means the terminal is non-functional. + #[property(get, set, name = "error")] + pub error: Cell, + + /// The requested font size. This only applies to initialization + /// and has no effect later. + #[property(get, set, nullable, name = "font-size-request")] + pub font_size_request: RefCell>, + + /// The current focus state of the terminal based on the + /// focus events. + #[property(get, set)] + pub focused: Cell, + + /// The minimum size for this surface. Embedders enforce this, + /// not the surface itself. + #[property(get, set, nullable, name = "min-size")] + pub min_size: RefCell>, + + /// Whether the mouse should be hidden or not as requested + /// externally. + #[property(get, set, name = "mouse-hidden")] + pub mouse_hidden: Cell, + + /// The mouse shape to show for the surface. Exposed as the + /// `MouseShape` discriminant (Zig registers a GEnum). + #[property( + get = Self::mouse_shape_prop, + set = Self::set_mouse_shape_prop, + type = i32, + name = "mouse-shape", + minimum = 0, + maximum = 33, + default = 8 + )] + pub mouse_shape: Cell, + + /// The URL that the mouse is currently hovering over. + #[property(get, set, nullable, name = "mouse-hover-url")] + pub mouse_hover_url: RefCell>, + + /// The current working directory. This has to be reported + /// externally, usually by shell integration which then talks to + /// libghostty which triggers this property. + /// + /// If this is set prior to initialization then the surface will + /// start in this pwd. If it is set after, it has no impact on the + /// core surface. + #[property(get, set, nullable)] + pub pwd: RefCell>, + + /// The title of this surface, if any has been set. + #[property(get, set, nullable)] + pub title: RefCell>, + + /// The manually overridden title of this surface from + /// `prompt_title`. + #[property(get, set, nullable, name = "title-override")] + pub title_override: RefCell>, + + /// Whether this surface is "zoomed" or not. A zoomed surface + /// shows up taking the full bounds of a split view. + #[property(get, set)] + pub zoom: Cell, + + /// True if the current surface is a split, this is used to apply + /// unfocused-split-* options. + #[property(get, set, name = "is-split")] + pub is_split: Cell, + + // Gtk.Scrollable interface adjustments + #[property( + get, + set = Self::set_hadjustment_prop, + nullable, + override_interface = gtk::Scrollable, + name = "hadjustment" + )] + pub hadj: RefCell>, + #[property( + get, + set = Self::set_vadjustment_prop, + nullable, + override_interface = gtk::Scrollable, + name = "vadjustment" + )] + pub vadj: RefCell>, + #[property(get, set, override_interface = gtk::Scrollable, name = "hscroll-policy")] + pub hscroll_policy: Cell, + #[property(get, set, override_interface = gtk::Scrollable, name = "vscroll-policy")] + pub vscroll_policy: Cell, + + /// Key sequence state (human readable labels). + #[property(get = Self::key_sequence_prop, type = Vec, name = "key-sequence")] + pub key_sequence: RefCell>, + + /// Key table stack. + #[property(get = Self::key_table_prop, type = Vec, name = "key-table")] + pub key_tables: RefCell>, + + /// Read-only mode state (from the core surface). + #[property(get = Self::readonly_prop, type = bool, name = "readonly")] + _readonly: Cell, + + // ---- non-property state ---- + pub id: Cell, + pub rt_surface: RefCell>>, + /// The core surface backing this GTK surface. This starts out + /// None because it can't be initialized until there is an available + /// GLArea that is realized. + pub core_surface: RefCell>>, + + /// Cached metrics for libghostty callbacks + pub size: Cell, + pub cursor_pos: Cell, + + /// Various input method state. All related to key input. + pub in_keyevent: Cell, + pub im_composing: Cell, + pub im_buf: RefCell, + + /// True when we have a precision scroll in progress + pub precision_scroll: Cell, + + // Progress bar + pub progress_bar_timer: Cell>, + + /// The source that handles setting our child property. + pub idle_rechild: Cell>, + + pub action_group: RefCell>, + pub vadj_handler: RefCell>, + + /// The context for this surface (window, tab, or split) + pub context: Cell, + + /// Whether primary paste (middle-click paste) is enabled. + pub gtk_enable_primary_paste: Cell, + + /// True when a left mouse down was consumed purely for a focus + /// change, and the matching left mouse release should also be + /// suppressed. + pub suppress_left_mouse_release: Cell, + + /// How much pending horizontal scroll do we have? + pub pending_horizontal_scroll: Cell, + + /// Timer to reset the amount of horizontal scroll if the user + /// stops scrolling. + pub pending_horizontal_scroll_reset: Cell>, + + pub overrides: RefCell, + + // Template children + #[template_child] + pub gl_area: TemplateChild, + #[template_child] + pub url_left: TemplateChild, + #[template_child] + pub url_right: TemplateChild, + #[template_child] + pub resize_overlay: TemplateChild, + #[template_child] + pub search_overlay: TemplateChild, + #[template_child] + pub key_state_overlay: TemplateChild, + #[template_child] + pub child_exited_overlay: TemplateChild, + #[template_child] + pub context_menu: TemplateChild, + #[template_child] + pub drop_target: TemplateChild, + #[template_child] + pub progress_bar_overlay: TemplateChild, + #[template_child] + pub error_page: TemplateChild, + #[template_child] + pub terminal_page: TemplateChild, + #[template_child] + pub im_context: TemplateChild, + } + + impl Default for Surface { + fn default() -> Self { + Self { + bell_ringing: Cell::new(false), + config: RefCell::new(None), + child_exited: Cell::new(false), + default_size: RefCell::new(None), + error: Cell::new(false), + font_size_request: RefCell::new(None), + focused: Cell::new(true), + min_size: RefCell::new(None), + mouse_hidden: Cell::new(false), + mouse_shape: Cell::new(MouseShape::Text), + mouse_hover_url: RefCell::new(None), + pwd: RefCell::new(None), + title: RefCell::new(None), + title_override: RefCell::new(None), + zoom: Cell::new(false), + is_split: Cell::new(false), + hadj: RefCell::new(None), + vadj: RefCell::new(None), + hscroll_policy: Cell::new(gtk::ScrollablePolicy::Natural), + vscroll_policy: Cell::new(gtk::ScrollablePolicy::Natural), + key_sequence: RefCell::new(Vec::new()), + key_tables: RefCell::new(Vec::new()), + _readonly: Cell::new(false), + id: Cell::new(0), + rt_surface: RefCell::new(None), + core_surface: RefCell::new(None), + size: Cell::new(SurfaceSize { + width: 0, + height: 0, + }), + cursor_pos: Cell::new(CursorPos { x: 0.0, y: 0.0 }), + in_keyevent: Cell::new(IMKeyEvent::False), + im_composing: Cell::new(false), + im_buf: RefCell::new(String::new()), + precision_scroll: Cell::new(false), + progress_bar_timer: Cell::new(None), + idle_rechild: Cell::new(None), + action_group: RefCell::new(None), + vadj_handler: RefCell::new(None), + context: Cell::new(NewSurfaceContext::Window), + gtk_enable_primary_paste: Cell::new(true), + suppress_left_mouse_release: Cell::new(false), + pending_horizontal_scroll: Cell::new(0.0), + pending_horizontal_scroll_reset: Cell::new(None), + overrides: RefCell::new(SurfaceOverrides::default()), + gl_area: TemplateChild::default(), + url_left: TemplateChild::default(), + url_right: TemplateChild::default(), + resize_overlay: TemplateChild::default(), + search_overlay: TemplateChild::default(), + key_state_overlay: TemplateChild::default(), + child_exited_overlay: TemplateChild::default(), + context_menu: TemplateChild::default(), + drop_target: TemplateChild::default(), + progress_bar_overlay: TemplateChild::default(), + error_page: TemplateChild::default(), + terminal_page: TemplateChild::default(), + im_context: TemplateChild::default(), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for Surface { + const NAME: &'static str = "GhosttySurface"; + type Type = super::Surface; + type ParentType = adw::Bin; + type Interfaces = (gtk::Scrollable,); + + fn class_init(klass: &mut Self::Class) { + ResizeOverlay::ensure_type(); + SearchOverlay::ensure_type(); + KeyStateOverlay::ensure_type(); + SurfaceChildExited::ensure_type(); + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl Surface { + fn mouse_shape_prop(&self) -> i32 { + self.mouse_shape.get() as i32 + } + + fn set_mouse_shape_prop(&self, v: i32) { + self.mouse_shape.set(mouse_shape_from_i32(v)); + } + + fn key_sequence_prop(&self) -> Vec { + self.key_sequence.borrow().clone() + } + + fn key_table_prop(&self) -> Vec { + self.key_tables.borrow().clone() + } + + fn readonly_prop(&self) -> bool { + self.obj().get_readonly() + } + + pub(super) fn set_bell_ringing_prop(&self, ringing: bool) { + let obj = self.obj(); + + // Prevent duplicate change notifications if the signals we emit + // in this function cause this state to change again. + let _guard = obj.freeze_notify(); + + // Logic around bell reaction happens on every event even if + // we're already in the ringing state. + if ringing { + obj.ring_bell(); + } + + // Property change only happens on actual state change + if self.bell_ringing.get() != ringing { + self.bell_ringing.set(ringing); + obj.notify("bell-ringing"); + } + } + + fn set_hadjustment_prop(&self, adj: Option) { + // Notification is emitted by the property machinery. + self.hadj.replace(adj); + } + + fn set_vadjustment_prop(&self, adj: Option) { + self.vadj.replace(adj); + } + } + + impl ObjectImpl for Surface { + fn properties() -> &'static [glib::ParamSpec] { + Self::derived_properties() + } + + fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + self.derived_set_property(id, value, pspec); + } + + fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value { + self.derived_property(id, pspec) + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("bell").build(), + Signal::builder("close-request").build(), + Signal::builder("clipboard-write") + .param_types([u32::static_type(), String::static_type()]) + .build(), + Signal::builder("clipboard-read").build(), + Signal::builder("init").build(), + Signal::builder("menu").build(), + Signal::builder("present-request").build(), + Signal::builder("toggle-fullscreen").build(), + Signal::builder("toggle-maximize").build(), + ] + }) + } + + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + // Register ourselves so the core's `Target::Surface(id)` can be + // mapped back to this widget. + let id = NEXT_ID.with(|n| { + let id = n.get(); + n.set(id + 1); + id + }); + self.id.set(id); + SURFACES.with(|m| m.borrow_mut().insert(id, obj.downgrade())); + + // Initialize our actions + obj.init_action_map(); + + // If our configuration is null then we get the configuration + // from the application. + if self.config.borrow().is_none() { + self.config.replace(Some(app().get_config())); + } + + // Read GTK primary paste setting + self.gtk_enable_primary_paste.set( + crate::gsettings::get_bool(crate::gsettings::Key::GtkEnablePrimaryPaste) + .unwrap_or(true), + ); + + // Set up to handle items being dropped on our surface. Files can + // be dropped from Nautilus and strings can be dropped from many + // programs. The order of these types matter. + self.drop_target.set_types(&[ + gdk::FileList::static_type(), + gio::File::static_type(), + String::static_type(), + ]); + + // Setup properties we can't set from our Blueprint file. + obj.set_cursor_from_name(Some("text")); + + // Initialize our config + obj.prop_config(); + } + + fn dispose(&self) { + if let Some((adj, id)) = self.vadj_handler.take() { + adj.disconnect(id); + } + self.hadj.replace(None); + self.vadj.replace(None); + + if let Some(timer) = self.progress_bar_timer.take() { + timer.remove(); + } + if let Some(src) = self.idle_rechild.take() { + src.remove(); + } + if let Some(src) = self.pending_horizontal_scroll_reset.take() { + src.remove(); + } + + // This works around a GTK double-free bug where if you bind + // to a top-level template child, it frees twice if the widget is + // also the root child of the template. By unsetting the child + // here, we avoid the double-free. + self.obj().set_child(gtk::Widget::NONE); + + self.dispose_template(); + } + } + + impl Drop for Surface { + // Zig deinitializes the core surface in `finalize`, NOT `dispose`, + // because the inspector relies on that with a weakRef. + fn drop(&mut self) { + let id = self.id.get(); + let _ = SURFACES.try_with(|m| { + if let Ok(mut m) = m.try_borrow_mut() { + m.remove(&id); + } + }); + if let Some(core) = self.core_surface.take() { + // Remove ourselves from the list of known surfaces in the + // app. We do this before deinit in case a callback triggers + // searching for this surface. + if let Some(a) = + gio::Application::default().and_then(|a| a.downcast::().ok()) + { + a.core().delete_surface(id); + } + drop(core); + } + } + } + + impl WidgetImpl for Surface {} + impl BinImpl for Surface {} + impl ScrollableImpl for Surface {} + + #[gtk::template_callbacks] + impl Surface { + // ---- closures (template expressions) ---- + + /// Callback used to determine whether border should be shown around + /// the surface. + #[template_callback(function)] + fn should_border_be_shown(config: Option, bell_ringing: bool) -> bool { + // If the bell isn't ringing exit early because when the surface + // is first created there's a race between this code being run + // and the config being set on the surface. That way we don't + // overwhelm people with the warning that we issue if the config + // isn't set and overwhelm ourselves with large numbers of bug + // reports. + if !bell_ringing { + return false; + } + + let Some(config) = config else { + log::warn!( + "config unavailable for computing whether border should be shown, likely bug" + ); + return false; + }; + + config.get().bell_features.border + } + + /// Callback used to determine whether unfocused-split-fill / + /// unfocused-split-opacity should be applied to the surface + #[template_callback(function)] + fn should_unfocused_split_be_shown( + search_active: bool, + focused: bool, + is_split: bool, + ) -> bool { + !search_active && !focused && is_split + } + + // ---- property notifications ---- + + #[template_callback] + fn notify_config( + _emitter: &super::Surface, + _pspec: &glib::ParamSpec, + this: &super::Surface, + ) { + this.prop_config(); + } + + #[template_callback] + fn notify_error( + _emitter: &super::Surface, + _pspec: &glib::ParamSpec, + this: &super::Surface, + ) { + this.prop_error(); + } + + #[template_callback] + fn notify_mouse_hover_url( + _emitter: &super::Surface, + _pspec: &glib::ParamSpec, + this: &super::Surface, + ) { + this.prop_mouse_hover_url(); + } + + #[template_callback] + fn notify_mouse_hidden( + _emitter: &super::Surface, + _pspec: &glib::ParamSpec, + this: &super::Surface, + ) { + this.prop_mouse_hidden(); + } + + #[template_callback] + fn notify_mouse_shape( + _emitter: &super::Surface, + _pspec: &glib::ParamSpec, + this: &super::Surface, + ) { + this.prop_mouse_shape(); + } + + #[template_callback] + fn notify_vadjustment( + _emitter: &super::Surface, + _pspec: &glib::ParamSpec, + this: &super::Surface, + ) { + this.prop_vadjustment(); + } + + // ---- child widgets ---- + + #[template_callback] + fn child_exited_close(_overlay: &SurfaceChildExited, this: &super::Surface) { + // This closes the surface with no confirmation. + this.close(); + } + + #[template_callback] + fn context_menu_closed(_popover: >k::PopoverMenu, this: &super::Surface) { + // When the context menu closes, it moves focus back to the tab + // bar if there are tabs. That's not correct. We need to grab it + // on the surface. + this.grab_focus(); + } + + #[template_callback] + fn search_stop(_overlay: &SearchOverlay, this: &super::Surface) { + let Some(surface) = this.core() else { return }; + if let Err(err) = + surface.perform_binding_action(&ghostty_input::binding::Action::EndSearch) + { + log::warn!("unable to perform end_search action err={err}"); + } + this.imp().gl_area.grab_focus(); + } + + #[template_callback] + fn search_changed(_overlay: &SearchOverlay, needle: Option, this: &super::Surface) { + let Some(surface) = this.core() else { return }; + if let Err(err) = surface.perform_binding_action( + &ghostty_input::binding::Action::Search(needle.unwrap_or_default()), + ) { + log::warn!("unable to perform search action err={err}"); + } + } + + #[template_callback] + fn search_next_match(_overlay: &SearchOverlay, this: &super::Surface) { + let Some(surface) = this.core() else { return }; + if let Err(err) = + surface.perform_binding_action(&ghostty_input::binding::Action::NavigateSearch( + ghostty_input::binding::action::NavigateSearch::Next, + )) + { + log::warn!("unable to perform navigate_search action err={err}"); + } + } + + #[template_callback] + fn search_previous_match(_overlay: &SearchOverlay, this: &super::Surface) { + let Some(surface) = this.core() else { return }; + if let Err(err) = + surface.perform_binding_action(&ghostty_input::binding::Action::NavigateSearch( + ghostty_input::binding::action::NavigateSearch::Previous, + )) + { + log::warn!("unable to perform navigate_search action err={err}"); + } + } + + #[template_callback] + fn url_mouse_enter( + _ec: >k::EventControllerMotion, + _x: f64, + _y: f64, + this: &super::Surface, + ) { + this.imp().url_right.set_visible(true); + } + + #[template_callback] + fn url_mouse_leave(_ec: >k::EventControllerMotion, this: &super::Surface) { + this.imp().url_right.set_visible(false); + } + + // ---- drag & drop ---- + + #[template_callback] + #[allow(clippy::needless_pass_by_value)] + fn drop( + _dt: >k::DropTarget, + value: glib::Value, + _x: f64, + _y: f64, + this: &super::Surface, + ) -> bool { + if let Ok(list) = value.get::() { + let mut escaped = Vec::new(); + { + let mut w = ghostty_os::ShellEscapeWriter::new(&mut escaped); + for file in list.files() { + let Some(path) = file.path() else { continue }; + let path = path.to_string_lossy().into_owned(); + if let Err(err) = w + .write_all(path.as_bytes()) + .and_then(|()| w.write_all(b"\n")) + { + log::error!("unable to write path to buffer: {err}"); + } + } + } + let string = String::from_utf8_lossy(&escaped).into_owned(); + this.paste(&string); + return true; + } + + if let Ok(file) = value.get::() { + let Some(path) = file.path() else { + return false; + }; + let mut escaped = Vec::new(); + { + let mut w = ghostty_os::ShellEscapeWriter::new(&mut escaped); + if let Err(err) = w + .write_all(path.to_string_lossy().as_bytes()) + .and_then(|()| w.write_all(b"\n")) + { + log::error!("unable to write path to buffer: {err}"); + return false; + } + } + // NOTE: faithful to Zig, which builds the escaped string for + // a single dropped `GFile` but never pastes it. + return true; + } + + if value.type_().is_a(glib::Type::STRING) { + if let Ok(Some(string)) = value.get::>() { + this.paste(&string); + } + return true; + } + + true + } + + // ---- keyboard ---- + + #[template_callback] + fn key_pressed( + ec_key: >k::EventControllerKey, + keyval: u32, + keycode: u32, + state: gdk::ModifierType, + this: &super::Surface, + ) -> bool { + this.key_event(KeyAction::Press, ec_key, keyval, keycode, state) + } + + #[template_callback] + fn key_released( + ec_key: >k::EventControllerKey, + keyval: u32, + keycode: u32, + state: gdk::ModifierType, + this: &super::Surface, + ) { + let _ = this.key_event(KeyAction::Release, ec_key, keyval, keycode, state); + } + + #[template_callback] + fn focus_enter(_ec: >k::EventControllerFocus, this: &super::Surface) { + let imp = this.imp(); + imp.focused.set(true); + imp.im_context.focus_in(); + idle_focus(this); + this.notify("focused"); + + // Bell stops ringing as soon as we gain focus + this.set_bell_ringing(false); + } + + #[template_callback] + fn focus_leave(_ec: >k::EventControllerFocus, this: &super::Surface) { + let imp = this.imp(); + imp.focused.set(false); + imp.im_context.focus_out(); + idle_focus(this); + this.notify("focused"); + } + + // ---- mouse ---- + + #[template_callback] + fn mouse_down( + gesture: >k::GestureClick, + _n_press: i32, + x: f64, + y: f64, + this: &super::Surface, + ) { + let Some(event) = gesture.current_event() else { + return; + }; + + // Bell stops ringing if any mouse button is pressed. + this.set_bell_ringing(false); + + // Get our surface. If we don't have one, ignore this. + let imp = this.imp(); + let Some(core_surface) = this.core() else { + return; + }; + + // If we don't have focus, grab it. + let had_focus = imp.gl_area.has_focus(); + if !had_focus { + imp.gl_area.grab_focus(); + } + + // Report the event + let button = translate_mouse_button(gesture.current_button()); + + // If this click is only transitioning split focus, suppress it so + // it doesn't get forwarded to the terminal as a mouse event. + if !had_focus && button == MouseButton::Left { + imp.suppress_left_mouse_release.set(true); + return; + } + + if button == MouseButton::Middle && !imp.gtk_enable_primary_paste.get() { + return; + } + + let consumed = { + let mods = gtk_key::translate_mods(event.modifier_state()); + match core_surface.mouse_button_callback(MouseButtonState::Press, button, mods) { + Ok(c) => c, + Err(err) => { + log::warn!("error in key callback err={err}"); + false + } + } + }; + + // If a right click isn't consumed, mouse_button_callback selects + // the hovered word and returns false. We can use this to handle + // the context menu opening under normal scenarios. + if !consumed && button == MouseButton::Right { + this.emit_by_name::<()>("menu", &[]); + + let rect = gdk::Rectangle::new(x as i32, y as i32, 1, 1); + let popover = imp.context_menu.upcast_ref::(); + popover.set_pointing_to(Some(&rect)); + popover.popup(); + } + } + + #[template_callback] + fn mouse_up( + gesture: >k::GestureClick, + _n_press: i32, + _x: f64, + _y: f64, + this: &super::Surface, + ) { + let Some(event) = gesture.current_event() else { + return; + }; + + let imp = this.imp(); + let Some(surface) = this.core() else { return }; + let gtk_mods = event.modifier_state(); + let button = translate_mouse_button(gesture.current_button()); + + if button == MouseButton::Left && imp.suppress_left_mouse_release.get() { + imp.suppress_left_mouse_release.set(false); + return; + } + + if button == MouseButton::Middle && !imp.gtk_enable_primary_paste.get() { + return; + } + + let mods = gtk_key::translate_mods(gtk_mods); + let consumed = + match surface.mouse_button_callback(MouseButtonState::Release, button, mods) { + Ok(c) => c, + Err(err) => { + log::warn!("error in key callback err={err}"); + return; + } + }; + + // Trigger the on-screen keyboard if we have no selection, and + // that the mouse event hasn't been intercepted by the callback. + // + // It's better to do this here rather than within the core + // callback since we have direct access to the underlying + // gdk.Event here. + if !consumed + && button == MouseButton::Left + && !surface.has_selection() + && !this.show_on_screen_keyboard_event(Some(&event)) + { + log::warn!("failed to activate the on-screen keyboard"); + } + } + + #[template_callback] + fn mouse_motion(ec: >k::EventControllerMotion, x: f64, y: f64, this: &super::Surface) { + let Some(event) = ec.current_event() else { + return; + }; + let imp = this.imp(); + + let (sx, sy) = this.scaled_coordinates(x, y); + let pos = CursorPos { + x: sx as f32, + y: sy as f32, + }; + + // There seem to be at least two cases where GTK issues a mouse + // motion event without the cursor actually moving: + // 1. GLArea is resized under the mouse. This has the unfortunate + // side effect of causing focus to potentially change when + // `focus-follows-mouse` is enabled. + // 2. The window title is updated. This can cause the mouse to + // unhide incorrectly when hide-mouse-when-typing is enabled. + // To prevent incorrect behavior, we'll only grab focus and + // continue with callback logic if the cursor has actually moved. + let cur = imp.cursor_pos.get(); + let is_cursor_still = (cur.x - pos.x).abs() < 1.0 && (cur.y - pos.y).abs() < 1.0; + if is_cursor_still { + return; + } + + // If we don't have focus, and we want it, grab it. + let config = imp.config.borrow().clone(); + if let Some(config) = config + && !imp.gl_area.has_focus() + && config.get().focus_follows_mouse + { + imp.gl_area.grab_focus(); + } + + // Our pos changed, update + imp.cursor_pos.set(pos); + + // Notify the callback + if let Some(surface) = this.core() { + let mods = gtk_key::translate_mods(event.modifier_state()); + if let Err(err) = surface.cursor_pos_callback(pos, Some(mods)) { + log::warn!("error in cursor pos callback err={err}"); + } + } + } + + #[template_callback] + fn mouse_leave(ec_motion: >k::EventControllerMotion, this: &super::Surface) { + let Some(event) = ec_motion.current_event() else { + return; + }; + + // Get our modifiers + if let Some(surface) = this.core() { + // If we have a core surface then we can send the cursor pos + // callback with an invalid position to indicate the mouse + // left. + let mods = gtk_key::translate_mods(event.modifier_state()); + if let Err(err) = + surface.cursor_pos_callback(CursorPos { x: -1.0, y: -1.0 }, Some(mods)) + { + log::warn!("error in cursor pos callback err={err}"); + } + } + } + + #[template_callback] + fn scroll_vertical_begin(_ec: >k::EventControllerScroll, this: &super::Surface) { + this.imp().precision_scroll.set(true); + } + + #[template_callback] + fn scroll_vertical_end(_ec: >k::EventControllerScroll, this: &super::Surface) { + this.imp().precision_scroll.set(false); + } + + #[template_callback] + fn scroll_vertical( + _ec: >k::EventControllerScroll, + x: f64, + y: f64, + this: &super::Surface, + ) -> bool { + let imp = this.imp(); + let Some(surface) = this.core() else { + return false; + }; + + // Multiply precision scrolls by 10 to get a better response from + // touchpad scrolling + let precision = imp.precision_scroll.get(); + let multiplier = if precision { 10.0 } else { 1.0 }; + let scroll_mods = ghostty_input::ScrollMods { + precision, + ..ghostty_input::ScrollMods::default() + }; + + let (sx, sy) = this.scaled_coordinates(x, y); + // We invert because we apply natural scrolling to the values. + // This behavior has existed for years without Linux users + // complaining but I suspect we'll have to make this configurable + // in the future or read a system setting. + match surface.scroll_callback(-sx * multiplier, -sy * multiplier, scroll_mods) { + Ok(()) => true, + Err(err) => { + log::warn!("error in scroll callback err={err}"); + false + } + } + } + + #[template_callback] + fn scroll_horizontal( + ec: >k::EventControllerScroll, + x: f64, + _y: f64, + this: &super::Surface, + ) -> bool { + let imp = this.imp(); + + match ec.unit() { + gdk::ScrollUnit::Surface => {} + _ => return false, + } + + imp.pending_horizontal_scroll + .set(imp.pending_horizontal_scroll.get() + x); + + if imp.pending_horizontal_scroll.get().abs() < 120.0 { + if let Some(v) = imp.pending_horizontal_scroll_reset.take() { + v.remove(); + } + let weak = this.downgrade(); + let id = + glib::timeout_add_local(std::time::Duration::from_millis(500), move || { + if let Some(this) = weak.upgrade() { + let imp = this.imp(); + imp.pending_horizontal_scroll.set(0.0); + let _ = imp.pending_horizontal_scroll_reset.take(); + } + glib::ControlFlow::Break + }); + imp.pending_horizontal_scroll_reset.set(Some(id)); + return true; + } + + let _ = this.activate_action( + if imp.pending_horizontal_scroll.get() < 0.0 { + "tab.next-page" + } else { + "tab.previous-page" + }, + None, + ); + + if let Some(v) = imp.pending_horizontal_scroll_reset.take() { + v.remove(); + } + + imp.pending_horizontal_scroll.set(0.0); + + true + } + + // ---- input method ---- + + #[template_callback] + fn im_preedit_start(_ctx: >k::IMMulticontext, this: &super::Surface) { + // Start our composing state for the input method and reset our + // input buffer to empty. + let imp = this.imp(); + imp.im_composing.set(true); + imp.im_buf.borrow_mut().clear(); + } + + #[template_callback] + fn im_preedit_changed(ctx: >k::IMMulticontext, this: &super::Surface) { + let imp = this.imp(); + + // Any preedit change should mark that we're composing. Its + // possible this is false using fcitx5-hangul and typing + // "dkssud" ("안녕"). The second "s" results in a "commit" + // for "안" which sets composing to false, but then immediately + // sends a preedit change for the next symbol. With composing set + // to false we won't commit this text. Therefore, we must ensure + // it is set here. + imp.im_composing.set(true); + + // We can't set our preedit on our surface unless we're realized. + // We do this now because we want to still keep our input method + // state coherent. + let Some(surface) = this.core() else { return }; + + // Get our pre-edit string that we'll use to show the user. + let (text, _, _) = ctx.preedit_string(); + + // Update our preedit state in Ghostty core + if let Err(err) = surface.preedit_callback(Some(&text)) { + log::warn!("error in preedit callback err={err}"); + } + } + + #[template_callback] + fn im_preedit_end(_ctx: >k::IMMulticontext, this: &super::Surface) { + // End our composing state for GTK, allowing us to commit the text. + let imp = this.imp(); + imp.im_composing.set(false); + + // End our preedit state in Ghostty core + let Some(surface) = this.core() else { return }; + if let Err(err) = surface.preedit_callback(None) { + log::warn!("error in preedit callback err={err}"); + } + } + + #[template_callback] + fn im_commit(_ctx: >k::IMMulticontext, str: &str, this: &super::Surface) { + let imp = this.imp(); + + // We need to handle commit specially if we're in a key event. + // Specifically, GTK will send us a commit event for basic key + // encodings like "a" (on a US layout keyboard). We don't want + // to treat this as IME committed text because we want to + // associate it with a key event (i.e. "a" key press). + match imp.in_keyevent.get() { + // If we're not in a key event then this commit is from some + // other source (i.e. on-screen keyboard, tablet, etc.) and we + // want to commit the text to the core surface. + // + // If we're in a composing state and in a key event then this + // key event is resulting in a commit of multiple keypresses + // and we don't want to encode it alongside the keypress. + IMKeyEvent::False | IMKeyEvent::Composing => {} + + // If we're not composing then this commit is just a normal + // key encoding and we want our key event to handle it so + // that Ghostty can be aware of the key event alongside the + // text. + IMKeyEvent::NotComposing => { + if str.len() > 128 { + log::warn!("not enough buffer space for input method commit"); + return; + } + + // Copy our committed text to the buffer + imp.im_buf.replace(str.to_owned()); + return; + } + } + + // If we reach this point from above it means we're composing OR + // not in a keypress. In either case, we want to commit the text + // given to us because that's what GTK is asking us to do. If + // we're not in a keypress it means that this commit came via a + // non-keyboard event (i.e. on-screen keyboard, tablet of some + // kind, etc.). + + // Committing ends composing state + imp.im_composing.set(false); + + // We can't set our preedit on our surface unless we're realized. + // We do this now because we want to still keep our input method + // state coherent. + if let Some(surface) = this.core() { + // End our preedit state. Well-behaved input methods do this + // for us by triggering a preedit-end event but some do not + // (ibus 1.5.29). + if let Err(err) = surface.preedit_callback(None) { + log::warn!("error in preedit callback err={err}"); + } + + // Send the text to the core surface, associated with no key + // (an invalid key, which should produce no PTY encoding). + if let Err(err) = surface.key_callback(KeyEvent { + action: KeyAction::Press, + key: Key::Unidentified, + mods: Mods::default(), + consumed_mods: Mods::default(), + composing: false, + utf8: str, + ..KeyEvent::default() + }) { + log::warn!("error in key callback err={err}"); + } + } + } + + // ---- GLArea ---- + + #[template_callback] + fn gl_realize(_area: >k::GLArea, this: &super::Surface) { + log::debug!("realize"); + + // Make the GL area current so we can detect any OpenGL errors. + // If we have errors here we can't render and we switch to the + // error state. + let imp = this.imp(); + imp.gl_area.make_current(); + if let Some(err) = imp.gl_area.error() { + log::warn!("failed to make GL context current: {}", err.message()); + log::warn!("this error is almost always due to a library, driver, or GTK issue"); + log::warn!( + "this is a common cause of this issue: https://ghostty.org/docs/help/gtk-opengl-context" + ); + this.set_error(true); + return; + } + + // If we already have an initialized surface then we notify it. + // If we don't, we'll initialize it on the first resize so we have + // our proper initial dimensions. + if let Some(core) = this.core() { + match core.renderer_realized() { + Ok(()) => this.redraw(), + Err(err) => log::warn!("core displayRealized failed err={err}"), + } + } + + // Setup our input method. We do this here because this will + // create a strong reference back to ourself and we want to be + // able to release that in unrealize. + imp.im_context.set_client_widget(Some(this)); + } + + #[template_callback] + fn gl_unrealize(gl_area: >k::GLArea, this: &super::Surface) { + log::debug!("unrealize"); + + // Notify our core surface + let imp = this.imp(); + if let Some(surface) = this.core() { + // There is no guarantee that our GLArea context is current + // when unrealize is emitted, so we need to make it current. + gl_area.make_current(); + if let Some(err) = gl_area.error() { + // I don't know a scenario this can happen, but it means + // we probably leaked memory because displayUnrealized + // below frees resources that aren't specifically OpenGL + // related. I didn't make the OpenGL renderer handle this + // scenario because I don't know if its even possible + // under valid circumstances, so let's log. + log::warn!( + "gl_area_make_current failed in unrealize msg={}", + err.message() + ); + log::warn!("OpenGL resources and memory likely leaked"); + return; + } + + surface.renderer_unrealized(); + } + + // Unset our input method + imp.im_context.set_client_widget(gtk::Widget::NONE); + } + + #[template_callback] + fn gl_render(_area: >k::GLArea, _ctx: &gdk::GLContext, this: &super::Surface) -> bool { + // If we don't have a surface then we failed to initialize for + // some reason and there's nothing to draw to the GLArea. + let Some(surface) = this.core() else { + return true; + }; + + // Framebuffer 0 means "the renderer queries GL_FRAMEBUFFER_BINDING + // itself" (GLArea has bound its own FBO at this point). + if let Err(err) = surface.draw_frame(0) { + log::warn!("failed to draw frame err={err}"); + return false; + } + + true + } + + #[template_callback] + fn gl_resize(gl_area: >k::GLArea, width: i32, height: i32, this: &super::Surface) { + // Some debug output to help understand what GTK is telling us. + { + let scale_factor = gl_area.scale_factor(); + let window_scale_factor = gl_area + .root() + .and_then(|r| r.surface()) + .map_or(0, |s| s.scale_factor()); + log::debug!( + "gl resize width={width} height={height} scale={scale_factor} window_scale={window_scale_factor}" + ); + } + + // Store our cached size + let imp = this.imp(); + let size = SurfaceSize { + width: width.max(0) as u32, + height: height.max(0) as u32, + }; + imp.size.set(size); + + // If our surface is realize, we send callbacks. + if let Some(surface) = this.core() { + // We also update the content scale because there is no signal + // for content scale change and it seems to trigger a resize + // event. + if let Err(err) = surface.content_scale_callback(this.get_content_scale()) { + log::warn!("error in content scale callback err={err}"); + } + + if let Err(err) = surface.size_callback(size) { + log::warn!("error in size callback err={err}"); + } + + // Setup our resize overlay if configured + this.resize_overlay_schedule(); + + return; + } + + // If we don't have a surface, then we initialize it. + if let Err(err) = this.init_surface() { + log::warn!("surface failed to initialize err={err:?}"); + } + } + } + + /// The focus callback must be triggered on an idle loop source because + /// there are actions within libghostty callbacks (such as showing close + /// confirmation dialogs) that can trigger focus loss and cause a deadlock + /// because the lock may be held during the callback. + fn idle_focus(this: &super::Surface) { + let this = this.clone(); + glib::idle_add_local_once(move || { + let imp = this.imp(); + let Some(surface) = this.core() else { return }; + if let Err(err) = surface.focus_callback(imp.focused.get()) { + log::warn!("error in focus callback err={err}"); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_fraction_clamps() { + assert!((compute_fraction(100) - 1.0).abs() < f64::EPSILON); + assert!((compute_fraction(255) - 1.0).abs() < f64::EPSILON); + assert!(compute_fraction(0).abs() < f64::EPSILON); + assert!((compute_fraction(50) - 0.5).abs() < f64::EPSILON); + } + + #[test] + fn translate_mouse_button_maps() { + assert_eq!(translate_mouse_button(1), MouseButton::Left); + assert_eq!(translate_mouse_button(2), MouseButton::Middle); + assert_eq!(translate_mouse_button(3), MouseButton::Right); + assert_eq!(translate_mouse_button(11), MouseButton::Eleven); + assert_eq!(translate_mouse_button(0), MouseButton::Unknown); + assert_eq!(translate_mouse_button(12), MouseButton::Unknown); + } + + #[test] + fn mouse_shape_roundtrip() { + for v in 0..34 { + assert_eq!(mouse_shape_from_i32(v) as i32, v); + } + assert_eq!(mouse_shape_from_i32(-1), MouseShape::Default); + assert_eq!(mouse_shape_from_i32(99), MouseShape::Default); + assert_eq!(mouse_shape_name(MouseShape::ContextMenu), "context-menu"); + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/surface_child_exited.rs b/crates/ghostty-apprt-gtk/src/class/surface_child_exited.rs new file mode 100644 index 0000000..176685b --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/surface_child_exited.rs @@ -0,0 +1,145 @@ +//! Port of apprt/gtk/class/surface_child_exited.zig. +//! +//! Child exited overlay based on `adw.Banner` (Adwaita 1.3+; the crate +//! requires 1.5 so the Zig `SurfaceChildExitedNoop` fallback is omitted). +//! Emits `close-request` when the banner button is clicked. + +use std::sync::OnceLock; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_apprt::surface::ChildExited; +use gtk::glib; +use gtk::glib::subclass::Signal; + +/// Boxed wrapper so `ChildExited` can be a GObject property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, glib::Boxed)] +#[boxed_type(name = "GhosttyChildExited", nullable)] +pub struct ChildExitedBoxed(pub ChildExited); + +glib::wrapper! { + pub struct SurfaceChildExited(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for SurfaceChildExited { + fn default() -> Self { + Self::new() + } +} + +impl SurfaceChildExited { + #[must_use] + pub fn new() -> Self { + glib::Object::new() + } + + /// Set the child exited data (Zig: `setData`). + pub fn set_child_exited(&self, data: Option<&ChildExited>) { + self.set_data(data.map(|d| ChildExitedBoxed(*d))); + } + + pub fn connect_close_request(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "close-request", + false, + glib::closure_local!(move |this: &Self| f(this)), + ) + } +} + +mod imp { + use std::cell::RefCell; + + #[allow(clippy::wildcard_imports)] + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties, Default)] + #[properties(wrapper_type = super::SurfaceChildExited)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.3/surface-child-exited.ui")] + pub struct SurfaceChildExited { + /// The child exited data sent by the apprt. + #[property(get, set = Self::set_data, nullable)] + pub data: RefCell>, + + #[template_child] + pub banner: TemplateChild, + } + + impl SurfaceChildExited { + fn set_data(&self, v: Option) { + // Zig only notifies when new data is given; the derive notifies + // on every set which is harmless. + self.data.replace(v); + } + } + + #[gtk::template_callbacks] + impl SurfaceChildExited { + /// `notify::data`; the template instance is the emitter. + #[template_callback] + fn notify_data(this: &super::SurfaceChildExited, _pspec: &glib::ParamSpec) { + let imp = this.imp(); + let banner = &imp.banner; + let Some(data) = imp.data.borrow().as_ref().map(|d| d.0) else { + // Not localized on purpose. + banner.set_title("This is a bug in Ghostty. Please report it."); + return; + }; + if data.exit_code == 0 { + banner.set_title("Command succeeded"); + this.add_css_class("normal"); + this.remove_css_class("abnormal"); + } else { + banner.set_title("Command failed"); + this.remove_css_class("normal"); + this.add_css_class("abnormal"); + } + } + + #[template_callback] + fn clicked(banner: &adw::Banner) { + if let Some(this) = banner + .ancestor(super::SurfaceChildExited::static_type()) + .and_downcast::() + { + this.emit_by_name::<()>("close-request", &[]); + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for SurfaceChildExited { + const NAME: &'static str = "GhosttySurfaceChildExited"; + type Type = super::SurfaceChildExited; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for SurfaceChildExited { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + // Emitted when the banner would like to be closed. + vec![Signal::builder("close-request").build()] + }) + } + + fn dispose(&self) { + self.dispose_template(); + } + } + + impl WidgetImpl for SurfaceChildExited {} + impl BinImpl for SurfaceChildExited {} +} diff --git a/crates/ghostty-apprt-gtk/src/class/surface_scrolled_window.rs b/crates/ghostty-apprt-gtk/src/class/surface_scrolled_window.rs new file mode 100644 index 0000000..bb382bd --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/surface_scrolled_window.rs @@ -0,0 +1,147 @@ +//! Port of apprt/gtk/class/surface_scrolled_window.zig. +//! +//! A wrapper widget that embeds a Surface inside a `GtkScrolledWindow`, +//! providing scrollbar functionality for the terminal surface. The +//! `surface` property can be set at construction or changed dynamically. +//! +//! The GType name `GhostttySurfaceScrolledWindow` (sic, three t's) matches +//! the Zig source and the blueprint template. +//! +//! To limit compile coupling the `surface` property is typed as +//! `GtkWidget` (any `crate::class::surface::Surface` is accepted) and the +//! `config` property as `GObject` (a `crate::class::config::Config`). + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib; + +glib::wrapper! { + pub struct SurfaceScrolledWindow(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for SurfaceScrolledWindow { + fn default() -> Self { + Self::new(None::<>k::Widget>) + } +} + +impl SurfaceScrolledWindow { + /// Create a scrolled window, optionally with the given surface widget. + #[must_use] + pub fn new(surface: Option<&impl IsA>) -> Self { + glib::Object::builder() + .property("surface", surface.map(|s| s.as_ref().clone())) + .build() + } + + /// Zig: `getSurface`. Downcast to `Surface` at the call site. + #[must_use] + pub fn get_surface(&self) -> Option { + self.surface() + } +} + +mod imp { + use std::cell::RefCell; + + #[allow(clippy::wildcard_imports)] + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties, Default)] + #[properties(wrapper_type = super::SurfaceScrolledWindow)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/surface-scrolled-window.ui")] + pub struct SurfaceScrolledWindow { + #[property(get, set, nullable)] + pub config: RefCell>, + + pub config_binding: RefCell>, + + #[property(get, set = Self::set_surface, nullable)] + pub surface: RefCell>, + } + + impl SurfaceScrolledWindow { + fn set_surface(&self, v: Option) { + if *self.surface.borrow() == v { + return; + } + self.surface.replace(v); + } + } + + #[gtk::template_callbacks] + impl SurfaceScrolledWindow { + #[template_callback(function)] + fn scrollbar_policy(config: Option) -> gtk::PolicyType { + use crate::class::config::Config; + use ghostty_config::types_b::Scrollbar; + + let Some(config) = config.and_then(|c| c.downcast::().ok()) else { + return gtk::PolicyType::Automatic; + }; + match config.get().scrollbar { + Scrollbar::Never => gtk::PolicyType::Never, + Scrollbar::System => gtk::PolicyType::Automatic, + } + } + + #[template_callback] + fn notify_surface(this: &super::SurfaceScrolledWindow, _pspec: &glib::ParamSpec) { + let imp = this.imp(); + let Some(child) = this.child() else { + return; + }; + let Ok(scrolled) = child.downcast::() else { + return; + }; + let surface = imp.surface.borrow().clone(); + scrolled.set_child(surface.as_ref()); + + // Unbind old config binding if it exists + if let Some(binding) = imp.config_binding.take() { + binding.unbind(); + } + + // Bind config from surface to our config property + if let Some(surface) = surface { + let binding = surface + .bind_property("config", this, "config") + .sync_create() + .build(); + imp.config_binding.replace(Some(binding)); + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for SurfaceScrolledWindow { + const NAME: &'static str = "GhostttySurfaceScrolledWindow"; + type Type = super::SurfaceScrolledWindow; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for SurfaceScrolledWindow { + fn dispose(&self) { + if let Some(binding) = self.config_binding.take() { + binding.unbind(); + } + self.config.replace(None); + self.dispose_template(); + } + } + + impl WidgetImpl for SurfaceScrolledWindow {} + impl BinImpl for SurfaceScrolledWindow {} +} diff --git a/crates/ghostty-apprt-gtk/src/class/tab.rs b/crates/ghostty-apprt-gtk/src/class/tab.rs new file mode 100644 index 0000000..1284fc7 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/tab.rs @@ -0,0 +1,423 @@ +//! Port of apprt/gtk/class/tab.zig: `GhosttyTab`, the content of one tab +//! page (a split tree plus tab-level title/actions). + +#![allow(clippy::wildcard_imports)] + +use std::cell::RefCell; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_apprt::surface::NewSurfaceContext; +use gtk::gio; +use gtk::glib::{self, clone, subclass::Signal}; + +use super::application::Application; +use super::config::Config; +use super::split_tree::{SplitTree, SurfaceTree}; +use super::surface::{Surface, SurfaceOverrides}; +use super::title_dialog::{Target as TitleTarget, TitleDialog}; + +glib::wrapper! { + pub struct Tab(ObjectSubclass) + @extends gtk::Box, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +impl Tab { + /// Create a new tab. If `config` is None the application config is used. + /// An initial surface is created in the split tree. + pub fn new(config: Option<&Config>, overrides: SurfaceOverrides) -> Self { + let tab: Self = glib::Object::new(); + let imp = tab.imp(); + + // If our configuration is null then we get the configuration from + // the application. + let config = config + .cloned() + .unwrap_or_else(|| Application::default().get_config()); + imp.config.replace(Some(config)); + tab.notify("config"); + + // Create our initial surface in the split tree. + if let Err(e) = imp.split_tree.new_split( + ghostty_datastruct::split_tree::Direction::Right, + None, + overrides, + ) { + // TODO: show a nicer "no surfaces" state. Incredibly unlikely. + panic!("oom creating initial split: {e}"); + } + + tab + } + + /// Set the parent of this tab page. This only affects the first surface + /// ever created for a tab. If a surface was already created this does + /// nothing. + pub fn set_parent(&self, parent: &Surface) { + self.set_parent_with_context(parent, NewSurfaceContext::Tab); + } + + pub fn set_parent_with_context(&self, parent: &Surface, context: NewSurfaceContext) { + if let Some(surface) = self.get_active_surface() { + surface.set_parent(parent, context); + } + } + + /// Overridden title. Generally shown over the title unless unset. + pub fn set_title_override(&self, title: Option<&str>) { + self.imp().title_override.replace(title.map(str::to_owned)); + self.notify("title-override"); + } + + pub fn get_title_override(&self) -> Option { + self.imp().title_override.borrow().clone() + } + + pub fn get_title(&self) -> Option { + self.imp().title.borrow().clone() + } + + pub fn prompt_tab_title(&self) { + let imp = self.imp(); + let initial = imp + .title_override + .borrow() + .clone() + .or_else(|| imp.title.borrow().clone()); + let dialog = TitleDialog::new(TitleTarget::Tab, initial.as_deref()); + dialog.connect_set(clone!( + #[weak(rename_to = this)] + self, + move |_, title| this.set_title_override(if title.is_empty() { + None + } else { + Some(title) + }) + )); + dialog.present(self); + } + + /// Get the currently active surface. See the "active-surface" property. + pub fn get_active_surface(&self) -> Option { + self.get_split_tree().get_active_surface() + } + + /// Get the surface tree of this tab. + pub fn get_surface_tree(&self) -> Option { + self.get_split_tree().get_tree() + } + + /// Get the split tree widget that is in this tab. + pub fn get_split_tree(&self) -> SplitTree { + self.imp().split_tree.get() + } + + /// Get the config this tab was created with. + pub fn get_config(&self) -> Option { + self.imp().config.borrow().clone() + } + + /// Returns true if this tab needs confirmation before quitting based on + /// the various Ghostty configurations. + pub fn get_needs_confirm_quit(&self) -> bool { + self.get_split_tree().get_needs_confirm_quit() + } + + /// Connect to the `close-request` signal, emitted whenever the tab would + /// like to be closed. + pub fn connect_close_request(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("close-request", false, move |args| { + if let Some(Ok(this)) = args.first().map(glib::Value::get::) { + f(&this); + } + None + }) + } + + /// Get the tab view holding this tab, if any. + fn get_tab_view(&self) -> Option { + self.ancestor(adw::TabView::static_type()) + .and_then(|w| w.downcast().ok()) + } + + /// Get the tab page holding this tab, if any. + fn get_tab_page(&self) -> Option { + Some(self.get_tab_view()?.page(self)) + } + + fn init_action_map(&self) { + let group = gio::SimpleActionGroup::new(); + + let close = gio::SimpleAction::new("close", Some(glib::VariantTy::STRING)); + close.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, param| this.action_close(param.and_then(|p| p.str())) + )); + group.add_action(&close); + + let ring_bell = gio::SimpleAction::new("ring-bell", None); + ring_bell.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, _| { + // If the page is selected already we don't mark it as + // needing attention. We only want to mark unfocused pages. + // This will then clear when the page is selected. + let Some(page) = this.get_tab_page() else { + return; + }; + if page.is_selected() { + return; + } + page.set_needs_attention(true); + } + )); + group.add_action(&ring_bell); + + let next = gio::SimpleAction::new("next-page", None); + next.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, _| { + if let Some(v) = this.get_tab_view() { + v.select_next_page(); + } + } + )); + group.add_action(&next); + + let prev = gio::SimpleAction::new("previous-page", None); + prev.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, _| { + if let Some(v) = this.get_tab_view() { + v.select_previous_page(); + } + } + )); + group.add_action(&prev); + + let prompt = gio::SimpleAction::new("prompt-tab-title", None); + prompt.connect_activate(clone!( + #[weak(rename_to = this)] + self, + move |_, _| this.prompt_tab_title() + )); + group.add_action(&prompt); + + self.insert_action_group("tab", Some(&group)); + } + + fn action_close(&self, mode: Option<&str>) { + let Some(mode) = mode else { + log::warn!("tab.close-tab called without a parameter"); + return; + }; + let Some(tab_view) = self.get_tab_view() else { + return; + }; + let page = tab_view.page(self); + + // Delegate to our parent to handle this, since this will emit a + // close-page signal that the parent can intercept. + match mode { + "this" => tab_view.close_page(&page), + "other" => tab_view.close_other_pages(&page), + "right" => tab_view.close_pages_after(&page), + // Need to be defensive here since actions can be triggered + // externally. + _ => log::warn!("invalid mode provided to tab.close-tab: {mode}"), + } + } +} + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/tab.ui")] + pub struct Tab { + /// The configuration that this tab is using. + pub config: RefCell>, + + /// The title of this tab. This is usually bound to the active surface. + pub title: RefCell>, + + /// The manually overridden title from `prompt_tab_title`. + pub title_override: RefCell>, + + /// The tooltip of this tab. This is usually bound to the active surface. + pub tooltip: RefCell>, + + #[template_child] + pub split_tree: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for Tab { + const NAME: &'static str = "GhosttyTab"; + type Type = super::Tab; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + SplitTree::ensure_type(); + Surface::ensure_type(); + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[gtk::template_callbacks] + impl Tab { + #[template_callback] + fn notify_active_surface(split: &SplitTree, _pspec: glib::ParamSpec) { + if let Some(tab) = split.ancestor(super::Tab::static_type()) { + tab.notify("active-surface"); + } + } + + #[template_callback] + fn notify_tree(split: &SplitTree, _pspec: glib::ParamSpec) { + let Some(tab) = split + .ancestor(super::Tab::static_type()) + .and_then(|w| w.downcast::().ok()) + else { + return; + }; + tab.notify("surface-tree"); + + // If our tree is empty we close the tab. + if tab.get_surface_tree().is_none_or(|t| t.is_empty()) { + tab.emit_by_name::<()>("close-request", &[]); + } + } + + #[template_callback(function)] + fn computed_title( + config: Option, + terminal: Option, + surface_override: Option, + tab_override: Option, + zoomed: bool, + bell_ringing: bool, + ) -> String { + // Our plain title is the manually tab overridden title if it + // exists, otherwise the overridden title if it exists, + // otherwise the terminal title if it exists, otherwise a + // default string. + let config_title = config.as_ref().and_then(|c| c.get().title.clone()); + let plain = tab_override + .or(surface_override) + .or(terminal) + .or(config_title) + .unwrap_or_else(|| "Ghostty".to_owned()); + + // We don't need a config in every case, but if we don't have one + // let's just assume something went terribly wrong and use our + // default title. + let Some(config) = config else { + log::warn!("config unavailable for computed title, likely bug"); + return plain; + }; + let config = config.get(); + + let mut out = String::new(); + + // If our bell is ringing, then we prefix the bell icon. + if bell_ringing && config.bell_features.title { + out.push_str("🔔 "); + } + + // If we're zoomed, prefix with the magnifying glass emoji. + if zoomed { + out.push_str("🔍 "); + } + + out.push_str(&plain); + out + } + } + + impl ObjectImpl for Tab { + fn properties() -> &'static [glib::ParamSpec] { + static PROPS: std::sync::OnceLock> = std::sync::OnceLock::new(); + PROPS.get_or_init(|| { + vec![ + glib::ParamSpecObject::builder::("active-surface") + .read_only() + .build(), + glib::ParamSpecObject::builder::("config").build(), + glib::ParamSpecObject::builder::("split-tree") + .read_only() + .build(), + glib::ParamSpecBoxed::builder::("surface-tree") + .read_only() + .build(), + glib::ParamSpecString::builder("tooltip").build(), + glib::ParamSpecString::builder("title").build(), + glib::ParamSpecString::builder("title-override").build(), + ] + }) + } + + fn signals() -> &'static [Signal] { + static SIGNALS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SIGNALS.get_or_init(|| { + // Emitted whenever the tab would like to be closed. + vec![Signal::builder("close-request").build()] + }) + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + match pspec.name() { + "active-surface" => obj.get_active_surface().to_value(), + "config" => self.config.borrow().to_value(), + "split-tree" => self.split_tree.get().to_value(), + "surface-tree" => obj.get_surface_tree().map(SurfaceTree).to_value(), + "tooltip" => self.tooltip.borrow().to_value(), + "title" => self.title.borrow().to_value(), + "title-override" => self.title_override.borrow().to_value(), + _ => unreachable!(), + } + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "config" => { + self.config.replace(value.get().expect("Config")); + } + "tooltip" => { + self.tooltip.replace(value.get().expect("string")); + } + "title" => { + self.title.replace(value.get().expect("string")); + } + "title-override" => { + self.title_override.replace(value.get().expect("string")); + } + _ => unreachable!(), + } + } + + fn constructed(&self) { + self.parent_constructed(); + self.obj().init_action_map(); + } + + fn dispose(&self) { + self.config.replace(None); + self.dispose_template(); + } + } + + impl WidgetImpl for Tab {} + impl BoxImpl for Tab {} +} diff --git a/crates/ghostty-apprt-gtk/src/class/title_dialog.rs b/crates/ghostty-apprt-gtk/src/class/title_dialog.rs new file mode 100644 index 0000000..61af100 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/title_dialog.rs @@ -0,0 +1,144 @@ +//! Port of apprt/gtk/class/title_dialog.zig (`GhosttyTitleDialog`). +//! Properties `target`, `initial-value`; signal `set(title: string)`. + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_os::i18n::tr; +use gtk::glib; +use gtk::glib::subclass::Signal; +use std::cell::{Cell, RefCell}; +use std::sync::OnceLock; + +/// `GhosttyTitleDialogTarget`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)] +#[enum_type(name = "GhosttyTitleDialogTarget")] +#[repr(i32)] +pub enum Target { + #[default] + Surface, + Tab, +} + +impl Target { + pub fn title(self) -> String { + match self { + Target::Surface => tr("Change Terminal Title"), + Target::Tab => tr("Change Tab Title"), + } + } +} + +mod imp { + use super::{ + AdwAlertDialogImpl, AdwDialogImpl, Cell, ObjectImpl, ObjectSubclass, OnceLock, RefCell, + Signal, Target, WidgetImpl, + }; + use gtk::glib; + use gtk::glib::prelude::*; + use gtk::subclass::prelude::*; + + #[derive(Default, glib::Properties, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::TitleDialog)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/title-dialog.ui")] + pub struct TitleDialog { + #[property(get, set, builder(Target::Surface))] + pub target: Cell, + + /// The initial value of the entry field. + #[property(get, set, nullable, name = "initial-value")] + pub initial_value: RefCell>, + + #[template_child] + pub entry: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for TitleDialog { + const NAME: &'static str = "GhosttyTitleDialog"; + type Type = super::TitleDialog; + type ParentType = adw::AlertDialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for TitleDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("set") + .param_types([String::static_type()]) + .build(), + ] + }) + } + + fn dispose(&self) { + self.dispose_template(); + } + } + impl WidgetImpl for TitleDialog {} + impl AdwDialogImpl for TitleDialog {} + impl AdwAlertDialogImpl for TitleDialog {} +} + +glib::wrapper! { + pub struct TitleDialog(ObjectSubclass) + @extends adw::AlertDialog, adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::ShortcutManager; +} + +impl TitleDialog { + pub fn new(target: Target, initial_value: Option<&str>) -> Self { + crate::register_resources(); + glib::Object::builder() + .property("target", target) + .property("initial-value", initial_value) + .build() + } + + /// Present the dialog, anchored to the window containing `parent`. + /// Emits `set` with the entered title if the user accepts. + pub fn present(&self, parent: &impl IsA) { + let parent_w = parent.upcast_ref::(); + let anchor: gtk::Widget = parent_w + .ancestor(adw::ApplicationWindow::static_type()) + .or_else(|| parent_w.ancestor(adw::Window::static_type())) + .unwrap_or_else(|| parent_w.clone()); + + let imp = self.imp(); + if let Some(v) = imp.initial_value.borrow().as_deref() { + imp.entry.buffer().set_text(v); + } + + self.set_heading(Some(&imp.target.get().title())); + + let this = self.clone(); + self.clone().choose( + Some(&anchor), + None::<>k::gio::Cancellable>, + move |response| { + if response != "ok" { + return; + } + let title = this.imp().entry.buffer().text().to_string(); + this.emit_by_name::<()>("set", &[&title]); + }, + ); + } + + pub fn connect_set(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "set", + false, + glib::closure_local!(move |obj: &Self, title: &str| f(obj, title)), + ) + } +} diff --git a/crates/ghostty-apprt-gtk/src/class/window.rs b/crates/ghostty-apprt-gtk/src/class/window.rs new file mode 100644 index 0000000..0839c24 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/class/window.rs @@ -0,0 +1,1779 @@ +//! Port of apprt/gtk/class/window.zig (`GhosttyWindow`, an +//! `AdwApplicationWindow` subclass). +//! +//! Properties: `active-surface`, `config`, `debug`, `titlebar-style`, +//! `headerbar-visible`, `quick-terminal`, `tabs-autohide`, `tabs-wide`, +//! `tabs-visible`, `toolbar-style`. Actions (`win.*`): about, close, +//! close-tab(s), new-tab, new-window, prompt-surface-title, +//! prompt-tab-title, prompt-context-tab-title, ring-bell, split-{right,left, +//! up,down}, copy, paste, reset, clear, toggle-command-palette, +//! toggle-inspector. +//! +//! Deviations: the parent surface of `new_tab` & friends is the GTK +//! [`Surface`] object (Zig took the `*CoreSurface` and used +//! `rt_surface.gobj()`); the core surface is looked up via `Surface::core()`. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use ghostty_apprt::surface::NewSurfaceContext; +use ghostty_config::command::Command as ConfigCommand; +use ghostty_config::types_a::WindowSubtitle; +use ghostty_config::types_b::{ + GtkTabsLocation, GtkTitlebarStyle, GtkToolbarStyle, WindowDecoration, WindowNewTabPosition, + WindowShowTabBar, WindowTheme, +}; +use ghostty_input::binding::{Action as BindingAction, CloseTabMode}; +use ghostty_os::i18n::tr; +use gtk::glib::{self, clone}; +use gtk::{CompositeTemplate, TemplateChild, gio}; + +use super::application::Application; +use super::close_confirmation_dialog::{CloseConfirmationDialog, Target as CloseTarget}; +use super::command_palette::CommandPalette; +use super::config::Config; +use super::debug_warning::DebugWarning; +use super::split_tree::{SplitTree, SurfaceTree, Tree}; +use super::surface::{Surface, SurfaceOverrides}; +use super::tab::Tab; +use crate::winproto::{self, WindowHost}; +use crate::{adw_version, gtk_version}; + +/// Which tab to select (Zig: `Window.SelectTab`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectTab { + Previous, + Next, + Last, + /// 1-indexed. + N(usize), +} + +/// Mirrors the Zig `overrides` struct of `newTabForWindow`. +#[derive(Debug, Clone, Default)] +pub struct TabOverrides { + pub command: Option, + pub working_directory: Option, + pub title: Option, +} + +/// GEnum mirror of `configpkg.Config.GtkTitlebarStyle` (used by the +/// `titlebar-style` property and a blueprint closure). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)] +#[enum_type(name = "GhosttyWindowTitlebarStyle")] +#[repr(i32)] +pub enum TitlebarStyle { + #[default] + Native, + Tabs, +} + +impl From for TitlebarStyle { + fn from(v: GtkTitlebarStyle) -> Self { + match v { + GtkTitlebarStyle::Native => TitlebarStyle::Native, + GtkTitlebarStyle::Tabs => TitlebarStyle::Tabs, + } + } +} + +/// Host adapter handed to winproto (holds only a weak reference so the +/// winproto state owned by the window does not create a cycle). +struct Host(glib::WeakRef); + +impl WindowHost for Host { + fn gtk_window(&self) -> Option { + self.0.upgrade().map(glib::object::Cast::upcast) + } + + fn is_quick_terminal(&self) -> bool { + self.0.upgrade().is_some_and(|w| w.is_quick_terminal()) + } + + fn window_decoration(&self) -> WindowDecoration { + self.0 + .upgrade() + .map_or(WindowDecoration::Auto, |w| w.get_window_decoration()) + } + + fn config(&self) -> Option> { + self.0 + .upgrade() + .and_then(|w| w.get_config()) + .map(|c| c.get()) + } +} + +mod imp { + use super::{ + Cell, CommandPalette, CompositeTemplate, Config, DebugWarning, NewSurfaceContext, RefCell, + SplitTree, Surface, Tab, TabOverrides, TemplateChild, TitlebarStyle, Window as WindowObj, + WindowDecoration, glib, gtk_version, + }; + use crate::winproto; + use adw::prelude::*; + use adw::subclass::prelude::*; + use std::marker::PhantomData; + + #[derive(glib::Properties, CompositeTemplate)] + #[properties(wrapper_type = WindowObj)] + #[template(resource = "/com/mitchellh/ghostty/ui/1.5/window.ui")] + pub struct Window { + /// The active surface is the focus that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a + /// non-surface widget. + #[property(name = "active-surface", get = Self::get_active_surface, type = Option)] + #[property(name = "debug", get = Self::get_debug, type = bool)] + #[property(name = "titlebar-style", get = Self::get_titlebar_style, type = TitlebarStyle, builder(TitlebarStyle::Native))] + #[property(name = "headerbar-visible", get = Self::get_headerbar_visible, type = bool)] + #[property(name = "tabs-autohide", get = Self::get_tabs_autohide, type = bool)] + #[property(name = "tabs-wide", get = Self::get_tabs_wide, type = bool)] + #[property(name = "tabs-visible", get = Self::get_tabs_visible, type = bool)] + #[property(name = "toolbar-style", get = Self::get_toolbar_style, type = adw::ToolbarStyle, builder(adw::ToolbarStyle::Raised))] + _props: PhantomData<()>, + + /// The configuration that this window is using. + #[property(get, set, nullable)] + pub config: RefCell>, + + /// Whether this window is a quick terminal. If it is then it + /// behaves slightly differently under certain scenarios. + #[property(name = "quick-terminal", get, set, default = false)] + pub quick_terminal: Cell, + + /// The window decoration override. If this is not set then we'll + /// inherit whatever the config has. + #[allow(clippy::struct_field_names)] + pub window_decoration: Cell>, + + /// Binding for our active tab (Zig: `tab_bindings` BindingGroup). + pub tab_binding: RefCell>, + + /// State and logic for windowing protocol for a window. + pub winproto: RefCell, + + /// Whether we've initialized any single surface yet. We gate + /// default size on this so that we don't resize the window after + /// surfaces already exist. + pub surface_init: Cell, + + /// See tab_overview_open for why we have this. + pub tab_overview_focus_timer: RefCell>, + + /// A weak reference to a command palette. + pub command_palette: glib::WeakRef, + + /// Tab page that the context menu was opened for (set by + /// `setup-menu`). + pub context_menu_page: RefCell>, + + /// Signal handlers connected to surfaces (so we can disconnect them + /// by "data == self" like the Zig code). + pub surface_handlers: RefCell, Vec)>>, + + /// Signal handlers connected to tabs (close-request). + pub tab_handlers: RefCell, glib::SignalHandlerId)>>, + + /// Handlers on split trees (changed), keyed by tab. + pub tree_handlers: RefCell, glib::SignalHandlerId)>>, + + #[template_child] + pub tab_overview: TemplateChild, + #[template_child] + pub tab_bar: TemplateChild, + #[template_child] + pub tab_view: TemplateChild, + #[template_child] + pub toolbar: TemplateChild, + #[template_child] + pub toast_overlay: TemplateChild, + } + + impl Default for Window { + fn default() -> Self { + Self { + _props: PhantomData, + config: RefCell::new(None), + quick_terminal: Cell::new(false), + window_decoration: Cell::new(None), + tab_binding: RefCell::new(None), + winproto: RefCell::new(winproto::Window::None(winproto::noop::Window)), + surface_init: Cell::new(false), + tab_overview_focus_timer: RefCell::new(None), + command_palette: glib::WeakRef::new(), + context_menu_page: RefCell::new(None), + surface_handlers: RefCell::new(Vec::new()), + tab_handlers: RefCell::new(Vec::new()), + tree_handlers: RefCell::new(Vec::new()), + tab_overview: TemplateChild::default(), + tab_bar: TemplateChild::default(), + tab_view: TemplateChild::default(), + toolbar: TemplateChild::default(), + toast_overlay: TemplateChild::default(), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for Window { + const NAME: &'static str = "GhosttyWindow"; + type Type = WindowObj; + type ParentType = adw::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + DebugWarning::ensure_type(); + SplitTree::ensure_type(); + Surface::ensure_type(); + Tab::ensure_type(); + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for Window { + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + // If our configuration is null then we get the configuration + // from the application. + let config = { + if self.config.borrow().is_none() { + let app = super::Application::default(); + self.config.replace(Some(app.get_config())); + } + self.config.borrow().as_ref().map(Config::get) + }; + + // Add our dev CSS class if we're in debug mode. + if cfg!(debug_assertions) { + obj.add_css_class("devel"); + } + + // The tab binding (certain properties are only synced from the + // currently active tab) is set up in `tab_view_selected_page`. + + // Set our window icon. We can't set this in the blueprint file + // because its dependent on the build config. + obj.set_icon_name(Some(crate::APPLICATION_ID)); + + // Initialize our actions + obj.init_action_map(); + + // Start states based on config. + if let Some(config) = config { + if config.maximize { + obj.maximize(); + } + if config.fullscreen != ghostty_config::types_a::Fullscreen::False { + obj.fullscreen(); + } + // If we have an explicit title set, we set that immediately + // so that any applications inspecting the window states see + // an immediate title set when the window appears. + if let Some(title) = &config.title { + obj.set_title(Some(title)); + } + } + + // We always sync our appearance at the end because loading our + // config and such can affect our bindings which are setup + // initially in init_template. + obj.sync_appearance(); + + // We need to do this so that the title initializes properly, + // I think because its a dynamic getter. + obj.notify("active-surface"); + } + + fn dispose(&self) { + self.command_palette.set(None::<&CommandPalette>); + self.config.replace(None); + if let Some(b) = self.tab_binding.take() { + b.unbind(); + } + self.dispose_template(); + } + } + + impl WidgetImpl for Window {} + impl WindowImpl for Window {} + impl ApplicationWindowImpl for Window {} + impl AdwApplicationWindowImpl for Window {} + + // ---- property getters ---------------------------------------- + impl Window { + fn get_active_surface(&self) -> Option { + self.obj().get_active_surface() + } + + #[allow(clippy::unused_self)] + fn get_debug(&self) -> bool { + cfg!(debug_assertions) + } + + fn cfg(&self) -> Option> { + self.config.borrow().as_ref().map(Config::get) + } + + fn get_headerbar_visible(&self) -> bool { + let obj = self.obj(); + + // Never display the header bar when CSDs are disabled. + if !self.winproto.borrow().client_side_decoration_enabled() { + return false; + } + + // Never display the header bar as a quick terminal. + if self.quick_terminal.get() { + return false; + } + + // If we're fullscreen we never show the header bar. + if obj.is_fullscreen() { + return false; + } + + // The remainder needs a config + let Some(config) = self.cfg() else { + return true; + }; + + // *Conditionally* disable the header bar when maximized, and + // gtk-titlebar-hide-when-maximized is set + if obj.is_maximized() && config.gtk_titlebar_hide_when_maximized { + return false; + } + + match config.gtk_titlebar_style { + // If the titlebar style is tabs never show the titlebar. + GtkTitlebarStyleAlias::Tabs => false, + // If native show the titlebar if configured to do so. + GtkTitlebarStyleAlias::Native => config.gtk_titlebar, + } + } + + fn get_tabs_autohide(&self) -> bool { + let Some(config) = self.cfg() else { + return true; + }; + match config.gtk_titlebar_style { + // If the titlebar style is tabs we cannot autohide. + GtkTitlebarStyleAlias::Tabs => false, + GtkTitlebarStyleAlias::Native => match config.window_show_tab_bar { + // Auto we always autohide... obviously. + super::WindowShowTabBar::Auto => true, + // Always we never autohide because we always show it. + super::WindowShowTabBar::Always => false, + // Never we autohide because it doesn't actually matter, + // since get_tabs_visible will return false. + super::WindowShowTabBar::Never => true, + }, + } + } + + fn get_tabs_visible(&self) -> bool { + let Some(config) = self.cfg() else { + return true; + }; + match config.gtk_titlebar_style { + GtkTitlebarStyleAlias::Tabs => { + // *Conditionally* disable the tab bar when maximized, + // the titlebar style is tabs, and + // gtk-titlebar-hide-when-maximized is set. + if self.obj().is_maximized() && config.gtk_titlebar_hide_when_maximized { + return false; + } + // If the titlebar style is tabs the tab bar must always + // be visible. + true + } + GtkTitlebarStyleAlias::Native => match config.window_show_tab_bar { + super::WindowShowTabBar::Always | super::WindowShowTabBar::Auto => true, + super::WindowShowTabBar::Never => false, + }, + } + } + + fn get_tabs_wide(&self) -> bool { + self.cfg().is_none_or(|c| c.gtk_wide_tabs) + } + + fn get_toolbar_style(&self) -> adw::ToolbarStyle { + let Some(config) = self.cfg() else { + return adw::ToolbarStyle::Raised; + }; + match config.gtk_toolbar_style { + super::GtkToolbarStyle::Flat => adw::ToolbarStyle::Flat, + super::GtkToolbarStyle::Raised => adw::ToolbarStyle::Raised, + super::GtkToolbarStyle::RaisedBorder => adw::ToolbarStyle::RaisedBorder, + } + } + + fn get_titlebar_style(&self) -> TitlebarStyle { + self.cfg() + .map_or(TitlebarStyle::Native, |c| c.gtk_titlebar_style.into()) + } + } + + use ghostty_config::types_b::GtkTitlebarStyle as GtkTitlebarStyleAlias; + + // ---- template callbacks ------------------------------------- + // + // GtkBuilder passes `[sender, args.., template_instance]` for signal + // handlers (swapped="no"), which is why the instance is the last + // parameter. Closures (`function`) get the expression arguments only. + #[gtk::template_callbacks] + impl Window { + #[template_callback] + fn realize(_w: >k::Widget, win: &WindowObj) { + win.on_realize(); + } + + #[template_callback] + fn new_tab(_b: &adw::SplitButton, win: &WindowObj) { + win.perform_binding_action(&ghostty_input::binding::Action::NewTab); + } + + #[template_callback] + fn overview_create_tab(_o: &adw::TabOverview, win: &WindowObj) -> adw::TabPage { + let parent = win.get_active_surface(); + win.new_tab_page( + parent.as_ref(), + NewSurfaceContext::Tab, + TabOverrides::default(), + ) + } + + #[template_callback] + fn overview_notify_open( + overview: &adw::TabOverview, + _pspec: &glib::ParamSpec, + win: &WindowObj, + ) { + win.tab_overview_open(overview); + } + + #[template_callback] + fn close_request(_w: >k::Window, win: &WindowObj) -> glib::Propagation { + win.window_close_request() + } + + #[template_callback] + fn close_page( + _v: &adw::TabView, + page: &adw::TabPage, + win: &WindowObj, + ) -> glib::Propagation { + win.tab_view_close_page(page) + } + + #[template_callback] + fn page_attached(_v: &adw::TabView, page: &adw::TabPage, _pos: i32, win: &WindowObj) { + win.tab_view_page_attached(page); + } + + #[template_callback] + fn page_detached(_v: &adw::TabView, page: &adw::TabPage, _pos: i32, win: &WindowObj) { + win.tab_view_page_detached(page); + } + + #[template_callback] + fn setup_tab_menu(_v: &adw::TabView, page: Option<&adw::TabPage>, win: &WindowObj) { + win.imp().context_menu_page.replace(page.cloned()); + } + + #[template_callback] + fn tab_create_window(_v: &adw::TabView, _win: &WindowObj) -> adw::TabView { + // Create a new window without creating a new tab. + let new_win = WindowObj::new_empty(&super::Application::default()); + + // We have to show it otherwise it'll just be hidden. + new_win.present(); + + // Get our tab view + new_win.tab_view() + } + + #[template_callback] + fn notify_n_pages(_v: &adw::TabView, _pspec: &glib::ParamSpec, win: &WindowObj) { + let imp = win.imp(); + if imp.tab_view.n_pages() == 0 { + // If we have no pages left then we want to close window. + + // If the tab overview is open, then we don't close the + // window because its a rather abrupt experience. This also + // fixes an issue where dragging out the last tab in the tab + // overview won't cause Ghostty to exit. + if imp.tab_overview.is_open() { + return; + } + + win.close(); + } + } + + #[template_callback] + fn notify_selected_page(_v: &adw::TabView, _pspec: &glib::ParamSpec, win: &WindowObj) { + win.tab_view_selected_page(); + } + + #[template_callback] + fn notify_config(_w: &WindowObj, _pspec: &glib::ParamSpec, win: &WindowObj) { + win.prop_config(); + } + + #[template_callback] + fn notify_fullscreened(_w: &WindowObj, _pspec: &glib::ParamSpec, win: &WindowObj) { + win.sync_appearance(); + } + + #[template_callback] + fn notify_is_active(_w: &WindowObj, _pspec: &glib::ParamSpec, win: &WindowObj) { + win.prop_is_active(); + } + + #[template_callback] + fn notify_maximized(_w: &WindowObj, _pspec: &glib::ParamSpec, win: &WindowObj) { + win.sync_appearance(); + } + + #[template_callback] + fn notify_menu_active(button: >k::MenuButton, _pspec: &glib::ParamSpec, win: &WindowObj) { + // Debian 12 is stuck on GTK 4.8 + if !gtk_version::at_least(4, 10, 0) { + return; + } + + // We only care if we're activating. If we're activating then + // we need to check the validity of our menu items. + if !button.is_active() { + return; + } + + win.sync_actions(); + } + + #[template_callback] + fn notify_quick_terminal(_w: &WindowObj, _pspec: &glib::ParamSpec, win: &WindowObj) { + win.prop_quick_terminal(); + } + + #[template_callback] + fn notify_scale_factor(_w: &WindowObj, _pspec: &glib::ParamSpec, win: &WindowObj) { + // On some platforms (namely X11) we need to refresh our + // appearance when the scale factor changes. + if let Err(err) = win.imp().winproto.borrow_mut().sync_appearance() { + log::warn!("failed to sync appearance after scale factor has been updated={err}"); + } + } + + #[template_callback(function)] + fn titlebar_style_is_tabs(value: TitlebarStyle) -> bool { + value == TitlebarStyle::Tabs + } + + #[template_callback(function)] + fn computed_subtitle(config: Option, pwd: Option) -> Option { + let config = config?.get(); + match config.window_subtitle { + super::WindowSubtitle::False => None, + super::WindowSubtitle::WorkingDirectory => pwd, + } + } + } +} + +glib::wrapper! { + pub struct Window(ObjectSubclass) + @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, + @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, + gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; +} + +impl Window { + /// Create a new window for `app`. If `title` is set it is applied + /// immediately (`overrides.title`). The window starts with no tabs; use + /// [`Window::new_tab`] / [`Window::new_tab_for_window`]. + pub fn new(app: &Application, title: Option<&str>) -> Self { + crate::register_resources(); + let win: Self = glib::Object::builder().property("application", app).build(); + if let Some(title) = title { + // If the overrides have a title set, we set that immediately + // so that any applications inspecting the window states see an + // immediate title set when the window appears, rather than + // waiting possibly a few event loop ticks for it to sync from + // the surface. + win.set_title(Some(title)); + } + win + } + + /// Create a window flagged as a quick terminal (Zig: + /// `newInstance(Window, .{ .application = app, .quick-terminal = true })`). + pub fn new_quick_terminal(app: &Application) -> Self { + crate::register_resources(); + glib::Object::builder() + .property("application", app) + .property("quick-terminal", true) + .build() + } + + fn new_empty(app: &Application) -> Self { + Self::new(app, None) + } + + // ---- accessors ------------------------------------------------- + + /// Winproto backend for this window. + pub fn winproto(&self) -> std::cell::RefMut<'_, winproto::Window> { + self.imp().winproto.borrow_mut() + } + + /// Whether this terminal is a quick terminal or not. + pub fn is_quick_terminal(&self) -> bool { + self.imp().quick_terminal.get() + } + + /// Get the currently active surface. See the "active-surface" property. + pub fn get_active_surface(&self) -> Option { + self.get_selected_tab()?.get_active_surface() + } + + /// Returns the configuration for this window. + pub fn get_config(&self) -> Option { + self.imp().config.borrow().clone() + } + + /// Get the tab view for this window. + pub fn tab_view(&self) -> adw::TabView { + self.imp().tab_view.get() + } + + /// Alias for [`Window::tab_view`] (Zig: `getTabView`). + pub fn get_tab_view(&self) -> adw::TabView { + self.tab_view() + } + + /// Get the current window decoration value for this window. + pub fn get_window_decoration(&self) -> WindowDecoration { + let imp = self.imp(); + if let Some(v) = imp.window_decoration.get() { + return v; + } + if let Some(c) = imp.config.borrow().as_ref() { + return c.get().window_decoration; + } + WindowDecoration::Auto + } + + /// Toggle the window decorations for this window. + pub fn toggle_window_decorations(&self) { + let imp = self.imp(); + + if imp.window_decoration.get().is_some() { + // Unset any previously set window decoration settings + self.set_window_decoration(None); + return; + } + + let Some(config) = self.get_config() else { + return; + }; + self.set_window_decoration(Some(match config.get().window_decoration { + // Use auto when the decoration is initially none + WindowDecoration::None => WindowDecoration::Auto, + // Anything non-none to none + WindowDecoration::Auto | WindowDecoration::Client | WindowDecoration::Server => { + WindowDecoration::None + } + })); + } + + /// Set the window decoration override for this window. If this is None, + /// then we'll revert back to the configuration's default. + fn set_window_decoration(&self, new: Option) { + self.imp().window_decoration.set(new); + self.sync_appearance(); + } + + fn get_selected_tab(&self) -> Option { + let page = self.imp().tab_view.selected_page()?; + let child = page.child(); + debug_assert!(child.is::()); + child.downcast::().ok() + } + + /// Returns true if this window needs confirmation before quitting. + pub fn get_needs_confirm_quit(&self) -> bool { + let tab_view = &self.imp().tab_view; + for i in 0..tab_view.n_pages() { + let page = tab_view.nth_page(i); + let Ok(tab) = page.child().downcast::() else { + log::warn!("unexpected non-Tab child in tab view"); + continue; + }; + if tab.get_needs_confirm_quit() { + return true; + } + } + false + } + + fn is_fullscreen_(&self) -> bool { + gtk::prelude::GtkWindowExt::is_fullscreen(self) + } + + fn is_maximized_(&self) -> bool { + gtk::prelude::GtkWindowExt::is_maximized(self) + } + + // ---- tabs -------------------------------------------------------- + + /// Create a new tab with the given parent. The tab will be inserted at + /// the position dictated by the `window-new-tab-position` config. The + /// new tab will be selected. + pub fn new_tab(&self, parent: Option<&Surface>) { + self.new_tab_page(parent, NewSurfaceContext::Tab, TabOverrides::default()); + } + + /// Create the first tab of a new window (inherits the parent's initial + /// size hints). + pub fn new_tab_for_window(&self, parent: Option<&Surface>, overrides: TabOverrides) { + self.new_tab_page(parent, NewSurfaceContext::Window, overrides); + } + + fn new_tab_page( + &self, + parent: Option<&Surface>, + context: NewSurfaceContext, + overrides: TabOverrides, + ) -> adw::TabPage { + let imp = self.imp(); + let tab_view = &imp.tab_view; + + // Create our new tab object + let config = imp.config.borrow().clone(); + let tab = Tab::new( + config.as_ref(), + SurfaceOverrides { + command: overrides.command, + working_directory: overrides.working_directory, + title: overrides.title, + }, + ); + + if let Some(p) = parent { + // For a new window's first tab, inherit the parent's initial + // size hints. + if context == NewSurfaceContext::Window { + self.surface_init(p); + } + tab.set_parent_with_context(p, context); + } + + // Get the position that we should insert the new tab at. + let Some(config) = config else { + // If we don't have a config we just append it at the end. This + // should never happen. + return tab_view.append(&tab); + }; + let position = match config.get().window_new_tab_position { + WindowNewTabPosition::Current => match tab_view.selected_page() { + Some(selected) => tab_view.page_position(&selected) + 1, + None => tab_view.n_pages(), + }, + WindowNewTabPosition::End => tab_view.n_pages(), + }; + + // Add the page and select it + let page = tab_view.insert(&tab, position); + tab_view.set_selected_page(&page); + + // Create some property bindings + tab.bind_property("title", &page, "title") + .sync_create() + .build(); + tab.bind_property("tooltip", &page, "tooltip") + .sync_create() + .build(); + + // Bind signals + let split_tree = tab.get_split_tree(); + let id = split_tree.connect_closure( + "changed", + false, + glib::closure_local!( + #[weak(rename_to = win)] + self, + move |_st: &SplitTree, old: Option, new: Option| { + win.tab_split_tree_changed( + old.as_ref().map(|t| &t.0), + new.as_ref().map(|t| &t.0), + ); + } + ), + ); + imp.tree_handlers + .borrow_mut() + .push((split_tree.downgrade(), id)); + + // Run an initial notification for the surface tree so we can setup + // initial state. + self.tab_split_tree_changed(None, split_tree.get_tree().as_ref()); + + page + } + + /// Select the tab as requested. Returns true if the tab selection + /// changed. + pub fn select_tab(&self, n: SelectTab) -> bool { + let tab_view = &self.imp().tab_view; + + // Get our current tab numeric position + let Some(selected) = tab_view.selected_page() else { + return false; + }; + let current = tab_view.page_position(&selected); + + // Get our total + let total = tab_view.n_pages(); + + let goto: i32 = match n { + SelectTab::Previous => { + if current > 0 { + current - 1 + } else { + total - 1 + } + } + SelectTab::Next => { + if current < total - 1 { + current + 1 + } else { + 0 + } + } + SelectTab::Last => total - 1, + SelectTab::N(v) => { + // 1-indexed + if v == 0 { + return false; + } + let Ok(n_int) = i32::try_from(v) else { + return false; + }; + (n_int - 1).min(total - 1) + } + }; + debug_assert!(goto >= 0); + debug_assert!(goto < total); + + // If our target is the same as our current then we do nothing. + if goto == current { + return false; + } + + // Add the page and select it + let page = tab_view.nth_page(goto); + tab_view.set_selected_page(&page); + + true + } + + /// Move the tab containing the given surface by the given amount. + /// Returns if this affected any tab positioning. + pub fn move_tab(&self, surface: &Surface, amount: isize) -> bool { + let tab_view = &self.imp().tab_view; + + // If we have one tab we never move. + let total = tab_view.n_pages(); + if total == 1 { + return false; + } + + // Get the tab that contains the given surface. + let Some(tab) = get_ancestor::(surface) else { + return false; + }; + + // Get the page position that contains the tab. + let page = tab_view.page(&tab); + let pos = tab_view.page_position(&page); + + // Move it + let initial = pos + amount as i32; + let max = total - 1; + let desired_pos = if initial < 0 { + max + initial + 1 + } else if initial > max { + initial - max - 1 + } else { + initial + }; + debug_assert!(desired_pos >= 0); + debug_assert!(desired_pos < total); + + tab_view.reorder_page(&page, desired_pos) + } + + pub fn toggle_tab_overview(&self) { + let overview = &self.imp().tab_overview; + overview.set_open(!overview.is_open()); + } + + /// Toggle the visible property. + pub fn toggle_visibility(&self) { + self.set_visible(!self.is_visible()); + } + + // ---- appearance --------------------------------------------------- + + /// Updates various appearance properties. This should always be safe to + /// call multiple times. This should be called whenever a change happens + /// that might affect how the window appears (config change, fullscreen, + /// etc.). + fn sync_appearance(&self) { + let imp = self.imp(); + + // Toggle style classes based on whether we're using CSDs or SSDs. + // + // These classes are defined in the gtk.Window documentation: + // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. + { + // Reset all style classes first + for class in ["ssd", "csd", "solid-csd", "no-border-radius"] { + self.remove_css_class(class); + } + + let csd_enabled = imp.winproto.borrow().client_side_decoration_enabled(); + self.set_decorated(csd_enabled); + + if csd_enabled { + let display = WidgetExt::display(self); + + // We do the exact same check GTK is doing internally and + // toggle either the `csd` or `solid-csd` style, based on + // whether the user's window manager is deemed + // _non-compositing_. + // + // In practice this only impacts users of traditional X11 + // window managers (e.g. i3, dwm, awesomewm, etc.) and not + // X11 desktop environments or Wayland compositors/DEs. + if display.is_rgba() && display.is_composited() { + self.add_css_class("csd"); + } else { + self.add_css_class("solid-csd"); + } + } else { + self.add_css_class("ssd"); + // Fix any artifacting that may occur in window corners. + self.add_css_class("no-border-radius"); + } + } + + // Trigger all our dynamic properties that depend on the config. + for key in [ + "headerbar-visible", + "tabs-autohide", + "tabs-visible", + "tabs-wide", + "toolbar-style", + "titlebar-style", + ] { + self.notify(key); + } + + // Remainder uses the config + let Some(config) = self.get_config() else { + return; + }; + let config = config.get(); + + // Only add a solid background if we're opaque. + self.toggle_css_class("background", config.background_opacity >= 1.0); + + // Apply class to color headerbar if window-theme is set to `ghostty` + // and GTK version is before 4.16. The conditional is because above + // 4.16 we use GTK CSS color variables. + self.toggle_css_class( + "window-theme-ghostty", + !gtk_version::at_least(4, 16, 0) && config.window_theme == WindowTheme::Ghostty, + ); + + // Move the tab bar to the proper location. + imp.toolbar.remove(&*imp.tab_bar); + match config.gtk_tabs_location { + GtkTabsLocation::Top => imp.toolbar.add_top_bar(&*imp.tab_bar), + GtkTabsLocation::Bottom => imp.toolbar.add_bottom_bar(&*imp.tab_bar), + } + + // Do our window-protocol specific appearance sync. + if let Err(err) = imp.winproto.borrow_mut().sync_appearance() { + log::warn!("failed to sync winproto appearance error={err}"); + } + } + + /// Sync the state of any actions on this window. + fn sync_actions(&self) { + let has_selection = self + .get_active_surface() + .and_then(|s| s.core()) + .is_some_and(|c| c.has_selection()); + + let Some(action) = self + .lookup_action("copy") + .and_then(|a| a.downcast::().ok()) + else { + return; + }; + action.set_enabled(has_selection); + } + + fn toggle_css_class(&self, class: &str, value: bool) { + if value { + self.add_css_class(class); + } else { + self.remove_css_class(class); + } + } + + /// Perform a binding action on the window's active surface. + fn perform_binding_action(&self, action: &BindingAction) { + let Some(core) = self.get_active_surface().and_then(|s| s.core()) else { + return; + }; + if let Err(err) = core.perform_binding_action(action) { + log::warn!("error performing binding action error={err}"); + } + } + + /// Queue a simple text-based toast. All text-based toasts share the + /// same timeout for consistency. + fn add_toast(&self, title: &str) { + let toast = adw::Toast::new(title); + toast.set_timeout(3); + self.imp().toast_overlay.add_toast(toast); + } + + // ---- surface handlers ------------------------------------------------ + + fn connect_surface_handlers(&self, tree: &Tree) { + let imp = self.imp(); + for entry in tree.iterator() { + let surface = entry.view; + + // Before adding any new signal handlers, disconnect any that we + // may have added before. Otherwise we may get multiple handlers + // for the same signal. + self.disconnect_surface(surface); + + let mut ids = Vec::new(); + + ids.push(surface.connect_closure( + "present-request", + false, + glib::closure_local!( + #[weak(rename_to = win)] + self, + move |surface: &Surface| win.surface_present_request(surface) + ), + )); + ids.push(surface.connect_closure( + "clipboard-write", + false, + glib::closure_local!( + #[weak(rename_to = win)] + self, + move |_surface: &Surface, clipboard: glib::Value, text: &str| { + win.surface_clipboard_write(&clipboard, text); + } + ), + )); + ids.push(surface.connect_closure( + "menu", + false, + glib::closure_local!( + #[weak(rename_to = win)] + self, + move |_surface: &Surface| win.sync_actions() + ), + )); + ids.push(surface.connect_closure( + "toggle-fullscreen", + false, + glib::closure_local!( + #[weak(rename_to = win)] + self, + move |_surface: &Surface| { + if win.is_fullscreen_() { + win.unfullscreen(); + } else { + win.fullscreen(); + } + // We react to the changes in the fullscreened notify + } + ), + )); + ids.push(surface.connect_closure( + "toggle-maximize", + false, + glib::closure_local!( + #[weak(rename_to = win)] + self, + move |_surface: &Surface| { + if win.is_maximized_() { + win.unmaximize(); + } else { + win.maximize(); + } + // We react to the changes in the maximized notify + } + ), + )); + + // If we've never had a surface initialize yet, then we register + // this signal. Its theoretically possible to launch multiple + // surfaces before init so we could register this on multiple + // and that is not a problem because we'll check the flag again + // in each handler. + if !imp.surface_init.get() { + ids.push(surface.connect_closure( + "init", + false, + glib::closure_local!( + #[weak(rename_to = win)] + self, + move |surface: &Surface| win.surface_init(surface) + ), + )); + } + + imp.surface_handlers + .borrow_mut() + .push((surface.downgrade(), ids)); + } + } + + /// Disconnect all handlers we added to `surface`. + fn disconnect_surface(&self, surface: &Surface) { + let mut handlers = self.imp().surface_handlers.borrow_mut(); + let mut i = 0; + while i < handlers.len() { + let same = handlers[i].0.upgrade().is_none_or(|s| &s == surface); + if same { + let (weak, ids) = handlers.swap_remove(i); + if let Some(s) = weak.upgrade() { + for id in ids { + s.disconnect(id); + } + } + } else { + i += 1; + } + } + } + + /// Disconnect all the surface handlers for the given tree. This should + /// be called whenever a tree is no longer present in the window, e.g. + /// when a tab is detached or the tree changes. + fn disconnect_surface_handlers(&self, tree: &Tree) { + for entry in tree.iterator() { + self.disconnect_surface(entry.view); + } + } + + fn tab_split_tree_changed(&self, old: Option<&Tree>, new: Option<&Tree>) { + if let Some(tree) = old { + self.disconnect_surface_handlers(tree); + } + if let Some(tree) = new { + self.connect_surface_handlers(tree); + } + } + + // ---- notify / signal handlers ------------------------------------ + + fn prop_config(&self) { + if let Some(config) = self.get_config() + && config.get().app_notifications.config_reload + { + self.add_toast(&tr("Reloaded the configuration")); + } + self.sync_appearance(); + } + + fn prop_is_active(&self) { + // Hide quick-terminal if set to autohide + if self.is_quick_terminal() + && let Some(cfg) = self.get_config() + && cfg.get().quick_terminal_autohide + && !self.is_active() + { + self.toggle_visibility(); + } + + // Don't change urgency if we're not the active window. + if !self.is_active() { + return; + } + + if let Err(err) = self.winproto().set_urgent(false) { + log::warn!("winproto failed to reset urgency={err}"); + } + } + + fn prop_quick_terminal(&self) { + let imp = self.imp(); + if imp.surface_init.get() { + log::warn!( + "quick terminal property can't be changed after surfaces have been initialized" + ); + return; + } + + if imp.quick_terminal.get() { + // Initialize the quick terminal at the app-layer + if let Err(err) = Application::default() + .winproto_mut() + .init_quick_terminal(self.upcast_ref()) + { + log::warn!("failed to initialize quick terminal error={err}"); + } + } + } + + fn on_realize(&self) { + let app = Application::default(); + + // Initialize our window protocol logic + let host: Rc = Rc::new(Host(self.downgrade())); + match winproto::Window::init(&app.winproto(), host) { + Ok(wp) => { + self.imp().winproto.replace(wp); + } + Err(err) => { + log::warn!("failed to initialize window protocol error={err}"); + return; + } + } + + // We need to setup resize notifications on our surface, which is + // only available after the window had been realized. + if let Some(gdk_surface) = gtk::prelude::NativeExt::surface(self) { + for prop in ["width", "height"] { + gdk_surface.connect_notify_local( + Some(prop), + clone!( + #[weak(rename_to = win)] + self, + move |_, _| { + // X11 needs to fix blurring on resize, but + // winproto implementations could do anything. + if let Err(err) = win.imp().winproto.borrow_mut().resize_event() { + log::warn!("winproto resize event failed error={err}"); + } + } + ), + ); + } + } + + // When we are realized we always setup our appearance since this + // calls some winproto functions. + self.sync_appearance(); + } + + fn tab_overview_open(&self, overview: &adw::TabOverview) { + // We only care about when the tab overview is closed. + if overview.is_open() { + return; + } + + // On tab overview close, focus is sometimes lost. This is an + // upstream issue in libadwaita[1]. When this is resolved we can put + // a runtime version check here to avoid this workaround. + // + // Our workaround is to start a timer after 500ms to refocus the + // currently selected tab. We choose 500ms because the adw animation + // is 400ms. + // + // [1]: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/670 + + // If we have an old timer remove it + let imp = self.imp(); + if let Some(timer) = imp.tab_overview_focus_timer.take() { + timer.remove(); + } + + // Restart our timer + let id = glib::timeout_add_local( + Duration::from_millis(500), + clone!( + #[weak(rename_to = win)] + self, + #[upgrade_or] + glib::ControlFlow::Break, + move || { + // Always note our timer is removed + win.imp().tab_overview_focus_timer.replace(None); + + // Get our currently active surface which should respect + // the newly selected tab. Grab focus. + if let Some(surface) = win.get_active_surface() { + surface.grab_focus(); + } + + // Remove the timer + glib::ControlFlow::Break + } + ), + ); + imp.tab_overview_focus_timer.replace(Some(id)); + } + + fn window_close_request(&self) -> glib::Propagation { + if self.get_needs_confirm_quit() { + // Show a confirmation dialog + let dialog = CloseConfirmationDialog::new(CloseTarget::Window); + dialog.connect_close_request(clone!( + #[weak(rename_to = win)] + self, + move |_| win.destroy() + )); + + // Show it + dialog.present(Some(self)); + return glib::Propagation::Stop; + } + + self.destroy(); + glib::Propagation::Proceed + } + + fn tab_view_close_page(&self, page: &adw::TabPage) -> glib::Propagation { + let tab_view = &self.imp().tab_view; + let child = page.child(); + let Ok(tab) = child.clone().downcast::() else { + return glib::Propagation::Proceed; + }; + + // If the tab says it doesn't need confirmation then we go ahead and + // close immediately. + if !tab.get_needs_confirm_quit() { + tab_view.close_page_finish(page, true); + return glib::Propagation::Stop; + } + + // Show a confirmation dialog + let dialog = CloseConfirmationDialog::new(CloseTarget::Tab); + dialog.connect_close_request(clone!( + #[strong] + page, + move |_| finish_close_page(&page, true) + )); + dialog.connect_cancel(clone!( + #[strong] + page, + move |_| finish_close_page(&page, false) + )); + + // Show it + dialog.present(Some(&child)); + glib::Propagation::Stop + } + + fn tab_view_selected_page(&self) { + let imp = self.imp(); + + // Always reset our binding source in case we have no pages. + if let Some(b) = imp.tab_binding.take() { + b.unbind(); + } + + // Get our current page which MUST be a Tab object. + let Some(page) = imp.tab_view.selected_page() else { + return; + }; + let child = page.child(); + debug_assert!(child.is::()); + + // Setup our binding group. This ensures things like the title are + // synced from the active tab. + imp.tab_binding + .replace(Some(child.bind_property("title", self, "title").build())); + + // If the tab was previously marked as needing attention (e.g. due + // to a bell character), we now unmark that + page.set_needs_attention(false); + } + + fn tab_view_page_attached(&self, page: &adw::TabPage) { + // Get the attached page which must be a Tab object. + let Ok(tab) = page.child().downcast::() else { + return; + }; + + // Attach listeners for the tab. + let id = tab.connect_close_request(clone!( + #[weak(rename_to = win)] + self, + move |tab| win.tab_close_request(tab) + )); + self.imp() + .tab_handlers + .borrow_mut() + .push((tab.downgrade(), id)); + + // Attach listeners for the surface. + // + // Interesting behavior here that was previously undocumented but + // I'm going to make it explicit here: we accept all the signals + // here (like toggle-fullscreen) regardless of whether the surface + // or tab is focused. At the time of writing this we have no API + // that could really trigger these that way but its theoretically + // possible. + // + // What is DEFINITELY possible is something like OSC52 triggering a + // clipboard-write signal on an unfocused tab/surface. We + // definitely want to show the user a notification about that but + // our notification right now is a toast that doesn't make it clear + // WHO used the clipboard. + if let Some(tree) = tab.get_surface_tree() { + self.connect_surface_handlers(&tree); + } + } + + fn tab_view_page_detached(&self, page: &adw::TabPage) { + // We need to get the tab to disconnect the signals. + let Ok(tab) = page.child().downcast::() else { + return; + }; + { + let mut handlers = self.imp().tab_handlers.borrow_mut(); + let mut i = 0; + while i < handlers.len() { + if handlers[i].0.upgrade().is_none_or(|t| t == tab) { + let (weak, id) = handlers.swap_remove(i); + if let Some(t) = weak.upgrade() { + t.disconnect(id); + } + } else { + i += 1; + } + } + } + + // Remove the tree handlers + if let Some(tree) = tab.get_surface_tree() { + self.disconnect_surface_handlers(&tree); + } + } + + fn tab_close_request(&self, tab: &Tab) { + let tab_view = &self.imp().tab_view; + let page = tab_view.page(tab); + // TODO: connect close page handler to tab to check for confirmation + tab_view.close_page(&page); + } + + fn surface_clipboard_write(&self, clipboard: &glib::Value, text: &str) { + // We only toast for the standard clipboard. + let is_standard = glib::EnumValue::from_value(clipboard) + .is_some_and(|(_, v)| v.value() == 0 || v.nick() == "standard"); + if !is_standard { + return; + } + + // We only toast if configured to + let Some(config) = self.get_config() else { + return; + }; + if !config.get().app_notifications.clipboard_copy { + return; + } + + if text.is_empty() { + self.add_toast(&tr("Cleared clipboard")); + } else { + self.add_toast(&tr("Copied to clipboard")); + } + } + + fn surface_present_request(&self, surface: &Surface) { + // Verify that this surface is actually in this window. + match get_ancestor::(surface) { + None => { + log::warn!("present request called for non-existent surface"); + return; + } + Some(w) if &w != self => { + log::warn!("present request called for surface in different window"); + return; + } + Some(_) => {} + } + + // Get the tab for this surface. + let Some(tab) = get_ancestor::(surface) else { + log::warn!("present request surface not found"); + return; + }; + + // Get the page that contains this tab + let tab_view = &self.imp().tab_view; + let page = tab_view.page(&tab); + tab_view.set_selected_page(&page); + + // Grab focus + surface.grab_focus(); + + // Bring the window to the front. + self.present(); + } + + fn surface_init(&self, surface: &Surface) { + let imp = self.imp(); + + // Make sure we init only once + if imp.surface_init.get() { + return; + } + imp.surface_init.set(true); + + // Setup our default and minimum size. + if let Some(size) = surface.get_default_size() { + self.set_default_size(size.width as i32, size.height as i32); + } + if let Some(size) = surface.get_min_size() { + self.set_size_request(size.width as i32, size.height as i32); + } + } + + // ---- actions --------------------------------------------------------- + + fn init_action_map(&self) { + let simple = |name: &str, f: fn(&Window)| { + gio::ActionEntry::builder(name) + .activate(move |win: &Window, _, _| f(win)) + .build() + }; + let binding = |name: &str, action: BindingAction| { + gio::ActionEntry::builder(name) + .activate(move |win: &Window, _, _| win.perform_binding_action(&action)) + .build() + }; + + self.add_action_entries([ + simple("about", Window::action_about), + gio::ActionEntry::builder("close") + .activate(|win: &Window, _, _| win.close()) + .build(), + gio::ActionEntry::builder("close-tab") + .parameter_type(Some(glib::VariantTy::STRING)) + .activate(|win: &Window, _, param| win.action_close_tab(param)) + .build(), + binding("new-tab", BindingAction::NewTab), + binding("new-window", BindingAction::NewWindow), + binding("prompt-surface-title", BindingAction::PromptSurfaceTitle), + binding("prompt-tab-title", BindingAction::PromptTabTitle), + simple( + "prompt-context-tab-title", + Window::action_prompt_context_tab_title, + ), + simple("ring-bell", Window::action_ring_bell), + binding( + "split-right", + BindingAction::NewSplit(ghostty_input::binding::SplitDirection::Right), + ), + binding( + "split-left", + BindingAction::NewSplit(ghostty_input::binding::SplitDirection::Left), + ), + binding( + "split-up", + BindingAction::NewSplit(ghostty_input::binding::SplitDirection::Up), + ), + binding( + "split-down", + BindingAction::NewSplit(ghostty_input::binding::SplitDirection::Down), + ), + binding( + "copy", + BindingAction::CopyToClipboard(ghostty_input::binding::CopyToClipboard::Mixed), + ), + binding("paste", BindingAction::PasteFromClipboard), + binding("reset", BindingAction::Reset), + binding("clear", BindingAction::ClearScreen), + // TODO: accept the surface that toggled the command palette + simple("toggle-command-palette", Window::toggle_command_palette), + simple("toggle-inspector", Window::toggle_inspector), + ]); + } + + fn action_about(&self) { + let name = "Ghostty"; + let icon = "com.mitchellh.ghostty"; + let website = "https://ghostty.org"; + + // libadwaita >= 1.5 is assumed, so AdwAboutDialog is always used + // (the Zig GtkAboutDialog fallback is unreachable here). + let _ = adw_version::supports_dialogs(); + let dialog = adw::AboutDialog::builder() + .application_name(name) + .developer_name(tr("Ghostty Developers")) + .application_icon(icon) + .version(env!("CARGO_PKG_VERSION")) + .issue_url("https://github.com/ghostty-org/ghostty/issues") + .website(website) + .build(); + dialog.present(Some(self)); + } + + fn action_close_tab(&self, param: Option<&glib::Variant>) { + let Some(param) = param else { + log::warn!("win.close-tab called without a parameter"); + return; + }; + let Some(s) = param.str() else { + log::warn!("invalid mode provided to win.close-tab"); + return; + }; + let Some(mode) = CloseTabMode::from_name(s) else { + log::warn!("invalid mode provided to win.close-tab: {s}"); + return; + }; + self.perform_binding_action(&BindingAction::CloseTab(mode)); + } + + fn action_prompt_context_tab_title(&self) { + let Some(page) = self.imp().context_menu_page.borrow().clone() else { + return; + }; + let Ok(tab) = page.child().downcast::() else { + return; + }; + tab.prompt_tab_title(); + } + + fn action_ring_bell(&self) { + let Some(config) = self.get_config() else { + return; + }; + let config = config.get(); + + if config.bell_features.system { + match gtk::prelude::NativeExt::surface(self) { + Some(native) => native.beep(), + None => log::warn!("unable to get native surface from window"), + } + } + + // Dont set urgency if the window is already active. + if config.bell_features.attention && !self.is_active() { + // Request user attention + if let Err(err) = self.winproto().set_urgent(true) { + log::warn!("winproto failed to set urgency={err}"); + } + } + } + + /// Toggle the command palette. + /// + /// TODO: accept the surface that toggled the command palette as a + /// parameter + fn toggle_command_palette(&self) { + let imp = self.imp(); + + // Get a reference to a command palette. First check the weak + // reference that we save to see if we already have one stored. If + // we don't then create a new one. + let palette = if let Some(p) = imp.command_palette.upgrade() { + p + } else { + // Create a fresh command palette. + let palette = CommandPalette::new(); + + // Synchronize our config to the command palette's config. + self.bind_property("config", &palette, "config") + .sync_create() + .build(); + + // Listen to the trigger signal to know if the user selected an + // option in the command palette. + palette.connect_trigger(clone!( + #[weak(rename_to = win)] + self, + move |_, action| { + // If the activation actually has an action, perform it. + win.perform_binding_action(action); + } + )); + + // Save a weak reference to the command palette. We use a weak + // reference to avoid reference counting cycles that might cause + // problems later. + imp.command_palette.set(Some(&palette)); + palette + }; + + // Tell the command palette to toggle itself. If the dialog gets + // presented (instead of hidden) it will be modal over our window. + palette.toggle(self); + } + + /// Toggle the Ghostty inspector for the active surface. + fn toggle_inspector(&self) { + // TODO(inspector): deferred; the inspector is not ported yet. + if let Some(surface) = self.get_active_surface() { + log::debug!("toggle-inspector requested for {surface:?} (inspector deferred)"); + } + } +} + +/// Find the first ancestor of `widget` of type `T` (Zig: `ext.getAncestor`). +fn get_ancestor>(widget: &impl IsA) -> Option { + widget + .ancestor(T::static_type()) + .and_then(|w| w.downcast::().ok()) +} + +fn finish_close_page(page: &adw::TabPage, confirm: bool) { + let Some(tab_view) = get_ancestor::(&page.child()) else { + log::warn!("close confirmation called for non-existent page"); + return; + }; + tab_view.close_page_finish(page, confirm); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn titlebar_style_conversion() { + assert_eq!( + TitlebarStyle::from(GtkTitlebarStyle::Native), + TitlebarStyle::Native + ); + assert_eq!( + TitlebarStyle::from(GtkTitlebarStyle::Tabs), + TitlebarStyle::Tabs + ); + } + + #[test] + fn select_tab_n_zero_is_noop_variant() { + assert_eq!(SelectTab::N(0), SelectTab::N(0)); + assert_ne!(SelectTab::Next, SelectTab::Previous); + } +} diff --git a/crates/ghostty-apprt-gtk/src/core.rs b/crates/ghostty-apprt-gtk/src/core.rs new file mode 100644 index 0000000..56f8ee6 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/core.rs @@ -0,0 +1,282 @@ +//! The trait boundary between this app runtime 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 +//! be a dependency of this crate without a cycle once core implements these +//! traits), so the exact surface of what GTK needs is expressed here: +//! +//! * [`CoreApp`] / [`CoreSurface`]: implemented by ghostty-core, called by GTK. +//! * [`RtApp`] / [`RtSurface`]: implemented by this crate, handed to core so it +//! can call back (Zig: `rt_app.performAction`, `rt_surface.getSize()`, ...). +//! +//! Threading: everything here is **main-thread only** (`!Send`), like the GTK +//! objects behind it. The only cross-thread entry point is [`Waker`] +//! (`App.wakeup`), used by core threads to request an app `tick()`. +//! +//! See `docs/rewrite/notes/ghostty-apprt-gtk.md` for the full spec. + +use std::rc::Rc; +use std::sync::Arc; + +use ghostty_apprt::action::{Action, Target}; +use ghostty_apprt::structs::{ + Clipboard, ClipboardRequest, ColorScheme, ContentScale, CursorPos, ImePos, SurfaceSize, +}; +use ghostty_config::config::Config; +use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods}; + +/// Identifier for a core surface (Zig: `*CoreSurface` pointer). Allocated by +/// this crate for each GTK surface; core stores it in its `Target::Surface`. +pub type SurfaceId = u64; + +/// Minimum window size in cells (Zig: `CoreSurface.min_window_{width,height}_cells`). +pub const MIN_WINDOW_WIDTH_CELLS: u32 = 10; +/// See [`MIN_WINDOW_WIDTH_CELLS`]. +pub const MIN_WINDOW_HEIGHT_CELLS: u32 = 4; + +/// Result of `CoreSurface::key_callback` (Zig: `Surface.InputEffect`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputEffect { + /// The key was consumed by a binding that closed the surface; the + /// surface may no longer be valid. + Closed, + /// The key wasn't used. + Ignored, + /// The key was used (binding performed or sent to the pty). + Consumed, +} + +/// Errors from `CoreSurface::complete_clipboard_request` that the GTK side +/// reacts to (Zig error set: `UnsafePaste`, `UnauthorizedPaste`). +#[derive(Debug, thiserror::Error)] +pub enum ClipboardError { + #[error("unsafe paste")] + UnsafePaste, + #[error("unauthorized paste")] + UnauthorizedPaste, + #[error("{0}")] + Other(String), +} + +/// Generic core error (Zig: `anyerror`). +pub type CoreError = Box; + +/// A message sent by the GTK side to the core app mailbox +/// (Zig: `core_app.mailbox.push`). +#[derive(Debug, Clone)] +pub enum AppMessage { + /// Open the config file in the default editor (`App.Message.open_config`). + OpenConfig, + /// `App.Message.new_window` (queued by `activate`; core responds with + /// `rt.perform_action(Target::App, Action::NewWindow)`). + NewWindow, + /// `surface_message{ surface, .present_surface }`; core validates the id. + PresentSurface(SurfaceId), +} + +/// Desired font size request used when initializing a surface (Zig: +/// `font.face.DesiredSize` points). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DesiredFontSize { + pub points: f32, + pub xdpi: u16, + pub ydpi: u16, +} + +/// Input to `CoreSurface::key_callback`: `ghostty_input::KeyEvent` is used +/// directly. +pub use ghostty_input::KeyEvent; + +/// Cell metrics for a config+DPI combo (Zig: `font_grid_set.ref(..)` -> +/// `grid.cellSize()`), used to compute the default window size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CellSize { + pub width: u32, + pub height: u32, +} + +/// Implemented by ghostty-core's `App`. +pub trait CoreApp { + /// `App.tick`: drain the app mailbox and perform pending work. Core calls + /// back into `rt` (e.g. `rt.perform_action`). Called on the main thread + /// whenever a [`Waker`] wake was requested, and after config updates. + fn tick(&self, rt: &dyn RtApp) -> Result<(), CoreError>; + + /// `App.updateConfig`: apply a new config to the app and all surfaces + /// (surfaces get `Message::ChangeConfig`). Returns Ok on success. + fn update_config(&self, rt: &dyn RtApp, config: &Arc) -> Result<(), CoreError>; + + /// `App.needsConfirmQuit`: any surface with a running process? + fn needs_confirm_quit(&self) -> bool; + + /// `App.colorSchemeEvent`: the system color scheme changed. + fn color_scheme_event(&self, rt: &dyn RtApp, scheme: ColorScheme); + + /// `App.performAllAction`: perform `action` on every surface + /// (`rt.perform_action(Target::Surface(id), ..)` for each). + fn perform_all_action( + &self, + rt: &dyn RtApp, + action: &ghostty_input::binding::Action, + ) -> Result<(), CoreError>; + + /// `App.mailbox.push(.., .forever)`. + fn push_message(&self, msg: AppMessage); + + /// `App.addSurface` + `Surface.init`: create a core surface for a GTK + /// surface. `config` is the (possibly modified) surface config. The + /// returned surface is owned by the GTK surface. `rt_surface` is the + /// back-reference core uses (Zig: `&priv.rt_surface`). + fn new_surface( + &self, + id: SurfaceId, + config: Config, + rt_app: Rc, + rt_surface: Rc, + ) -> Result, CoreError>; + + /// `App.deleteSurface`: called when the GTK surface is finalized, after + /// the `CoreSurface` was dropped. + fn delete_surface(&self, id: SurfaceId); + + /// Ids of all live surfaces (`core_app.surfaces.items`). + fn surface_ids(&self) -> Vec; + + /// Cell size for the given config and DPI, used for the default window + /// size (`font_grid_set.ref(..).cellSize()`); None if fonts fail. + fn cell_size(&self, config: &Config, font_size: DesiredFontSize) -> Option; +} + +/// Implemented by ghostty-core's `Surface`. All methods take `&self` +/// (interior mutability inside core), main thread only. +pub trait CoreSurface { + /// The config the surface was created with / last updated to. + fn config(&self) -> Arc; + + // ---- input ------------------------------------------------------- + fn key_callback(&self, event: KeyEvent<'_>) -> Result; + /// `preeditCallback(null)` clears. + fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError>; + fn focus_callback(&self, focused: bool) -> Result<(), CoreError>; + /// Returns true if the click was consumed. + fn mouse_button_callback( + &self, + state: MouseButtonState, + button: MouseButton, + mods: Mods, + ) -> Result; + /// `pos` is in surface (logical*scale) pixels; `(-1,-1)` = left window. + fn cursor_pos_callback(&self, pos: CursorPos, mods: Option) -> Result<(), CoreError>; + fn scroll_callback(&self, xoff: f64, yoff: f64, mods: ScrollMods) -> Result<(), CoreError>; + fn mouse_pressure_callback(&self, _stage: ghostty_input::MousePressureStage, _pressure: f64) {} + fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError>; + fn content_scale_callback(&self, scale: ContentScale) -> Result<(), CoreError>; + /// IME candidate window position in surface pixels (`imePoint`). + fn ime_point(&self) -> ImePos; + fn has_selection(&self) -> bool; + /// `colorSchemeCallback`: the system color scheme changed. + fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError>; + /// `Surface.updateConfig`: apply a new config to just this surface. + fn update_config(&self, config: &Arc) -> Result<(), CoreError>; + /// Perform a keybinding action on this surface; returns whether it was + /// performed. + fn perform_binding_action( + &self, + action: &ghostty_input::binding::Action, + ) -> Result; + + // ---- clipboard --------------------------------------------------- + /// `completeClipboardRequest(req, data, confirmed)`. + fn complete_clipboard_request( + &self, + req: ClipboardRequest, + data: &str, + confirmed: bool, + ) -> Result<(), ClipboardError>; + /// After the user confirms (`surface.config.clipboard_read = .allow`). + fn set_clipboard_read_allowed(&self, allow: bool); + /// See [`CoreSurface::set_clipboard_read_allowed`] (`clipboard_write`). + fn set_clipboard_write_allowed(&self, allow: bool); + + // ---- lifecycle / rendering ---------------------------------------- + /// Whether closing needs confirmation (`needsConfirmQuit`). + fn needs_confirm_quit(&self) -> bool; + /// Whether the child exited (`child_exited`) - used by close logic. + fn child_exited(&self) -> bool; + /// `renderer.displayRealized()`: GLArea realized, GL context current. + fn renderer_realized(&self) -> Result<(), CoreError>; + /// `renderer.displayUnrealized()`: GL context current (if it could be). + fn renderer_unrealized(&self); + /// `renderer.drawFrame(true)`: draw into the currently bound GLArea + /// framebuffer (GL context is current). `default_framebuffer` is the + /// GLArea's FBO id (`GL_FRAMEBUFFER_BINDING`). + fn draw_frame(&self, default_framebuffer: u32) -> Result<(), CoreError>; + + // ---- misc state read by the surface widget (added by F5) ------------- + /// The `SurfaceId` this core surface was created with (`new_surface`'s + /// `id`); lets the GTK side map a core surface back to its widget. + fn id(&self) -> Option { + None + } + /// `surface.readonly` (read-only mode state). + fn readonly(&self) -> bool { + false + } + /// `surface.size.grid()` -> (columns, rows); used by the resize overlay. + fn grid_size(&self) -> (u32, u32) { + (0, 0) + } + /// `surface.font_size` (inherited by child surfaces in `setParent`). + fn font_size(&self) -> DesiredFontSize { + DesiredFontSize { + points: 12.0, + xdpi: 96, + ydpi: 96, + } + } + + // ---- inspector (deferred) ------------------------------------------- + fn activate_inspector(&self) -> Result<(), CoreError> { + Ok(()) + } + fn deactivate_inspector(&self) {} +} + +/// Implemented by this crate (GTK `Application`); handed to core. +pub trait RtApp { + /// `App.performAction`. Returns whether the action was performed. + /// Runs on the main thread. + fn perform_action(&self, target: Target, action: Action) -> bool; +} + +/// Implemented by this crate (GTK `Surface`); handed to core. Mirrors +/// `apprt/gtk/Surface.zig`. +pub trait RtSurface { + /// `close(process_active)`. + fn close(&self, process_active: bool); + /// Cgroup path of the surface (Linux, `gtk-single-instance` scopes). + fn cgroup(&self) -> Option; + fn get_title(&self) -> Option; + fn get_content_scale(&self) -> ContentScale; + fn get_size(&self) -> SurfaceSize; + fn get_cursor_pos(&self) -> CursorPos; + fn supports_clipboard(&self, clipboard: Clipboard) -> bool { + let _ = clipboard; + true + } + /// `clipboardRequest`; the answer arrives later through + /// `CoreSurface::complete_clipboard_request`. Returns true if started. + fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool; + /// `setClipboard(type, contents, confirm)`; contents are (mime, data). + fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool); + /// `defaultTermioEnv`: env vars to set for the child (GDK/GTK related + /// cleanups). + fn default_termio_env(&self) -> Vec<(String, String)>; + fn redraw_inspector(&self) {} +} + +/// 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 { + fn wake(&self); +} diff --git a/crates/ghostty-apprt-gtk/src/css.rs b/crates/ghostty-apprt-gtk/src/css.rs new file mode 100644 index 0000000..66a14f5 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/css.rs @@ -0,0 +1,335 @@ +//! CSS handling for the application (the CSS portions of +//! ZIG/src/apprt/gtk/class/application.zig). +//! +//! The static stylesheets (`style.css`, `style-dark.css`, `style-hc.css`, +//! `style-hc-dark.css`) live in the gresource at +//! `/com/mitchellh/ghostty/` and are loaded automatically by +//! `AdwApplication` when its `resource-base-path` is +//! [`crate::RESOURCE_PREFIX`]; so the application must set that. +//! +//! This module owns the *dynamic* providers: the runtime CSS derived from +//! the configuration and the user's `gtk-custom-css` files. + +use std::fmt::Write as _; + +use ghostty_config::config::Config; +use ghostty_config::path::Path; +use ghostty_config::types_b::WindowTheme; +use gtk::{gdk, glib, prelude::*}; + +/// Maximum size of a `gtk-custom-css` file. +const CSS_FILE_SIZE_LIMIT: u64 = 5 * 1024 * 1024; + +/// The set of CSS providers installed on the display. +#[derive(Debug)] +pub struct CssProviders { + display: gdk::Display, + /// Styles based on Ghostty configuration values. + runtime: gtk::CssProvider, + /// Providers for `gtk-custom-css`. + custom: Vec, + /// Last applied color scheme (dark?), reapplied to new providers. + dark: Option, +} + +fn at_least_gtk(major: u32, minor: u32) -> bool { + (gtk::major_version(), gtk::minor_version()) >= (major, minor) +} + +/// Log a CSS parsing error (Zig: `signalCssParsingError`). +fn log_parsing_error(section: >k::CssSection, err: &glib::Error) { + let location = section.to_str(); + log::warn!("css parsing failed at {location}: {err}"); +} + +fn new_provider() -> gtk::CssProvider { + let p = gtk::CssProvider::new(); + p.connect_parsing_error(|_, section, err| log_parsing_error(section, err)); + p +} + +impl CssProviders { + /// Create the runtime provider and install it on the display + /// (`STYLE_PROVIDER_PRIORITY_APPLICATION + 3`). + pub fn new(display: &gdk::Display) -> Self { + let runtime = new_provider(); + gtk::style_context_add_provider_for_display( + display, + &runtime, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 3, + ); + Self { + display: display.clone(), + runtime, + custom: Vec::new(), + dark: None, + } + } + + /// Reload runtime and custom CSS from the config. Failures are logged + /// and leave the previous CSS in place. + pub fn load_all(&mut self, config: &Config) { + self.load_runtime(config); + self.load_custom(config); + } + + /// Load the config-derived runtime CSS (Zig: `loadRuntimeCss`). + pub fn load_runtime(&self, config: &Config) { + let contents = runtime_css(config, at_least_gtk(4, 16)); + log::debug!("runtime CSS is {} bytes", contents.len()); + self.runtime + .load_from_bytes(&glib::Bytes::from_owned(contents.into_bytes())); + } + + /// Load `gtk-custom-css` files (Zig: `loadCustomCss`). + pub fn load_custom(&mut self, config: &Config) { + for p in self.custom.drain(..) { + gtk::style_context_remove_provider_for_display(&self.display, &p); + } + + for p in &config.gtk_custom_css.value { + let (path, optional) = match p { + Path::Optional(path) => (path, true), + Path::Required(path) => (path, false), + }; + let meta = match std::fs::metadata(path) { + Ok(m) => m, + Err(e) => { + if e.kind() != std::io::ErrorKind::NotFound || !optional { + log::warn!("error opening gtk-custom-css file {path}: {e}"); + } + continue; + } + }; + log::info!("loading gtk-custom-css path={path}"); + if meta.len() > CSS_FILE_SIZE_LIMIT { + log::warn!( + "gtk-custom-css file {path} was larger than {CSS_FILE_SIZE_LIMIT} bytes" + ); + continue; + } + let contents = match std::fs::read(path) { + Ok(c) => c, + Err(e) => { + log::warn!("error reading gtk-custom-css file {path}: {e}"); + continue; + } + }; + + let provider = new_provider(); + provider.load_from_bytes(&glib::Bytes::from_owned(contents)); + gtk::style_context_add_provider_for_display( + &self.display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_USER, + ); + if let Some(dark) = self.dark { + set_provider_scheme(&provider, dark); + } + self.custom.push(provider); + } + } + + /// Propagate the system color scheme to the providers so that + /// `prefers-color-scheme` media queries match (GTK >= 4.20 only). + pub fn set_color_scheme(&mut self, dark: bool) { + self.dark = Some(dark); + set_provider_scheme(&self.runtime, dark); + for p in &self.custom { + set_provider_scheme(p, dark); + } + } +} + +/// Set the `prefers-color-scheme` property on a provider (GTK >= 4.20). +/// Done through the dynamic property API since the crate is built against +/// an older GTK feature level. +fn set_provider_scheme(provider: >k::CssProvider, dark: bool) { + if !at_least_gtk(4, 20) { + return; + } + let Some(pspec) = provider.find_property("prefers-color-scheme") else { + return; + }; + let Some(class) = glib::EnumClass::with_type(pspec.value_type()) else { + return; + }; + let nick = if dark { "dark" } else { "light" }; + if let Some(v) = class.to_value_by_nick(nick) { + provider.set_property_from_value("prefers-color-scheme", &v); + } +} + +/// Build the runtime CSS string. Pure function (testable). +/// `gtk_416` selects between the pre/post GTK 4.16 variants. +#[must_use] +pub fn runtime_css(config: &Config, gtk_416: bool) -> String { + let mut out = String::with_capacity(2048); + // Standard css first as it can override some user configured styling. + if gtk_416 { + runtime_css_416(config, &mut out); + } else { + runtime_css_414(config, &mut out); + } + + let fill = config.unfocused_split_fill.unwrap_or(config.background); + let _ = write!( + out, + "widget.unfocused-split {{\n opacity: {:.2};\n background-color: rgb({},{},{});\n}}\n", + 1.0 - config.unfocused_split_opacity, + fill.r, + fill.g, + fill.b, + ); + + if let Some(c) = config.split_divider_color { + let _ = write!( + out, + ".window .split paned > separator {{\n color: rgb({r},{g},{b});\n background: rgb({r},{g},{b});\n}}\n", + r = c.r, + g = c.g, + b = c.b, + ); + } + + if let Some(family) = &config.window_title_font_family { + let _ = write!( + out, + ".window headerbar {{\n font-family: \"{family}\";\n}}\n" + ); + } + out +} + +fn runtime_css_414(config: &Config, out: &mut String) { + if config.window_theme != WindowTheme::Ghostty { + return; + } + let bg = config + .window_titlebar_background + .unwrap_or(config.background); + let fg = config + .window_titlebar_foreground + .unwrap_or(config.foreground); + let _ = write!( + out, + "windowhandle {{\n background-color: rgb({},{},{});\n color: rgb({},{},{});\n}}\nwindowhandle:backdrop {{\n background-color: oklab(from rgb({},{},{}) calc(l * 0.9) a b / alpha);\n}}\n", + bg.r, bg.g, bg.b, fg.r, fg.g, fg.b, bg.r, bg.g, bg.b, + ); +} + +fn runtime_css_416(config: &Config, out: &mut String) { + out.push_str( + "/* + * Child Exited Overlay + */ + +.child-exited.normal revealer widget { + background-color: color-mix( + in srgb, + var(--success-bg-color), + transparent 50% + ); +} + +.child-exited.abnormal revealer widget { + background-color: color-mix( + in srgb, + var(--error-bg-color), + transparent 50% + ); +} + +/* + * Surface + */ + +.surface progressbar.error trough progress { + background-color: color-mix( + in srgb, + var(--error-bg-color), + transparent 50% + ); +} + +.surface .bell-overlay { + border-color: color-mix( + in srgb, + var(--accent-color), + transparent 50% + ); +} + +/* + * Splits + */ + +.window .split paned > separator { + background-color: color-mix( + in srgb, + var(--window-bg-color), + transparent 0% + ); +} +", + ); + + if config.window_theme == WindowTheme::Ghostty { + let bg = config + .window_titlebar_background + .unwrap_or(config.background); + let fg = config + .window_titlebar_foreground + .unwrap_or(config.foreground); + let _ = write!( + out, + ":root {{ + --ghostty-fg: rgb({},{},{}); + --ghostty-bg: rgb({},{},{}); + --headerbar-fg-color: var(--ghostty-fg); + --headerbar-bg-color: var(--ghostty-bg); + --headerbar-backdrop-color: oklab(from var(--headerbar-bg-color) calc(l * 0.9) a b / alpha); + --overview-fg-color: var(--ghostty-fg); + --overview-bg-color: var(--ghostty-bg); + --popover-fg-color: var(--ghostty-fg); + --popover-bg-color: var(--ghostty-bg); + --window-fg-color: var(--ghostty-fg); + --window-bg-color: var(--ghostty-bg); +}} +windowhandle {{ + background-color: var(--headerbar-bg-color); + color: var(--headerbar-fg-color); +}} +windowhandle:backdrop {{ + background-color: var(--headerbar-backdrop-color); +}}", + fg.r, fg.g, fg.b, bg.r, bg.g, bg.b, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_css_has_unfocused_split() { + let c = Config::default(); + let css = runtime_css(&c, true); + assert!(css.contains("widget.unfocused-split")); + assert!(css.contains("opacity: 0.30;")); + assert!(css.contains(".child-exited.normal")); + let old = runtime_css(&c, false); + assert!(!old.contains(".child-exited.normal")); + } + + #[test] + fn runtime_css_ghostty_theme() { + let c = Config { + window_theme: WindowTheme::Ghostty, + ..Config::default() + }; + assert!(runtime_css(&c, true).contains("--ghostty-bg")); + assert!(runtime_css(&c, false).contains("windowhandle:backdrop")); + } +} diff --git a/crates/ghostty-apprt-gtk/src/ext/actions.rs b/crates/ghostty-apprt-gtk/src/ext/actions.rs new file mode 100644 index 0000000..80ae7cb --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/ext/actions.rs @@ -0,0 +1,138 @@ +//! GAction helpers (port of `apprt/gtk/ext/actions.zig`). + +use gtk::gio; +use gtk::glib; +use gtk::glib::prelude::*; +use gtk::prelude::*; + +fn g_action_name_is_valid(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|c| c == b'-' || c == b'.' || c.is_ascii_alphanumeric()) +} + +/// Callback signature: `(action, parameter, self)`. +pub type Callback = fn(&gio::SimpleAction, Option<&glib::Variant>, &T); + +/// An action definition for objects of type `T`. +pub struct Action { + pub name: &'static str, + pub callback: Callback, + pub parameter_type: Option, + pub state: Option, +} + +impl Action { + pub fn new( + name: &'static str, + callback: Callback, + parameter_type: Option, + ) -> Self { + assert!(g_action_name_is_valid(name)); + Self { + name, + callback, + parameter_type, + state: None, + } + } + + pub fn stateful( + name: &'static str, + callback: Callback, + parameter_type: Option, + state: glib::Variant, + ) -> Self { + assert!(g_action_name_is_valid(name)); + Self { + name, + callback, + parameter_type, + state: Some(state), + } + } +} + +/// Add the actions to `obj` itself (it must be an `ActionMap`). +pub fn add>(obj: &T, actions: &[Action]) { + add_to_map(obj, obj.upcast_ref::(), actions); +} + +/// Add the actions to `map`; callbacks receive `obj` (held weakly, so no +/// reference cycle is created). +pub fn add_to_map(obj: &T, map: &impl IsA, actions: &[Action]) { + for entry in actions { + assert!(g_action_name_is_valid(entry.name)); + let action = if let Some(state) = &entry.state { + gio::SimpleAction::new_stateful(entry.name, entry.parameter_type.as_deref(), state) + } else { + gio::SimpleAction::new(entry.name, entry.parameter_type.as_deref()) + }; + let weak = obj.downgrade(); + let callback = entry.callback; + action.connect_activate(move |a, p| { + if let Some(obj) = weak.upgrade() { + callback(a, p, &obj); + } + }); + map.add_action(&action); + } +} + +/// Collect the actions into a group inserted into the widget under `name`. +pub fn add_as_group>( + obj: &T, + name: &str, + actions: &[Action], +) -> gio::SimpleActionGroup { + assert!(g_action_name_is_valid(name)); + + // We're a plain widget that doesn't implement ActionGroup directly. + let group = gio::SimpleActionGroup::new(); + add_to_map(obj, &group, actions); + obj.insert_action_group(name, Some(&group)); + group +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn g_action_name_is_valid_test() { + assert!(g_action_name_is_valid("ring-bell")); + assert!(!g_action_name_is_valid("ring_bell")); + assert!(!g_action_name_is_valid("")); + } + + fn cb(_: &gio::SimpleAction, v: Option<&glib::Variant>, b: >k::Box) { + if let Some(v) = v.and_then(glib::Variant::get::) { + b.set_spacing(v); + } + } + + #[test] + fn adding_actions_to_an_object() { + // This test requires a connection to an active display environment. + if gtk::init().is_err() { + return; + } + + let b = gtk::Box::new(gtk::Orientation::Vertical, 0); + let actions = [Action::new( + "test", + cb, + Some(i32::static_variant_type().into_owned()), + )]; + let _group = add_as_group(&b, "test", &actions); + + let expected = 12345; + assert!( + b.activate_action("test.test", Some(&expected.to_variant())) + .is_ok() + ); + while glib::MainContext::default().iteration(false) {} + assert_eq!(b.spacing(), expected); + } +} diff --git a/crates/ghostty-apprt-gtk/src/ext/mod.rs b/crates/ghostty-apprt-gtk/src/ext/mod.rs new file mode 100644 index 0000000..d362f0e --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/ext/mod.rs @@ -0,0 +1,37 @@ +//! Extensions/helpers for GTK objects (port of `apprt/gtk/ext.zig`). +//! +//! `boxedCopy`/`boxedFree`/`listFind` are subsumed by glib-rs (`Clone`, +//! `Drop`, iterators) and are not ported. + +pub mod actions; +pub mod slice; + +pub use slice::StringList; + +use gtk::glib; +use gtk::glib::prelude::*; +use gtk::prelude::*; + +/// Get the first ancestor of `widget` of type `T`. +pub fn get_ancestor>(widget: &impl IsA) -> Option { + widget.ancestor(T::static_type()).and_downcast::() +} + +/// Whether the value holds (a subtype of) the given type. +pub fn g_value_holds(value: Option<&glib::Value>, g_type: glib::Type) -> bool { + let Some(value) = value else { return false }; + value.type_() == g_type || value.type_().is_a(g_type) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn g_value_holds_test() { + let v = 5i32.to_value(); + assert!(g_value_holds(Some(&v), glib::Type::I32)); + assert!(!g_value_holds(Some(&v), glib::Type::STRING)); + assert!(!g_value_holds(None, glib::Type::I32)); + } +} diff --git a/crates/ghostty-apprt-gtk/src/ext/slice.rs b/crates/ghostty-apprt-gtk/src/ext/slice.rs new file mode 100644 index 0000000..558c004 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/ext/slice.rs @@ -0,0 +1,48 @@ +//! Port of `apprt/gtk/ext/slice.zig`: a boxed list of strings. + +use gtk::glib; + +/// A boxed list of strings (GType `GhosttyStringList`). Copy/free semantics +/// are provided by `Clone`/`Drop`. +#[derive(Debug, Clone, Default, PartialEq, Eq, glib::Boxed)] +#[boxed_type(name = "GhosttyStringList")] +pub struct StringList { + pub strings: Vec, +} + +impl StringList { + pub fn create>(strings: &[S]) -> Self { + Self { + strings: strings.iter().map(|s| s.as_ref().to_owned()).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gtk::glib::prelude::*; + + #[test] + fn string_list_create_and_destroy() { + let list = StringList::create(&["hello", "world"]); + assert_eq!(list.strings.len(), 2); + assert_eq!(list.strings[0], "hello"); + assert_eq!(list.strings[1], "world"); + } + + #[test] + fn string_list_create_empty_list() { + let list = StringList::create::<&str>(&[]); + assert_eq!(list.strings.len(), 0); + } + + #[test] + fn string_list_boxed_copy_and_free() { + let original = StringList::create(&["foo", "bar", "baz"]); + let value = original.to_value(); + let copied = value.get::().expect("boxed value"); + assert_eq!(copied.strings, ["foo", "bar", "baz"]); + assert_ne!(original.strings.as_ptr(), copied.strings.as_ptr()); + } +} diff --git a/crates/ghostty-apprt-gtk/src/flatpak.rs b/crates/ghostty-apprt-gtk/src/flatpak.rs new file mode 100644 index 0000000..035394c --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/flatpak.rs @@ -0,0 +1,38 @@ +//! Flatpak helpers (port of `apprt/gtk/flatpak.zig`). + +use ghostty_os::ResourcesDir; +use gtk::glib; + +/// Returns the resources directory, consulting the Flatpak runtime data +/// when running inside a Flatpak (host path discovery). +pub fn resources_dir() -> std::io::Result { + // Only consult Flatpak runtime data for the host case. + if ghostty_os::is_flatpak() { + let mut result = ResourcesDir { + app_path: Some("/app/share/ghostty".to_owned()), + host_path: None, + }; + + let keyfile = glib::KeyFile::new(); + if keyfile + .load_from_file("/.flatpak-info", glib::KeyFileFlags::NONE) + .is_err() + { + return Ok(result); + } + let Ok(app_dir) = keyfile.string("Instance", "app-path") else { + return Ok(result); + }; + + result.host_path = Some( + std::path::Path::new(app_dir.as_str()) + .join("share") + .join("ghostty") + .to_string_lossy() + .into_owned(), + ); + return Ok(result); + } + + ghostty_os::resources_dir() +} diff --git a/crates/ghostty-apprt-gtk/src/gl.rs b/crates/ghostty-apprt-gtk/src/gl.rs new file mode 100644 index 0000000..9521b16 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/gl.rs @@ -0,0 +1,44 @@ +//! 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 +//! 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. + +use std::ffi::c_void; +use std::sync::OnceLock; + +fn epoxy() -> Option<&'static libloading::Library> { + static LIB: OnceLock> = OnceLock::new(); + LIB.get_or_init(|| { + for name in ["libepoxy.so.0", "libepoxy.so"] { + // SAFETY: loading libepoxy runs only its trivial constructors; + // GTK itself already links it into this process. + #[allow(unsafe_code)] + if let Ok(lib) = unsafe { libloading::Library::new(name) } { + return Some(lib); + } + } + log::error!("unable to load libepoxy; OpenGL functions are unavailable"); + None + }) + .as_ref() +} + +/// Resolve an OpenGL function by name (null if unavailable). +#[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. + #[allow(unsafe_code)] + unsafe { + lib.get::<*const c_void>(name.as_bytes()) + .map_or(std::ptr::null(), |sym| *sym) + } +} diff --git a/crates/ghostty-apprt-gtk/src/gsettings.rs b/crates/ghostty-apprt-gtk/src/gsettings.rs new file mode 100644 index 0000000..1dd19bf --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/gsettings.rs @@ -0,0 +1,76 @@ +//! Typed access to `GtkSettings` (port of `apprt/gtk/gsettings.zig`). + +use gtk::glib; +use gtk::prelude::*; + +/// Settings keys we read (the GTK property names). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + GtkEnablePrimaryPaste, + GtkXftDpi, + GtkFontName, +} + +impl Key { + /// The GTK property name. + pub fn name(self) -> &'static str { + match self { + Key::GtkEnablePrimaryPaste => "gtk-enable-primary-paste", + Key::GtkXftDpi => "gtk-xft-dpi", + Key::GtkFontName => "gtk-font-name", + } + } + + /// Whether the value type needs heap allocation (strings). + pub fn requires_allocation(self) -> bool { + matches!(self, Key::GtkFontName) + } +} + +fn settings() -> Option { + gtk::Settings::default() +} + +/// Get a boolean setting (`gtk-enable-primary-paste`). +pub fn get_bool(key: Key) -> Option { + debug_assert_eq!(key, Key::GtkEnablePrimaryPaste); + let s = settings()?; + Some(s.property::(key.name())) +} + +/// Get an integer setting (`gtk-xft-dpi`). +pub fn get_int(key: Key) -> Option { + debug_assert_eq!(key, Key::GtkXftDpi); + let s = settings()?; + Some(s.property::(key.name())) +} + +/// Get a string setting (`gtk-font-name`). None if unset. +pub fn get_string(key: Key) -> Option { + debug_assert_eq!(key, Key::GtkFontName); + let s = settings()?; + s.property::>(key.name()) + .map(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_requires_allocation() { + assert!(!Key::GtkEnablePrimaryPaste.requires_allocation()); + assert!(!Key::GtkXftDpi.requires_allocation()); + assert!(Key::GtkFontName.requires_allocation()); + } + + #[test] + fn key_names() { + assert_eq!( + Key::GtkEnablePrimaryPaste.name(), + "gtk-enable-primary-paste" + ); + assert_eq!(Key::GtkXftDpi.name(), "gtk-xft-dpi"); + assert_eq!(Key::GtkFontName.name(), "gtk-font-name"); + } +} diff --git a/crates/ghostty-apprt-gtk/src/gtk_version.rs b/crates/ghostty-apprt-gtk/src/gtk_version.rs new file mode 100644 index 0000000..127f875 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/gtk_version.rs @@ -0,0 +1,79 @@ +//! GTK version helpers (port of `apprt/gtk/gtk_version.zig`). +//! +//! Zig reads the compile-time version from the headers; here it is the +//! version of the gtk4-rs `v4_*` feature level this crate is compiled against +//! (see `Cargo.toml`) combined with the system library detected at runtime. + +/// A (major, minor, micro) version triple. +pub type Version = (u32, u32, u32); + +/// The minimum GTK version this crate is compiled against (feature level +/// `v4_14`). Newer symbols must be guarded with [`at_least`]/feature flags. +pub const COMPTIME_VERSION: Version = (4, 14, 0); + +/// Returns the runtime GTK version. +pub fn get_runtime_version() -> Version { + ( + gtk::major_version(), + gtk::minor_version(), + gtk::micro_version(), + ) +} + +/// Logs the build and runtime GTK version. +pub fn log_version() { + let (a, b, c) = COMPTIME_VERSION; + let (x, y, z) = get_runtime_version(); + log::info!("GTK version build={a}.{b}.{c} runtime={x}.{y}.{z}"); +} + +/// Verifies that the GTK version at runtime is at least the given version. +/// Unlike Zig this can't compile out unknown symbols; callers needing newer +/// API must gate it on a cargo feature too. +pub fn at_least(major: u32, minor: u32, micro: u32) -> bool { + // Anything below the build level is trivially satisfied by the runtime + // requirement of the build level, but we still check the runtime. + runtime_at_least(major, minor, micro) +} + +/// Verifies that the GTK runtime version is at least the given version. +pub fn runtime_at_least(major: u32, minor: u32, micro: u32) -> bool { + get_runtime_version() >= (major, minor, micro) +} + +/// Verifies that the GTK runtime version is older than the given version. +pub fn runtime_until(major: u32, minor: u32, micro: u32) -> bool { + get_runtime_version() < (major, minor, micro) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn at_least_test() { + let (a, b, c) = get_runtime_version(); + for f in [at_least, runtime_at_least] { + assert!(f(a, b, c)); + assert!(!f(a, b, c + 1)); + assert!(!f(a, b + 1, c)); + assert!(!f(a + 1, b, c)); + assert!(f(a - 1, b, c)); + assert!(f(a - 1, b + 1, c)); + assert!(f(a - 1, b, c + 1)); + assert!(f(a, b - 1, c + 1)); + } + } + + #[test] + fn runtime_until_test() { + let (a, b, c) = get_runtime_version(); + assert!(!runtime_until(a, b, c)); + assert!(runtime_until(a, b, c + 1)); + assert!(runtime_until(a, b + 1, c)); + assert!(runtime_until(a + 1, b, c)); + assert!(!runtime_until(a - 1, b, c)); + assert!(!runtime_until(a - 1, b + 1, c)); + assert!(!runtime_until(a, b - 1, c + 1)); + } +} diff --git a/crates/ghostty-apprt-gtk/src/ipc/dbus.rs b/crates/ghostty-apprt-gtk/src/ipc/dbus.rs new file mode 100644 index 0000000..e57af0f --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/ipc/dbus.rs @@ -0,0 +1,114 @@ +//! D-Bus helper for IPC (port of `apprt/gtk/ipc/DBus.zig`). + +use ghostty_apprt::ipc::{Error, Target}; +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; + +/// A pending `org.gtk.Actions.Activate` call. +pub struct DBus { + dbus: gio::DBusConnection, + bus_name: String, + object_path: String, + action: String, + parameters: Vec, +} + +/// Bus name and object path for a target. +pub fn bus_name_and_path(target: &Target) -> (String, String) { + match target { + // Force the usage of the class specified on the CLI to determine the + // bus name and object path. + Target::Class(class) => ( + class.clone(), + format!("/{class}").replace('.', "/").replace('-', "_"), + ), + Target::Detect => ( + crate::APPLICATION_ID.to_owned(), + crate::OBJECT_PATH.to_owned(), + ), + } +} + +impl DBus { + pub fn new(target: &Target, action: &str) -> Result { + let (bus_name, object_path) = bus_name_and_path(target); + + if !gio::Application::id_is_valid(&bus_name) { + eprintln!("D-Bus bus name is not valid: {bus_name}"); + return Err(Error::IpcFailed); + } + + if !glib::Variant::is_object_path(&object_path) { + eprintln!("D-Bus object path is not valid: {object_path}"); + return Err(Error::IpcFailed); + } + + // Get a connection to the DBus session bus. + let dbus = + gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE).map_err(|e| { + eprintln!("Unable to establish connection to D-Bus session bus: {e}"); + Error::IpcFailed + })?; + + Ok(Self { + dbus, + bus_name, + object_path, + action: action.to_owned(), + parameters: Vec::new(), + }) + } + + pub fn add_parameter(&mut self, variant: &glib::Variant) { + self.parameters.push(variant.clone()); + } + + /// The `(sava{sv})` payload (action, parameters, platform data). + pub fn payload(&self) -> glib::Variant { + // We have no platform data. + let platform: std::collections::HashMap = + std::collections::HashMap::new(); + (self.action.clone(), self.parameters.clone(), platform).to_variant() + } + + pub fn send(&self) -> Result<(), Error> { + let payload = self.payload(); + self.dbus + .call_sync( + Some(&self.bus_name), + &self.object_path, + "org.gtk.Actions", + "Activate", + Some(&payload), + None, // we don't do anything with the return value + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) + .map(|_| ()) + .map_err(|e| { + eprintln!("D-Bus method call returned an error err={e}"); + Error::IpcFailed + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn class_bus_name_and_path() { + let (n, p) = bus_name_and_path(&Target::Class("com.example.my-app".into())); + assert_eq!(n, "com.example.my-app"); + assert_eq!(p, "/com/example/my_app"); + } + + #[test] + fn detect_bus_name_and_path() { + let (n, p) = bus_name_and_path(&Target::Detect); + assert_eq!(n, crate::APPLICATION_ID); + assert_eq!(p, crate::OBJECT_PATH); + } +} diff --git a/crates/ghostty-apprt-gtk/src/ipc/mod.rs b/crates/ghostty-apprt-gtk/src/ipc/mod.rs new file mode 100644 index 0000000..f2e01f2 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/ipc/mod.rs @@ -0,0 +1,16 @@ +//! IPC to a running instance over D-Bus (`apprt/gtk/ipc/*.zig` and +//! `App.performIpc`). + +pub mod dbus; +pub mod new_window; + +use ghostty_apprt::ipc::{Action, Error, Target}; + +/// Send the given IPC to a running Ghostty. Returns `Ok(true)` if the action +/// was performed. This is a free function: it's called from a CLI process +/// without a full-featured app (`App.performIpc`). +pub fn perform_ipc(target: &Target, action: &Action) -> Result { + match action { + Action::NewWindow(v) => new_window::new_window(target, v), + } +} diff --git a/crates/ghostty-apprt-gtk/src/ipc/new_window.rs b/crates/ghostty-apprt-gtk/src/ipc/new_window.rs new file mode 100644 index 0000000..cf44273 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/ipc/new_window.rs @@ -0,0 +1,43 @@ +//! `+new-window` IPC (port of `apprt/gtk/ipc/new_window.zig`). +//! +//! Use a D-Bus method call to open a new window on GTK. See +//! . +//! +//! `ghostty +new-window` is equivalent to (release build): +//! +//! ```text +//! gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window [] [] +//! ``` +//! +//! `ghostty +new-window -e echo hello` is equivalent to: +//! +//! ```text +//! gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["-e" "echo" "hello"]>]' [] +//! ``` + +use ghostty_apprt::ipc::{Error, NewWindow, Target}; +use gtk::prelude::*; + +use super::dbus::DBus; + +pub fn new_window(target: &Target, value: &NewWindow) -> Result { + let mut dbus = DBus::new( + target, + if value.arguments.is_none() { + "new-window" + } else { + "new-window-command" + }, + )?; + + if let Some(arguments) = &value.arguments { + // If any arguments were specified on the command line, the first + // parameter is an array of strings that contain the arguments. They + // will be sent to the main Ghostty instance and interpreted as CLI + // arguments. + dbus.add_parameter(&arguments.to_variant()); + } + + dbus.send()?; + Ok(true) +} diff --git a/crates/ghostty-apprt-gtk/src/key.rs b/crates/ghostty-apprt-gtk/src/key.rs new file mode 100644 index 0000000..7870cd7 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/key.rs @@ -0,0 +1,505 @@ +//! GDK key <-> Ghostty input translation (port of `apprt/gtk/key.zig`). + +use std::fmt::Write as _; + +use ghostty_input::binding::{Trigger, TriggerKey}; +use ghostty_input::key::{Action, Key as K}; +use ghostty_input::key_mods::{ModSide, Mods}; +use gtk::gdk; +use gtk::gdk::prelude::DeviceExt; +use gtk::glib::translate::IntoGlib; +use gtk::prelude::*; + +/// Name of a keyval (`gdk.keyvalName`). +fn keyval_name(keyval: u32) -> Option { + #[allow(unsafe_code)] + // SAFETY: `gdk::Key` is a transparent newtype over the guint keyval; + // every u32 is a valid value (unknown keyvals simply have no name). + let key = unsafe { >::from_glib(keyval) }; + key.name().map(|s| s.to_string()) +} + +/// Returns a GTK accelerator string from a trigger, or None if the trigger +/// can't be represented (e.g. catch_all or unmapped key). +pub fn accel_from_trigger(trigger: Trigger) -> Option { + let mut s = String::new(); + if trigger.mods.shift { + s.push_str(""); + } + if trigger.mods.ctrl { + s.push_str(""); + } + if trigger.mods.alt { + s.push_str(""); + } + if trigger.mods.super_ { + s.push_str(""); + } + write_trigger_key(&mut s, trigger).then_some(s) +} + +/// Returns a XDG-compliant shortcuts string from a trigger. +/// Spec: +pub fn xdg_shortcut_from_trigger(trigger: Trigger) -> Option { + let mut s = String::new(); + if trigger.mods.shift { + s.push_str("SHIFT+"); + } + if trigger.mods.ctrl { + s.push_str("CTRL+"); + } + if trigger.mods.alt { + s.push_str("ALT+"); + } + if trigger.mods.super_ { + s.push_str("LOGO+"); + } + // NOTE: the spec expects libxkbcommon keysyms; GTK's are identical to + // X11's, which works for now (same assumption as upstream). + write_trigger_key(&mut s, trigger).then_some(s) +} + +fn write_trigger_key(w: &mut String, trigger: Trigger) -> bool { + match trigger.key { + TriggerKey::Physical(k) => { + let Some(keyval) = keyval_from_key(k) else { + return false; + }; + let Some(name) = keyval_name(keyval) else { + return false; + }; + w.push_str(&name); + } + TriggerKey::Unicode(cp) => { + if let Some(name) = keyval_name(cp) { + w.push_str(&name); + } else if let Some(c) = char::from_u32(cp) { + w.push(c); + } + } + TriggerKey::CatchAll => return false, + } + true +} + +pub fn translate_mods(state: gdk::ModifierType) -> Mods { + Mods { + shift: state.contains(gdk::ModifierType::SHIFT_MASK), + ctrl: state.contains(gdk::ModifierType::CONTROL_MASK), + alt: state.contains(gdk::ModifierType::ALT_MASK), + super_: state.contains(gdk::ModifierType::SUPER_MASK), + // Lock is dependent on the X settings but we just assume caps lock. + caps_lock: state.contains(gdk::ModifierType::LOCK_MASK), + ..Mods::default() + } +} + +/// Get the unshifted unicode value of the keyval. This is used by the Kitty +/// keyboard protocol. Returns 0 if unknown. +pub fn keyval_unicode_unshifted( + widget: &impl IsA, + event: &gdk::KeyEvent, + keycode: u32, +) -> u32 { + let display = widget.as_ref().display(); + + // We need the currently active keyboard layout so we know what group to + // look at. + let layout = event.layout(); + + // All possible keyboard mappings for this keycode (the physical key). + let Some(entries) = display.map_keycode(keycode) else { + return 0; + }; + + for (key, keyval) in entries { + if key.group() == layout.cast_signed() && key.level() == 0 { + return keyval.to_unicode().map_or(0, |c| c as u32); + } + } + + 0 +} + +/// Returns the mods to use for a key event from a GTK event. This requires +/// a lot of context because the GdkEvent doesn't contain enough on its own. +/// +/// `winproto_event_mods` is `winproto::App::event_mods(device, gtk_mods)`. +pub fn event_mods( + event: &gdk::Event, + physical_key: K, + gtk_mods: gdk::ModifierType, + action: Action, + winproto_event_mods: impl FnOnce(Option<&gdk::Device>, gdk::ModifierType) -> Mods, +) -> Mods { + let device = event.device(); + + let mut mods = winproto_event_mods(device.as_ref(), gtk_mods); + mods.num_lock = device.as_ref().is_some_and(DeviceExt::is_num_locked); + + // We use the physical key to determine sided modifiers. As far as I can + // tell there's no other way to reliably determine this. + // + // We also set the main modifier to true if either side is true, since on + // both X11/Wayland, GTK doesn't set the main modifier if only the + // modifier key is pressed, but our core logic relies on it. + let down = action != Action::Release; + match physical_key { + K::ShiftLeft => { + mods.shift = down; + mods.sides.shift = ModSide::Left; + } + K::ShiftRight => { + mods.shift = down; + mods.sides.shift = ModSide::Right; + } + K::ControlLeft => { + mods.ctrl = down; + mods.sides.ctrl = ModSide::Left; + } + K::ControlRight => { + mods.ctrl = down; + mods.sides.ctrl = ModSide::Right; + } + K::AltLeft => { + mods.alt = down; + mods.sides.alt = ModSide::Left; + } + K::AltRight => { + mods.alt = down; + mods.sides.alt = ModSide::Right; + } + K::MetaLeft => { + mods.super_ = down; + mods.sides.super_ = ModSide::Left; + } + K::MetaRight => { + mods.super_ = down; + mods.sides.super_ = ModSide::Right; + } + _ => {} + } + + mods +} + +/// Returns an input key from a keyval or None if we don't have a mapping. +pub fn key_from_keyval(keyval: u32) -> Option { + KEYMAP + .iter() + .find(|(g, _)| g.into_glib() == keyval) + .map(|(_, k)| *k) +} + +/// Returns a keyval from an input key or None if we don't have a mapping. +pub fn keyval_from_key(key: K) -> Option { + KEYMAP + .iter() + .find(|(_, k)| *k == key) + .map(|(g, _)| g.into_glib()) +} + +/// Converts a trigger to a human-readable label for display in UI, using +/// GTK accelerator-style formatting (e.g. "Ctrl+Shift+A"). Returns None if +/// the trigger cannot be formatted (e.g. catch_all). +pub fn label_from_trigger(trigger: Trigger) -> Option { + let mut s = String::new(); + if trigger.mods.super_ { + s.push_str("Super+"); + } + if trigger.mods.ctrl { + s.push_str("Ctrl+"); + } + if trigger.mods.alt { + s.push_str("Alt+"); + } + if trigger.mods.shift { + s.push_str("Shift+"); + } + write_trigger_key_label(&mut s, trigger).then_some(s) +} + +fn push_capitalized(w: &mut String, name: &str) { + let mut chars = name.chars(); + if let Some(first) = chars.next() { + if first.is_ascii_lowercase() { + w.push(first.to_ascii_uppercase()); + w.push_str(chars.as_str()); + } else { + w.push_str(name); + } + } +} + +/// Writes the key portion of a trigger in human-readable format. +fn write_trigger_key_label(w: &mut String, trigger: Trigger) -> bool { + match trigger.key { + TriggerKey::Physical(k) => { + let Some(keyval) = keyval_from_key(k) else { + return false; + }; + let Some(name) = keyval_name(keyval) else { + return false; + }; + push_capitalized(w, &name); + } + TriggerKey::Unicode(cp) => { + if let Some(name) = keyval_name(cp) { + push_capitalized(w, &name); + } else if let Some(c) = char::from_u32(cp) { + let _ = write!(w, "{c}"); + } + } + TriggerKey::CatchAll => return false, + } + true +} + +/// The keymap: mappings between GDK keyvals and our own key enum. +// TODO: media keys +const KEYMAP: &[(gdk::Key, K)] = &[ + (gdk::Key::a, K::KeyA), + (gdk::Key::b, K::KeyB), + (gdk::Key::c, K::KeyC), + (gdk::Key::d, K::KeyD), + (gdk::Key::e, K::KeyE), + (gdk::Key::f, K::KeyF), + (gdk::Key::g, K::KeyG), + (gdk::Key::h, K::KeyH), + (gdk::Key::i, K::KeyI), + (gdk::Key::j, K::KeyJ), + (gdk::Key::k, K::KeyK), + (gdk::Key::l, K::KeyL), + (gdk::Key::m, K::KeyM), + (gdk::Key::n, K::KeyN), + (gdk::Key::o, K::KeyO), + (gdk::Key::p, K::KeyP), + (gdk::Key::q, K::KeyQ), + (gdk::Key::r, K::KeyR), + (gdk::Key::s, K::KeyS), + (gdk::Key::t, K::KeyT), + (gdk::Key::u, K::KeyU), + (gdk::Key::v, K::KeyV), + (gdk::Key::w, K::KeyW), + (gdk::Key::x, K::KeyX), + (gdk::Key::y, K::KeyY), + (gdk::Key::z, K::KeyZ), + (gdk::Key::_0, K::Digit0), + (gdk::Key::_1, K::Digit1), + (gdk::Key::_2, K::Digit2), + (gdk::Key::_3, K::Digit3), + (gdk::Key::_4, K::Digit4), + (gdk::Key::_5, K::Digit5), + (gdk::Key::_6, K::Digit6), + (gdk::Key::_7, K::Digit7), + (gdk::Key::_8, K::Digit8), + (gdk::Key::_9, K::Digit9), + (gdk::Key::semicolon, K::Semicolon), + (gdk::Key::space, K::Space), + (gdk::Key::apostrophe, K::Quote), + (gdk::Key::comma, K::Comma), + (gdk::Key::grave, K::Backquote), + (gdk::Key::period, K::Period), + (gdk::Key::slash, K::Slash), + (gdk::Key::minus, K::Minus), + (gdk::Key::equal, K::Equal), + (gdk::Key::bracketleft, K::BracketLeft), + (gdk::Key::bracketright, K::BracketRight), + (gdk::Key::backslash, K::Backslash), + (gdk::Key::Up, K::ArrowUp), + (gdk::Key::Down, K::ArrowDown), + (gdk::Key::Right, K::ArrowRight), + (gdk::Key::Left, K::ArrowLeft), + (gdk::Key::Home, K::Home), + (gdk::Key::End, K::End), + (gdk::Key::Insert, K::Insert), + (gdk::Key::Delete, K::Delete), + (gdk::Key::Caps_Lock, K::CapsLock), + (gdk::Key::Scroll_Lock, K::ScrollLock), + (gdk::Key::Num_Lock, K::NumLock), + (gdk::Key::Page_Up, K::PageUp), + (gdk::Key::Page_Down, K::PageDown), + (gdk::Key::Escape, K::Escape), + (gdk::Key::Return, K::Enter), + (gdk::Key::Tab, K::Tab), + (gdk::Key::BackSpace, K::Backspace), + (gdk::Key::Print, K::PrintScreen), + (gdk::Key::Pause, K::Pause), + (gdk::Key::F1, K::F1), + (gdk::Key::F2, K::F2), + (gdk::Key::F3, K::F3), + (gdk::Key::F4, K::F4), + (gdk::Key::F5, K::F5), + (gdk::Key::F6, K::F6), + (gdk::Key::F7, K::F7), + (gdk::Key::F8, K::F8), + (gdk::Key::F9, K::F9), + (gdk::Key::F10, K::F10), + (gdk::Key::F11, K::F11), + (gdk::Key::F12, K::F12), + (gdk::Key::F13, K::F13), + (gdk::Key::F14, K::F14), + (gdk::Key::F15, K::F15), + (gdk::Key::F16, K::F16), + (gdk::Key::F17, K::F17), + (gdk::Key::F18, K::F18), + (gdk::Key::F19, K::F19), + (gdk::Key::F20, K::F20), + (gdk::Key::F21, K::F21), + (gdk::Key::F22, K::F22), + (gdk::Key::F23, K::F23), + (gdk::Key::F24, K::F24), + (gdk::Key::F25, K::F25), + (gdk::Key::KP_0, K::Numpad0), + (gdk::Key::KP_1, K::Numpad1), + (gdk::Key::KP_2, K::Numpad2), + (gdk::Key::KP_3, K::Numpad3), + (gdk::Key::KP_4, K::Numpad4), + (gdk::Key::KP_5, K::Numpad5), + (gdk::Key::KP_6, K::Numpad6), + (gdk::Key::KP_7, K::Numpad7), + (gdk::Key::KP_8, K::Numpad8), + (gdk::Key::KP_9, K::Numpad9), + (gdk::Key::KP_Decimal, K::NumpadDecimal), + (gdk::Key::KP_Divide, K::NumpadDivide), + (gdk::Key::KP_Multiply, K::NumpadMultiply), + (gdk::Key::KP_Subtract, K::NumpadSubtract), + (gdk::Key::KP_Add, K::NumpadAdd), + (gdk::Key::KP_Enter, K::NumpadEnter), + (gdk::Key::KP_Equal, K::NumpadEqual), + (gdk::Key::KP_Separator, K::NumpadSeparator), + (gdk::Key::KP_Left, K::NumpadLeft), + (gdk::Key::KP_Right, K::NumpadRight), + (gdk::Key::KP_Up, K::NumpadUp), + (gdk::Key::KP_Down, K::NumpadDown), + (gdk::Key::KP_Page_Up, K::NumpadPageUp), + (gdk::Key::KP_Page_Down, K::NumpadPageDown), + (gdk::Key::KP_Home, K::NumpadHome), + (gdk::Key::KP_End, K::NumpadEnd), + (gdk::Key::KP_Insert, K::NumpadInsert), + (gdk::Key::KP_Delete, K::NumpadDelete), + (gdk::Key::KP_Begin, K::NumpadBegin), + (gdk::Key::Copy, K::Copy), + (gdk::Key::Cut, K::Cut), + (gdk::Key::Paste, K::Paste), + (gdk::Key::Shift_L, K::ShiftLeft), + (gdk::Key::Control_L, K::ControlLeft), + (gdk::Key::Alt_L, K::AltLeft), + (gdk::Key::Super_L, K::MetaLeft), + (gdk::Key::Shift_R, K::ShiftRight), + (gdk::Key::Control_R, K::ControlRight), + (gdk::Key::Alt_R, K::AltRight), + (gdk::Key::Super_R, K::MetaRight), +]; + +#[cfg(test)] +mod tests { + use super::*; + + fn uni(mods: Mods, cp: u32) -> Trigger { + Trigger { + mods, + key: TriggerKey::Unicode(cp), + } + } + + fn all_mods() -> Mods { + Mods { + ctrl: true, + alt: true, + super_: true, + shift: true, + ..Mods::default() + } + } + + #[test] + fn accel_from_trigger_test() { + let m = Mods { + super_: true, + ..Mods::default() + }; + assert_eq!( + accel_from_trigger(uni(m, 'q' as u32)).as_deref(), + Some("q") + ); + assert_eq!( + accel_from_trigger(uni(all_mods(), 92)).as_deref(), + Some("backslash") + ); + } + + #[test] + fn xdg_shortcut_from_trigger_test() { + let m = Mods { + super_: true, + ..Mods::default() + }; + assert_eq!( + xdg_shortcut_from_trigger(uni(m, 'q' as u32)).as_deref(), + Some("LOGO+q") + ); + assert_eq!( + xdg_shortcut_from_trigger(uni(all_mods(), 92)).as_deref(), + Some("SHIFT+CTRL+ALT+LOGO+backslash") + ); + } + + #[test] + fn label_from_trigger_test() { + let m = Mods { + super_: true, + ..Mods::default() + }; + assert_eq!( + label_from_trigger(uni(m, 'q' as u32)).as_deref(), + Some("Super+Q") + ); + assert_eq!( + label_from_trigger(uni(all_mods(), 92)).as_deref(), + Some("Super+Ctrl+Alt+Shift+Backslash") + ); + let ctrl = Mods { + ctrl: true, + ..Mods::default() + }; + assert_eq!( + label_from_trigger(Trigger { + mods: ctrl, + key: TriggerKey::Physical(K::KeyA) + }) + .as_deref(), + Some("Ctrl+A") + ); + assert_eq!( + label_from_trigger(Trigger { + mods: Mods::default(), + key: TriggerKey::Physical(K::Escape) + }) + .as_deref(), + Some("Escape") + ); + assert_eq!( + label_from_trigger(Trigger { + mods: Mods::default(), + key: TriggerKey::CatchAll + }), + None + ); + } + + #[test] + fn keymap_roundtrip() { + for (g, k) in KEYMAP { + assert_eq!(key_from_keyval(g.into_glib()), Some(*k)); + } + assert_eq!(keyval_from_key(K::KeyA), Some(gdk::Key::a.into_glib())); + assert_eq!(keyval_from_key(K::Unidentified), None); + } + + #[test] + fn translate_mods_test() { + let m = translate_mods(gdk::ModifierType::SHIFT_MASK | gdk::ModifierType::LOCK_MASK); + assert!(m.shift && m.caps_lock && !m.ctrl && !m.alt && !m.super_); + } +} diff --git a/crates/ghostty-apprt-gtk/src/lib.rs b/crates/ghostty-apprt-gtk/src/lib.rs index 66e15bd..50fdabd 100644 --- a/crates/ghostty-apprt-gtk/src/lib.rs +++ b/crates/ghostty-apprt-gtk/src/lib.rs @@ -1 +1,47 @@ -//! ghostty-apprt-gtk: see docs/rewrite/ARCHITECTURE.md for the module map. +//! ghostty-apprt-gtk: the Linux GTK4 + libadwaita application runtime +//! (ZIG/src/apprt/gtk/*). See docs/rewrite/notes/ghostty-apprt-gtk.md. + +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; +pub mod gl; +pub mod gsettings; +pub mod gtk_version; +pub mod ipc; +pub mod key; +pub mod post_fork; +pub mod pre_exec; +pub mod weak_ref; +pub mod winproto; + +/// GTK application ID (Zig: `App.application_id`). +pub const APPLICATION_ID: &str = if cfg!(debug_assertions) { + "com.mitchellh.ghostty-debug" +} else { + "com.mitchellh.ghostty" +}; + +/// GTK object path (Zig: `App.object_path`). +pub const OBJECT_PATH: &str = if cfg!(debug_assertions) { + "/com/mitchellh/ghostty_debug" +} else { + "/com/mitchellh/ghostty" +}; + +/// Resource prefix of the bundled gresource. +pub const RESOURCE_PREFIX: &str = "/com/mitchellh/ghostty"; + +/// Register the compiled gresource bundle (UI files, CSS, icons). Must be +/// called before any template class is instantiated. Idempotent. +pub fn register_resources() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + gtk::gio::resources_register_include!("ghostty.gresource") + .expect("failed to register ghostty gresource"); + }); +} diff --git a/crates/ghostty-apprt-gtk/src/post_fork.rs b/crates/ghostty-apprt-gtk/src/post_fork.rs new file mode 100644 index 0000000..2c23727 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/post_fork.rs @@ -0,0 +1,112 @@ +//! Parent post-fork hook creating the transient scope (port of +//! `apprt/gtk/post_fork.zig`). + +use std::time::{Duration, Instant}; + +use ghostty_config::config::Config; +use ghostty_config::types_b::{GtkSingleInstance, LinuxCgroup}; +use gtk::gio; +use gtk::prelude::*; + +use crate::cgroup; +use crate::pre_exec::current_scope_matches; + +/// Allowable error from a post-fork hook; fails surface creation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("post fork hook failed")] +pub struct PostForkError; + +/// Config values needed by [`post_fork`]. +#[derive(Debug, Clone, Copy)] +pub struct PostForkInfo { + pub gtk_single_instance: GtkSingleInstance, + pub linux_cgroup: LinuxCgroup, + pub linux_cgroup_hard_fail: bool, + pub linux_cgroup_memory_limit: Option, + pub linux_cgroup_processes_limit: Option, +} + +impl PostForkInfo { + pub fn new(cfg: &Config) -> Self { + Self { + gtk_single_instance: cfg.gtk_single_instance, + linux_cgroup: cfg.linux_cgroup, + linux_cgroup_hard_fail: cfg.linux_cgroup_hard_fail, + linux_cgroup_memory_limit: cfg.linux_cgroup_memory_limit, + linux_cgroup_processes_limit: cfg.linux_cgroup_processes_limit, + } + } +} + +/// Called in the parent after fork with the child's `pid`. Must run on the +/// GTK main thread (uses the default `gio::Application` D-Bus connection) +/// or at least where the application's connection is usable. +pub fn post_fork(info: &PostForkInfo, pid: Option) -> Result<(), PostForkError> { + match info.linux_cgroup { + LinuxCgroup::Always => {} + LinuxCgroup::Never => return Ok(()), + LinuxCgroup::SingleInstance => match info.gtk_single_instance { + GtkSingleInstance::True => {} + GtkSingleInstance::False => return Ok(()), + GtkSingleInstance::Detect => { + log::error!("gtk-single-instance is set to detect which should be impossible!"); + return Err(PostForkError); + } + }, + } + + let Some(pid) = pid else { + log::error!("PID of child not known!"); + return Err(PostForkError); + }; + + let expected = cgroup::fmt_scope(pid); + log::debug!("beginning transition to transient systemd scope {expected}"); + + let Some(dbus) = gio::Application::default().and_then(|a| a.dbus_connection()) else { + if info.linux_cgroup_hard_fail { + log::error!("dbus connection required for cgroup isolation, exiting"); + return Err(PostForkError); + } + return Ok(()); + }; + + if let Err(err) = cgroup::create_scope( + &dbus, + pid, + cgroup::Options { + memory_high: info.linux_cgroup_memory_limit, + tasks_max: info.linux_cgroup_processes_limit, + }, + ) { + if info.linux_cgroup_hard_fail { + log::error!("unable to create transient systemd scope {expected}: {err}"); + return Err(PostForkError); + } + log::warn!("unable to create transient systemd scope {expected}: {err}"); + return Ok(()); + } + + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_millis(250) { + // Zig checks `rt_pre_exec_info.linux_cgroup_hard_fail` here; + // both infos come from the same config so they're identical. + if info.linux_cgroup_hard_fail { + log::error!("transition to new transient systemd scope {expected} took too long"); + return Err(PostForkError); + } + log::warn!("transition to transient systemd scope {expected} took too long"); + break; + } + + if current_scope_matches(pid, &expected) { + log::debug!("transition to transient systemd scope {expected} complete"); + break; + } + + std::thread::sleep(Duration::from_millis(25)); + } + + Ok(()) +} diff --git a/crates/ghostty-apprt-gtk/src/pre_exec.rs b/crates/ghostty-apprt-gtk/src/pre_exec.rs new file mode 100644 index 0000000..fd2f046 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/pre_exec.rs @@ -0,0 +1,102 @@ +//! Child pre-exec hook for cgroup transitions (port of `apprt/gtk/pre_exec.zig`). +//! +//! Runs in the child between fork and exec, so it only reads procfs and +//! sleeps. The logic is also mirrored by `ghostty-termio`'s `Command`. + +use std::time::{Duration, Instant}; + +use ghostty_config::config::Config; +use ghostty_config::types_b::{GtkSingleInstance, LinuxCgroup}; + +use crate::cgroup; + +/// Config values needed by [`pre_exec`]. +#[derive(Debug, Clone, Copy)] +pub struct PreExecInfo { + pub gtk_single_instance: GtkSingleInstance, + pub linux_cgroup: LinuxCgroup, + pub linux_cgroup_hard_fail: bool, +} + +impl PreExecInfo { + pub fn new(cfg: &Config) -> Self { + Self { + gtk_single_instance: cfg.gtk_single_instance, + linux_cgroup: cfg.linux_cgroup, + linux_cgroup_hard_fail: cfg.linux_cgroup_hard_fail, + } + } +} + +/// Waits (up to 250ms) for the process to be moved to its transient scope. +/// Returns `Some(exit_code)` if the child must exit. +pub fn pre_exec(info: &PreExecInfo) -> Option { + match info.linux_cgroup { + LinuxCgroup::Always => {} + LinuxCgroup::Never => return None, + LinuxCgroup::SingleInstance => match info.gtk_single_instance { + GtkSingleInstance::True => {} + GtkSingleInstance::False => return None, + GtkSingleInstance::Detect => { + log::error!("gtk-single-instance is set to detect"); + return Some(127); + } + }, + } + + let pid = std::process::id(); + let expected = cgroup::fmt_scope(pid); + let start = Instant::now(); + + loop { + if start.elapsed() > Duration::from_millis(250) { + if info.linux_cgroup_hard_fail { + log::error!("transition to new transient systemd scope took too long"); + return Some(127); + } + break; + } + + if current_scope_matches(pid, &expected) { + return None; + } + + std::thread::sleep(Duration::from_millis(25)); + } + + None +} + +/// True if the final path component of `pid`'s cgroup equals `expected`. +pub(crate) fn current_scope_matches(pid: u32, expected: &str) -> bool { + let Some(raw) = ghostty_os::cgroup::current(pid) else { + return false; + }; + raw.rsplit_once('/') + .is_some_and(|(_, last)| last == expected) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn never_returns_immediately() { + let info = PreExecInfo { + gtk_single_instance: GtkSingleInstance::Detect, + linux_cgroup: LinuxCgroup::Never, + linux_cgroup_hard_fail: true, + }; + assert_eq!(pre_exec(&info), None); + } + + #[test] + fn detect_fails() { + let info = PreExecInfo { + gtk_single_instance: GtkSingleInstance::Detect, + linux_cgroup: LinuxCgroup::SingleInstance, + linux_cgroup_hard_fail: false, + }; + assert_eq!(pre_exec(&info), Some(127)); + } +} diff --git a/crates/ghostty-apprt-gtk/src/weak_ref.rs b/crates/ghostty-apprt-gtk/src/weak_ref.rs new file mode 100644 index 0000000..d67e0ed --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/weak_ref.rs @@ -0,0 +1,40 @@ +//! Weak object references (port of `apprt/gtk/weak_ref.zig`). glib-rs +//! already provides `glib::WeakRef`; this wrapper keeps the Zig API +//! (`set`/`get`/`empty`). + +use gtk::glib; +use gtk::glib::prelude::*; + +/// A weak reference to a GObject of type `T`. +#[derive(Debug, Default)] +pub struct WeakRef(glib::WeakRef); + +impl WeakRef { + /// An empty weak reference. + pub fn empty() -> Self { + Self(glib::WeakRef::new()) + } + + pub fn set(&self, v: Option<&T>) { + self.0.set(v); + } + + pub fn get(&self) -> Option { + self.0.upgrade() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weak_ref() { + let r: WeakRef = WeakRef::empty(); + let obj = glib::Object::new::(); + r.set(Some(&obj)); + assert!(r.get().is_some()); + drop(obj); + assert!(r.get().is_none()); + } +} diff --git a/crates/ghostty-apprt-gtk/src/winproto/mod.rs b/crates/ghostty-apprt-gtk/src/winproto/mod.rs new file mode 100644 index 0000000..0fa1178 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/winproto/mod.rs @@ -0,0 +1,208 @@ +//! Port of apprt/gtk/winproto.zig: abstraction over the windowing protocol +//! (X11 / Wayland / none) in use by the GDK display. +//! +//! * [`App`] is application-wide protocol state (one per application). +//! * [`Window`] is per-toplevel protocol state. +//! +//! Zig's tagged unions over `noop`/`x11`/`wayland` become Rust enums. The Zig +//! code held a `*ApprtWindow` pointer; here the window GObject implements +//! [`WindowHost`] (a small, weak-referencing adapter), see below. + +use std::rc::Rc; +use std::sync::Arc; + +use gtk::gdk; +use gtk::prelude::*; + +use ghostty_config::config::Config; +use ghostty_config::types_b::WindowDecoration; +use ghostty_input::Mods; + +pub mod noop; +pub mod wayland; +pub mod x11; + +/// Errors from the winproto backends. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("window surface is not an X11 surface")] + NotX11Surface, + #[error("window surface is not a Wayland surface")] + NotWaylandSurface, + #[error("no Wayland display/surface available")] + NoWaylandDisplay, + #[error("error initializing the XKB extension")] + XkbInitialization, + #[error("X11 request failed")] + RequestFailed, + #[error("X11 property not found")] + PropertyNotFound, + #[error("X11 property type mismatch")] + PropertyTypeMismatch, + #[error("X11 property format mismatch")] + PropertyFormatMismatch, + #[error("wayland: {0}")] + Wayland(String), +} + +pub type Result = std::result::Result; + +/// What the winproto backends need from the Ghostty window GObject +/// (Zig: `ApprtWindow`). Implemented by `class::window` using a weak +/// reference to the window (so winproto state, which the window owns, does not +/// create a reference cycle). +pub trait WindowHost { + /// `apprt_window.as(gtk.Window)` (also a `gtk::Native`). + fn gtk_window(&self) -> Option; + /// `apprt_window.isQuickTerminal()`. + fn is_quick_terminal(&self) -> bool; + /// `apprt_window.getWindowDecoration()`. + fn window_decoration(&self) -> WindowDecoration; + /// `apprt_window.getConfig()?.get()`. + fn config(&self) -> Option>; +} + +/// Which protocol is active (Zig: `winproto.Protocol`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Protocol { + None, + Wayland, + X11, +} + +/// App-state for the underlying windowing protocol. +pub enum App { + None(noop::App), + Wayland(wayland::App), + X11(x11::App), +} + +impl App { + /// Probe wayland, then x11, falling back to `None` (noop). + pub fn init(gdk_display: &gdk::Display, app_id: &str, config: &Config) -> Result { + if let Some(v) = wayland::App::init(gdk_display, app_id, config)? { + return Ok(App::Wayland(v)); + } + if let Some(v) = x11::App::init(gdk_display, app_id, config)? { + return Ok(App::X11(v)); + } + Ok(App::None(noop::App)) + } + + pub fn protocol(&self) -> Protocol { + match self { + App::None(_) => Protocol::None, + App::Wayland(_) => Protocol::Wayland, + App::X11(_) => Protocol::X11, + } + } + + /// Modifier state for a key event, falling back to the translation of + /// the GDK modifiers (`key.translateMods`). + pub fn event_mods(&self, device: Option<&gdk::Device>, gtk_mods: gdk::ModifierType) -> Mods { + let v = match self { + App::None(v) => v.event_mods(device, gtk_mods), + App::Wayland(v) => v.event_mods(device, gtk_mods), + App::X11(v) => v.event_mods(device, gtk_mods), + }; + v.unwrap_or_else(|| crate::key::translate_mods(gtk_mods)) + } + + pub fn supports_quick_terminal(&self) -> bool { + match self { + App::None(v) => v.supports_quick_terminal(), + App::Wayland(v) => v.supports_quick_terminal(), + App::X11(v) => v.supports_quick_terminal(), + } + } + + /// Set up necessary support for the quick terminal that must occur + /// *before* the window-level winproto object is created (and before the + /// window is realized). Only has an effect on Wayland, where + /// gtk4-layer-shell is initialized for the window. + pub fn init_quick_terminal(&mut self, window: >k::Window) -> Result<()> { + match self { + App::None(v) => v.init_quick_terminal(window), + App::Wayland(v) => v.init_quick_terminal(window), + App::X11(v) => v.init_quick_terminal(window), + } + } +} + +/// Per-Window state for the underlying windowing protocol. +pub enum Window { + None(noop::Window), + Wayland(wayland::Window), + X11(x11::Window), +} + +impl Window { + /// Create the per-window state. The window must be realized (it needs its + /// `gdk::Surface`). + pub fn init(app: &App, host: Rc) -> Result { + Ok(match app { + App::None(a) => Window::None(noop::Window::init(a, host)?), + App::Wayland(a) => Window::Wayland(wayland::Window::init(a, host)?), + App::X11(a) => Window::X11(x11::Window::init(a, host)?), + }) + } + + pub fn resize_event(&mut self) -> Result<()> { + match self { + Window::None(v) => v.resize_event(), + Window::Wayland(v) => v.resize_event(), + Window::X11(v) => v.resize_event(), + } + } + + pub fn sync_appearance(&mut self) -> Result<()> { + match self { + Window::None(v) => v.sync_appearance(), + Window::Wayland(v) => v.sync_appearance(), + Window::X11(v) => v.sync_appearance(), + } + } + + /// True if CSD is currently enabled for this window (actual state, not + /// desired state). + pub fn client_side_decoration_enabled(&self) -> bool { + match self { + Window::None(v) => v.client_side_decoration_enabled(), + Window::Wayland(v) => v.client_side_decoration_enabled(), + Window::X11(v) => v.client_side_decoration_enabled(), + } + } + + pub fn add_subprocess_env(&mut self, env: &mut Vec<(String, String)>) -> Result<()> { + match self { + Window::None(v) => v.add_subprocess_env(env), + Window::Wayland(v) => v.add_subprocess_env(env), + Window::X11(v) => v.add_subprocess_env(env), + } + } + + pub fn set_urgent(&mut self, urgent: bool) -> Result<()> { + match self { + Window::None(v) => v.set_urgent(urgent), + Window::Wayland(v) => v.set_urgent(urgent), + Window::X11(v) => v.set_urgent(urgent), + } + } +} + +/// Helper: widget scale factor etc. used by backends. +pub(crate) fn native_surface(w: >k::Window) -> Option { + w.upcast_ref::().surface() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_defaults() { + let a = noop::App; + assert!(!a.supports_quick_terminal()); + assert!(a.event_mods(None, gdk::ModifierType::empty()).is_none()); + } +} diff --git a/crates/ghostty-apprt-gtk/src/winproto/noop.rs b/crates/ghostty-apprt-gtk/src/winproto/noop.rs new file mode 100644 index 0000000..f2a33a4 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/winproto/noop.rs @@ -0,0 +1,63 @@ +//! Port of apprt/gtk/winproto/noop.zig: used when neither X11 nor Wayland is +//! detected. + +use std::rc::Rc; + +use gtk::gdk; + +use super::{Result, WindowHost}; +use ghostty_input::Mods; + +#[derive(Debug, Default, Clone, Copy)] +pub struct App; + +impl App { + pub fn event_mods(&self, _: Option<&gdk::Device>, _: gdk::ModifierType) -> Option { + None + } + + pub fn supports_quick_terminal(&self) -> bool { + false + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn init_quick_terminal(&mut self, _: >k::Window) -> Result<()> { + Ok(()) + } +} + +#[derive(Debug, Default)] +pub struct Window; + +impl Window { + #[allow(clippy::unnecessary_wraps)] + pub fn init(_: &App, _: Rc) -> Result { + Ok(Window) + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn resize_event(&mut self) -> Result<()> { + Ok(()) + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn sync_appearance(&mut self) -> Result<()> { + Ok(()) + } + + /// Always true: this should be the actual present state of the window. + #[allow(clippy::unused_self)] + pub fn client_side_decoration_enabled(&self) -> bool { + true + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn add_subprocess_env(&mut self, _: &mut Vec<(String, String)>) -> Result<()> { + Ok(()) + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn set_urgent(&mut self, _: bool) -> Result<()> { + Ok(()) + } +} diff --git a/crates/ghostty-apprt-gtk/src/winproto/wayland.rs b/crates/ghostty-apprt-gtk/src/winproto/wayland.rs new file mode 100644 index 0000000..eed0ea3 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/winproto/wayland.rs @@ -0,0 +1,615 @@ +//! Port of apprt/gtk/winproto/wayland.zig: Wayland protocol implementation. +//! +//! The GDK display's `wl_display` is shared through `gdk4-wayland`'s +//! `wayland_crate` feature (a `wayland_client::Connection` over GDK's foreign +//! display), so no `unsafe` is required here. We use a private event queue on +//! that connection for the protocol objects we own (KDE blur / server +//! decoration / slide, xdg-activation). GTK's own queue dispatching reads the +//! socket; events destined for our queue are picked up by +//! [`Shared::dispatch_pending`] (blocking roundtrips at init, a short poll +//! while an activation token request is in flight). +//! +//! # gtk4-layer-shell +//! The quick terminal uses the `gtk4-layer-shell` library. Upstream documents +//! that libgtk4-layer-shell must be linked/loaded *before* libwayland-client +//! (otherwise it cannot interpose the protocol). With Rust the link order is +//! not under our control: the final binary should link `gtk4-layer-shell` +//! first (or run with `LD_PRELOAD=libgtk4-layer-shell.so`). Without it, +//! `layer_shell::is_supported()` is false and the quick terminal is disabled. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use gdk4_wayland::WaylandDisplay; +use gdk4_wayland::prelude::*; +use gtk::gdk; +use gtk::glib; +use gtk::prelude::*; +use gtk4_layer_shell::{self as layer_shell, LayerShell}; +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +use wayland_client::protocol::wl_registry::WlRegistry; +use wayland_client::protocol::wl_surface::WlSurface; +use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle}; +use wayland_protocols::xdg::activation::v1::client::xdg_activation_token_v1::{ + self, XdgActivationTokenV1, +}; +use wayland_protocols::xdg::activation::v1::client::xdg_activation_v1::XdgActivationV1; +use wayland_protocols_plasma::blur::client::org_kde_kwin_blur::OrgKdeKwinBlur; +use wayland_protocols_plasma::blur::client::org_kde_kwin_blur_manager::OrgKdeKwinBlurManager; +use wayland_protocols_plasma::server_decoration::client::org_kde_kwin_server_decoration::OrgKdeKwinServerDecoration; +use wayland_protocols_plasma::server_decoration::client::org_kde_kwin_server_decoration_manager::{ + self, OrgKdeKwinServerDecorationManager, +}; +use wayland_protocols_plasma::slide::client::org_kde_kwin_slide::OrgKdeKwinSlide; +use wayland_protocols_plasma::slide::client::org_kde_kwin_slide_manager::OrgKdeKwinSlideManager; + +use super::{Error, Result, WindowHost}; +use ghostty_config::config::Config; +use ghostty_config::types_b::{ + QuickTerminalDimensions, QuickTerminalKeyboardInteractivity, QuickTerminalLayer, + QuickTerminalPosition, WindowDecoration, +}; +use ghostty_input::Mods; + +/// `org_kde_kwin_server_decoration_manager.mode` values. +const DECO_NONE: u32 = 0; +const DECO_CLIENT: u32 = 1; +const DECO_SERVER: u32 = 2; + +/// `org_kde_kwin_slide.location` values. +const SLIDE_TOP: u32 = 1; +const SLIDE_LEFT: u32 = 2; +const SLIDE_RIGHT: u32 = 3; +const SLIDE_BOTTOM: u32 = 4; + +/// Application-wide Wayland globals (Zig: `App.Context`). +#[derive(Default)] +struct State { + kde_blur_manager: Option, + // FIXME (upstream): replace with `zxdg_decoration_v1` once GTK merges + // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 + kde_decoration_manager: Option, + kde_slide_manager: Option, + default_deco_mode: Option, + xdg_activation: Option, + /// Whether the xdg_wm_dialog_v1 protocol is present. If so, + /// gtk4-layer-shell < 1.0.4 may crash creating a quick terminal. + /// See https://github.com/wmww/gtk4-layer-shell/issues/50 + xdg_wm_dialog_present: bool, + /// Activation tokens requested but not yet `done`. + pending_tokens: usize, +} + +struct Shared { + conn: Connection, + qh: QueueHandle, + queue: RefCell>, + state: RefCell, +} + +impl Shared { + fn dispatch_pending(&self) { + let mut queue = self.queue.borrow_mut(); + let mut state = self.state.borrow_mut(); + if let Err(e) = queue.dispatch_pending(&mut *state) { + log::warn!("wayland dispatch error: {e}"); + } + } + + fn flush(&self) { + if let Err(e) = self.conn.flush() { + log::warn!("wayland flush error: {e}"); + } + } +} + +fn bind( + globals: &wayland_client::globals::GlobalList, + qh: &QueueHandle, +) -> Option +where + State: Dispatch, +{ + match globals.bind::(qh, 1..=1, ()) { + Ok(v) => Some(v), + Err(wayland_client::globals::BindError::NotPresent) => None, + Err(e) => { + log::warn!("error binding interface {} error={e}", I::interface().name); + None + } + } +} + +/// Wayland application-wide state. +pub struct App { + shared: Rc, +} + +impl App { + /// Returns `Ok(None)` if the display isn't Wayland. + pub fn init( + gdk_display: &gdk::Display, + _app_id: &str, + _config: &Config, + ) -> Result> { + let Some(wl_display) = gdk_display.downcast_ref::() else { + return Ok(None); + }; + let display = wl_display.wl_display().ok_or(Error::NoWaylandDisplay)?; + let backend = display.backend().upgrade().ok_or(Error::NoWaylandDisplay)?; + let conn = Connection::from_backend(backend); + + // Get our display registry so we can get all the available interfaces + // and bind to what we need. + let (globals, mut queue) = registry_queue_init::(&conn) + .map_err(|e| Error::Wayland(format!("registry: {e}")))?; + let qh = queue.handle(); + + let mut state = State::default(); + globals.contents().with_list(|list| { + for g in list { + log::debug!("found global {}", g.interface); + if g.interface == "xdg_wm_dialog_v1" { + state.xdg_wm_dialog_present = true; + } + } + }); + + state.kde_blur_manager = bind(&globals, &qh); + state.kde_decoration_manager = bind(&globals, &qh); + state.kde_slide_manager = bind(&globals, &qh); + state.xdg_activation = bind(&globals, &qh); + + // Do another round-trip to get the default decoration mode + queue + .roundtrip(&mut state) + .map_err(|e| Error::Wayland(format!("roundtrip: {e}")))?; + + Ok(Some(App { + shared: Rc::new(Shared { + conn, + qh, + queue: RefCell::new(queue), + state: RefCell::new(state), + }), + })) + } + + #[allow(clippy::unused_self)] + pub fn event_mods(&self, _: Option<&gdk::Device>, _: gdk::ModifierType) -> Option { + None + } + + pub fn supports_quick_terminal(&self) -> bool { + if !layer_shell::is_supported() { + log::warn!( + "your compositor does not support the wlr-layer-shell protocol; disabling quick terminal" + ); + return false; + } + + let version = ( + layer_shell::major_version(), + layer_shell::minor_version(), + layer_shell::micro_version(), + ); + if self.shared.state.borrow().xdg_wm_dialog_present && version < (1, 0, 4) { + log::warn!( + "the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal" + ); + return false; + } + + true + } + + /// Initialize gtk4-layer-shell for the window (must happen before the + /// window is realized). + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn init_quick_terminal(&mut self, window: >k::Window) -> Result<()> { + window.init_layer_shell(); + Ok(()) + } +} + +/// Data attached to an xdg activation token. +struct ActivationData { + surface: WlSurface, + /// The live token; cleared when `done` fires. + token: Mutex>, + done: AtomicBool, +} + +/// Per-window (wl_surface) state for the Wayland protocol. +pub struct Window { + host: Rc, + shared: Rc, + surface: WlSurface, + /// Present when the window is blurred. + blur_token: Option, + /// Controls the decoration mode (client/server/auto) of the window. + decoration: Option, + /// Slide-in/out animation of the quick terminal (otherwise None). + slide: Option, + /// Present while the window is requesting attention. + activation: Option>, + monitor_handler: Option<(gdk::Surface, glib::SignalHandlerId)>, +} + +impl Window { + pub fn init(app: &App, host: Rc) -> Result { + let win = host.gtk_window().ok_or(Error::NotWaylandSurface)?; + let gdk_surface = super::native_surface(&win).ok_or(Error::NotWaylandSurface)?; + let wl_surface = gdk_surface + .downcast_ref::() + .ok_or(Error::NotWaylandSurface)? + .wl_surface() + .ok_or(Error::NotWaylandSurface)?; + + let shared = Rc::clone(&app.shared); + + // Get our decoration object so we can control the CSD vs SSD status. + let decoration = shared + .state + .borrow() + .kde_decoration_manager + .as_ref() + .map(|mgr| mgr.create(&wl_surface, &shared.qh, ())); + + let monitor_handler = if host.is_quick_terminal() { + let host = Rc::clone(&host); + let id = gdk_surface.connect_enter_monitor(move |_, monitor| { + entered_monitor(&*host, monitor); + }); + Some((gdk_surface.clone(), id)) + } else { + None + }; + + Ok(Window { + host, + shared, + surface: wl_surface, + blur_token: None, + decoration, + slide: None, + activation: None, + monitor_handler, + }) + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn resize_event(&mut self) -> Result<()> { + Ok(()) + } + + #[allow(clippy::unnecessary_wraps)] + pub fn sync_appearance(&mut self) -> Result<()> { + self.sync_blur(); + self.sync_decoration(); + if self.host.is_quick_terminal() + && let Err(e) = self.sync_quick_terminal() + { + log::warn!("failed to sync quick terminal appearance={e}"); + } + self.shared.flush(); + Ok(()) + } + + pub fn client_side_decoration_enabled(&self) -> bool { + match self.decoration_mode() { + DECO_CLIENT => true, + // If we support SSDs, then we should *not* enable CSDs if we prefer + // SSDs. However, if we do not support SSDs (e.g. GNOME) then we + // should enable CSDs even if the user prefers SSDs. + DECO_SERVER => self.shared.state.borrow().kde_decoration_manager.is_none(), + _ => false, + } + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn add_subprocess_env(&mut self, _: &mut Vec<(String, String)>) -> Result<()> { + Ok(()) + } + + pub fn set_urgent(&mut self, urgent: bool) -> Result<()> { + let Some(activation) = self.shared.state.borrow().xdg_activation.clone() else { + return Ok(()); + }; + + // If there already is a token, destroy and unset it + if let Some(old) = self.activation.take() + && let Some(tok) = old.token.lock().ok().and_then(|mut t| t.take()) + { + tok.destroy(); + if !old.done.load(Ordering::Relaxed) { + let mut st = self.shared.state.borrow_mut(); + st.pending_tokens = st.pending_tokens.saturating_sub(1); + } + } + + if urgent { + let data = Arc::new(ActivationData { + surface: self.surface.clone(), + token: Mutex::new(None), + done: AtomicBool::new(false), + }); + let token = activation.get_activation_token(&self.shared.qh, Arc::clone(&data)); + token.set_surface(&self.surface); + token.commit(); + if let Ok(mut t) = data.token.lock() { + *t = Some(token); + } + self.shared.state.borrow_mut().pending_tokens += 1; + self.shared.flush(); + self.activation = Some(data); + + // Poll our queue until the compositor answers. + let shared = Rc::clone(&self.shared); + glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + shared.dispatch_pending(); + if shared.state.borrow().pending_tokens == 0 { + shared.flush(); + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); + } + Ok(()) + } + + /// Update the blur state of the window. + fn sync_blur(&mut self) { + let Some(manager) = self.shared.state.borrow().kde_blur_manager.clone() else { + return; + }; + let Some(config) = self.host.config() else { + return; + }; + let blur = config.background_blur; + + if let Some(tok) = &self.blur_token { + // Only release token when transitioning from blurred -> not blurred + if !blur.enabled() { + manager.unset(&self.surface); + tok.release(); + self.blur_token = None; + } + } else if blur.enabled() { + // Only acquire token when transitioning from not blurred -> blurred + let tok = manager.create(&self.surface, &self.shared.qh, ()); + tok.commit(); + self.blur_token = Some(tok); + } + } + + fn sync_decoration(&self) { + let Some(deco) = &self.decoration else { + return; + }; + deco.request_mode(self.decoration_mode()); + } + + fn decoration_mode(&self) -> u32 { + match self.host.window_decoration() { + WindowDecoration::Auto => self + .shared + .state + .borrow() + .default_deco_mode + .unwrap_or(DECO_CLIENT), + WindowDecoration::Client => DECO_CLIENT, + WindowDecoration::Server => DECO_SERVER, + WindowDecoration::None => DECO_NONE, + } + } + + fn sync_quick_terminal(&mut self) -> Result<()> { + let window = self.host.gtk_window().ok_or(Error::NotWaylandSurface)?; + let Some(config) = self.host.config() else { + return Ok(()); + }; + + window.set_layer(match config.gtk_quick_terminal_layer { + QuickTerminalLayer::Overlay => layer_shell::Layer::Overlay, + QuickTerminalLayer::Top => layer_shell::Layer::Top, + QuickTerminalLayer::Bottom => layer_shell::Layer::Bottom, + QuickTerminalLayer::Background => layer_shell::Layer::Background, + }); + window.set_namespace(Some(config.gtk_quick_terminal_namespace.as_str())); + + window.set_keyboard_mode(match config.quick_terminal_keyboard_interactivity { + QuickTerminalKeyboardInteractivity::None => layer_shell::KeyboardMode::None, + QuickTerminalKeyboardInteractivity::OnDemand => { + if layer_shell::protocol_version() < 4 { + log::warn!( + "your compositor does not support on-demand keyboard access; falling back to exclusive access" + ); + layer_shell::KeyboardMode::Exclusive + } else { + layer_shell::KeyboardMode::OnDemand + } + } + QuickTerminalKeyboardInteractivity::Exclusive => { + layer_shell::KeyboardMode::Exclusive + } + }, + ); + + let anchored_edge = match config.quick_terminal_position { + QuickTerminalPosition::Left => Some(layer_shell::Edge::Left), + QuickTerminalPosition::Right => Some(layer_shell::Edge::Right), + QuickTerminalPosition::Top => Some(layer_shell::Edge::Top), + QuickTerminalPosition::Bottom => Some(layer_shell::Edge::Bottom), + QuickTerminalPosition::Center => None, + }; + + for edge in [ + layer_shell::Edge::Left, + layer_shell::Edge::Right, + layer_shell::Edge::Top, + layer_shell::Edge::Bottom, + ] { + if anchored_edge == Some(edge) { + window.set_margin(edge, 0); + window.set_anchor(edge, true); + continue; + } + // Arbitrary margin - could be made customizable? + window.set_margin(edge, 20); + window.set_anchor(edge, false); + } + + if let Some(slide) = self.slide.take() { + slide.release(); + } + + self.slide = anchored_edge.and_then(|anchored| { + let mgr = self.shared.state.borrow().kde_slide_manager.clone()?; + let slide = mgr.create(&self.surface, &self.shared.qh, ()); + slide.set_location(match anchored { + layer_shell::Edge::Top => SLIDE_TOP, + layer_shell::Edge::Bottom => SLIDE_BOTTOM, + layer_shell::Edge::Left => SLIDE_LEFT, + _ => SLIDE_RIGHT, + }); + slide.commit(); + Some(slide) + }); + Ok(()) + } +} + +impl Drop for Window { + fn drop(&mut self) { + if let Some((surface, id)) = self.monitor_handler.take() { + surface.disconnect(id); + } + if let Some(blur) = self.blur_token.take() { + blur.release(); + } + if let Some(deco) = self.decoration.take() { + deco.release(); + } + if let Some(slide) = self.slide.take() { + slide.release(); + } + if let Some(act) = self.activation.take() + && let Some(tok) = act.token.lock().ok().and_then(|mut t| t.take()) + { + tok.destroy(); + } + self.shared.flush(); + } +} + +/// Update the size of the quick terminal based on monitor dimensions. +fn entered_monitor(host: &dyn WindowHost, monitor: &gdk::Monitor) { + let Some(window) = host.gtk_window() else { + return; + }; + let Some(config) = host.config() else { + return; + }; + let size = monitor.geometry(); + let dims = config.quick_terminal_size.calculate( + config.quick_terminal_position, + QuickTerminalDimensions { + width: size.width() as u32, + height: size.height() as u32, + }, + ); + window.set_default_size(dims.width as i32, dims.height as i32); +} + +// ---- wayland-client dispatch plumbing --------------------------------- + +impl Dispatch for State { + fn event( + _: &mut Self, + _: &WlRegistry, + _: wayland_client::protocol::wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + // Globals appearing/disappearing after init are not tracked. + } +} + +macro_rules! no_events { + ($($t:ty),*) => {$( + impl Dispatch<$t, ()> for State { + fn event( + _: &mut Self, + _: &$t, + _: <$t as Proxy>::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + } + } + )*}; +} + +no_events!( + OrgKdeKwinBlurManager, + OrgKdeKwinBlur, + OrgKdeKwinSlideManager, + OrgKdeKwinSlide, + XdgActivationV1, + OrgKdeKwinServerDecoration +); + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &OrgKdeKwinServerDecorationManager, + event: org_kde_kwin_server_decoration_manager::Event, + (): &(), + _: &Connection, + _: &QueueHandle, + ) { + if let org_kde_kwin_server_decoration_manager::Event::DefaultMode { mode } = event { + state.default_deco_mode = Some(mode); + } + } +} + +impl Dispatch> for State { + fn event( + state: &mut Self, + token: &XdgActivationTokenV1, + event: xdg_activation_token_v1::Event, + data: &Arc, + _: &Connection, + _: &QueueHandle, + ) { + let xdg_activation_token_v1::Event::Done { token: id } = event else { + return; + }; + // Ignore events for tokens we've already dropped. + let is_current = data + .token + .lock() + .is_ok_and(|t| t.as_ref().is_some_and(|t| t.id() == token.id())); + if !is_current { + log::warn!("received event for unknown activation token; ignoring"); + return; + } + if let Some(activation) = &state.xdg_activation { + activation.activate(id, &data.surface); + } + token.destroy(); + if let Ok(mut t) = data.token.lock() { + t.take(); + } + data.done.store(true, Ordering::Relaxed); + state.pending_tokens = state.pending_tokens.saturating_sub(1); + } +} diff --git a/crates/ghostty-apprt-gtk/src/winproto/x11.rs b/crates/ghostty-apprt-gtk/src/winproto/x11.rs new file mode 100644 index 0000000..a9b6e61 --- /dev/null +++ b/crates/ghostty-apprt-gtk/src/winproto/x11.rs @@ -0,0 +1,524 @@ +//! Port of apprt/gtk/winproto/x11.zig: X11 window protocol implementation. +//! +//! Uses Xlib (through the `x11` crate, sharing GDK's `Display*`) exactly like +//! the Zig code; all raw FFI is confined to small `unsafe` blocks with SAFETY +//! comments. +#![allow(unsafe_code)] + +use std::ffi::{c_int, c_long, c_uchar, c_uint, c_ulong, c_void}; +use std::rc::Rc; + +use gdk4_x11::x11::xlib; +use gdk4_x11::{X11Display, X11Surface}; +use gtk::gdk; +use gtk::glib; +use gtk::prelude::*; + +use super::{Error, Result, WindowHost}; +use ghostty_input::Mods; + +// XKB bits we need that the `x11` crate doesn't expose (XKBlib.h / XKB.h). +const XKB_MAJOR_VERSION: c_int = 1; +const XKB_MINOR_VERSION: c_int = 0; +const XKB_USE_CORE_KBD: c_uint = 0x0100; +const XKB_STATE_NOTIFY: c_int = 2; +const QUEUED_AFTER_READING: c_int = 1; +const XKB_MODIFIER_STATE_MASK: c_ulong = 1 << 0; + +#[link(name = "X11")] +unsafe extern "C" { + fn XkbQueryExtension( + dpy: *mut c_void, + opcode_rtrn: *mut c_int, + event_rtrn: *mut c_int, + error_rtrn: *mut c_int, + major_in_out: *mut c_int, + minor_in_out: *mut c_int, + ) -> c_int; + + fn XkbSelectEventDetails( + dpy: *mut c_void, + device_spec: c_uint, + event_type: c_uint, + bits_to_change: c_ulong, + values_for_bits: c_ulong, + ) -> c_int; +} + +/// Leading part of `XkbStateNotifyEvent` (XKBlib.h) up to `lookup_mods`. +#[repr(C)] +#[allow(dead_code)] +struct XkbStateNotifyEvent { + type_: c_int, + serial: c_ulong, + send_event: c_int, + display: *mut xlib::Display, + time: c_ulong, + xkb_type: c_int, + device: c_int, + changed: c_uint, + group: c_int, + base_group: c_int, + latched_group: c_int, + locked_group: c_int, + mods: c_uint, + base_mods: c_uint, + latched_mods: c_uint, + locked_mods: c_uint, + compat_state: c_int, + grab_mods: c_uchar, + compat_grab_mods: c_uchar, + lookup_mods: c_uchar, +} + +#[derive(Debug, Clone, Copy)] +struct Atoms { + kde_blur: xlib::Atom, + motif_wm_hints: xlib::Atom, +} + +impl Atoms { + fn new(display: &X11Display) -> Atoms { + Atoms { + kde_blur: gdk4_x11::x11_get_xatom_by_name_for_display( + display, + "_KDE_NET_WM_BLUR_BEHIND_REGION", + ), + motif_wm_hints: gdk4_x11::x11_get_xatom_by_name_for_display(display, "_MOTIF_WM_HINTS"), + } + } +} + +/// X11 application-wide state. +pub struct App { + xl: Rc, + display: *mut xlib::Display, + base_event_code: c_int, + atoms: Atoms, +} + +impl App { + /// Returns `Ok(None)` if the display isn't X11. + pub fn init( + gdk_display: &gdk::Display, + app_id: &str, + config: &ghostty_config::config::Config, + ) -> Result> { + let Some(x11_display) = gdk_display.downcast_ref::() else { + return Ok(None); + }; + + let xl = xlib::Xlib::open().map_err(|e| { + log::error!("failed to load libX11: {e}"); + Error::XkbInitialization + })?; + + // SAFETY: the display is a live X11 display; the returned pointer is + // owned by GDK and valid for the lifetime of the display (the whole + // application). + let xlib_display = unsafe { x11_display.xdisplay() }; + + let program_name = match &config.x11_instance_name { + Some(pn) => pn.clone(), + None if cfg!(debug_assertions) => "ghostty-debug".to_string(), + None => "ghostty".to_string(), + }; + + // Set the X11 window class property (WM_CLASS); the instance name is + // derived from g_set_prgname. Results in e.g. + // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" + glib::set_prgname(Some(program_name.as_str())); + x11_display.set_program_class(app_id); + + log::debug!("Xkb.init: initializing Xkb"); + let mut opcode: c_int = 0; + let mut base_event_code: c_int = 0; + let mut base_error_code: c_int = 0; + let mut major = XKB_MAJOR_VERSION; + let mut minor = XKB_MINOR_VERSION; + // SAFETY: valid display pointer, valid out-pointers to locals. + let ok = unsafe { + XkbQueryExtension( + xlib_display.cast(), + &raw mut opcode, + &raw mut base_event_code, + &raw mut base_error_code, + &raw mut major, + &raw mut minor, + ) + }; + if ok == 0 { + log::error!( + "Fatal: error initializing Xkb extension: error executing XkbQueryExtension" + ); + return Err(Error::XkbInitialization); + } + + // SAFETY: valid display pointer. + let ok = unsafe { + XkbSelectEventDetails( + xlib_display.cast(), + XKB_USE_CORE_KBD, + XKB_STATE_NOTIFY as c_uint, + XKB_MODIFIER_STATE_MASK, + XKB_MODIFIER_STATE_MASK, + ) + }; + if ok == 0 { + log::error!( + "Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails" + ); + return Err(Error::XkbInitialization); + } + + Ok(Some(App { + xl: Rc::new(xl), + display: xlib_display, + base_event_code, + atoms: Atoms::new(x11_display), + })) + } + + /// Checks for an immediate pending XKB state update event and returns the + /// keyboard state based on it. Necessary because the standard GTK X11 API + /// does not include the currently pressed key in the modifier snapshot + /// (paraphrased from Firefox/Gecko widget/gtk/nsGtkKeyUtils.cpp). + /// + /// Returns `None` if there is no such event; callers fall back to the GDK + /// modifier state. + pub fn event_mods(&self, _: Option<&gdk::Device>, _: gdk::ModifierType) -> Option { + // SAFETY: `display` is valid for the application's lifetime; XEvent is + // plain-old-data, zero-initialised, and filled by XPeekEvent. + let lookup_mods = unsafe { + if (self.xl.XEventsQueued)(self.display, QUEUED_AFTER_READING) == 0 { + return None; + } + let mut next: xlib::XEvent = std::mem::zeroed(); + (self.xl.XPeekEvent)(self.display, &raw mut next); + if next.type_ != self.base_event_code { + return None; + } + // XkbStateNotifyEvent is a prefix-compatible view of the XEvent + // union (which is at least as large and aligned as a long). + let ev = &*std::ptr::from_ref(&next).cast::(); + if ev.xkb_type != XKB_STATE_NOTIFY { + return None; + } + ev.lookup_mods + }; + + let lookup = c_uint::from(lookup_mods); + let mut mods = Mods::default(); + log::debug!("X11: found extra XkbStateNotify event w/lookup_mods: {lookup_mods:b}"); + if lookup & xlib::ShiftMask != 0 { + mods.shift = true; + } + if lookup & xlib::ControlMask != 0 { + mods.ctrl = true; + } + if lookup & xlib::Mod1Mask != 0 { + mods.alt = true; + } + if lookup & xlib::Mod4Mask != 0 { + mods.super_ = true; + } + if lookup & xlib::LockMask != 0 { + mods.caps_lock = true; + } + Some(mods) + } + + #[allow(clippy::unused_self)] + pub fn supports_quick_terminal(&self) -> bool { + log::warn!("quick terminal is not yet supported on X11"); + false + } + + #[allow(clippy::unused_self, clippy::unnecessary_wraps)] + pub fn init_quick_terminal(&mut self, _: >k::Window) -> Result<()> { + Ok(()) + } +} + +/// `_KDE_NET_WM_BLUR_BEHIND_REGION` payload: x, y, width, height (32-bit +/// format properties are arrays of `long`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(C)] +struct Region { + x: c_long, + y: c_long, + width: c_long, + height: c_long, +} + +/// See Xm/MwmUtil.h, packaged with the Motif Window Manager. All fields are +/// `long`-sized in the 32-bit format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(C)] +struct MotifWmHints { + flags: c_ulong, + functions: c_ulong, + decorations: c_ulong, + input_mode: c_long, + status: c_ulong, +} + +impl MotifWmHints { + const FLAG_DECORATIONS: c_ulong = 1 << 1; + const DECORATIONS_ALL: c_ulong = 1 << 0; +} + +/// X11 per-window state. +pub struct Window { + xl: Rc, + display: *mut xlib::Display, + atoms: Atoms, + host: Rc, + x11_surface: X11Surface, + + blur_region: Region, + + // Cache last applied values to avoid redundant X11 property updates, which + // cause visual glitches with some WMs (ghostty-org/ghostty#8075). + last_applied_blur_region: Option, + last_applied_decoration_hints: Option, +} + +impl Window { + pub fn init(app: &App, host: Rc) -> Result { + let win = host.gtk_window().ok_or(Error::NotX11Surface)?; + let surface = super::native_surface(&win).ok_or(Error::NotX11Surface)?; + let x11_surface = surface + .downcast::() + .map_err(|_| Error::NotX11Surface)?; + Ok(Window { + xl: Rc::clone(&app.xl), + display: app.display, + atoms: app.atoms, + host, + x11_surface, + blur_region: Region::default(), + last_applied_blur_region: None, + last_applied_decoration_hints: None, + }) + } + + pub fn resize_event(&mut self) -> Result<()> { + // The blur region must update with window resizes + self.sync_blur() + } + + pub fn sync_appearance(&mut self) -> Result<()> { + // The user could have toggled between CSDs and SSDs, therefore we need + // to recalculate the blur region offset. + if let Some(win) = self.host.gtk_window() { + let (mut x, mut y) = win.upcast_ref::().surface_transform(); + // Transform surface coordinates to device coordinates. + let scale = f64::from(win.scale_factor()); + x *= scale; + y *= scale; + self.blur_region = Region { + x: x as c_long, + y: y as c_long, + ..Region::default() + }; + } + if let Err(err) = self.sync_blur() { + log::error!("failed to synchronize blur={err}"); + } + if let Err(err) = self.sync_decorations() { + log::error!("failed to synchronize decorations={err}"); + } + Ok(()) + } + + pub fn client_side_decoration_enabled(&self) -> bool { + use ghostty_config::types_b::WindowDecoration as D; + match self.host.window_decoration() { + D::Auto | D::Client => true, + D::Server | D::None => false, + } + } + + fn sync_blur(&mut self) -> Result<()> { + // FIXME (upstream): doesn't factor in rounded corners on Adwaita. + let Some(win) = self.host.gtk_window() else { + return Ok(()); + }; + let Some(config) = self.host.config() else { + return Ok(()); + }; + + // When blur is disabled, remove the property if it was previously set + let blur = config.background_blur; + if !blur.enabled() { + if self.last_applied_blur_region.is_some() { + self.delete_property(self.atoms.kde_blur)?; + self.last_applied_blur_region = None; + } + return Ok(()); + } + + // Transform surface coordinates to device coordinates. + let scale = win.scale_factor(); + self.blur_region.width = c_long::from(win.width() * scale); + self.blur_region.height = c_long::from(win.height() * scale); + + // Only update X11 properties when the blur region actually changes + if self.last_applied_blur_region == Some(self.blur_region) { + return Ok(()); + } + + log::debug!( + "set blur={blur:?}, window xid={}, region={:?}", + self.x11_surface.xid(), + self.blur_region + ); + + let region = self.blur_region; + self.change_property(self.atoms.kde_blur, xlib::XA_CARDINAL, ®ion)?; + self.last_applied_blur_region = Some(region); + Ok(()) + } + + fn sync_decorations(&mut self) -> Result<()> { + use ghostty_config::types_b::WindowDecoration as D; + + let mut hints = match self.get_window_property::( + self.atoms.motif_wm_hints, + self.atoms.motif_wm_hints, + ) { + Ok(h) => h, + // already default-initialised, so this is fine + Err(Error::PropertyNotFound) => MotifWmHints::default(), + Err(e) => return Err(e), + }; + + hints.flags |= MotifWmHints::FLAG_DECORATIONS; + if matches!(self.host.window_decoration(), D::Server) { + hints.decorations |= MotifWmHints::DECORATIONS_ALL; + } else { + hints.decorations &= !MotifWmHints::DECORATIONS_ALL; + } + + // Only update decoration hints when they actually change + if self.last_applied_decoration_hints == Some(hints) { + return Ok(()); + } + + self.change_property(self.atoms.motif_wm_hints, self.atoms.motif_wm_hints, &hints)?; + self.last_applied_decoration_hints = Some(hints); + Ok(()) + } + + pub fn add_subprocess_env(&mut self, env: &mut Vec<(String, String)>) -> Result<()> { + env.push(("WINDOWID".to_string(), self.x11_surface.xid().to_string())); + Ok(()) + } + + #[allow(clippy::unnecessary_wraps)] + pub fn set_urgent(&mut self, urgent: bool) -> Result<()> { + self.x11_surface.set_urgency_hint(urgent); + Ok(()) + } + + /// `XGetWindowProperty` for a 32-format property that is an array of + /// `long`s whose layout matches `T` (`T` is a `repr(C)` struct of longs). + fn get_window_property(&self, name: xlib::Atom, typ: xlib::Atom) -> Result { + let mut actual_type: xlib::Atom = 0; + let mut actual_format: c_int = 0; + let mut nitems: c_ulong = 0; + let mut bytes_after: c_ulong = 0; + let mut prop: *mut c_uchar = std::ptr::null_mut(); + + // SAFETY: valid display and window id; all out-pointers refer to locals. + let code = unsafe { + (self.xl.XGetWindowProperty)( + self.display, + self.x11_surface.xid(), + name, + 0, + c_long::MAX, + xlib::False, + typ, + &raw mut actual_type, + &raw mut actual_format, + &raw mut nitems, + &raw mut bytes_after, + &raw mut prop, + ) + }; + if code != c_int::from(xlib::Success) { + return Err(Error::RequestFailed); + } + if actual_type == 0 { + return Err(Error::PropertyNotFound); + } + + let result = if typ != actual_type { + Err(Error::PropertyTypeMismatch) + } else if actual_format != 32 { + Err(Error::PropertyFormatMismatch) + } else if (nitems as usize) * std::mem::size_of::() < std::mem::size_of::() + || prop.is_null() + { + Err(Error::RequestFailed) + } else { + // SAFETY: Xlib returns 32-format data as an array of `long` + // (suitably aligned), at least size_of::() bytes of which we + // just verified; T is a repr(C) aggregate of longs. + Ok(unsafe { prop.cast::().read() }) + }; + if !prop.is_null() { + // SAFETY: allocated by Xlib. + unsafe { (self.xl.XFree)(prop.cast()) }; + } + result + } + + fn change_property(&self, name: xlib::Atom, typ: xlib::Atom, value: &T) -> Result<()> { + let nelements = std::mem::size_of::() / std::mem::size_of::(); + // SAFETY: valid display/window; `value` points to `nelements` longs + // (T is a repr(C) aggregate of longs); data is copied by Xlib. + let status = unsafe { + (self.xl.XChangeProperty)( + self.display, + self.x11_surface.xid(), + name, + typ, + 32, + xlib::PropModeReplace, + std::ptr::from_ref(value).cast::(), + nelements as c_int, + ) + }; + // Xlib alternates between error values (0 = success) and booleans + // (1 = success); the Zig code treats 0 as failure here. + if status == 0 { + return Err(Error::RequestFailed); + } + Ok(()) + } + + fn delete_property(&self, name: xlib::Atom) -> Result<()> { + // SAFETY: valid display and window id. + let status = + unsafe { (self.xl.XDeleteProperty)(self.display, self.x11_surface.xid(), name) }; + if status == 0 { + return Err(Error::RequestFailed); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn property_layouts_are_long_arrays() { + let l = std::mem::size_of::(); + assert_eq!(std::mem::size_of::(), 4 * l); + assert_eq!(std::mem::size_of::(), 5 * l); + } +} diff --git a/docs/rewrite/notes/ghostty-apprt-gtk.md b/docs/rewrite/notes/ghostty-apprt-gtk.md index 45f6a32..75b42d3 100644 --- a/docs/rewrite/notes/ghostty-apprt-gtk.md +++ b/docs/rewrite/notes/ghostty-apprt-gtk.md @@ -1,4 +1,33 @@ +# ghostty-apprt-gtk notes +## Overview / status +Port of ZIG/src/apprt/gtk (GTK4 + libadwaita, gtk4-rs 0.11 / libadwaita 0.9, baseline GTK 4.14 / adw 1.5). +build.rs compiles resources/ui/**/*.blp with blueprint-compiler and bundles UI+CSS+icons into a gresource +(`register_resources()`). Inspector/imgui deferred (menu action is a no-op with TODO). Dropped the ghostty-core +dependency (cycle): the core boundary is the trait set in `src/core.rs`. + +## Core trait boundary (src/core.rs, authoritative spec = the file) +- `CoreApp` (impl by ghostty-core): tick, update_config, needs_confirm_quit, color_scheme_event, perform_all_action, + push_message(AppMessage::{OpenConfig,PresentSurface(id),NewWindow}), new_surface(id, config, rt_app, rt_surface) + -> Rc, delete_surface, surface_ids, cell_size. +- `CoreSurface`: config, key/preedit/focus/mouse_button/cursor_pos/scroll/size/content_scale callbacks, ime_point, + has_selection, perform_binding_action, complete_clipboard_request (+ClipboardError::{UnsafePaste,UnauthorizedPaste}), + set_clipboard_read/write_allowed, needs_confirm_quit, child_exited, renderer_realized/unrealized, + draw_frame(fbo) (core must query GL_FRAMEBUFFER_BINDING; GL loader: `gl::proc_address`), color_scheme_callback, + update_config, readonly/grid_size/font_size, inspector hooks. +- `RtApp::perform_action(Target, Action) -> bool`, `RtSurface` (close, cgroup, get_title/size/scale/cursor_pos, + clipboard_request/set_clipboard, default_termio_env), `Waker` (Send+Sync, `app::waker()`). +- All main-thread only except Waker. Core must not call RtApp from other threads. + +## Integration TODOs / gaps +- libgtk4-layer-shell must be linked/preloaded before libwayland-client for quick terminal. +- ghostty-termio has its own cgroup wait loop; choose one (cgroup.rs here). +- Stubs (as upstream): toggle_visibility, float_window, secure_input, close_all_windows; inspector; URLs via xdg-open. +- Runtime coverage is light: demo launches cleanly (no GTK criticals); tabs/splits/dialogs/palette not driven by hand. +- Cargo: workspace root lacks `ghostty-apprt` in workspace.dependencies; this crate uses a path dep. +- Demo: `DEMO_QUIT_AFTER_MS=4000 cargo run -p ghostty-apprt-gtk --example demo`. + +# Per-component notes (by sub-agent) ## F3: dialogs + config object (class/{dialog,config,config_errors_dialog,close_confirmation_dialog,clipboard_confirmation_dialog,title_dialog,debug_warning}.rs) Libadwaita >= 1.5 is assumed, so `Dialog` is always an `adw::AlertDialog` subclass (no `MessageDialog` fallback) and DebugWarning uses the `adw::Banner` (1.3) template; the clipboard dialog always uses the 1.4 template (remember switch). GType names, property names, signals and template children match the Zig classes.