diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 8243d8e39..2be637736 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1471,6 +1471,14 @@ pub const Application = extern struct { self, .{}, ); + + _ = GlobalShortcuts.signals.@"bind-failed".connect( + priv.global_shortcuts, + *Application, + globalShortcutBindFailed, + self, + .{}, + ); } fn activate(self: *Self) callconv(.c) void { @@ -1679,6 +1687,41 @@ pub const Application = extern struct { }; } + /// May fire before any window exists, hence a desktop notification + /// rather than a toast. + fn globalShortcutBindFailed( + _: *GlobalShortcuts, + failure: *const GlobalShortcuts.BindFailed, + self: *Self, + ) callconv(.c) void { + var label_buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&label_buf); + const label: []const u8 = label: { + const ok = key.labelFromTrigger(&writer, failure.trigger) catch false; + break :label if (ok) writer.buffered() else "?"; + }; + + const detail: [*:0]const u8 = detail: { + if (failure.message[0] != 0) break :detail failure.message; + break :detail if (failure.revoked) + i18n._("The keybind was revoked by the system.") + else + i18n._("The keybind was denied by the system."); + }; + + var body_buf: [512]u8 = undefined; + const body = std.fmt.bufPrintZ( + &body_buf, + "{s}: {s}", + .{ label, detail }, + ) catch return; + + Action.desktopNotification(self, .app, .{ + .title = std.mem.span(i18n._("Global keybind unavailable")), + .body = body, + }); + } + fn actionReloadConfig( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index dd44afbdd..88aaba9f4 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -83,6 +83,24 @@ pub const GlobalShortcuts = extern struct { pub var offset: c_int = 0; }; + /// A global shortcut that failed to bind or was revoked. + /// Only valid for the duration of the signal emission. + pub const BindFailed = struct { + trigger: Binding.Trigger, + action: Binding.Action, + + /// May be empty. + message: [*:0]const u8, + + /// Revoked after binding rather than denied up front. + revoked: bool, + + pub const getGObjectType = gobject.ext.defineBoxed( + BindFailed, + .{ .name = "GhosttyGlobalShortcutsBindFailed" }, + ); + }; + pub const signals = struct { /// Emitted whenever a global shortcut is triggered. pub const trigger = struct { @@ -95,6 +113,18 @@ pub const GlobalShortcuts = extern struct { void, ); }; + + /// Emitted when a global shortcut fails to bind or is revoked. + pub const @"bind-failed" = struct { + pub const name = "bind-failed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const BindFailed}, + void, + ); + }; }; fn init(self: *Self, _: *Class) callconv(.c) void { @@ -110,6 +140,10 @@ pub const GlobalShortcuts = extern struct { fn close(self: *Self) void { const priv = self.private(); + // This is safe to call even if the winproto was already + // deinitialized, which happens during application teardown. + Application.default().winproto().clearGlobalShortcuts(); + if (priv.dbus_connection) |dbus| { if (priv.response_subscription != 0) { dbus.signalUnsubscribe(priv.response_subscription); @@ -153,10 +187,19 @@ pub const GlobalShortcuts = extern struct { const priv = self.private(); - // We need a dbus connection and configuration to proceed. - if (priv.dbus_connection == null) return; + // We need a configuration to proceed. const config = if (priv.config) |v| v.get() else return; + // Prefer a windowing protocol native mechanism over the + // XDG desktop portal when available. + if (Application.default().winproto().bindGlobalShortcuts(self, config)) { + log.debug("global shortcuts bound via winproto", .{}); + return; + } + + // The portal fallback requires a dbus connection. + if (priv.dbus_connection == null) return; + // Setup our new arena that we'll use for memory allocations. assert(priv.arena == null); var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); @@ -559,10 +602,25 @@ pub const GlobalShortcuts = extern struct { log.debug("activated={s}", .{shortcut_id}); const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return; + self.emitTrigger(&action); + } + + /// Emit the trigger signal. Public for winproto shortcut backends. + pub fn emitTrigger(self: *Self, action: *const Binding.Action) void { signals.trigger.impl.emit( self, null, - .{&action}, + .{action}, + null, + ); + } + + /// Emit the bind-failed signal. Public for winproto shortcut backends. + pub fn emitBindFailed(self: *Self, failure: *const BindFailed) void { + signals.@"bind-failed".impl.emit( + self, + null, + .{failure}, null, ); } @@ -612,6 +670,7 @@ pub const GlobalShortcuts = extern struct { // Signals signals.trigger.impl.register(.{}); + signals.@"bind-failed".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 5f717e14a..198c15491 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -57,6 +57,16 @@ pub fn xdgShortcutFromTrigger( return slice[0 .. slice.len - 1 :0]; } +/// Returns the keysym (identical to a GDK keyval) for a trigger key, +/// or null if the trigger has no keysym representation. +pub fn keysymFromTrigger(trigger: input.Binding.Trigger) ?c_uint { + return switch (trigger.key) { + .physical => |k| keyvalFromKey(k), + .unicode => |cp| gdk.unicodeToKeyval(cp), + .catch_all => null, + }; +} + fn writeTriggerKey( writer: *std.Io.Writer, trigger: input.Binding.Trigger, @@ -327,6 +337,28 @@ test "xdgShortcutFromTrigger" { })).?); } +test "keysymFromTrigger" { + const testing = std.testing; + + // Unicode keys use the keysym numbering (latin-1 maps directly). + try testing.expectEqual(@as(?c_uint, 'q'), keysymFromTrigger(.{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })); + + // Physical keys map through our keymap. + try testing.expectEqual(@as(?c_uint, gdk.KEY_a), keysymFromTrigger(.{ + .mods = .{}, + .key = .{ .physical = .key_a }, + })); + + // catch_all has no keysym representation. + try testing.expectEqual(@as(?c_uint, null), keysymFromTrigger(.{ + .mods = .{}, + .key = .catch_all, + })); +} + test "labelFromTrigger" { const testing = std.testing; diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index a6bc14d96..abf46d99b 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -8,6 +8,7 @@ const Config = @import("../../config.zig").Config; const input = @import("../../input.zig"); const key = @import("key.zig"); const ApprtWindow = @import("class/window.zig").Window; +const GlobalShortcuts = @import("class/global_shortcuts.zig").GlobalShortcuts; pub const noop = @import("winproto/noop.zig"); pub const x11 = @import("winproto/x11.zig"); @@ -78,6 +79,26 @@ pub const App = union(Protocol) { inline else => |*v| try v.initQuickTerminal(apprt_window), } } + + /// Bind all global keybinds through a mechanism native to the + /// windowing protocol. Returns false if there is none; the caller + /// should then fall back to the XDG desktop portal. + pub fn bindGlobalShortcuts( + self: *App, + shortcuts: *GlobalShortcuts, + config: *const Config, + ) bool { + return switch (self.*) { + inline else => |*v| v.bindGlobalShortcuts(shortcuts, config), + }; + } + + /// Unbind all global keybinds bound via bindGlobalShortcuts. + pub fn clearGlobalShortcuts(self: *App) void { + switch (self.*) { + inline else => |*v| v.clearGlobalShortcuts(), + } + } }; /// Per-Window state for the underlying windowing protocol. diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 3b9a3fbc0..e5f59dc40 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -6,6 +6,7 @@ const gdk = @import("gdk"); const Config = @import("../../../config.zig").Config; const input = @import("../../../input.zig"); const ApprtWindow = @import("../class/window.zig").Window; +const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts; const log = std.log.scoped(.winproto_noop); @@ -35,6 +36,16 @@ pub const App = struct { return false; } pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} + + pub fn bindGlobalShortcuts( + _: *App, + _: *GlobalShortcuts, + _: *const Config, + ) bool { + return false; + } + + pub fn clearGlobalShortcuts(_: *App) void {} }; pub const Window = struct { diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index a53035869..dc3647679 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -17,8 +17,10 @@ const xdg = wayland.client.xdg; const Config = @import("../../../config.zig").Config; const Globals = @import("wayland/Globals.zig"); +const Hotkeys = @import("wayland/Hotkeys.zig"); const input = @import("../../../input.zig"); const ApprtWindow = @import("../class/window.zig").Window; +const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts; const BlurRegion = @import("BlurRegion.zig"); const log = std.log.scoped(.winproto_wayland); @@ -27,6 +29,7 @@ const log = std.log.scoped(.winproto_wayland); pub const App = struct { display: *wl.Display, globals: *Globals, + hotkeys: Hotkeys, pub fn init( alloc: Allocator, @@ -35,7 +38,6 @@ pub const App = struct { config: *const Config, ) !?App { _ = config; - _ = app_id; const gdk_wayland_display = gobject.ext.cast( gdk_wayland.WaylandDisplay, @@ -49,16 +51,35 @@ pub const App = struct { const globals: *Globals = try .init(alloc, display); errdefer globals.deinit(); + var hotkeys: Hotkeys = try .init(alloc, app_id); + errdefer hotkeys.deinit(); + return .{ .display = display, .globals = globals, + .hotkeys = hotkeys, }; } pub fn deinit(self: *App) void { + self.hotkeys.deinit(); self.globals.deinit(); } + pub fn bindGlobalShortcuts( + self: *App, + shortcuts: *GlobalShortcuts, + config: *const Config, + ) bool { + const manager = self.globals.get(.vicinae_hotkey_manager) orelse return false; + self.hotkeys.bind(manager, shortcuts, config); + return true; + } + + pub fn clearGlobalShortcuts(self: *App) void { + self.hotkeys.clear(); + } + pub fn eventMods( _: *App, _: ?*gdk.Device, diff --git a/src/apprt/gtk/winproto/wayland/Globals.zig b/src/apprt/gtk/winproto/wayland/Globals.zig index 83052cbeb..bb4a48c9e 100644 --- a/src/apprt/gtk/winproto/wayland/Globals.zig +++ b/src/apprt/gtk/winproto/wayland/Globals.zig @@ -8,6 +8,7 @@ const wl = wayland.client.wl; const ext = wayland.client.ext; const kde = wayland.client.kde; const org = wayland.client.org; +const vicinae = wayland.client.vicinae; const xdg = wayland.client.xdg; const log = std.log.scoped(.winproto_wayland_globals); @@ -32,6 +33,7 @@ pub const Tag = enum { kde_decoration_manager, kde_slide_manager, kde_output_order, + vicinae_hotkey_manager, xdg_activation, fn Type(comptime self: Tag) type { @@ -41,6 +43,7 @@ pub const Tag = enum { .kde_decoration_manager => org.KdeKwinServerDecorationManager, .kde_slide_manager => org.KdeKwinSlideManager, .kde_output_order => kde.OutputOrderV1, + .vicinae_hotkey_manager => vicinae.HotkeyManagerV1, .xdg_activation => xdg.ActivationV1, }; } diff --git a/src/apprt/gtk/winproto/wayland/Hotkeys.zig b/src/apprt/gtk/winproto/wayland/Hotkeys.zig new file mode 100644 index 000000000..2b0123673 --- /dev/null +++ b/src/apprt/gtk/winproto/wayland/Hotkeys.zig @@ -0,0 +1,163 @@ +//! Global shortcuts backed by the vicinae-hotkey Wayland protocol. +const Hotkeys = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const wayland = @import("wayland"); +const vicinae = wayland.client.vicinae; + +const Config = @import("../../../../config.zig").Config; +const Binding = @import("../../../../input.zig").Binding; +const key = @import("../../key.zig"); +const GlobalShortcuts = @import("../../class/global_shortcuts.zig").GlobalShortcuts; + +const log = std.log.scoped(.winproto_wayland_hotkeys); + +alloc: Allocator, +app_id: [:0]const u8, +entries: std.ArrayListUnmanaged(*Entry) = .empty, + +const Entry = struct { + /// Null once the binding was denied or revoked. + hotkey: ?*vicinae.HotkeyV1, + trigger: Binding.Trigger, + action: Binding.Action, + shortcuts: *GlobalShortcuts, + + fn fail(entry: *Entry, message: [*:0]const u8, revoked: bool) void { + entry.hotkey.?.destroy(); + entry.hotkey = null; + + entry.shortcuts.emitBindFailed(&.{ + .trigger = entry.trigger, + .action = entry.action, + .message = message, + .revoked = revoked, + }); + } +}; + +pub fn init(alloc: Allocator, app_id: [:0]const u8) Allocator.Error!Hotkeys { + return .{ + .alloc = alloc, + .app_id = try alloc.dupeZ(u8, app_id), + }; +} + +/// Must leave the entries in a valid empty state: clear may still be +/// called after deinit during application teardown. +pub fn deinit(self: *Hotkeys) void { + self.clear(); + self.entries.deinit(self.alloc); + self.entries = .empty; + self.alloc.free(self.app_id); +} + +pub fn clear(self: *Hotkeys) void { + for (self.entries.items) |entry| { + if (entry.hotkey) |hotkey| hotkey.destroy(); + self.alloc.destroy(entry); + } + self.entries.clearRetainingCapacity(); +} + +pub fn bind( + self: *Hotkeys, + manager: *vicinae.HotkeyManagerV1, + shortcuts: *GlobalShortcuts, + config: *const Config, +) void { + self.clear(); + + var it = config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { + .leader => continue, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), + }; + if (!leaf.flags.global) continue; + + // Only single-action global keybinds are supported, as in the + // portal implementation. + const actions = leaf.actionsSlice(); + if (actions.len != 1) continue; + + self.bindOne(manager, shortcuts, entry.key_ptr.*, actions[0]) catch |err| { + log.warn("failed to request hotkey trigger={f} err={}", .{ + entry.key_ptr.*, + err, + }); + }; + } +} + +fn bindOne( + self: *Hotkeys, + manager: *vicinae.HotkeyManagerV1, + shortcuts: *GlobalShortcuts, + trigger: Binding.Trigger, + action: Binding.Action, +) !void { + const keysym = key.keysymFromTrigger(trigger) orelse return error.NoKeysym; + + var desc_buf: [256]u8 = undefined; + const description = std.fmt.bufPrintZ(&desc_buf, "{f}", .{action}) catch ""; + + const entry = try self.alloc.create(Entry); + errdefer self.alloc.destroy(entry); + try self.entries.ensureUnusedCapacity(self.alloc, 1); + + const hotkey = try manager.bind( + keysym, + .{ + .shift = trigger.mods.shift, + .ctrl = trigger.mods.ctrl, + .alt = trigger.mods.alt, + .super = trigger.mods.super, + }, + null, + self.app_id.ptr, + description.ptr, + ); + + entry.* = .{ + .hotkey = hotkey, + .trigger = trigger, + .action = action, + .shortcuts = shortcuts, + }; + hotkey.setListener(*Entry, hotkeyListener, entry); + self.entries.appendAssumeCapacity(entry); +} + +fn hotkeyListener( + _: *vicinae.HotkeyV1, + event: vicinae.HotkeyV1.Event, + entry: *Entry, +) void { + switch (event) { + .bound => log.debug("hotkey bound action={f}", .{entry.action}), + + .denied => |v| { + log.warn("hotkey denied action={f} reason={} message={s}", .{ + entry.action, + @intFromEnum(v.reason), + v.message, + }); + entry.fail(v.message, false); + }, + + .revoked => |v| { + log.warn("hotkey revoked action={f} reason={} message={s}", .{ + entry.action, + @intFromEnum(v.reason), + v.message, + }); + entry.fail(v.message, true); + }, + + .pressed => entry.shortcuts.emitTrigger(&entry.action), + .released => {}, + } +} diff --git a/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml b/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml new file mode 100644 index 000000000..30f973942 --- /dev/null +++ b/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml @@ -0,0 +1,305 @@ + + + + Copyright © 2026 Aurelien Brabant + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This protocol lets a client choose, and freely reconfigure, its own + global hotkeys: it requests a specific key combination and the compositor + accepts it or rejects it with a reason. The compositor remains the eventual + arbiter (it may deny a request, revoke a binding at any time, or apply + whatever policy it likes), but within that the application manages its own + bindings, including changing them at runtime, with no out-of-band user + configuration and no persisted compositor state. + + A hotkey is a key combination that fires regardless of which surface holds + keyboard focus. This is unrelated to keyboard-shortcuts-inhibit, which lets a + focused client suppress the compositor's own shortcuts. + + Whether a combination is exclusive is compositor policy. A compositor MAY + treat combinations as exclusive and reject a clashing request with + "already_bound", or MAY grant the same combination to more than one binding + (each bound hotkey then receives the events), as some platforms do. Clients + must cope with either. + + It is intentionally a thin mechanism. It does NOT define which combinations + are acceptable: that is policy and belongs to the compositor, expressed + through the accept/deny channel. It does NOT persist hotkeys: a binding lives + only as long as its vicinae_hotkey_v1 object and the client connection; there is + no configure or storage step, a binding never outlives the client that created + it, and reconfiguring is simply destroying a hotkey and binding the new + combination. + + Warning! The protocol described in this file is currently in the testing + phase. Backward incompatible changes may be added together with the + corresponding interface version bump. Backward compatible changes are added + by bumping the interface version. + + Security considerations: + + A global hotkey lets an unfocused client observe that a specific key + combination was pressed. Compositors are the security boundary and SHOULD + apply policy when deciding whether to accept a request. In particular, + implementations SHOULD reject combinations that do not include at least one + non-latching modifier among Ctrl, Alt or Super, unless the trigger is a + function key, because unmodified (and Shift-only) keys carry ordinary text + entry and grabbing them turns this protocol into a keylogger. + + The protocol's one hard guarantee is containment: a compositor MUST NOT + deliver events for a combination the client did not successfully bind, and + never forwards the raw key stream, so a client learns nothing about input it + did not explicitly, and successfully, bind. Beyond that, a compositor MAY + apply any further policy it sees fit (for example prompting the user, + restricting which clients may bind, or limiting how many bindings a client may + hold) and MAY revoke a binding at any time via the "revoked" event, including + under user control. + + These are recommendations, not wire requirements: a combination the + compositor disallows is simply reported through the "denied" event with the + "not_permitted" reason, so applications can rely on a uniform rejection path + regardless of each compositor's policy. + + + + + This interface is the entry point of the protocol. It is used to request + global hotkeys. + + + + + A bitmask of the modifiers that must be held for the hotkey to fire. + + These are fixed, keymap-independent semantic bits naming the standard + modifiers, suitable for expressing a binding, unlike wl_keyboard.modifiers, + which carries opaque, keymap-derived masks for runtime state. A compositor + matches each bit against the corresponding modifier in its keyboard state + as the keymap defines it; for the xkb_v1 keymap format that is shift -> + XKB_MOD_NAME_SHIFT, ctrl -> XKB_MOD_NAME_CTRL, alt -> XKB_MOD_NAME_ALT + ("Mod1"), super -> XKB_MOD_NAME_LOGO ("Mod4"). + + Lock modifiers (Caps Lock, Num Lock) are never part of a binding and are + ignored when matching. + + + + + + + + + + Destroy the manager object. Hotkey objects created through this manager + are unaffected and remain valid; their bindings persist until they are + themselves destroyed or the client disconnects. + + + + + + Request that the given key combination be bound as a global hotkey. + + The keysym identifies the trigger key using the keysym numbering shared + with the keymap delivered over wl_keyboard (for the xkb_v1 keymap format, + an XKB_KEY_* value), taken in its unshifted form (XKB_KEY_b not XKB_KEY_B). A compositor SHOULD match + it against any of the user's layout groups, so a binding keeps firing after + the user switches layout group. This is one deliberately specified rule, + not a universal one: other platforms differ (macOS matches a physical + keycode, X11 re-grabs per layout, Windows tracks the active layout's key), + and no rule serves a layout that never produces the keysym; the single + documented rule is chosen for predictability across compositors. + + The request creates an vicinae_hotkey_v1 object immediately, but the binding + is not active yet. The compositor replies asynchronously with either the + "bound" event (the hotkey is now active) or the "denied" event (the + request was rejected, with a reason). + + seat is the wl_seat whose keyboard should trigger the hotkey, or null to + request it on all of the client's seats. Most clients pass null; seat is + provided for completeness on multi-seat systems. + + app_id identifies the requesting application, for use in the compositor's + policy and any user-facing audit UI. Where the application has a desktop + entry, app_id SHOULD be its desktop file ID as defined by the + freedesktop.org Desktop Entry specification, that is the .desktop file + name with the .desktop suffix removed (for example "org.example.Launcher"), + matching the convention used by xdg_toplevel.set_app_id so a compositor + can correlate the two. It is advisory and may be spoofed; a compositor + that needs a trustworthy identity SHOULD obtain it through the + security-context mechanism rather than relying on this string. + + description is a human-readable, localized description of what the hotkey + does (e.g. "Toggle the launcher"), for display in compositor UI or even in this protocol error messages. + + + + + + + + + + + + + Represents one requested global hotkey. Its binding is ephemeral: it is + released when this object is destroyed or when the client disconnects, and + it is never written to any persistent store. + + After bind, exactly one of "bound" or "denied" is sent. While the hotkey is + bound, "pressed" and "released" are sent as the combination is activated. + The compositor may send "revoked" at any time after "bound" to indicate the + binding is no longer active (for example because the user removed it, or a + higher-priority binding took the combination). + + + + + Carried by the "denied" event. This is a reason code, not a fatal + protocol error: the object remains valid and the client should destroy + it (or try a different combination). + + + + + + + + + Carried by the "revoked" event. This is a reason code, not a fatal + protocol error: the object remains valid and the client should destroy + it. + + The distinction is actionable: on "superseded" the combination may + become available again, so a client may reasonably re-request it later; + on "removed" the withdrawal is a deliberate user or compositor decision, + so a client MUST NOT silently re-request the same combination. A client + that receives a value it does not recognise (a future addition) MUST + treat it conservatively as "removed" and not auto-rebind. + + + + + + + + + Release the binding and destroy the object. The combination becomes + available again immediately. It is valid to destroy the object before + receiving "bound" or "denied", which cancels the pending request. + + + + + + The requested combination was accepted and is now active. "pressed" and + "released" events may follow. + + + + + + The requested combination was not bound. No input events will be sent. + The client should destroy this object; it may then create a new request + with a different combination. + + message is an optional, advisory, human-readable explanation in the + compositor's locale, intended for verbatim display in client UI (for + example a "change shortcut" settings screen). It may be empty, and a + compositor is never required to provide it. Clients MUST NOT parse it or + rely on its contents in any way; all programmatic behavior keys off + reason. A client with nowhere to show it simply ignores it. + + + + + + + + A binding that was previously "bound" is no longer active. No further + input events will be sent for it. The client should destroy this object; + it may request the combination again later. + + message is an optional, advisory, human-readable explanation in the + compositor's locale, intended for verbatim display in client UI. It may + be empty, and a compositor is never required to provide it. Clients MUST + NOT parse it or rely on its contents in any way; all programmatic + behavior keys off reason. A client with nowhere to show it simply + ignores it. + + + + + + + + The bound combination was activated. + + This event is sent once per activation. While the combination is held + down no further "pressed" events are sent, and a single "released" + follows when the trigger key is released: the compositor does not + auto-repeat the hotkey. + + serial is a compositor-issued input serial that counts as a recent user + interaction, so the client can act on the hotkey even though the + triggering key press is not otherwise delivered to it (the compositor + consumes it). In particular, a client may pass this serial to + xdg_activation_token_v1.set_serial in order to raise or focus a surface in + response to the hotkey; a compositor SHOULD honor activation carried by + this serial, since the hotkey is itself a deliberate user action. The + serial is drawn from the same space as other input event serials. + + time is the event timestamp, with the same millisecond clock as wl_pointer + and wl_keyboard input events; it is useful for ordering and for measuring + press-to-release duration. + + + + + + + + The trigger key of a previously pressed combination was released. This + enables press-and-hold uses (e.g. push-to-talk); clients that only act + on activation may ignore it. + + serial and time have the same meaning as in the "pressed" event. + + + + + + diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 3ef33137a..fe0e6f613 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -19,6 +19,7 @@ pub const c = @cImport({ const input = @import("../../../input.zig"); const Config = @import("../../../config.zig").Config; const ApprtWindow = @import("../class/window.zig").Window; +const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts; const BlurRegion = @import("BlurRegion.zig"); const log = std.log.scoped(.gtk_x11); @@ -158,6 +159,16 @@ pub const App = struct { return mods; } + pub fn bindGlobalShortcuts( + _: *App, + _: *GlobalShortcuts, + _: *const Config, + ) bool { + return false; + } + + pub fn clearGlobalShortcuts(_: *App) void {} + pub fn supportsQuickTerminal(_: App) bool { log.warn("quick terminal is not yet supported on X11", .{}); return false; diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 4877686c4..f1ec7c9d5 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -816,13 +816,19 @@ fn addGtkNg( ); scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); scanner.addSystemProtocol("staging/ext-background-effect/ext-background-effect-v1.xml"); + scanner.addCustomProtocol( + b.path("src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml"), + ); scanner.generate("wl_compositor", 1); + // Only referenced by vicinae_hotkey_manager_v1.bind (nullable arg). + scanner.generate("wl_seat", 1); scanner.generate("org_kde_kwin_server_decoration_manager", 1); scanner.generate("org_kde_kwin_slide_manager", 1); scanner.generate("kde_output_order_v1", 1); scanner.generate("xdg_activation_v1", 1); scanner.generate("ext_background_effect_manager_v1", 1); + scanner.generate("vicinae_hotkey_manager_v1", 1); step.root_module.addImport("wayland", b.createModule(.{ .root_source_file = scanner.result,