mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-01-03 20:12:42 +00:00
gtk: implement global shortcuts
It's been a lot of D-Bus related pain and suffering, but here it is. I'm not sure about how well this is integrated inside App, but I'm fairly proud of the standalone logic.
This commit is contained in:
@@ -40,6 +40,7 @@ const Window = @import("Window.zig");
|
||||
const ConfigErrorsDialog = @import("ConfigErrorsDialog.zig");
|
||||
const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
|
||||
const CloseDialog = @import("CloseDialog.zig");
|
||||
const GlobalShortcuts = @import("GlobalShortcuts.zig");
|
||||
const Split = @import("Split.zig");
|
||||
const inspector = @import("inspector.zig");
|
||||
const key = @import("key.zig");
|
||||
@@ -95,6 +96,8 @@ css_provider: *gtk.CssProvider,
|
||||
/// Providers for loading custom stylesheets defined by user
|
||||
custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .{},
|
||||
|
||||
global_shortcuts: ?GlobalShortcuts,
|
||||
|
||||
/// The timer used to quit the application after the last window is closed.
|
||||
quit_timer: union(enum) {
|
||||
off: void,
|
||||
@@ -422,6 +425,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
// our "activate" call above will open a window.
|
||||
.running = gio_app.getIsRemote() == 0,
|
||||
.css_provider = css_provider,
|
||||
.global_shortcuts = .init(core_app.alloc, gio_app),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -443,6 +447,8 @@ pub fn terminate(self: *App) void {
|
||||
|
||||
self.winproto.deinit(self.core_app.alloc);
|
||||
|
||||
if (self.global_shortcuts) |*shortcuts| shortcuts.deinit();
|
||||
|
||||
self.config.deinit();
|
||||
}
|
||||
|
||||
@@ -1012,6 +1018,12 @@ fn syncConfigChanges(self: *App, window: ?*Window) !void {
|
||||
ConfigErrorsDialog.maybePresent(self, window);
|
||||
try self.syncActionAccelerators();
|
||||
|
||||
if (self.global_shortcuts) |*shortcuts| {
|
||||
shortcuts.refreshSession(self) catch |err| {
|
||||
log.warn("failed to refresh global shortcuts={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
// Load our runtime and custom CSS. If this fails then our window is just stuck
|
||||
// with the old CSS but we don't want to fail the entire sync operation.
|
||||
self.loadRuntimeCss() catch |err| switch (err) {
|
||||
|
||||
419
src/apprt/gtk/GlobalShortcuts.zig
Normal file
419
src/apprt/gtk/GlobalShortcuts.zig
Normal file
@@ -0,0 +1,419 @@
|
||||
const GlobalShortcuts = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const gio = @import("gio");
|
||||
const glib = @import("glib");
|
||||
const gobject = @import("gobject");
|
||||
|
||||
const App = @import("App.zig");
|
||||
const configpkg = @import("../../config.zig");
|
||||
const Binding = @import("../../input.zig").Binding;
|
||||
const key = @import("key.zig");
|
||||
|
||||
const log = std.log.scoped(.global_shortcuts);
|
||||
const Token = [16]u8;
|
||||
|
||||
app: *App,
|
||||
arena: std.heap.ArenaAllocator,
|
||||
dbus: *gio.DBusConnection,
|
||||
|
||||
/// A mapping from a unique ID to an action.
|
||||
/// Currently the unique ID is simply the serialized representation of the
|
||||
/// trigger that was used for the action as triggers are unique in the keymap,
|
||||
/// but this may change in the future.
|
||||
map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{},
|
||||
|
||||
/// The handle of the current global shortcuts portal session,
|
||||
/// as a D-Bus object path.
|
||||
handle: ?[:0]const u8 = null,
|
||||
|
||||
/// The D-Bus signal subscription for the response signal on requests.
|
||||
/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
|
||||
response_subscription: c_uint = 0,
|
||||
|
||||
/// The D-Bus signal subscription for the keybind activate signal.
|
||||
/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
|
||||
activate_subscription: c_uint = 0,
|
||||
|
||||
pub fn init(alloc: Allocator, gio_app: *gio.Application) ?GlobalShortcuts {
|
||||
const dbus = gio_app.getDbusConnection() orelse return null;
|
||||
|
||||
return .{
|
||||
// To be initialized later
|
||||
.app = undefined,
|
||||
.arena = .init(alloc),
|
||||
.dbus = dbus,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *GlobalShortcuts) void {
|
||||
self.close();
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
fn close(self: *GlobalShortcuts) void {
|
||||
if (self.response_subscription != 0) {
|
||||
self.dbus.signalUnsubscribe(self.response_subscription);
|
||||
self.response_subscription = 0;
|
||||
}
|
||||
|
||||
if (self.activate_subscription != 0) {
|
||||
self.dbus.signalUnsubscribe(self.activate_subscription);
|
||||
self.activate_subscription = 0;
|
||||
}
|
||||
|
||||
if (self.handle) |handle| {
|
||||
// Close existing session
|
||||
self.dbus.call(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
handle,
|
||||
"org.freedesktop.portal.Session",
|
||||
"Close",
|
||||
null,
|
||||
null,
|
||||
.{},
|
||||
-1,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
self.handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void {
|
||||
// Ensure we have a valid reference to the app
|
||||
// (it was left uninitialized in `init`)
|
||||
self.app = app;
|
||||
|
||||
// Close any existing sessions
|
||||
self.close();
|
||||
|
||||
// Update map
|
||||
var trigger_buf: [256]u8 = undefined;
|
||||
|
||||
self.map.clearRetainingCapacity();
|
||||
var it = self.app.config.keybind.set.bindings.iterator();
|
||||
|
||||
while (it.next()) |entry| {
|
||||
const leaf = switch (entry.value_ptr.*) {
|
||||
// Global shortcuts can't have leaders
|
||||
.leader => continue,
|
||||
.leaf => |leaf| leaf,
|
||||
};
|
||||
if (!leaf.flags.global) continue;
|
||||
|
||||
const trigger = try key.xdgShortcutFromTrigger(
|
||||
&trigger_buf,
|
||||
entry.key_ptr.*,
|
||||
) orelse continue;
|
||||
|
||||
try self.map.put(
|
||||
self.arena.allocator(),
|
||||
try self.arena.allocator().dupeZ(u8, trigger),
|
||||
leaf.action,
|
||||
);
|
||||
}
|
||||
|
||||
try self.request(.create_session);
|
||||
}
|
||||
|
||||
fn shortcutActivated(
|
||||
_: *gio.DBusConnection,
|
||||
_: ?[*:0]const u8,
|
||||
_: [*:0]const u8,
|
||||
_: [*:0]const u8,
|
||||
_: [*:0]const u8,
|
||||
params: *glib.Variant,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.c) void {
|
||||
const self: *GlobalShortcuts = @ptrCast(@alignCast(ud));
|
||||
|
||||
// 2nd value in the tuple is the activated shortcut ID
|
||||
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated
|
||||
var shortcut_id: [*:0]const u8 = undefined;
|
||||
params.getChild(1, "&s", &shortcut_id);
|
||||
log.debug("activated={s}", .{shortcut_id});
|
||||
|
||||
const action = self.map.get(std.mem.span(shortcut_id)) orelse return;
|
||||
|
||||
self.app.core_app.performAllAction(self.app, action) catch |err| {
|
||||
log.err("failed to perform action={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
const Method = enum {
|
||||
create_session,
|
||||
bind_shortcuts,
|
||||
|
||||
fn name(self: Method) [:0]const u8 {
|
||||
return switch (self) {
|
||||
.create_session => "CreateSession",
|
||||
.bind_shortcuts => "BindShortcuts",
|
||||
};
|
||||
}
|
||||
|
||||
/// Construct the payload expected by the XDG portal call.
|
||||
fn makePayload(
|
||||
self: Method,
|
||||
shortcuts: *GlobalShortcuts,
|
||||
request_token: [:0]const u8,
|
||||
) ?*glib.Variant {
|
||||
switch (self) {
|
||||
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession
|
||||
.create_session => {
|
||||
var session_token: Token = undefined;
|
||||
return glib.Variant.newParsed(
|
||||
"({'handle_token': <%s>, 'session_handle_token': <%s>},)",
|
||||
request_token.ptr,
|
||||
generateToken(&session_token).ptr,
|
||||
);
|
||||
},
|
||||
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts
|
||||
.bind_shortcuts => {
|
||||
const handle = shortcuts.handle orelse return null;
|
||||
|
||||
const bind_type = glib.VariantType.new("a(sa{sv})");
|
||||
defer glib.free(bind_type);
|
||||
|
||||
var binds: glib.VariantBuilder = undefined;
|
||||
glib.VariantBuilder.init(&binds, bind_type);
|
||||
|
||||
var action_buf: [256]u8 = undefined;
|
||||
|
||||
var it = shortcuts.map.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const trigger = entry.key_ptr.*.ptr;
|
||||
const action = std.fmt.bufPrintZ(
|
||||
&action_buf,
|
||||
"{}",
|
||||
.{entry.value_ptr.*},
|
||||
) catch continue;
|
||||
|
||||
binds.addParsed(
|
||||
"(%s, {'description': <%s>, 'preferred_trigger': <%s>})",
|
||||
trigger,
|
||||
action.ptr,
|
||||
trigger,
|
||||
);
|
||||
}
|
||||
|
||||
return glib.Variant.newParsed(
|
||||
"(%o, %*, '', {'handle_token': <%s>})",
|
||||
handle.ptr,
|
||||
binds.end(),
|
||||
request_token.ptr,
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void {
|
||||
switch (self) {
|
||||
.create_session => {
|
||||
var handle: ?[*:0]u8 = null;
|
||||
if (vardict.lookup("session_handle", "&s", &handle) == 0) {
|
||||
log.err(
|
||||
"session handle not found in response={s}",
|
||||
.{vardict.print(@intFromBool(true))},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
shortcuts.handle = shortcuts.arena.allocator().dupeZ(u8, std.mem.span(handle.?)) catch {
|
||||
log.err("out of memory: failed to clone session handle", .{});
|
||||
return;
|
||||
};
|
||||
|
||||
log.debug("session_handle={?s}", .{handle});
|
||||
|
||||
// Subscribe to keybind activations
|
||||
shortcuts.activate_subscription = shortcuts.dbus.signalSubscribe(
|
||||
null,
|
||||
"org.freedesktop.portal.GlobalShortcuts",
|
||||
"Activated",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
handle,
|
||||
.{ .match_arg0_path = true },
|
||||
shortcutActivated,
|
||||
shortcuts,
|
||||
null,
|
||||
);
|
||||
|
||||
shortcuts.request(.bind_shortcuts) catch |err| {
|
||||
log.err("failed to bind shortcuts={}", .{err});
|
||||
return;
|
||||
};
|
||||
},
|
||||
.bind_shortcuts => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Submit a request to the global shortcuts portal.
|
||||
fn request(
|
||||
self: *GlobalShortcuts,
|
||||
comptime method: Method,
|
||||
) !void {
|
||||
// NOTE(pluiedev):
|
||||
// XDG Portals are really, really poorly-designed pieces of hot garbage.
|
||||
// How the protocol is _initially_ designed to work is as follows:
|
||||
//
|
||||
// 1. The client calls a method which returns the path of a Request object;
|
||||
// 2. The client waits for the Response signal under said object path;
|
||||
// 3. When the signal arrives, the actual return value and status code
|
||||
// become available for the client for further processing.
|
||||
//
|
||||
// THIS DOES NOT WORK. Once the first two steps are complete, the client
|
||||
// needs to immediately start listening for the third step, but an overeager
|
||||
// server implementation could easily send the Response signal before the
|
||||
// client is even ready, causing communications to break down over a simple
|
||||
// race condition/two generals' problem that even _TCP_ had figured out
|
||||
// decades ago. Worse yet, you get exactly _one_ chance to listen for the
|
||||
// signal, or else your communication attempt so far has all been in vain.
|
||||
//
|
||||
// And they know this. Instead of fixing their freaking protocol, they just
|
||||
// ask clients to manually construct the expected object path and subscribe
|
||||
// to the request signal beforehand, making the whole response value of
|
||||
// the original call COMPLETELY MEANINGLESS.
|
||||
//
|
||||
// Furthermore, this is _entirely undocumented_ aside from one tiny
|
||||
// paragraph under the documentation for the Request interface, and
|
||||
// anyone would be forgiven for missing it without reading the libportal
|
||||
// source code.
|
||||
//
|
||||
// When in Rome, do as the Romans do, I guess...?
|
||||
|
||||
const callbacks = struct {
|
||||
fn gotResponseHandle(
|
||||
source: ?*gobject.Object,
|
||||
res: *gio.AsyncResult,
|
||||
_: ?*anyopaque,
|
||||
) callconv(.c) void {
|
||||
const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?;
|
||||
|
||||
var err: ?*glib.Error = null;
|
||||
defer if (err) |err_| err_.free();
|
||||
|
||||
const params_ = dbus_.callFinish(res, &err) orelse {
|
||||
if (err) |err_| log.err("request failed={s} ({})", .{
|
||||
err_.f_message orelse "(unknown)",
|
||||
err_.f_code,
|
||||
});
|
||||
return;
|
||||
};
|
||||
defer params_.unref();
|
||||
|
||||
// TODO: XDG recommends updating the signal subscription if the actual
|
||||
// returned request path is not the same as the expected request
|
||||
// path, to retain compatibility with older versions of XDG portals.
|
||||
// Although it suffers from the race condition outlined above,
|
||||
// we should still implement this at some point.
|
||||
}
|
||||
|
||||
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response
|
||||
fn responded(
|
||||
dbus: *gio.DBusConnection,
|
||||
_: ?[*:0]const u8,
|
||||
_: [*:0]const u8,
|
||||
_: [*:0]const u8,
|
||||
_: [*:0]const u8,
|
||||
params_: *glib.Variant,
|
||||
ud: ?*anyopaque,
|
||||
) callconv(.c) void {
|
||||
const self_: *GlobalShortcuts = @ptrCast(@alignCast(ud));
|
||||
|
||||
// Unsubscribe from the response signal
|
||||
if (self_.response_subscription != 0) {
|
||||
dbus.signalUnsubscribe(self_.response_subscription);
|
||||
self_.response_subscription = 0;
|
||||
}
|
||||
|
||||
var response: u32 = 0;
|
||||
var vardict: ?*glib.Variant = null;
|
||||
params_.get("(u@a{sv})", &response, &vardict);
|
||||
|
||||
switch (response) {
|
||||
0 => {
|
||||
log.debug("request successful", .{});
|
||||
method.onResponse(self_, vardict.?);
|
||||
},
|
||||
1 => log.debug("request was cancelled by user", .{}),
|
||||
2 => log.warn("request ended unexpectedly", .{}),
|
||||
else => log.err("unrecognized response code={}", .{response}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request_token_buf: Token = undefined;
|
||||
const request_token = generateToken(&request_token_buf);
|
||||
|
||||
const payload = method.makePayload(self, request_token) orelse return;
|
||||
const request_path = try self.getRequestPath(request_token);
|
||||
|
||||
self.response_subscription = self.dbus.signalSubscribe(
|
||||
null,
|
||||
"org.freedesktop.portal.Request",
|
||||
"Response",
|
||||
request_path,
|
||||
null,
|
||||
.{},
|
||||
callbacks.responded,
|
||||
self,
|
||||
null,
|
||||
);
|
||||
|
||||
self.dbus.call(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"org.freedesktop.portal.GlobalShortcuts",
|
||||
method.name(),
|
||||
payload,
|
||||
null,
|
||||
.{},
|
||||
-1,
|
||||
null,
|
||||
callbacks.gotResponseHandle,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate a random token suitable for use in requests.
|
||||
fn generateToken(buf: *Token) [:0]const u8 {
|
||||
// u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL
|
||||
// 7 + 8 + 1 = 16
|
||||
return std.fmt.bufPrintZ(
|
||||
buf,
|
||||
"ghostty_{x:0<7}",
|
||||
.{std.crypto.random.int(u28)},
|
||||
) catch unreachable;
|
||||
}
|
||||
|
||||
/// Get the XDG portal request path for the current Ghostty instance.
|
||||
///
|
||||
/// If this sounds like nonsense, see `request` for an explanation as to
|
||||
/// why we need to do this.
|
||||
fn getRequestPath(self: *GlobalShortcuts, token: [:0]const u8) ![:0]const u8 {
|
||||
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
|
||||
// for the syntax XDG portals expect.
|
||||
|
||||
// `getUniqueName` should never return null here as we're using an ordinary
|
||||
// message bus connection. If it doesn't, something is very wrong
|
||||
const unique_name = std.mem.span(self.dbus.getUniqueName().?);
|
||||
|
||||
const object_path = try std.mem.joinZ(self.arena.allocator(), "/", &.{
|
||||
"/org/freedesktop/portal/desktop/request",
|
||||
unique_name[1..], // Remove leading `:`
|
||||
token,
|
||||
});
|
||||
|
||||
// Sanitize the unique name by replacing every `.` with `_`.
|
||||
// In effect, this will turn a unique name like `:1.192` into `1_192`.
|
||||
// Valid D-Bus object path components never contain `.`s anyway, so we're
|
||||
// free to replace all instances of `.` here and avoid extra allocation.
|
||||
std.mem.replaceScalar(u8, object_path, '.', '_');
|
||||
|
||||
return object_path;
|
||||
}
|
||||
@@ -20,10 +20,45 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u
|
||||
if (trigger.mods.super) try writer.writeAll("<Super>");
|
||||
|
||||
// Write our key
|
||||
if (!try writeTriggerKey(writer, trigger)) return null;
|
||||
|
||||
// We need to make the string null terminated.
|
||||
try writer.writeByte(0);
|
||||
const slice = buf_stream.getWritten();
|
||||
return slice[0 .. slice.len - 1 :0];
|
||||
}
|
||||
|
||||
/// Returns a XDG-compliant shortcuts string from a trigger.
|
||||
/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/
|
||||
pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 {
|
||||
var buf_stream = std.io.fixedBufferStream(buf);
|
||||
const writer = buf_stream.writer();
|
||||
|
||||
// Modifiers
|
||||
if (trigger.mods.shift) try writer.writeAll("SHIFT+");
|
||||
if (trigger.mods.ctrl) try writer.writeAll("CTRL+");
|
||||
if (trigger.mods.alt) try writer.writeAll("ALT+");
|
||||
if (trigger.mods.super) try writer.writeAll("LOGO+");
|
||||
|
||||
// Write our key
|
||||
// NOTE: While the spec specifies that only libxkbcommon keysyms are
|
||||
// expected, using GTK's keysyms should still work as they are identical
|
||||
// to *X11's* keysyms (which I assume is a subset of libxkbcommon's).
|
||||
// I haven't been able to any evidence to back up that assumption but
|
||||
// this works for now
|
||||
if (!try writeTriggerKey(writer, trigger)) return null;
|
||||
|
||||
// We need to make the string null terminated.
|
||||
try writer.writeByte(0);
|
||||
const slice = buf_stream.getWritten();
|
||||
return slice[0 .. slice.len - 1 :0];
|
||||
}
|
||||
|
||||
fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool {
|
||||
switch (trigger.key) {
|
||||
.physical => |k| {
|
||||
const keyval = keyvalFromKey(k) orelse return null;
|
||||
try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return null));
|
||||
const keyval = keyvalFromKey(k) orelse return false;
|
||||
try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false));
|
||||
},
|
||||
|
||||
.unicode => |cp| {
|
||||
@@ -35,10 +70,7 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u
|
||||
},
|
||||
}
|
||||
|
||||
// We need to make the string null terminated.
|
||||
try writer.writeByte(0);
|
||||
const slice = buf_stream.getWritten();
|
||||
return slice[0 .. slice.len - 1 :0];
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn translateMods(state: gdk.ModifierType) input.Mods {
|
||||
@@ -208,6 +240,21 @@ test "accelFromTrigger" {
|
||||
})).?);
|
||||
}
|
||||
|
||||
test "xdgShortcutFromTrigger" {
|
||||
const testing = std.testing;
|
||||
var buf: [256]u8 = undefined;
|
||||
|
||||
try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{
|
||||
.mods = .{ .super = true },
|
||||
.key = .{ .translated = .q },
|
||||
})).?);
|
||||
|
||||
try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{
|
||||
.mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true },
|
||||
.key = .{ .unicode = 92 },
|
||||
})).?);
|
||||
}
|
||||
|
||||
/// A raw entry in the keymap. Our keymap contains mappings between
|
||||
/// GDK keys and our own key enum.
|
||||
const RawEntry = struct { c_uint, input.Key };
|
||||
|
||||
Reference in New Issue
Block a user