Skip to content

Commit 148534d

Browse files
committed
feat: add xx-hotkey-v1
1 parent 5d13710 commit 148534d

8 files changed

Lines changed: 676 additions & 16 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
runs-on: ubuntu-latest
1111
steps:
1212
- uses: actions/checkout@v4
13-
- name: Install libxml2-utils
14-
run: sudo apt-get update && sudo apt-get install -y libxml2-utils
13+
- name: Install libxml2-utils and wayland-scanner
14+
run: sudo apt-get update && sudo apt-get install -y libxml2-utils libwayland-bin
1515
- name: Validate protocols
1616
run: ./check.sh

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ Wayland protocol proposals by [Vicinae](https://vicinae.com), laid out
44
like [wayland-protocols](https://gitlab.freedesktop.org/wayland/wayland-protocols)
55
(`staging/<name>/`) for easy upstreaming.
66

7-
> Status: drafts / RFCs. Not yet submitted upstream.
8-
97
## Protocols
108

119
| Protocol | Status | Summary | Implementations |
1210
|---|---|---|---|
13-
| [`vicinae-hotkey`](staging/vicinae-hotkey/) | Staging | Client-managed global hotkeys: the client chooses & reconfigures its own key combos (the Windows/macOS/X11 model, a drop-in for cross-platform apps), while the compositor stays the arbiter (accept/deny/revoke). | [Hyprland](https://github.com/hyprwm/Hyprland/pull/15010)<br />[Ghostty](https://github.com/ghostty-org/ghostty/pull/13464)<br />[Niri (PR)](https://github.com/niri-wm/niri/pull/4145)<br />[wayland-protocols proposal](https://gitlab.freedesktop.org/wayland/wayland-protocols/-/merge_requests/525)|
11+
| [`vicinae-hotkey`](staging/vicinae-hotkey/) | Shipped | Client-managed global hotkeys: the client chooses & reconfigures its own key combos (the Windows/macOS/X11 model, a drop-in for cross-platform apps), while the compositor stays the arbiter (accept/deny/revoke). | [Hyprland](https://github.com/hyprwm/Hyprland/pull/15010)<br />[Ghostty](https://github.com/ghostty-org/ghostty/pull/13464)<br />[Niri (PR)](https://github.com/niri-wm/niri/pull/4145) |
12+
| [`xx-hotkey`](experimental/xx-hotkey/) | Proposed | The same goal, reshaped for upstream: a builder with a repeatable `commit`, a stable `name`, pointer-button and modifier-tap triggers, and protocol errors. Not wire-compatible with `vicinae-hotkey`. | [wayland-protocols proposal](https://gitlab.freedesktop.org/wayland/wayland-protocols/-/merge_requests/525) |
13+
14+
`vicinae-hotkey` is what compositors ship today and is frozen; `xx-hotkey` is the
15+
upstream proposal.
1416

1517
## Why
1618

‎check.sh‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@ cd "$(dirname "$0")"
77

88
status=0
99

10-
for xml in staging/*/*.xml; do
10+
for xml in staging/*/*.xml experimental/*/*.xml; do
11+
[ -e "$xml" ] || continue
12+
1113
if ! xmllint --noout --dtdvalid wayland.dtd "$xml"; then
1214
status=1
1315
fi
1416

17+
# Upstream CI parses with --strict; catch anything the DTD alone misses.
18+
if command -v wayland-scanner >/dev/null 2>&1; then
19+
if ! wayland-scanner --strict client-header "$xml" /dev/null; then
20+
status=1
21+
fi
22+
fi
23+
1524
if grep -nP '\t' "$xml"; then
1625
echo "$xml: tabs found (use spaces)" >&2
1726
status=1

‎examples/.gitignore‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
# wayland-scanner output + built binary
1+
# wayland-scanner output + built binaries
22
vicinae-hotkey-v1-client-protocol.h
33
vicinae-hotkey-v1-protocol.c
44
vicinae-hotkey-demo
5+
xx-hotkey-v1-client-protocol.h
6+
xx-hotkey-v1-protocol.c
7+
xx-hotkey-demo

‎examples/Makefile‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
11
WAYLAND_SCANNER ?= wayland-scanner
2-
PKG_CFLAGS := $(shell pkg-config --cflags wayland-client)
3-
PKG_LIBS := $(shell pkg-config --libs wayland-client)
2+
PKG_CFLAGS := $(shell pkg-config --cflags wayland-client xkbcommon)
3+
PKG_LIBS := $(shell pkg-config --libs wayland-client xkbcommon)
44
CFLAGS ?= -Wall -Wextra -O2
5-
PROTO := ../staging/vicinae-hotkey/vicinae-hotkey-v1.xml
65

7-
GEN := vicinae-hotkey-v1-client-protocol.h vicinae-hotkey-v1-protocol.c
6+
VICINAE_PROTO := ../staging/vicinae-hotkey/vicinae-hotkey-v1.xml
7+
XX_PROTO := ../experimental/xx-hotkey/xx-hotkey-v1.xml
88

9-
vicinae-hotkey-demo: vicinae-hotkey-demo.c $(GEN)
9+
GEN := vicinae-hotkey-v1-client-protocol.h vicinae-hotkey-v1-protocol.c \
10+
xx-hotkey-v1-client-protocol.h xx-hotkey-v1-protocol.c
11+
12+
BINS := vicinae-hotkey-demo xx-hotkey-demo
13+
14+
all: $(BINS)
15+
16+
vicinae-hotkey-demo: vicinae-hotkey-demo.c vicinae-hotkey-v1-protocol.c vicinae-hotkey-v1-client-protocol.h
1017
$(CC) $(CFLAGS) $(PKG_CFLAGS) -o $@ vicinae-hotkey-demo.c vicinae-hotkey-v1-protocol.c $(PKG_LIBS)
1118

12-
vicinae-hotkey-v1-client-protocol.h: $(PROTO)
19+
xx-hotkey-demo: xx-hotkey-demo.c xx-hotkey-v1-protocol.c xx-hotkey-v1-client-protocol.h
20+
$(CC) $(CFLAGS) $(PKG_CFLAGS) -o $@ xx-hotkey-demo.c xx-hotkey-v1-protocol.c $(PKG_LIBS)
21+
22+
vicinae-hotkey-v1-client-protocol.h: $(VICINAE_PROTO)
1323
$(WAYLAND_SCANNER) client-header $< $@
24+
vicinae-hotkey-v1-protocol.c: $(VICINAE_PROTO)
25+
$(WAYLAND_SCANNER) private-code $< $@
1426

15-
vicinae-hotkey-v1-protocol.c: $(PROTO)
27+
xx-hotkey-v1-client-protocol.h: $(XX_PROTO)
28+
$(WAYLAND_SCANNER) client-header $< $@
29+
xx-hotkey-v1-protocol.c: $(XX_PROTO)
1630
$(WAYLAND_SCANNER) private-code $< $@
1731

1832
clean:
19-
rm -f vicinae-hotkey-demo $(GEN)
33+
rm -f $(BINS) $(GEN)
2034

21-
.PHONY: clean
35+
.PHONY: all clean

‎examples/xx-hotkey-demo.c‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Minimal xx-hotkey demo client.
3+
*
4+
* Requests a single global hotkey (Super+Space) and prints the lifecycle and
5+
* trigger/release events. It requires a compositor that implements
6+
* xx_hotkey_manager_v1; without one it just reports that the global is
7+
* missing.
8+
*
9+
* Build with `make xx-hotkey-demo`
10+
*/
11+
#include "xx-hotkey-v1-client-protocol.h"
12+
#include <stdbool.h>
13+
#include <stdint.h>
14+
#include <stdio.h>
15+
#include <string.h>
16+
#include <wayland-client.h>
17+
#include <xkbcommon/xkbcommon-keysyms.h>
18+
#include <xkbcommon/xkbcommon.h>
19+
20+
static struct xx_hotkey_manager_v1 *manager;
21+
22+
static const char *deny_reason_str(uint32_t reason) {
23+
switch (reason) {
24+
case XX_HOTKEY_V1_DENY_REASON_ALREADY_BOUND:
25+
return "already_bound";
26+
case XX_HOTKEY_V1_DENY_REASON_NOT_PERMITTED:
27+
return "not_permitted";
28+
case XX_HOTKEY_V1_DENY_REASON_INVALID:
29+
return "invalid";
30+
default:
31+
return "unknown";
32+
}
33+
}
34+
35+
static const char *revoke_reason_str(uint32_t reason) {
36+
switch (reason) {
37+
case XX_HOTKEY_V1_REVOKE_REASON_REMOVED:
38+
return "removed";
39+
case XX_HOTKEY_V1_REVOKE_REASON_SUPERSEDED:
40+
return "superseded";
41+
case XX_HOTKEY_V1_REVOKE_REASON_NOT_PERMITTED:
42+
return "not_permitted";
43+
default:
44+
return "unknown";
45+
}
46+
}
47+
48+
static void hk_bound(void *data, struct xx_hotkey_v1 *hk) {
49+
(void)data;
50+
(void)hk;
51+
printf("bound: hotkey is active\n");
52+
}
53+
54+
static void hk_denied(void *data, struct xx_hotkey_v1 *hk, uint32_t reason,
55+
const char *message) {
56+
(void)data;
57+
(void)hk;
58+
if (message && message[0])
59+
printf("denied: %s (%s)\n", deny_reason_str(reason), message);
60+
else
61+
printf("denied: %s\n", deny_reason_str(reason));
62+
}
63+
64+
static void hk_revoked(void *data, struct xx_hotkey_v1 *hk, uint32_t reason,
65+
const char *message) {
66+
(void)data;
67+
(void)hk;
68+
if (message && message[0])
69+
printf("revoked: %s (%s)\n", revoke_reason_str(reason), message);
70+
else
71+
printf("revoked: %s\n", revoke_reason_str(reason));
72+
}
73+
74+
static void hk_triggered(void *data, struct xx_hotkey_v1 *hk, uint32_t serial,
75+
uint32_t time) {
76+
(void)data;
77+
(void)hk;
78+
printf("triggered (serial=%u, time=%u)\n", serial, time);
79+
}
80+
81+
static void hk_released(void *data, struct xx_hotkey_v1 *hk, uint32_t serial,
82+
uint32_t time) {
83+
(void)data;
84+
(void)hk;
85+
printf("released (serial=%u, time=%u)\n", serial, time);
86+
}
87+
88+
static const struct xx_hotkey_v1_listener hk_listener = {
89+
.bound = hk_bound,
90+
.denied = hk_denied,
91+
.revoked = hk_revoked,
92+
.triggered = hk_triggered,
93+
.released = hk_released,
94+
};
95+
96+
static void reg_global(void *data, struct wl_registry *reg, uint32_t name,
97+
const char *interface, uint32_t version) {
98+
(void)data;
99+
(void)version;
100+
if (strcmp(interface, xx_hotkey_manager_v1_interface.name) == 0)
101+
manager = wl_registry_bind(reg, name, &xx_hotkey_manager_v1_interface, 1);
102+
}
103+
104+
static void reg_global_remove(void *data, struct wl_registry *reg, uint32_t name) {
105+
(void)data;
106+
(void)reg;
107+
(void)name;
108+
}
109+
110+
static const struct wl_registry_listener reg_listener = {
111+
.global = reg_global,
112+
.global_remove = reg_global_remove,
113+
};
114+
115+
int main(void) {
116+
struct wl_display *display = wl_display_connect(NULL);
117+
if (!display) {
118+
fprintf(stderr, "failed to connect to a wayland display\n");
119+
return 1;
120+
}
121+
122+
struct wl_registry *registry = wl_display_get_registry(display);
123+
wl_registry_add_listener(registry, &reg_listener, NULL);
124+
wl_display_roundtrip(display);
125+
126+
if (!manager) {
127+
fprintf(stderr, "compositor does not implement xx_hotkey_manager_v1\n");
128+
wl_display_disconnect(display);
129+
return 1;
130+
}
131+
132+
xx_hotkey_manager_v1_set_app_id(manager, "com.example.xx-hotkey-demo");
133+
134+
struct xx_hotkey_v1 *hotkey =
135+
xx_hotkey_manager_v1_create_hotkey(manager, "toggle-launcher");
136+
xx_hotkey_v1_add_listener(hotkey, &hk_listener, NULL);
137+
138+
xx_hotkey_v1_set_description(hotkey, "Toggle the launcher");
139+
xx_hotkey_v1_set_key_trigger(hotkey, XKB_KEY_space,
140+
XX_HOTKEY_MANAGER_V1_MODIFIERS_SUPER);
141+
xx_hotkey_v1_commit(hotkey);
142+
143+
printf("requested Super+Space; waiting for events (Ctrl-C to quit)...\n");
144+
145+
while (wl_display_dispatch(display) != -1)
146+
;
147+
148+
xx_hotkey_v1_destroy(hotkey);
149+
xx_hotkey_manager_v1_destroy(manager);
150+
wl_registry_destroy(registry);
151+
wl_display_disconnect(display);
152+
return 0;
153+
}

‎experimental/xx-hotkey/README.md‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# xx-hotkey
2+
3+
A revision of [`vicinae-hotkey`](../../staging/vicinae-hotkey/) prepared for the
4+
`experimental/` track of wayland-protocols. Same goal — an application binds its
5+
own global hotkeys — with a different shape.
6+
7+
The two are **parallel, not sequential**. `vicinae-hotkey-v1` is what Hyprland,
8+
Ghostty and Vicinae ship today and cannot change without breaking them.
9+
`xx-hotkey-v1` is the upstream proposal. They are not wire-compatible.
10+
11+
## At a glance
12+
13+
```c
14+
xx_hotkey_manager_v1_set_app_id(manager, "com.example.app");
15+
16+
struct xx_hotkey_v1 *hotkey =
17+
xx_hotkey_manager_v1_create_hotkey(manager, "toggle-launcher");
18+
xx_hotkey_v1_add_listener(hotkey, &listener, NULL);
19+
20+
xx_hotkey_v1_set_description(hotkey, "Toggle the launcher");
21+
xx_hotkey_v1_set_key_trigger(hotkey, XKB_KEY_space,
22+
XX_HOTKEY_MANAGER_V1_MODIFIERS_SUPER);
23+
xx_hotkey_v1_commit(hotkey);
24+
```
25+
26+
Runnable client in [`../../examples/xx-hotkey-demo.c`](../../examples/xx-hotkey-demo.c).
27+
28+
## What changed, and why
29+
30+
**A builder instead of one `bind` call.** With three trigger kinds and a name to
31+
carry, a single request would take seven arguments and grow one more each time a
32+
trigger kind is added. Building state and applying it with `commit` also gives
33+
somewhere for protocol errors to live.
34+
35+
**`commit` is repeatable.** Committing again changes the trigger while keeping
36+
the binding's identity, so reconfiguring no longer means destroying and
37+
recreating the object. A denied commit leaves the previous binding active, so a
38+
rejected reconfiguration never leaves the user with no hotkey.
39+
40+
**A stable `name`.** A machine-readable identifier distinct from the localized
41+
description, so a client can correlate a binding with its own configuration. It
42+
is a `create_hotkey` argument rather than a setter, being mandatory and
43+
immutable.
44+
45+
**Pointer buttons and modifier-only taps.** A key was the only possible trigger
46+
before. Buttons use the `wl_pointer.button` code space. A modifier-only trigger
47+
fires on release, and only if nothing else was pressed, which is the
48+
tap-Super-to-launch idiom; for press-and-hold on a modifier, bind its keysym as
49+
a key trigger instead.
50+
51+
**`app_id` moved to the manager.** It identifies the connection, not each
52+
binding. Setting it per hotkey meant repeating one string, and let a client
53+
claim two identities.
54+
55+
**Protocol errors.** `invalid_trigger` and `no_trigger` on the hotkey,
56+
`invalid_app_id` and `invalid_name` on the manager. The builder makes illegal
57+
orderings expressible, so they need to be defined.
58+
59+
**RFC 2119 boilerplate**, lowercase keywords, and the `experimental/` disclaimer
60+
rather than the `staging/` one.
61+
62+
## Boundaries
63+
64+
Two things this deliberately does not do, both because they would require the
65+
compositor to store the user's trigger assignments:
66+
67+
- **The compositor never originates or reassigns a binding.** It answers a
68+
commit with `bound` or `denied` and may later `revoke`, but never substitutes
69+
a trigger of its own. Compositor-assigned, persisted shortcuts are what the
70+
desktop portal's GlobalShortcuts interface is for.
71+
- **Nothing is persisted.** A binding lives as long as its object and the client
72+
connection. Compositor state is limited to policy, which it may persist freely.
73+
74+
There is also no request to refuse a client outright: a compositor that does not
75+
want a client to have hotkeys should not advertise the global to it, as with
76+
other restricted protocols.

0 commit comments

Comments
 (0)