feat(apprt-gtk): port the GTK4/libadwaita app runtime
Ports ZIG/src/apprt/gtk into ghostty-apprt-gtk: every GObject class (application, window, tab, split tree, surface + scrolled window, child-exited banner, search/key-state/resize overlays, command palette, global shortcuts, all dialogs, config wrapper) as glib subclasses with the same GType names, properties, signals and template children; GDK key translation; X11 and Wayland window-protocol helpers (blur, CSD, quick terminal via gtk4-layer-shell); single-instance D-Bus IPC; gsettings; cgroup scope creation. build.rs compiles Ghostty's blueprints with blueprint-compiler and bundles UI, CSS and icons into a gresource, as Ghostty's build does. The core is not ported yet, so the crate defines the boundary it needs in src/core.rs (CoreApp, CoreSurface, RtApp, RtSurface, Waker); the spec is in docs/rewrite/notes/ghostty-apprt-gtk.md. ghostty-core will implement these traits (the crate no longer depends on core to avoid a cycle). examples/demo.rs runs the app with a dummy core that clears the GLArea; it launches on Wayland without GTK criticals. Stubs/open items: toggle_visibility, float_window, secure_input and close_all_windows return false; inspector is a no-op; X11/Wayland protocol code is untested against real compositors; gtk4-rs doesn't expose the GLArea framebuffer id, so the renderer must query it. Also registers ghostty-apprt and ghostty-cli in workspace.dependencies. Test Plan: - cargo test -p ghostty-apprt-gtk (36 tests) - DEMO_QUIT_AFTER_MS=3000 cargo run -p ghostty-apprt-gtk --example demo
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gresources>\n");
|
||||
|
||||
let _ = writeln!(xml, " <gresource prefix=\"{PREFIX}\">");
|
||||
for name in CSS {
|
||||
let _ = writeln!(
|
||||
xml,
|
||||
" <file compressed=\"true\" alias=\"{name}\">css/{name}</file>"
|
||||
);
|
||||
}
|
||||
xml.push_str(" </gresource>\n");
|
||||
|
||||
let _ = writeln!(xml, " <gresource prefix=\"{PREFIX}/icons\">");
|
||||
for &s in ICON_SIZES {
|
||||
let _ = writeln!(
|
||||
xml,
|
||||
" <file alias=\"{s}x{s}/apps/{APP_ID}.png\">icons/{s}.png</file>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
xml,
|
||||
" <file alias=\"{s}x{s}@2/apps/{APP_ID}.png\">icons/{}.png</file>",
|
||||
s * 2
|
||||
);
|
||||
}
|
||||
xml.push_str(" </gresource>\n");
|
||||
|
||||
let _ = writeln!(xml, " <gresource prefix=\"{PREFIX}/ui\">");
|
||||
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, " <file alias=\"{rel}.ui\">ui/{rel}.ui</file>");
|
||||
}
|
||||
xml.push_str(" </gresource>\n</gresources>\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)
|
||||
);
|
||||
}
|
||||
@@ -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=<n>`: 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<Action> {
|
||||
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<Arc<Config>>,
|
||||
rt_app: Rc<dyn RtApp>,
|
||||
_rt_surface: Rc<dyn RtSurface>,
|
||||
gl: RefCell<Option<glow::Context>>,
|
||||
focused: Cell<bool>,
|
||||
}
|
||||
|
||||
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<Config> {
|
||||
self.config.borrow().clone()
|
||||
}
|
||||
|
||||
fn key_callback(&self, event: KeyEvent<'_>) -> Result<InputEffect, CoreError> {
|
||||
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<bool, CoreError> {
|
||||
Ok(false)
|
||||
}
|
||||
fn cursor_pos_callback(&self, _pos: CursorPos, _mods: Option<Mods>) -> 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<Config>) -> 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<bool, CoreError> {
|
||||
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<Vec<SurfaceId>>,
|
||||
mailbox: RefCell<Vec<AppMessage>>,
|
||||
}
|
||||
|
||||
impl CoreApp for DemoApp {
|
||||
fn tick(&self, rt: &dyn RtApp) -> Result<(), CoreError> {
|
||||
let msgs: Vec<AppMessage> = 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<Config>) -> 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<dyn RtApp>,
|
||||
rt_surface: Rc<dyn RtSurface>,
|
||||
) -> Result<Rc<dyn CoreSurface>, 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<SurfaceId> {
|
||||
self.surfaces.borrow().clone()
|
||||
}
|
||||
fn cell_size(&self, _config: &Config, _font_size: DesiredFontSize) -> Option<CellSize> {
|
||||
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::<u64>().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();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.window .split paned > separator {
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
background-clip: content-box;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 572 B |
|
After Width: | Height: | Size: 802 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <bool>;
|
||||
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 <string>;
|
||||
xalign: 0.0;
|
||||
}
|
||||
|
||||
Label chevron_label {
|
||||
visible: bind $show_chevron(template.has-tables, template.has-sequence) as <bool>;
|
||||
label: "›";
|
||||
|
||||
styles [
|
||||
"dim-label",
|
||||
]
|
||||
}
|
||||
|
||||
Label sequence_label {
|
||||
visible: bind template.has-sequence;
|
||||
label: bind $sequence_text(template.sequence) as <string>;
|
||||
xalign: 0.0;
|
||||
}
|
||||
|
||||
Spinner pending_spinner {
|
||||
visible: bind template.has-sequence;
|
||||
spinning: bind template.has-sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <string>;
|
||||
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";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <bool>;
|
||||
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 <bool>;
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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 <Gtk.PolicyType>;
|
||||
}
|
||||
}
|
||||
@@ -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 <string>;
|
||||
tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd;
|
||||
|
||||
$GhosttySplitTree split_tree {
|
||||
notify::active-surface => $notify_active_surface();
|
||||
notify::tree => $notify_tree();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 <string>;
|
||||
};
|
||||
|
||||
[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 <bool>;
|
||||
|
||||
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 <bool>;
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Waker> {
|
||||
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<dyn CoreApp>) -> 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<SurfaceId>, 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<bool, ipc::Error> {
|
||||
crate::ipc::perform_ipc(target, action)
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
/// `TasksMax`.
|
||||
pub tasks_max: Option<u64>,
|
||||
}
|
||||
|
||||
#[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)))");
|
||||
}
|
||||
}
|
||||
@@ -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<dyn CoreApp>` / `Rc<dyn RtApp>`
|
||||
//! * [`Application::get_config`] -> `class::config::Config` object (property `config`)
|
||||
//! * [`Application::core_config`] -> `Arc<ghostty_config::config::Config>`
|
||||
//! * [`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<Command>,
|
||||
pub working_directory: Option<String>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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::<Vec<_>>()
|
||||
.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<SurfaceId>, 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<Rc<dyn CoreApp>>,
|
||||
pub rt_app: OnceCell<Rc<dyn RtApp>>,
|
||||
|
||||
/// The configuration for the application.
|
||||
pub config: RefCell<Option<Config>>,
|
||||
|
||||
/// State and logic for the underlying windowing protocol.
|
||||
pub winproto: RefCell<Option<winproto::App>>,
|
||||
|
||||
/// The global shortcut logic.
|
||||
pub global_shortcuts: OnceCell<GlobalShortcuts>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// Set to false internally when the event loop should exit.
|
||||
pub running: Cell<bool>,
|
||||
|
||||
pub quit_timer: RefCell<QuitTimer>,
|
||||
|
||||
/// The config errors dialog (weak: it can close on its own).
|
||||
pub config_errors_dialog: glib::WeakRef<ConfigErrorsDialog>,
|
||||
|
||||
pub signal_thread_started: Cell<bool>,
|
||||
|
||||
/// CSS providers (runtime + custom).
|
||||
pub css: RefCell<Option<CssProviders>>,
|
||||
|
||||
/// Copy of the LANG variable provided by the system, if any.
|
||||
pub saved_language: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
#[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<Vec<glib::ParamSpec>> = std::sync::OnceLock::new();
|
||||
PROPS.get_or_init(|| {
|
||||
vec![
|
||||
glib::ParamSpecObject::builder::<Config>("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::<gio::Application>());
|
||||
|
||||
// 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<imp::Application>)
|
||||
@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::<Application>().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<dyn CoreApp>) -> 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<dyn CoreApp> {
|
||||
self.imp().core_app.get().expect("core app set").clone()
|
||||
}
|
||||
|
||||
/// The `RtApp` implementation to hand to core.
|
||||
pub fn rt(&self) -> Rc<dyn RtApp> {
|
||||
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<CoreConfig> {
|
||||
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<String> {
|
||||
self.imp().saved_language.borrow().clone()
|
||||
}
|
||||
|
||||
/// All live surfaces (Zig: `core_app.surfaces.items` mapped to GObjects).
|
||||
pub fn surfaces(&self) -> Vec<Surface> {
|
||||
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::<gio::Application>();
|
||||
|
||||
// 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::<gtk::Window>()
|
||||
.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>() {
|
||||
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<dyn Fn(&Self, Option<&glib::Variant>)>| {
|
||||
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<String> = None;
|
||||
let mut title: Option<String> = None;
|
||||
let mut command: Option<Command> = None;
|
||||
let mut args: Vec<String> = 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::<Vec<String>>() 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::<u64>() 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<SurfaceId>, action: Action) -> bool {
|
||||
// Resolve the surface target (Zig deref'd `*CoreSurface` directly).
|
||||
let surface: Option<Surface> = 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::<Window>(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::<Window>(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::<Window>(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::<Tab>(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::<SplitTree>(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::<Tab>(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::<Window>(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::<Window>(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::<SplitTree>(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::<SplitTree>(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> = gtk::Window::list_toplevels()
|
||||
.into_iter()
|
||||
.filter_map(|w| w.downcast::<gtk::Window>().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::<Window>() 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<SurfaceId>, 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<CoreConfig> = 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<Window> {
|
||||
gtk::Window::list_toplevels()
|
||||
.into_iter()
|
||||
.filter_map(|w| w.downcast::<Window>().ok())
|
||||
.find(Window::is_quick_terminal)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Zig `apprt.Target` as seen by action handlers.
|
||||
struct Tgt {
|
||||
is_surface: bool,
|
||||
surface: Option<Surface>,
|
||||
}
|
||||
|
||||
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<T: IsA<gtk::Widget>>(widget: &impl IsA<gtk::Widget>) -> Option<T> {
|
||||
widget.ancestor(T::static_type()).and_downcast::<T>()
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -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<Option<ClipboardRequestBoxed>>,
|
||||
|
||||
/// The clipboard contents being read/written.
|
||||
#[property(get, set, nullable, name = "clipboard-contents")]
|
||||
pub clipboard_contents: RefCell<Option<gtk::TextBuffer>>,
|
||||
|
||||
/// Whether the contents should be blurred.
|
||||
#[property(get, set)]
|
||||
pub blur: Cell<bool>,
|
||||
|
||||
/// Whether the user can remember the choice.
|
||||
#[property(get, set, name = "can-remember")]
|
||||
pub can_remember: Cell<bool>,
|
||||
|
||||
#[template_child]
|
||||
pub text_view_scroll: TemplateChild<gtk::ScrolledWindow>,
|
||||
#[template_child]
|
||||
pub text_view: TemplateChild<gtk::TextView>,
|
||||
#[template_child]
|
||||
pub reveal_button: TemplateChild<gtk::Button>,
|
||||
#[template_child]
|
||||
pub hide_button: TemplateChild<gtk::Button>,
|
||||
#[template_child]
|
||||
pub remember_choice: TemplateChild<adw::SwitchRow>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
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<Vec<Signal>> = 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<imp::ClipboardConfirmationDialog>)
|
||||
@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<gtk::Widget>>) {
|
||||
self.upcast_ref::<Dialog>().present(parent);
|
||||
}
|
||||
|
||||
/// Get the clipboard request (`getRequest`).
|
||||
pub fn get_request(&self) -> Option<ClipboardRequest> {
|
||||
self.imp().request.borrow().map(|b| b.0)
|
||||
}
|
||||
|
||||
/// Get the clipboard contents (`getClipboardContents`).
|
||||
pub fn get_clipboard_contents(&self) -> Option<gtk::TextBuffer> {
|
||||
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<F: Fn(&Self, bool) + 'static>(&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<F: Fn(&Self, bool) + 'static>(&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()
|
||||
}
|
||||
}
|
||||
@@ -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<Target>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for CloseConfirmationDialog {
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: OnceLock<Vec<Signal>> = 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<imp::CloseConfirmationDialog>)
|
||||
@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<gtk::Widget>>) {
|
||||
let target = self.target();
|
||||
self.set_heading(Some(&target.title()));
|
||||
self.set_body(&target.body());
|
||||
self.upcast_ref::<Dialog>().present(parent);
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.upcast_ref::<Dialog>().close();
|
||||
}
|
||||
|
||||
pub fn connect_close_request<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"close-request",
|
||||
false,
|
||||
glib::closure_local!(move |obj: &Self| f(obj)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn connect_cancel<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"cancel",
|
||||
false,
|
||||
glib::closure_local!(move |obj: &Self| f(obj)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Surface>,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
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<Option<Config>>,
|
||||
|
||||
pub data: RefCell<CommandData>,
|
||||
|
||||
#[property(name = "action-key", get = Self::prop_get_action_key, type = Option<String>)]
|
||||
#[property(name = "action", get = Self::prop_get_action, type = Option<String>)]
|
||||
#[property(name = "title", get = Self::prop_get_title, type = Option<String>)]
|
||||
#[property(name = "description", get = Self::prop_get_description, type = Option<String>)]
|
||||
_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<String> {
|
||||
match &*self.data.borrow() {
|
||||
CommandData::Regular { command } => Some(command.action.to_string()),
|
||||
CommandData::Jump { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn prop_get_action(&self) -> Option<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<command_imp::Command>);
|
||||
}
|
||||
|
||||
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<BindingAction> {
|
||||
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<Surface> {
|
||||
match &*self.imp().data.borrow() {
|
||||
command_imp::CommandData::Regular { .. } => None,
|
||||
command_imp::CommandData::Jump { surface, .. } => surface.upgrade(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_key(&self) -> Option<usize> {
|
||||
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::<Option<String>>("title") else {
|
||||
return false;
|
||||
};
|
||||
let Some(b_title) = b.property::<Option<String>>("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<bool> {
|
||||
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<Option<Config>>,
|
||||
|
||||
/// Self reference held while the palette is alive (until the dialog
|
||||
/// closes), see `new()`.
|
||||
pub self_ref: RefCell<Option<super::CommandPalette>>,
|
||||
|
||||
#[template_child]
|
||||
pub dialog: TemplateChild<adw::Dialog>,
|
||||
#[template_child]
|
||||
pub search: TemplateChild<gtk::SearchEntry>,
|
||||
#[template_child]
|
||||
pub view: TemplateChild<gtk::ListView>,
|
||||
#[template_child]
|
||||
pub model: TemplateChild<gtk::SingleSelection>,
|
||||
#[template_child]
|
||||
pub source: TemplateChild<gio::ListStore>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
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<Vec<Signal>> = 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<Command>) {
|
||||
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<Command>) {
|
||||
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<imp::CommandPalette>)
|
||||
@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<Command> = 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::<gio::ListModel>().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::<Command>().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<F: Fn(&Self, &BindingAction) + 'static>(
|
||||
&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));
|
||||
}
|
||||
}
|
||||
@@ -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<Arc<CoreConfig>>,
|
||||
|
||||
#[property(name = "diagnostics-buffer", get = Self::diagnostics_buffer, type = Option<gtk::TextBuffer>)]
|
||||
#[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<gtk::TextBuffer> {
|
||||
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<imp::Config>);
|
||||
}
|
||||
|
||||
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<CoreConfig>) -> 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<CoreConfig> {
|
||||
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<R>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Config>>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for ConfigErrorsDialog {
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: OnceLock<Vec<Signal>> = 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<imp::ConfigErrorsDialog>)
|
||||
@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<gtk::Widget>>) {
|
||||
self.upcast_ref::<Dialog>().present(parent);
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.upcast_ref::<Dialog>().close();
|
||||
}
|
||||
|
||||
pub fn connect_reload_config<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"reload-config",
|
||||
false,
|
||||
glib::closure_local!(move |obj: &Self| f(obj)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Self>) {
|
||||
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<imp::DebugWarning>)
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -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<imp::Dialog>)
|
||||
@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<T: DialogImpl> IsSubclassable<T> for Dialog {
|
||||
fn class_init(class: &mut glib::Class<Self>) {
|
||||
<adw::AlertDialog as IsSubclassable<T>>::class_init(class.upcast_ref_mut());
|
||||
}
|
||||
|
||||
fn instance_init(instance: &mut glib::subclass::InitializingObject<T>) {
|
||||
<adw::AlertDialog as IsSubclassable<T>>::instance_init(instance);
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
/// Present the dialog over `parent`.
|
||||
pub fn present(&self, parent: Option<&impl IsA<gtk::Widget>>) {
|
||||
adw::prelude::AdwDialogExt::present(self, parent);
|
||||
}
|
||||
|
||||
/// Close the dialog without emitting `close-attempt`.
|
||||
pub fn close(&self) {
|
||||
self.force_close();
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Config>>,
|
||||
|
||||
/// The dbus connection.
|
||||
#[property(get, set = Self::set_dbus_connection, explicit_notify, nullable)]
|
||||
pub dbus_connection: RefCell<Option<gio::DBusConnection>>,
|
||||
|
||||
/// Unique ID (the xdg shortcut string) -> action.
|
||||
pub map: RefCell<Vec<(String, BindingAction)>>,
|
||||
|
||||
/// The current portal session handle (D-Bus object path).
|
||||
pub handle: RefCell<Option<String>>,
|
||||
|
||||
pub response_subscription: RefCell<Option<gio::SignalSubscriptionId>>,
|
||||
pub activate_subscription: RefCell<Option<gio::SignalSubscriptionId>>,
|
||||
|
||||
/// Counter making sure async callbacks of stale sessions are ignored.
|
||||
pub generation: Cell<u64>,
|
||||
}
|
||||
|
||||
#[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<Config>) {
|
||||
*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<gio::DBusConnection>) {
|
||||
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<Vec<Signal>> = 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<imp::GlobalShortcuts>);
|
||||
}
|
||||
|
||||
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<String, glib::Variant> = 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<F: Fn(&Self, &BindingAction) + 'static>(
|
||||
&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<glib::Variant> {
|
||||
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<String, glib::Variant>) {
|
||||
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<String, glib::Variant>)>()
|
||||
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<String> {
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
@@ -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<String>`) for the `tables` and `sequence` properties.
|
||||
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use gtk::glib;
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct KeyStateOverlay(ObjectSubclass<imp::KeyStateOverlay>)
|
||||
@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<Vec<String>>,
|
||||
|
||||
/// The key sequence.
|
||||
#[property(get, set = Self::set_sequence)]
|
||||
pub sequence: RefCell<Vec<String>>,
|
||||
|
||||
#[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<gtk::Align>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) -> Option<String> {
|
||||
if tables.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(tables.join(" > "))
|
||||
}
|
||||
|
||||
#[template_callback(function)]
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn sequence_text(sequence: Vec<String>) -> Option<String> {
|
||||
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::<super::KeyStateOverlay>() 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<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for KeyStateOverlay {
|
||||
fn dispose(&self) {
|
||||
self.dispose_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for KeyStateOverlay {}
|
||||
impl BinImpl for KeyStateOverlay {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<imp::ResizeOverlay>)
|
||||
@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<gtk::Label>,
|
||||
|
||||
/// The time that the overlay appears (ms).
|
||||
#[property(get, set, minimum = 250, default = 750)]
|
||||
pub duration: Cell<u32>,
|
||||
|
||||
/// The first delay before any overlay is shown (ms).
|
||||
#[property(get, set, minimum = 250, default = 250, name = "first-delay")]
|
||||
pub first_delay: Cell<u32>,
|
||||
|
||||
/// The text to set on the label when scheduled.
|
||||
#[property(get, set, name = "label", nullable)]
|
||||
pub label_text: RefCell<Option<String>>,
|
||||
|
||||
/// The alignment of the label
|
||||
#[property(get, set, name = "overlay-halign", builder(gtk::Align::Center))]
|
||||
pub halign: Cell<gtk::Align>,
|
||||
#[property(get, set, name = "overlay-valign", builder(gtk::Align::Center))]
|
||||
pub valign: Cell<gtk::Align>,
|
||||
|
||||
pub idler: RefCell<Option<glib::SourceId>>,
|
||||
pub timer: RefCell<Option<glib::SourceId>>,
|
||||
pub delay_timer: RefCell<Option<glib::SourceId>>,
|
||||
}
|
||||
|
||||
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<Self>) {
|
||||
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 {}
|
||||
}
|
||||
@@ -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<imp::SearchOverlay>)
|
||||
@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<usize>) {
|
||||
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<usize>) {
|
||||
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<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"stop-search",
|
||||
false,
|
||||
glib::closure_local!(move |this: &Self| f(this)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn connect_search_changed<F: Fn(&Self, Option<String>) + 'static>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"search-changed",
|
||||
false,
|
||||
glib::closure_local!(move |this: &Self, text: Option<String>| f(this, text)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn connect_next_match<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"next-match",
|
||||
false,
|
||||
glib::closure_local!(move |this: &Self| f(this)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn connect_previous_match<F: Fn(&Self) + 'static>(&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<gtk::SearchEntry>,
|
||||
|
||||
/// True when a search is active, meaning we should show the overlay.
|
||||
#[property(get, set = Self::set_active)]
|
||||
pub active: Cell<bool>,
|
||||
|
||||
/// Total number of search matches (None means unknown/none).
|
||||
pub search_total: Cell<Option<usize>>,
|
||||
|
||||
/// Currently selected match index (None means none selected).
|
||||
pub search_selected: Cell<Option<usize>>,
|
||||
|
||||
#[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<gtk::Align>,
|
||||
|
||||
/// Target vertical alignment for the overlay.
|
||||
#[property(get, set, name = "valign-target", builder(gtk::Align::Start))]
|
||||
pub valign_target: Cell<gtk::Align>,
|
||||
}
|
||||
|
||||
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<gtk::Widget>) -> Option<super::SearchOverlay> {
|
||||
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::<super::SearchOverlay>() 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<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for SearchOverlay {
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: OnceLock<Vec<Signal>> = 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::<String>::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 {}
|
||||
}
|
||||
@@ -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<Option<Tree>>` 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<Surface>;
|
||||
|
||||
/// 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<T: IsA<gtk::Widget>>(widget: &impl IsA<gtk::Widget>) -> Option<T> {
|
||||
widget
|
||||
.ancestor(T::static_type())
|
||||
.and_then(|w| w.downcast::<T>().ok())
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
/// A tree of surfaces rendered as nested `GtkPaned`s.
|
||||
pub struct SplitTree(ObjectSubclass<imp::SplitTree>)
|
||||
@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<bool, SplitError> {
|
||||
// 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<Surface> {
|
||||
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<Handle> {
|
||||
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::<bool>("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<Surface> {
|
||||
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<Tree> {
|
||||
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::<gtk::Widget>()));
|
||||
}
|
||||
|
||||
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<Surface> = (|| {
|
||||
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::<bool>("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::<SurfaceScrolledWindow>(v) {
|
||||
// The surface isn't in a window already so we don't have to
|
||||
// worry about reuse.
|
||||
None => glib::Object::builder::<SurfaceScrolledWindow>()
|
||||
.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::<gtk::Paned>() {
|
||||
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::<adw::Bin>()
|
||||
&& 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<Option<Tree>>,
|
||||
|
||||
#[template_child]
|
||||
pub tree_bin: TemplateChild<adw::Bin>,
|
||||
|
||||
/// Last focused surface in the tree.
|
||||
pub last_focused: glib::WeakRef<Surface>,
|
||||
|
||||
/// The source used to rebuild the tree (also debounces updates).
|
||||
pub rebuild_source: RefCell<Option<glib::SourceId>>,
|
||||
|
||||
/// State about a pending surface close for the close dialog.
|
||||
pub pending_close: Cell<Option<Handle>>,
|
||||
|
||||
/// Handlers connected on surfaces in the tree.
|
||||
pub handlers: RefCell<Vec<(glib::WeakRef<Surface>, Vec<glib::SignalHandlerId>)>>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
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<Vec<glib::ParamSpec>> = 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::<Surface>("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::<SurfaceTree>("tree").build(),
|
||||
glib::ParamSpecBoolean::builder("is-split")
|
||||
.default_value(false)
|
||||
.read_only()
|
||||
.build(),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: std::sync::OnceLock<Vec<Signal>> = 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::<Option<SurfaceTree>>().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<split_imp::SplitTreeSplit>)
|
||||
@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::<SplitTree>(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<Option<Handle>>,
|
||||
|
||||
/// Source to handle repositioning the split when properties change.
|
||||
pub idle: RefCell<Option<glib::SourceId>>,
|
||||
|
||||
#[template_child]
|
||||
pub paned: TemplateChild<gtk::Paned>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
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 {}
|
||||
}
|
||||
@@ -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<Command>,
|
||||
pub working_directory: Option<String>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
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<HashMap<SurfaceId, glib::WeakRef<Surface>>> =
|
||||
RefCell::new(HashMap::new());
|
||||
static NEXT_ID: Cell<SurfaceId> = 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<imp::Surface>)
|
||||
@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<imp::Surface>)
|
||||
@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<Surface> {
|
||||
SURFACES.with(|m| m.borrow().get(&id).and_then(glib::WeakRef::upgrade))
|
||||
}
|
||||
|
||||
/// All live surfaces.
|
||||
pub fn all() -> Vec<Surface> {
|
||||
SURFACES.with(|m| {
|
||||
m.borrow()
|
||||
.values()
|
||||
.filter_map(glib::WeakRef::upgrade)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The core surface, if initialized (Zig: `core()`).
|
||||
pub fn core(&self) -> Option<Rc<dyn CoreSurface>> {
|
||||
self.imp().core_surface.borrow().clone()
|
||||
}
|
||||
|
||||
/// The apprt surface handle (Zig: `rt()`).
|
||||
pub fn rt(&self) -> Rc<RtSurfaceHandle> {
|
||||
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::<bool>() 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::<gdk::KeyEvent>() 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<char> = 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::<Window>().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::<gio::Application>()
|
||||
.send_notification(Some(body), ¬ification);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Properties
|
||||
|
||||
/// Returns the title property.
|
||||
pub fn get_title(&self) -> Option<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
self.imp().title_override.borrow().clone()
|
||||
}
|
||||
|
||||
/// Returns the pwd property.
|
||||
pub fn get_pwd(&self) -> Option<String> {
|
||||
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<Config> {
|
||||
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<Size> {
|
||||
*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<Size> {
|
||||
*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<DesiredFontSize> {
|
||||
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<usize>) {
|
||||
self.imp().search_overlay.set_search_total(total);
|
||||
}
|
||||
|
||||
pub fn set_search_selected(&self, selected: Option<usize>) {
|
||||
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::<gtk::MediaStream>();
|
||||
media_stream.set_volume(volume);
|
||||
media_stream.play();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Gtk.Scrollable
|
||||
|
||||
pub fn get_hadjustment(&self) -> Option<gtk::Adjustment> {
|
||||
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<gtk::Adjustment> {
|
||||
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<F: Fn(&Self) + 'static>(&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<F: Fn(&Self) + 'static>(&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<F: Fn(&Self, Clipboard, &str) + 'static>(
|
||||
&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<F: Fn(&Self) + 'static>(&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<F: Fn(&Self) + 'static>(&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<F: Fn(&Self) + 'static>(&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<F: Fn(&Self) + 'static>(&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<F: Fn(&Self) + 'static>(&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<F: Fn(&Self) + 'static>(&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::<bool>() 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<String> {
|
||||
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<CoreConfig> = 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(|| "<<unknown>>".to_owned(), |p| p.display().to_string());
|
||||
|
||||
let media_stream = media_file.upcast_ref::<gtk::MediaStream>();
|
||||
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<Surface>,
|
||||
}
|
||||
|
||||
impl RtSurfaceHandle {
|
||||
/// The GObject surface for this apprt surface.
|
||||
pub fn gobj(&self) -> Option<Surface> {
|
||||
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<String> {
|
||||
// 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<String> {
|
||||
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<gdk::ContentProvider> = 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<bool>,
|
||||
|
||||
/// The configuration that this surface is using.
|
||||
#[property(get, set, nullable)]
|
||||
pub config: RefCell<Option<Config>>,
|
||||
|
||||
/// True when the child has exited.
|
||||
#[property(get, set, name = "child-exited")]
|
||||
pub child_exited: Cell<bool>,
|
||||
|
||||
/// The default size for a window that embeds this surface.
|
||||
#[property(get, set, nullable, name = "default-size")]
|
||||
pub default_size: RefCell<Option<Size>>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// 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<Option<FontSizeRequest>>,
|
||||
|
||||
/// The current focus state of the terminal based on the
|
||||
/// focus events.
|
||||
#[property(get, set)]
|
||||
pub focused: Cell<bool>,
|
||||
|
||||
/// The minimum size for this surface. Embedders enforce this,
|
||||
/// not the surface itself.
|
||||
#[property(get, set, nullable, name = "min-size")]
|
||||
pub min_size: RefCell<Option<Size>>,
|
||||
|
||||
/// Whether the mouse should be hidden or not as requested
|
||||
/// externally.
|
||||
#[property(get, set, name = "mouse-hidden")]
|
||||
pub mouse_hidden: Cell<bool>,
|
||||
|
||||
/// 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<MouseShape>,
|
||||
|
||||
/// The URL that the mouse is currently hovering over.
|
||||
#[property(get, set, nullable, name = "mouse-hover-url")]
|
||||
pub mouse_hover_url: RefCell<Option<String>>,
|
||||
|
||||
/// 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<Option<String>>,
|
||||
|
||||
/// The title of this surface, if any has been set.
|
||||
#[property(get, set, nullable)]
|
||||
pub title: RefCell<Option<String>>,
|
||||
|
||||
/// The manually overridden title of this surface from
|
||||
/// `prompt_title`.
|
||||
#[property(get, set, nullable, name = "title-override")]
|
||||
pub title_override: RefCell<Option<String>>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
// Gtk.Scrollable interface adjustments
|
||||
#[property(
|
||||
get,
|
||||
set = Self::set_hadjustment_prop,
|
||||
nullable,
|
||||
override_interface = gtk::Scrollable,
|
||||
name = "hadjustment"
|
||||
)]
|
||||
pub hadj: RefCell<Option<gtk::Adjustment>>,
|
||||
#[property(
|
||||
get,
|
||||
set = Self::set_vadjustment_prop,
|
||||
nullable,
|
||||
override_interface = gtk::Scrollable,
|
||||
name = "vadjustment"
|
||||
)]
|
||||
pub vadj: RefCell<Option<gtk::Adjustment>>,
|
||||
#[property(get, set, override_interface = gtk::Scrollable, name = "hscroll-policy")]
|
||||
pub hscroll_policy: Cell<gtk::ScrollablePolicy>,
|
||||
#[property(get, set, override_interface = gtk::Scrollable, name = "vscroll-policy")]
|
||||
pub vscroll_policy: Cell<gtk::ScrollablePolicy>,
|
||||
|
||||
/// Key sequence state (human readable labels).
|
||||
#[property(get = Self::key_sequence_prop, type = Vec<String>, name = "key-sequence")]
|
||||
pub key_sequence: RefCell<Vec<String>>,
|
||||
|
||||
/// Key table stack.
|
||||
#[property(get = Self::key_table_prop, type = Vec<String>, name = "key-table")]
|
||||
pub key_tables: RefCell<Vec<String>>,
|
||||
|
||||
/// Read-only mode state (from the core surface).
|
||||
#[property(get = Self::readonly_prop, type = bool, name = "readonly")]
|
||||
_readonly: Cell<bool>,
|
||||
|
||||
// ---- non-property state ----
|
||||
pub id: Cell<SurfaceId>,
|
||||
pub rt_surface: RefCell<Option<Rc<RtSurfaceHandle>>>,
|
||||
/// 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<Option<Rc<dyn CoreSurface>>>,
|
||||
|
||||
/// Cached metrics for libghostty callbacks
|
||||
pub size: Cell<SurfaceSize>,
|
||||
pub cursor_pos: Cell<CursorPos>,
|
||||
|
||||
/// Various input method state. All related to key input.
|
||||
pub in_keyevent: Cell<IMKeyEvent>,
|
||||
pub im_composing: Cell<bool>,
|
||||
pub im_buf: RefCell<String>,
|
||||
|
||||
/// True when we have a precision scroll in progress
|
||||
pub precision_scroll: Cell<bool>,
|
||||
|
||||
// Progress bar
|
||||
pub progress_bar_timer: Cell<Option<glib::SourceId>>,
|
||||
|
||||
/// The source that handles setting our child property.
|
||||
pub idle_rechild: Cell<Option<glib::SourceId>>,
|
||||
|
||||
pub action_group: RefCell<Option<gio::SimpleActionGroup>>,
|
||||
pub vadj_handler: RefCell<Option<(gtk::Adjustment, glib::SignalHandlerId)>>,
|
||||
|
||||
/// The context for this surface (window, tab, or split)
|
||||
pub context: Cell<NewSurfaceContext>,
|
||||
|
||||
/// Whether primary paste (middle-click paste) is enabled.
|
||||
pub gtk_enable_primary_paste: Cell<bool>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// How much pending horizontal scroll do we have?
|
||||
pub pending_horizontal_scroll: Cell<f64>,
|
||||
|
||||
/// Timer to reset the amount of horizontal scroll if the user
|
||||
/// stops scrolling.
|
||||
pub pending_horizontal_scroll_reset: Cell<Option<glib::SourceId>>,
|
||||
|
||||
pub overrides: RefCell<SurfaceOverrides>,
|
||||
|
||||
// Template children
|
||||
#[template_child]
|
||||
pub gl_area: TemplateChild<gtk::GLArea>,
|
||||
#[template_child]
|
||||
pub url_left: TemplateChild<gtk::Label>,
|
||||
#[template_child]
|
||||
pub url_right: TemplateChild<gtk::Label>,
|
||||
#[template_child]
|
||||
pub resize_overlay: TemplateChild<ResizeOverlay>,
|
||||
#[template_child]
|
||||
pub search_overlay: TemplateChild<SearchOverlay>,
|
||||
#[template_child]
|
||||
pub key_state_overlay: TemplateChild<KeyStateOverlay>,
|
||||
#[template_child]
|
||||
pub child_exited_overlay: TemplateChild<SurfaceChildExited>,
|
||||
#[template_child]
|
||||
pub context_menu: TemplateChild<gtk::PopoverMenu>,
|
||||
#[template_child]
|
||||
pub drop_target: TemplateChild<gtk::DropTarget>,
|
||||
#[template_child]
|
||||
pub progress_bar_overlay: TemplateChild<gtk::ProgressBar>,
|
||||
#[template_child]
|
||||
pub error_page: TemplateChild<adw::StatusPage>,
|
||||
#[template_child]
|
||||
pub terminal_page: TemplateChild<gtk::Overlay>,
|
||||
#[template_child]
|
||||
pub im_context: TemplateChild<gtk::IMMulticontext>,
|
||||
}
|
||||
|
||||
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<Self>) {
|
||||
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<String> {
|
||||
self.key_sequence.borrow().clone()
|
||||
}
|
||||
|
||||
fn key_table_prop(&self) -> Vec<String> {
|
||||
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<gtk::Adjustment>) {
|
||||
// Notification is emitted by the property machinery.
|
||||
self.hadj.replace(adj);
|
||||
}
|
||||
|
||||
fn set_vadjustment_prop(&self, adj: Option<gtk::Adjustment>) {
|
||||
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<Vec<Signal>> = 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::<Application>().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<Config>, 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<String>, 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::<gdk::FileList>() {
|
||||
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::<gio::File>() {
|
||||
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::<Option<String>>() {
|
||||
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::<gtk::Popover>();
|
||||
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<space>" ("안녕"). 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");
|
||||
}
|
||||
}
|
||||
@@ -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<imp::SurfaceChildExited>)
|
||||
@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<F: Fn(&Self) + 'static>(&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<Option<ChildExitedBoxed>>,
|
||||
|
||||
#[template_child]
|
||||
pub banner: TemplateChild<adw::Banner>,
|
||||
}
|
||||
|
||||
impl SurfaceChildExited {
|
||||
fn set_data(&self, v: Option<ChildExitedBoxed>) {
|
||||
// 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::<super::SurfaceChildExited>()
|
||||
{
|
||||
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<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for SurfaceChildExited {
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: OnceLock<Vec<Signal>> = 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 {}
|
||||
}
|
||||
@@ -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<imp::SurfaceScrolledWindow>)
|
||||
@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<gtk::Widget>>) -> 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<gtk::Widget> {
|
||||
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<Option<glib::Object>>,
|
||||
|
||||
pub config_binding: RefCell<Option<glib::Binding>>,
|
||||
|
||||
#[property(get, set = Self::set_surface, nullable)]
|
||||
pub surface: RefCell<Option<gtk::Widget>>,
|
||||
}
|
||||
|
||||
impl SurfaceScrolledWindow {
|
||||
fn set_surface(&self, v: Option<gtk::Widget>) {
|
||||
if *self.surface.borrow() == v {
|
||||
return;
|
||||
}
|
||||
self.surface.replace(v);
|
||||
}
|
||||
}
|
||||
|
||||
#[gtk::template_callbacks]
|
||||
impl SurfaceScrolledWindow {
|
||||
#[template_callback(function)]
|
||||
fn scrollbar_policy(config: Option<glib::Object>) -> gtk::PolicyType {
|
||||
use crate::class::config::Config;
|
||||
use ghostty_config::types_b::Scrollbar;
|
||||
|
||||
let Some(config) = config.and_then(|c| c.downcast::<Config>().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::<gtk::ScrolledWindow>() 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<Self>) {
|
||||
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 {}
|
||||
}
|
||||
@@ -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<imp::Tab>)
|
||||
@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<String> {
|
||||
self.imp().title_override.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn get_title(&self) -> Option<String> {
|
||||
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<Surface> {
|
||||
self.get_split_tree().get_active_surface()
|
||||
}
|
||||
|
||||
/// Get the surface tree of this tab.
|
||||
pub fn get_surface_tree(&self) -> Option<super::split_tree::Tree> {
|
||||
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<Config> {
|
||||
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<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_local("close-request", false, move |args| {
|
||||
if let Some(Ok(this)) = args.first().map(glib::Value::get::<Tab>) {
|
||||
f(&this);
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the tab view holding this tab, if any.
|
||||
fn get_tab_view(&self) -> Option<adw::TabView> {
|
||||
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<adw::TabPage> {
|
||||
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<Option<Config>>,
|
||||
|
||||
/// The title of this tab. This is usually bound to the active surface.
|
||||
pub title: RefCell<Option<String>>,
|
||||
|
||||
/// The manually overridden title from `prompt_tab_title`.
|
||||
pub title_override: RefCell<Option<String>>,
|
||||
|
||||
/// The tooltip of this tab. This is usually bound to the active surface.
|
||||
pub tooltip: RefCell<Option<String>>,
|
||||
|
||||
#[template_child]
|
||||
pub split_tree: TemplateChild<SplitTree>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
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::<super::Tab>().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<Config>,
|
||||
terminal: Option<String>,
|
||||
surface_override: Option<String>,
|
||||
tab_override: Option<String>,
|
||||
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<Vec<glib::ParamSpec>> = std::sync::OnceLock::new();
|
||||
PROPS.get_or_init(|| {
|
||||
vec![
|
||||
glib::ParamSpecObject::builder::<Surface>("active-surface")
|
||||
.read_only()
|
||||
.build(),
|
||||
glib::ParamSpecObject::builder::<Config>("config").build(),
|
||||
glib::ParamSpecObject::builder::<SplitTree>("split-tree")
|
||||
.read_only()
|
||||
.build(),
|
||||
glib::ParamSpecBoxed::builder::<SurfaceTree>("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<Vec<Signal>> = 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 {}
|
||||
}
|
||||
@@ -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<Target>,
|
||||
|
||||
/// The initial value of the entry field.
|
||||
#[property(get, set, nullable, name = "initial-value")]
|
||||
pub initial_value: RefCell<Option<String>>,
|
||||
|
||||
#[template_child]
|
||||
pub entry: TemplateChild<gtk::Entry>,
|
||||
}
|
||||
|
||||
#[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<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for TitleDialog {
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: OnceLock<Vec<Signal>> = 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<imp::TitleDialog>)
|
||||
@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<gtk::Widget>) {
|
||||
let parent_w = parent.upcast_ref::<gtk::Widget>();
|
||||
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<F: Fn(&Self, &str) + 'static>(&self, f: F) -> glib::SignalHandlerId {
|
||||
self.connect_closure(
|
||||
"set",
|
||||
false,
|
||||
glib::closure_local!(move |obj: &Self, title: &str| f(obj, title)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ConfigCommand>,
|
||||
pub working_directory: Option<String>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<GtkTitlebarStyle> 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<Window>);
|
||||
|
||||
impl WindowHost for Host {
|
||||
fn gtk_window(&self) -> Option<gtk::Window> {
|
||||
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<std::sync::Arc<ghostty_config::config::Config>> {
|
||||
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<Surface>)]
|
||||
#[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<Option<Config>>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// 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<Option<WindowDecoration>>,
|
||||
|
||||
/// Binding for our active tab (Zig: `tab_bindings` BindingGroup).
|
||||
pub tab_binding: RefCell<Option<glib::Binding>>,
|
||||
|
||||
/// State and logic for windowing protocol for a window.
|
||||
pub winproto: RefCell<winproto::Window>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// See tab_overview_open for why we have this.
|
||||
pub tab_overview_focus_timer: RefCell<Option<glib::SourceId>>,
|
||||
|
||||
/// A weak reference to a command palette.
|
||||
pub command_palette: glib::WeakRef<CommandPalette>,
|
||||
|
||||
/// Tab page that the context menu was opened for (set by
|
||||
/// `setup-menu`).
|
||||
pub context_menu_page: RefCell<Option<adw::TabPage>>,
|
||||
|
||||
/// Signal handlers connected to surfaces (so we can disconnect them
|
||||
/// by "data == self" like the Zig code).
|
||||
pub surface_handlers: RefCell<Vec<(glib::WeakRef<Surface>, Vec<glib::SignalHandlerId>)>>,
|
||||
|
||||
/// Signal handlers connected to tabs (close-request).
|
||||
pub tab_handlers: RefCell<Vec<(glib::WeakRef<Tab>, glib::SignalHandlerId)>>,
|
||||
|
||||
/// Handlers on split trees (changed), keyed by tab.
|
||||
pub tree_handlers: RefCell<Vec<(glib::WeakRef<SplitTree>, glib::SignalHandlerId)>>,
|
||||
|
||||
#[template_child]
|
||||
pub tab_overview: TemplateChild<adw::TabOverview>,
|
||||
#[template_child]
|
||||
pub tab_bar: TemplateChild<adw::TabBar>,
|
||||
#[template_child]
|
||||
pub tab_view: TemplateChild<adw::TabView>,
|
||||
#[template_child]
|
||||
pub toolbar: TemplateChild<adw::ToolbarView>,
|
||||
#[template_child]
|
||||
pub toast_overlay: TemplateChild<adw::ToastOverlay>,
|
||||
}
|
||||
|
||||
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<Self>) {
|
||||
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<Surface> {
|
||||
self.obj().get_active_surface()
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_self)]
|
||||
fn get_debug(&self) -> bool {
|
||||
cfg!(debug_assertions)
|
||||
}
|
||||
|
||||
fn cfg(&self) -> Option<std::sync::Arc<ghostty_config::config::Config>> {
|
||||
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<Config>, pwd: Option<String>) -> Option<String> {
|
||||
let config = config?.get();
|
||||
match config.window_subtitle {
|
||||
super::WindowSubtitle::False => None,
|
||||
super::WindowSubtitle::WorkingDirectory => pwd,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct Window(ObjectSubclass<imp::Window>)
|
||||
@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<Surface> {
|
||||
self.get_selected_tab()?.get_active_surface()
|
||||
}
|
||||
|
||||
/// Returns the configuration for this window.
|
||||
pub fn get_config(&self) -> Option<Config> {
|
||||
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<WindowDecoration>) {
|
||||
self.imp().window_decoration.set(new);
|
||||
self.sync_appearance();
|
||||
}
|
||||
|
||||
fn get_selected_tab(&self) -> Option<Tab> {
|
||||
let page = self.imp().tab_view.selected_page()?;
|
||||
let child = page.child();
|
||||
debug_assert!(child.is::<Tab>());
|
||||
child.downcast::<Tab>().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::<Tab>() 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<SurfaceTree>, new: Option<SurfaceTree>| {
|
||||
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::<Tab>(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::<gio::SimpleAction>().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<dyn WindowHost> = 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::<Tab>() 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::<Tab>());
|
||||
|
||||
// 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::<Tab>() 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::<Tab>() 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::<Window>(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::<Tab>(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::<Tab>() 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<T: IsA<gtk::Widget>>(widget: &impl IsA<gtk::Widget>) -> Option<T> {
|
||||
widget
|
||||
.ancestor(T::static_type())
|
||||
.and_then(|w| w.downcast::<T>().ok())
|
||||
}
|
||||
|
||||
fn finish_close_page(page: &adw::TabPage, confirm: bool) {
|
||||
let Some(tab_view) = get_ancestor::<adw::TabView>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
/// 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<Config>) -> 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<dyn RtApp>,
|
||||
rt_surface: Rc<dyn RtSurface>,
|
||||
) -> Result<Rc<dyn CoreSurface>, 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<SurfaceId>;
|
||||
|
||||
/// 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<CellSize>;
|
||||
}
|
||||
|
||||
/// 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<Config>;
|
||||
|
||||
// ---- input -------------------------------------------------------
|
||||
fn key_callback(&self, event: KeyEvent<'_>) -> Result<InputEffect, CoreError>;
|
||||
/// `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<bool, CoreError>;
|
||||
/// `pos` is in surface (logical*scale) pixels; `(-1,-1)` = left window.
|
||||
fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> 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<Config>) -> 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<bool, CoreError>;
|
||||
|
||||
// ---- 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<SurfaceId> {
|
||||
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<SurfaceId>, 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<String>;
|
||||
fn get_title(&self) -> Option<String>;
|
||||
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);
|
||||
}
|
||||
@@ -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<gtk::CssProvider>,
|
||||
/// Last applied color scheme (dark?), reapplied to new providers.
|
||||
dark: Option<bool>,
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<T> = fn(&gio::SimpleAction, Option<&glib::Variant>, &T);
|
||||
|
||||
/// An action definition for objects of type `T`.
|
||||
pub struct Action<T> {
|
||||
pub name: &'static str,
|
||||
pub callback: Callback<T>,
|
||||
pub parameter_type: Option<glib::VariantType>,
|
||||
pub state: Option<glib::Variant>,
|
||||
}
|
||||
|
||||
impl<T> Action<T> {
|
||||
pub fn new(
|
||||
name: &'static str,
|
||||
callback: Callback<T>,
|
||||
parameter_type: Option<glib::VariantType>,
|
||||
) -> Self {
|
||||
assert!(g_action_name_is_valid(name));
|
||||
Self {
|
||||
name,
|
||||
callback,
|
||||
parameter_type,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stateful(
|
||||
name: &'static str,
|
||||
callback: Callback<T>,
|
||||
parameter_type: Option<glib::VariantType>,
|
||||
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<T: IsA<gio::ActionMap>>(obj: &T, actions: &[Action<T>]) {
|
||||
add_to_map(obj, obj.upcast_ref::<gio::ActionMap>(), actions);
|
||||
}
|
||||
|
||||
/// Add the actions to `map`; callbacks receive `obj` (held weakly, so no
|
||||
/// reference cycle is created).
|
||||
pub fn add_to_map<T: ObjectType>(obj: &T, map: &impl IsA<gio::ActionMap>, actions: &[Action<T>]) {
|
||||
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<T: IsA<gtk::Widget>>(
|
||||
obj: &T,
|
||||
name: &str,
|
||||
actions: &[Action<T>],
|
||||
) -> 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::<i32>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<T: IsA<gtk::Widget>>(widget: &impl IsA<gtk::Widget>) -> Option<T> {
|
||||
widget.ancestor(T::static_type()).and_downcast::<T>()
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl StringList {
|
||||
pub fn create<S: AsRef<str>>(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::<StringList>().expect("boxed value");
|
||||
assert_eq!(copied.strings, ["foo", "bar", "baz"]);
|
||||
assert_ne!(original.strings.as_ptr(), copied.strings.as_ptr());
|
||||
}
|
||||
}
|
||||
@@ -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<ResourcesDir> {
|
||||
// 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()
|
||||
}
|
||||
@@ -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<Option<libloading::Library>> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
gtk::Settings::default()
|
||||
}
|
||||
|
||||
/// Get a boolean setting (`gtk-enable-primary-paste`).
|
||||
pub fn get_bool(key: Key) -> Option<bool> {
|
||||
debug_assert_eq!(key, Key::GtkEnablePrimaryPaste);
|
||||
let s = settings()?;
|
||||
Some(s.property::<bool>(key.name()))
|
||||
}
|
||||
|
||||
/// Get an integer setting (`gtk-xft-dpi`).
|
||||
pub fn get_int(key: Key) -> Option<i32> {
|
||||
debug_assert_eq!(key, Key::GtkXftDpi);
|
||||
let s = settings()?;
|
||||
Some(s.property::<i32>(key.name()))
|
||||
}
|
||||
|
||||
/// Get a string setting (`gtk-font-name`). None if unset.
|
||||
pub fn get_string(key: Key) -> Option<String> {
|
||||
debug_assert_eq!(key, Key::GtkFontName);
|
||||
let s = settings()?;
|
||||
s.property::<Option<glib::GString>>(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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<glib::Variant>,
|
||||
}
|
||||
|
||||
/// 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<Self, Error> {
|
||||
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<String, glib::Variant> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<bool, Error> {
|
||||
match action {
|
||||
Action::NewWindow(v) => new_window::new_window(target, v),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//! <https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI>.
|
||||
//!
|
||||
//! `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<bool, Error> {
|
||||
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)
|
||||
}
|
||||
@@ -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<String> {
|
||||
#[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 { <gdk::Key as gtk::glib::translate::FromGlib<u32>>::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<String> {
|
||||
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("<Super>");
|
||||
}
|
||||
write_trigger_key(&mut s, trigger).then_some(s)
|
||||
}
|
||||
|
||||
/// Returns a XDG-compliant shortcuts string from a trigger.
|
||||
/// Spec: <https://specifications.freedesktop.org/shortcuts-spec/latest/>
|
||||
pub fn xdg_shortcut_from_trigger(trigger: Trigger) -> Option<String> {
|
||||
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<gtk::Widget>,
|
||||
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<K> {
|
||||
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<u32> {
|
||||
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<String> {
|
||||
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("<Super>q")
|
||||
);
|
||||
assert_eq!(
|
||||
accel_from_trigger(uni(all_mods(), 92)).as_deref(),
|
||||
Some("<Shift><Ctrl><Alt><Super>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_);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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<u64>,
|
||||
pub linux_cgroup_processes_limit: Option<u64>,
|
||||
}
|
||||
|
||||
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<u32>) -> 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(())
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Weak object references (port of `apprt/gtk/weak_ref.zig`). glib-rs
|
||||
//! already provides `glib::WeakRef<T>`; 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<T: ObjectType>(glib::WeakRef<T>);
|
||||
|
||||
impl<T: ObjectType> WeakRef<T> {
|
||||
/// 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<T> {
|
||||
self.0.upgrade()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn weak_ref() {
|
||||
let r: WeakRef<glib::Object> = WeakRef::empty();
|
||||
let obj = glib::Object::new::<glib::Object>();
|
||||
r.set(Some(&obj));
|
||||
assert!(r.get().is_some());
|
||||
drop(obj);
|
||||
assert!(r.get().is_none());
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// 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<gtk::Window>;
|
||||
/// `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<Arc<Config>>;
|
||||
}
|
||||
|
||||
/// 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<App> {
|
||||
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<dyn WindowHost>) -> Result<Window> {
|
||||
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<gdk::Surface> {
|
||||
w.upcast_ref::<gtk::Native>().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());
|
||||
}
|
||||
}
|
||||
@@ -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<Mods> {
|
||||
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<dyn WindowHost>) -> Result<Window> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<OrgKdeKwinBlurManager>,
|
||||
// FIXME (upstream): replace with `zxdg_decoration_v1` once GTK merges
|
||||
// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
|
||||
kde_decoration_manager: Option<OrgKdeKwinServerDecorationManager>,
|
||||
kde_slide_manager: Option<OrgKdeKwinSlideManager>,
|
||||
default_deco_mode: Option<u32>,
|
||||
xdg_activation: Option<XdgActivationV1>,
|
||||
/// 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<State>,
|
||||
queue: RefCell<EventQueue<State>>,
|
||||
state: RefCell<State>,
|
||||
}
|
||||
|
||||
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<I: Proxy + 'static>(
|
||||
globals: &wayland_client::globals::GlobalList,
|
||||
qh: &QueueHandle<State>,
|
||||
) -> Option<I>
|
||||
where
|
||||
State: Dispatch<I, ()>,
|
||||
{
|
||||
match globals.bind::<I, _, _>(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<Shared>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Returns `Ok(None)` if the display isn't Wayland.
|
||||
pub fn init(
|
||||
gdk_display: &gdk::Display,
|
||||
_app_id: &str,
|
||||
_config: &Config,
|
||||
) -> Result<Option<App>> {
|
||||
let Some(wl_display) = gdk_display.downcast_ref::<WaylandDisplay>() 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::<State>(&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<Mods> {
|
||||
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<Option<XdgActivationTokenV1>>,
|
||||
done: AtomicBool,
|
||||
}
|
||||
|
||||
/// Per-window (wl_surface) state for the Wayland protocol.
|
||||
pub struct Window {
|
||||
host: Rc<dyn WindowHost>,
|
||||
shared: Rc<Shared>,
|
||||
surface: WlSurface,
|
||||
/// Present when the window is blurred.
|
||||
blur_token: Option<OrgKdeKwinBlur>,
|
||||
/// Controls the decoration mode (client/server/auto) of the window.
|
||||
decoration: Option<OrgKdeKwinServerDecoration>,
|
||||
/// Slide-in/out animation of the quick terminal (otherwise None).
|
||||
slide: Option<OrgKdeKwinSlide>,
|
||||
/// Present while the window is requesting attention.
|
||||
activation: Option<Arc<ActivationData>>,
|
||||
monitor_handler: Option<(gdk::Surface, glib::SignalHandlerId)>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
pub fn init(app: &App, host: Rc<dyn WindowHost>) -> Result<Window> {
|
||||
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::<gdk4_wayland::WaylandSurface>()
|
||||
.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<WlRegistry, GlobalListContents> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &WlRegistry,
|
||||
_: wayland_client::protocol::wl_registry::Event,
|
||||
_: &GlobalListContents,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// 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<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
no_events!(
|
||||
OrgKdeKwinBlurManager,
|
||||
OrgKdeKwinBlur,
|
||||
OrgKdeKwinSlideManager,
|
||||
OrgKdeKwinSlide,
|
||||
XdgActivationV1,
|
||||
OrgKdeKwinServerDecoration
|
||||
);
|
||||
|
||||
impl Dispatch<OrgKdeKwinServerDecorationManager, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &OrgKdeKwinServerDecorationManager,
|
||||
event: org_kde_kwin_server_decoration_manager::Event,
|
||||
(): &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let org_kde_kwin_server_decoration_manager::Event::DefaultMode { mode } = event {
|
||||
state.default_deco_mode = Some(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<XdgActivationTokenV1, Arc<ActivationData>> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
token: &XdgActivationTokenV1,
|
||||
event: xdg_activation_token_v1::Event,
|
||||
data: &Arc<ActivationData>,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<xlib::Xlib>,
|
||||
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<Option<App>> {
|
||||
let Some(x11_display) = gdk_display.downcast_ref::<X11Display>() 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<Mods> {
|
||||
// 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::<XkbStateNotifyEvent>();
|
||||
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<xlib::Xlib>,
|
||||
display: *mut xlib::Display,
|
||||
atoms: Atoms,
|
||||
host: Rc<dyn WindowHost>,
|
||||
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<Region>,
|
||||
last_applied_decoration_hints: Option<MotifWmHints>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
pub fn init(app: &App, host: Rc<dyn WindowHost>) -> Result<Window> {
|
||||
let win = host.gtk_window().ok_or(Error::NotX11Surface)?;
|
||||
let surface = super::native_surface(&win).ok_or(Error::NotX11Surface)?;
|
||||
let x11_surface = surface
|
||||
.downcast::<X11Surface>()
|
||||
.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::<gtk::Native>().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::<MotifWmHints>(
|
||||
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<T: Copy>(&self, name: xlib::Atom, typ: xlib::Atom) -> Result<T> {
|
||||
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::<c_long>() < std::mem::size_of::<T>()
|
||||
|| prop.is_null()
|
||||
{
|
||||
Err(Error::RequestFailed)
|
||||
} else {
|
||||
// SAFETY: Xlib returns 32-format data as an array of `long`
|
||||
// (suitably aligned), at least size_of::<T>() bytes of which we
|
||||
// just verified; T is a repr(C) aggregate of longs.
|
||||
Ok(unsafe { prop.cast::<T>().read() })
|
||||
};
|
||||
if !prop.is_null() {
|
||||
// SAFETY: allocated by Xlib.
|
||||
unsafe { (self.xl.XFree)(prop.cast()) };
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn change_property<T: Copy>(&self, name: xlib::Atom, typ: xlib::Atom, value: &T) -> Result<()> {
|
||||
let nelements = std::mem::size_of::<T>() / std::mem::size_of::<c_long>();
|
||||
// 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::<c_uchar>(),
|
||||
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::<c_long>();
|
||||
assert_eq!(std::mem::size_of::<Region>(), 4 * l);
|
||||
assert_eq!(std::mem::size_of::<MotifWmHints>(), 5 * l);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn CoreSurface>, 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<SurfaceId>, 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.
|
||||
|
||||