mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 04:39:01 +00:00
feat: implement vicinae-hotkey-v1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
163
src/apprt/gtk/winproto/wayland/Hotkeys.zig
Normal file
163
src/apprt/gtk/winproto/wayland/Hotkeys.zig
Normal file
@@ -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 => {},
|
||||
}
|
||||
}
|
||||
305
src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml
Normal file
305
src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml
Normal file
@@ -0,0 +1,305 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="vicinae_hotkey_v1">
|
||||
<copyright>
|
||||
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.
|
||||
</copyright>
|
||||
|
||||
<description summary="client-managed global hotkeys">
|
||||
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.
|
||||
</description>
|
||||
|
||||
<interface name="vicinae_hotkey_manager_v1" version="1">
|
||||
<description summary="global hotkey factory">
|
||||
This interface is the entry point of the protocol. It is used to request
|
||||
global hotkeys.
|
||||
</description>
|
||||
|
||||
<enum name="modifiers" bitfield="true">
|
||||
<description summary="modifier keys required by a hotkey">
|
||||
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.
|
||||
</description>
|
||||
<entry name="shift" value="1" summary="the Shift modifier"/>
|
||||
<entry name="ctrl" value="2" summary="the Control modifier"/>
|
||||
<entry name="alt" value="4" summary="the Alt modifier"/>
|
||||
<entry name="super" value="8" summary="the Super/Logo modifier"/>
|
||||
</enum>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the manager">
|
||||
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.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="bind">
|
||||
<description summary="request a global hotkey">
|
||||
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.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="vicinae_hotkey_v1"
|
||||
summary="the new hotkey object"/>
|
||||
<arg name="keysym" type="uint" summary="keysym of the trigger key (see description)"/>
|
||||
<arg name="modifiers" type="uint" enum="modifiers"
|
||||
summary="bitmask of required modifiers"/>
|
||||
<arg name="seat" type="object" interface="wl_seat" allow-null="true"
|
||||
summary="target seat, or null for all of the client's seats"/>
|
||||
<arg name="app_id" type="string"
|
||||
summary="advisory application identifier (SHOULD be the desktop file ID)"/>
|
||||
<arg name="description" type="string"
|
||||
summary="human-readable action description"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="vicinae_hotkey_v1" version="1">
|
||||
<description summary="a single global hotkey">
|
||||
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).
|
||||
</description>
|
||||
|
||||
<enum name="deny_reason">
|
||||
<description summary="why a bind request was denied">
|
||||
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).
|
||||
</description>
|
||||
<entry name="already_bound" value="0"
|
||||
summary="already held by another hotkey the compositor treats as exclusive. The compositor is not obligated to give this level of detail and can just give not_permitted as the general 'deny' reason."/>
|
||||
<entry name="not_permitted" value="1"
|
||||
summary="the combination is disallowed by compositor policy"/>
|
||||
<entry name="invalid" value="2"
|
||||
summary="the keysym or combination is not a valid trigger"/>
|
||||
</enum>
|
||||
|
||||
<enum name="revoke_reason">
|
||||
<description summary="why a previously bound hotkey was withdrawn">
|
||||
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.
|
||||
</description>
|
||||
<entry name="removed" value="0"
|
||||
summary="withdrawn by the user or compositor; do not auto-rebind"/>
|
||||
<entry name="superseded" value="1"
|
||||
summary="a higher-priority binding took the combination; it may become available again"/>
|
||||
<entry name="not_permitted" value="2"
|
||||
summary="the combination is no longer allowed by compositor policy"/>
|
||||
</enum>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="release the hotkey">
|
||||
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.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="bound">
|
||||
<description summary="the hotkey is now active">
|
||||
The requested combination was accepted and is now active. "pressed" and
|
||||
"released" events may follow.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="denied">
|
||||
<description summary="the request was rejected">
|
||||
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.
|
||||
</description>
|
||||
<arg name="reason" type="uint" enum="deny_reason" summary="why it was rejected"/>
|
||||
<arg name="message" type="string"
|
||||
summary="optional human-readable detail for display (may be empty)"/>
|
||||
</event>
|
||||
|
||||
<event name="revoked">
|
||||
<description summary="a previously active hotkey was withdrawn">
|
||||
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.
|
||||
</description>
|
||||
<arg name="reason" type="uint" enum="revoke_reason" summary="why it was withdrawn"/>
|
||||
<arg name="message" type="string"
|
||||
summary="optional human-readable detail for display (may be empty)"/>
|
||||
</event>
|
||||
|
||||
<event name="pressed">
|
||||
<description summary="the hotkey combination was pressed">
|
||||
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.
|
||||
</description>
|
||||
<arg name="serial" type="uint" summary="input serial for the activation (e.g. xdg-activation)"/>
|
||||
<arg name="time" type="uint" summary="timestamp in milliseconds"/>
|
||||
</event>
|
||||
|
||||
<event name="released">
|
||||
<description summary="the hotkey combination was released">
|
||||
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.
|
||||
</description>
|
||||
<arg name="serial" type="uint" summary="input serial for the activation (e.g. xdg-activation)"/>
|
||||
<arg name="time" type="uint" summary="timestamp in milliseconds"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user