diff --git a/CMakeLists.txt b/CMakeLists.txt
index 400b8e102..15e8f1d11 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -537,6 +537,7 @@ protocolnew("protocols" "wlr-virtual-pointer-unstable-v1" true)
protocolnew("protocols" "input-method-unstable-v2" true)
protocolnew("protocols" "wlr-output-management-unstable-v1" true)
protocolnew("protocols" "kde-server-decoration" true)
+protocolnew("protocols" "vicinae-hotkey-v1" true)
protocolnew("protocols" "wlr-data-control-unstable-v1" true)
protocolnew("${HYPRLAND_PROTOCOLS}/protocols" "hyprland-focus-grab-v1" true)
protocolnew("protocols" "wlr-layer-shell-unstable-v1" true)
diff --git a/protocols/vicinae-hotkey-v1.xml b/protocols/vicinae-hotkey-v1.xml
new file mode 100644
index 000000000..30f973942
--- /dev/null
+++ b/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/managers/KeybindManager.cpp b/src/managers/KeybindManager.cpp
index 7fd034728..79f971545 100644
--- a/src/managers/KeybindManager.cpp
+++ b/src/managers/KeybindManager.cpp
@@ -5,6 +5,7 @@
#include "../devices/IKeyboard.hpp"
#include "../managers/SeatManager.hpp"
#include "../protocols/ShortcutsInhibit.hpp"
+#include "../protocols/Hotkey.hpp"
#include "../protocols/core/DataDevice.hpp"
#include "../errorOverlay/Overlay.hpp"
#include "KeybindManager.hpp"
@@ -257,6 +258,32 @@ uint32_t CKeybindManager::keycodeToModifier(xkb_keycode_t keycode) {
}
}
+SP CKeybindManager::findConflictingKeybind(xkb_keysym_t keysym, uint32_t modmask) {
+ if (keysym == XKB_KEY_NoSymbol)
+ return nullptr;
+
+ for (const auto& k : m_keybinds) {
+ if (!k->enabled || k->mouse)
+ continue;
+ if (!k->submap.name.empty())
+ continue;
+ if (k->modmask != modmask)
+ continue;
+
+ xkb_keysym_t bindSym = XKB_KEY_NoSymbol;
+ if (!k->key.empty())
+ bindSym = xkb_keysym_from_name(k->key.c_str(), XKB_KEYSYM_CASE_INSENSITIVE);
+ else if (k->keycode != 0 && m_xkbTranslationState)
+ // "code:NN" binds store the xkb keycode
+ bindSym = xkb_state_key_get_one_sym(m_xkbTranslationState, k->keycode);
+
+ if (bindSym != XKB_KEY_NoSymbol && bindSym == keysym)
+ return k;
+ }
+
+ return nullptr;
+}
+
void CKeybindManager::updateXKBTranslationState() {
if (m_xkbTranslationState) {
xkb_state_unref(m_xkbTranslationState);
@@ -350,6 +377,9 @@ bool CKeybindManager::onKeyEvent(std::any event, SP pKeyboard) {
const auto MODS = g_pInputManager->getModsFromAllKBs();
+ if (PROTO::hotkey && PROTO::hotkey->onKey(keysym, MODS, KEYCODE, e.state == WL_KEYBOARD_KEY_STATE_PRESSED, e.timeMs))
+ return false;
+
Config::Actions::state()->m_timeLastMs = e.timeMs;
Config::Actions::state()->m_lastCode = KEYCODE;
Config::Actions::state()->m_lastMouseCode = 0;
diff --git a/src/managers/KeybindManager.hpp b/src/managers/KeybindManager.hpp
index 0973c02bd..921cdb258 100644
--- a/src/managers/KeybindManager.hpp
+++ b/src/managers/KeybindManager.hpp
@@ -124,6 +124,7 @@ class CKeybindManager {
void removeKeybind(const std::string& displayKeys);
uint32_t stringToModMask(std::string);
uint32_t keycodeToModifier(xkb_keycode_t);
+ SP findConflictingKeybind(xkb_keysym_t keysym, uint32_t modmask);
void clearKeybinds();
void shadowKeybinds(const xkb_keysym_t& doesntHave = 0, const uint32_t doesntHaveCode = 0);
SSubmap getCurrentSubmap();
diff --git a/src/managers/ProtocolManager.cpp b/src/managers/ProtocolManager.cpp
index e4265aa0c..2a0cf94fb 100644
--- a/src/managers/ProtocolManager.cpp
+++ b/src/managers/ProtocolManager.cpp
@@ -63,6 +63,7 @@
#include "../protocols/ContentType.hpp"
#include "../protocols/XDGTag.hpp"
#include "../protocols/XDGBell.hpp"
+#include "../protocols/Hotkey.hpp"
#include "../protocols/ExtWorkspace.hpp"
#include "../protocols/ExtDataDevice.hpp"
#include "../protocols/PointerWarp.hpp"
@@ -193,6 +194,7 @@ CProtocolManager::CProtocolManager() {
PROTO::contentType = makeUnique(&wp_content_type_manager_v1_interface, 1, "ContentType");
PROTO::xdgTag = makeUnique(&xdg_toplevel_tag_manager_v1_interface, 1, "XDGTag");
PROTO::xdgBell = makeUnique(&xdg_system_bell_v1_interface, 1, "XDGBell");
+ PROTO::hotkey = makeUnique(&vicinae_hotkey_manager_v1_interface, 1, "Hotkey");
PROTO::extWorkspace = makeUnique(&ext_workspace_manager_v1_interface, 1, "ExtWorkspace");
PROTO::extDataDevice = makeUnique(&ext_data_control_manager_v1_interface, 1, "ExtDataDevice");
PROTO::pointerWarp = makeUnique(&wp_pointer_warp_v1_interface, 1, "PointerWarp");
@@ -303,6 +305,7 @@ CProtocolManager::~CProtocolManager() {
PROTO::colorManagement.reset();
PROTO::xdgTag.reset();
PROTO::xdgBell.reset();
+ PROTO::hotkey.reset();
PROTO::extWorkspace.reset();
PROTO::extDataDevice.reset();
PROTO::pointerWarp.reset();
diff --git a/src/protocols/Hotkey.cpp b/src/protocols/Hotkey.cpp
new file mode 100644
index 000000000..d05004606
--- /dev/null
+++ b/src/protocols/Hotkey.cpp
@@ -0,0 +1,177 @@
+#include "Hotkey.hpp"
+#include "../Compositor.hpp"
+#include "../devices/IKeyboard.hpp"
+#include "../managers/KeybindManager.hpp"
+#include "../event/EventBus.hpp"
+
+#include
+#include
+
+static constexpr uint32_t RELEVANT_MODS = HL_MODIFIER_SHIFT | HL_MODIFIER_CTRL | HL_MODIFIER_ALT | HL_MODIFIER_META;
+
+static uint32_t protoModsToHL(uint32_t mods) {
+ uint32_t out = 0;
+ if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_SHIFT)
+ out |= HL_MODIFIER_SHIFT;
+ if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_CTRL)
+ out |= HL_MODIFIER_CTRL;
+ if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_ALT)
+ out |= HL_MODIFIER_ALT;
+ if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_SUPER)
+ out |= HL_MODIFIER_META;
+ return out;
+}
+
+static bool isFunctionKey(xkb_keysym_t sym) {
+ return sym >= XKB_KEY_F1 && sym <= XKB_KEY_F35;
+}
+
+static bool isValidTrigger(xkb_keysym_t sym, uint32_t modmask) {
+ if (isFunctionKey(sym))
+ return true;
+ return modmask & (HL_MODIFIER_CTRL | HL_MODIFIER_ALT | HL_MODIFIER_META);
+}
+
+static std::string keybindLabel(const SP& k) {
+ if (!k->description.empty())
+ return k->description;
+ if (!k->arg.empty())
+ return k->handler + ", " + k->arg;
+ return k->handler;
+}
+
+CVicinaeHotkeyManager::CVicinaeHotkeyManager(SP resource_) : m_resource(resource_) {
+ if UNLIKELY (!good())
+ return;
+
+ m_resource->setOnDestroy([this](CVicinaeHotkeyManagerV1*) { PROTO::hotkey->destroyManager(this); });
+ m_resource->setDestroy([this](CVicinaeHotkeyManagerV1*) { PROTO::hotkey->destroyManager(this); });
+
+ m_resource->setBind([this](CVicinaeHotkeyManagerV1* mgr, uint32_t id, uint32_t keysym, vicinaeHotkeyManagerV1Modifiers mods, wl_resource* seat, const char* appid,
+ const char* description) { PROTO::hotkey->onBind(m_resource, id, (xkb_keysym_t)keysym, (uint32_t)mods, appid, description); });
+}
+
+bool CVicinaeHotkeyManager::good() {
+ return m_resource->resource();
+}
+
+CHotkeyProtocol::CHotkeyProtocol(const wl_interface* iface, const int& ver, const std::string& name) : IWaylandProtocol(iface, ver, name) {
+ m_reloadListener = Event::bus()->m_events.config.reloaded.listen([this] { revokeConflicting(); });
+}
+
+void CHotkeyProtocol::bindManager(wl_client* client, void* data, uint32_t ver, uint32_t id) {
+ const auto RESOURCE = m_managers.emplace_back(makeShared(makeShared(client, ver, id)));
+
+ if UNLIKELY (!RESOURCE->good()) {
+ wl_client_post_no_memory(client);
+ m_managers.pop_back();
+ return;
+ }
+}
+
+void CHotkeyProtocol::destroyManager(CVicinaeHotkeyManager* mgr) {
+ // hotkeys outlive their manager
+ std::erase_if(m_managers, [&](const auto& other) { return other.get() == mgr; });
+}
+
+bool CHotkeyProtocol::comboTakenByHotkey(xkb_keysym_t keysym, uint32_t modmask) {
+ for (const auto& hk : m_hotkeys) {
+ if (hk->bound && hk->keysym == keysym && hk->modmask == modmask)
+ return true;
+ }
+ return false;
+}
+
+void CHotkeyProtocol::onBind(SP mgr, uint32_t id, xkb_keysym_t keysym, uint32_t protoMods, const char* appid, const char* description) {
+ auto hk = makeShared();
+ hk->resource = makeShared(mgr->client(), mgr->version(), id);
+
+ if UNLIKELY (!hk->resource->resource()) {
+ mgr->noMemory();
+ return;
+ }
+
+ hk->keysym = keysym;
+ hk->modmask = protoModsToHL(protoMods);
+ hk->appid = appid ? appid : "";
+ hk->description = description ? description : "";
+
+ SBoundHotkey* raw = hk.get();
+ hk->resource->setDestroy([raw](CVicinaeHotkeyV1*) { std::erase_if(PROTO::hotkey->m_hotkeys, [&](const auto& other) { return other.get() == raw; }); });
+ hk->resource->setOnDestroy([raw](CVicinaeHotkeyV1*) { std::erase_if(PROTO::hotkey->m_hotkeys, [&](const auto& other) { return other.get() == raw; }); });
+
+ m_hotkeys.emplace_back(hk);
+
+ if (keysym == XKB_KEY_NoSymbol) {
+ hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_INVALID, "the keysym is not a valid trigger");
+ return;
+ }
+
+ if (!isValidTrigger(keysym, hk->modmask)) {
+ hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_NOT_PERMITTED, "a non-latching modifier (Ctrl, Alt or Super) is required unless the trigger is a function key");
+ return;
+ }
+
+ if (comboTakenByHotkey(keysym, hk->modmask)) {
+ hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_ALREADY_BOUND, "the combination is already bound by another hotkey");
+ return;
+ }
+
+ if (const auto CONFLICT = g_pKeybindManager->findConflictingKeybind(keysym, hk->modmask)) {
+ hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_ALREADY_BOUND,
+ std::format("the combination is reserved by a compositor keybind ({})", keybindLabel(CONFLICT)).c_str());
+ return;
+ }
+
+ hk->bound = true;
+ hk->resource->sendBound();
+}
+
+bool CHotkeyProtocol::onKey(xkb_keysym_t keysym, uint32_t modmask, uint32_t keycode, bool pressed, uint32_t timeMs) {
+ const uint32_t mods = modmask & RELEVANT_MODS;
+ bool consumed = false;
+
+ if (pressed) {
+ for (const auto& hk : m_hotkeys) {
+ if (!hk->bound || hk->held)
+ continue;
+ if (hk->keysym != keysym || hk->modmask != mods)
+ continue;
+
+ hk->held = true;
+ hk->heldCode = keycode;
+ const uint32_t serial = wl_display_next_serial(g_pCompositor->m_wlDisplay);
+ hk->resource->sendPressed(serial, timeMs);
+ consumed = true;
+ }
+ } else {
+ for (const auto& hk : m_hotkeys) {
+ if (!hk->held || hk->heldCode != keycode)
+ continue;
+
+ hk->held = false;
+ hk->heldCode = 0;
+ const uint32_t serial = wl_display_next_serial(g_pCompositor->m_wlDisplay);
+ hk->resource->sendReleased(serial, timeMs);
+ consumed = true;
+ }
+ }
+
+ return consumed;
+}
+
+void CHotkeyProtocol::revokeConflicting() {
+ for (const auto& hk : m_hotkeys) {
+ if (!hk->bound)
+ continue;
+ const auto CONFLICT = g_pKeybindManager->findConflictingKeybind(hk->keysym, hk->modmask);
+ if (!CONFLICT)
+ continue;
+
+ hk->bound = false;
+ hk->held = false;
+ hk->heldCode = 0;
+ hk->resource->sendRevoked(VICINAE_HOTKEY_V1_REVOKE_REASON_SUPERSEDED,
+ std::format("the combination is now reserved by a compositor keybind ({})", keybindLabel(CONFLICT)).c_str());
+ }
+}
diff --git a/src/protocols/Hotkey.hpp b/src/protocols/Hotkey.hpp
new file mode 100644
index 000000000..c57b4f170
--- /dev/null
+++ b/src/protocols/Hotkey.hpp
@@ -0,0 +1,62 @@
+#pragma once
+
+#include "../defines.hpp"
+#include "vicinae-hotkey-v1.hpp"
+#include "./WaylandProtocol.hpp"
+#include "../helpers/signal/Signal.hpp"
+
+#include
+#include
+#include
+
+// hotkeys are owned by the protocol, not this manager, so they outlive it
+class CVicinaeHotkeyManager {
+ public:
+ CVicinaeHotkeyManager(SP resource);
+
+ bool good();
+
+ private:
+ SP m_resource;
+
+ friend class CHotkeyProtocol;
+};
+
+class CHotkeyProtocol : public IWaylandProtocol {
+ public:
+ CHotkeyProtocol(const wl_interface* iface, const int& ver, const std::string& name);
+
+ void bindManager(wl_client* client, void* data, uint32_t ver, uint32_t id) override;
+
+ // returns true if a bound hotkey consumed the key
+ bool onKey(xkb_keysym_t keysym, uint32_t modmask, uint32_t keycode, bool pressed, uint32_t timeMs);
+
+ void revokeConflicting();
+
+ private:
+ struct SBoundHotkey {
+ SP resource;
+ xkb_keysym_t keysym = XKB_KEY_NoSymbol;
+ uint32_t modmask = 0; // HL_MODIFIER_* bits
+ std::string appid;
+ std::string description;
+ bool bound = false;
+ bool held = false;
+ uint32_t heldCode = 0;
+ };
+
+ void onBind(SP mgr, uint32_t id, xkb_keysym_t keysym, uint32_t protoMods, const char* appid, const char* description);
+ void destroyManager(CVicinaeHotkeyManager* mgr);
+ bool comboTakenByHotkey(xkb_keysym_t keysym, uint32_t modmask);
+
+ std::vector> m_managers;
+ std::vector> m_hotkeys;
+
+ CHyprSignalListener m_reloadListener;
+
+ friend class CVicinaeHotkeyManager;
+};
+
+namespace PROTO {
+ inline UP hotkey;
+};