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
455 lines
16 KiB
Rust
455 lines
16 KiB
Rust
//! `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()]);
|
|
}
|
|
}
|