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
This commit is contained in:
1 parent
72434a1255
commit
88cc13871c
80 files changed
+17271
-4
No files matched your search
@@ -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()]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user