1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
|
diff --git a/README.md b/README.md
index 233d2ca..170dbe4 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,9 @@ in Wayfire 0.11.0-r19 or newer. Unpatched upstream Wayfire is not supported.
| `screensaver` | Per-monitor idle fade, black, power-off, keep-awake and auto-lock | Wayfire |
| `find-pointer` | Press Ctrl twice to spotlight the pointer | Wayfire (GLES2 renderer) |
| `clipboard-keys` | Super+C and Super+V copy and paste in every app | Wayfire |
+| `hold-super` | Hold Super to see the shortcut sheet | Wayfire, `pyr keys` |
-All six build by default. Building a module does not enable it in Wayfire.
+All seven build by default. Building a module does not enable it in Wayfire.
Window-actions owns the desktop's spatial transactions, previews and physical
capture identity; it has no storage and no options. It replaces the former
`window-state` plugin: in `wayfire.ini`, replace `window-state` with
@@ -40,6 +41,7 @@ The installed libraries:
- `libscreensaver.so`.
- `libfind-pointer.so`.
- `libclipboard-keys.so`.
+- `libhold-super.so`.
The package replaces the standalone implementations. Compatibility ebuilds for
`wayfire-window-state` and `wayfire-panel-desktop` depend
diff --git a/meson.build b/meson.build
index ac0e6bc..e98a7dd 100644
--- a/meson.build
+++ b/meson.build
@@ -2,10 +2,10 @@ project('wayfire-plugins-custom', 'cpp', version: '0.1.0',
default_options: ['cpp_std=c++17', 'warning_level=3'])
wf = dependency('wayfire', version: '>=0.11.0')
-if not get_option('window-actions') and not get_option('panel-desktop') and not get_option('workspace-backgrounds') and not get_option('screensaver') and not get_option('find-pointer') and not get_option('clipboard-keys')
+if not get_option('window-actions') and not get_option('panel-desktop') and not get_option('workspace-backgrounds') and not get_option('screensaver') and not get_option('find-pointer') and not get_option('clipboard-keys') and not get_option('hold-super')
error('Enable at least one plugin')
endif
-foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver', 'find-pointer', 'clipboard-keys']
+foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver', 'find-pointer', 'clipboard-keys', 'hold-super']
if get_option(plugin)
subdir('plugins' / plugin)
endif
@@ -17,5 +17,6 @@ summary({
'screensaver': get_option('screensaver'),
'find-pointer': get_option('find-pointer'),
'clipboard-keys': get_option('clipboard-keys'),
+ 'hold-super': get_option('hold-super'),
'tests': get_option('tests'),
}, section: 'Custom Wayfire plugins')
diff --git a/meson_options.txt b/meson_options.txt
index a2ec9d2..aa4b6d2 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -6,3 +6,4 @@ option('workspace-backgrounds-test-api', type: 'boolean', value: false, descript
option('screensaver', type: 'boolean', value: true, description: 'Per-monitor idle fading, black, power-off, keep-awake and auto-lock')
option('find-pointer', type: 'boolean', value: true, description: 'Press Ctrl twice to spotlight the pointer')
option('clipboard-keys', type: 'boolean', value: true, description: 'Super+C and Super+V copy and paste in every app')
+option('hold-super', type: 'boolean', value: true, description: 'Hold Super to see the shortcut sheet')
diff --git a/plugins/hold-super/LICENSE b/plugins/hold-super/LICENSE
new file mode 100644
index 0000000..767a17a
--- /dev/null
+++ b/plugins/hold-super/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 hold-super contributors
+
+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 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.
diff --git a/plugins/hold-super/README.md b/plugins/hold-super/README.md
new file mode 100644
index 0000000..b16f37a
--- /dev/null
+++ b/plugins/hold-super/README.md
@@ -0,0 +1,52 @@
+# hold-super
+
+Hold Super on its own for a moment to see every shortcut; let go to close the
+sheet. It is the same sheet as Super+K (`pyr keys`), so a forgotten shortcut is
+one held key away and gone again when you release it.
+
+Add `hold-super` to `[core] plugins`.
+
+## Behaviour
+
+- **Hold.** Press Super (either one, or a key remapped to Super) with nothing
+ else held and keep holding it for `delay` (800 ms). The sheet opens on the
+ focused monitor. Releasing Super closes it. A shorter press does nothing new:
+ a quick tap still toggles the menu (`[wayfire-shell] toggle_menu = <super>`,
+ which Wayfire fires only for taps under `input/modifier_binding_timeout`,
+ 400 ms), so tap Super for the menu and hold it for the shortcuts.
+- **Shortcuts are unaffected.** The plugin only watches input: bindings and
+ apps receive every key, Super included, exactly as before. Pressing another
+ key, clicking, scrolling or touching before the delay cancels the hold, so
+ Super+Space, Super+K, Super+C/V and Super+drag never open the sheet. Once the
+ sheet is open, other Super shortcuts still work until Super is released.
+- **The open sheet is left alone.** If the sheet is already open (Super+K), a
+ hold neither opens a second one nor closes it. Pressing K while holding
+ (Super+K) closes the held sheet early.
+- **Ignored** while the screen is locked, while a fullscreen window has focus
+ (games; the sheet would take their keyboard), and while the focused app
+ takes every key (virtual machines and remote desktops that inhibit
+ compositor shortcuts).
+- **Cost.** No render hooks and no per-frame work. Idle, it only watches
+ input events.
+
+The sheet is started through Wayfire with `pyr keys`, which execs in place into
+`pyr-keys`; the plugin keeps a pidfd for it and sends it SIGTERM on release.
+
+## Options
+
+| Option | Default | Meaning |
+| --- | --- | --- |
+| `show_shortcuts` | `true` | Hold Super to see the shortcuts. |
+| `delay` | `800` | Milliseconds Super must be held alone (500–5000). Keep it above `input/modifier_binding_timeout`. |
+
+```ini
+[hold-super]
+show_shortcuts = true
+delay = 800
+```
+
+## Tests
+
+`meson test` runs `hold-super-hold`. It covers the hold rules: the delay, a
+short tap, other input before and after the delay, chords, the other Super
+key and repeated events.
diff --git a/plugins/hold-super/hold-super.xml b/plugins/hold-super/hold-super.xml
new file mode 100644
index 0000000..eeb0740
--- /dev/null
+++ b/plugins/hold-super/hold-super.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0"?>
+<wayfire>
+ <plugin name="hold-super">
+ <_short>Hold Super</_short>
+ <_long>Hold Super alone for a moment to see every shortcut; release it to close the sheet. Pressing another key, clicking or scrolling first cancels, so Super shortcuts work as before.</_long>
+ <category>Desktop</category>
+ <option name="show_shortcuts" type="bool">
+ <_short>Hold Super to see the shortcuts</_short>
+ <_long>Ignored while the screen is locked, while a fullscreen window has focus, and in apps that take every key (virtual machines, remote desktops).</_long>
+ <default>true</default>
+ </option>
+ <option name="delay" type="int">
+ <_short>Hold time</_short>
+ <_long>Milliseconds Super must be held alone before the shortcuts show. Keep it above the modifier binding timeout (400 ms), so a quick tap of Super still opens the menu.</_long>
+ <default>800</default>
+ <min>500</min>
+ <max>5000</max>
+ </option>
+ </plugin>
+</wayfire>
diff --git a/plugins/hold-super/meson.build b/plugins/hold-super/meson.build
new file mode 100644
index 0000000..403bcd0
--- /dev/null
+++ b/plugins/hold-super/meson.build
@@ -0,0 +1,6 @@
+shared_module('hold-super', 'src/plugin.cpp', dependencies: wf,
+ install: true, install_dir: wf.get_variable(pkgconfig: 'plugindir'))
+install_data('hold-super.xml', install_dir: wf.get_variable(pkgconfig: 'metadatadir'))
+if get_option('tests')
+ test('hold-super-hold', executable('hold-super-test', 'tests/hold-test.cpp'), timeout: 30)
+endif
diff --git a/plugins/hold-super/src/hold.hpp b/plugins/hold-super/src/hold.hpp
new file mode 100644
index 0000000..a9453a7
--- /dev/null
+++ b/plugins/hold-super/src/hold.hpp
@@ -0,0 +1,61 @@
+#pragma once
+// Pure hold-super logic, no Wayfire dependencies: the hold detector.
+
+namespace hold_super
+{
+/* Super held alone. A press of Super with nothing else held arms the hold; it
+ * is due `delay` seconds later unless Super is released or another key,
+ * button or scroll comes first (a shortcut in progress). Once due it lasts
+ * until Super is released, whatever else is pressed meanwhile. A Super press
+ * that is part of a chord arms nothing until Super is released. */
+struct hold_detector_t
+{
+ double delay = 0.8;
+ enum state_t { IDLE, ARMED, DUE, CANCELLED } state = IDLE;
+ double since = 0;
+
+ // A Super press at time t (seconds). alone: no other key or button is
+ // held. Returns true when this press arms the hold (start a timer).
+ bool press(double t, bool alone)
+ {
+ if (state != IDLE)
+ {
+ return false; // the other Super key, or a repeated event
+ }
+
+ state = alone ? ARMED : CANCELLED;
+ since = t;
+ return state == ARMED;
+ }
+
+ // The timer at time t: true once, when an armed hold becomes due. A few
+ // milliseconds early counts, as timers are rounded to milliseconds.
+ bool due(double t)
+ {
+ if ((state != ARMED) || (t - since + 0.005 < delay))
+ {
+ return false;
+ }
+
+ state = DUE;
+ return true;
+ }
+
+ // Super released. Returns true when it ends a hold that was due.
+ bool release()
+ {
+ bool was_due = state == DUE;
+ state = IDLE;
+ return was_due;
+ }
+
+ // Another key, a button, a scroll or a touch.
+ void other()
+ {
+ if (state == ARMED)
+ {
+ state = CANCELLED;
+ }
+ }
+};
+}
diff --git a/plugins/hold-super/src/plugin.cpp b/plugins/hold-super/src/plugin.cpp
new file mode 100644
index 0000000..25f21a7
--- /dev/null
+++ b/plugins/hold-super/src/plugin.cpp
@@ -0,0 +1,224 @@
+// hold-super: hold Super alone for a moment to see the shortcut sheet (the
+// Super+K sheet); release Super to close it. The plugin only watches input:
+// every key, Super included, still reaches bindings and apps, and any other
+// key, click or scroll before the delay cancels the hold, so Super shortcuts
+// work exactly as before. No render hooks; idle, it only watches keys.
+#include "hold.hpp"
+#include <wayfire/core.hpp>
+#include <wayfire/option-wrapper.hpp>
+#include <wayfire/output.hpp>
+#include <wayfire/output-layout.hpp>
+#include <wayfire/plugin.hpp>
+#include <wayfire/seat.hpp>
+#include <wayfire/signal-definitions.hpp>
+#include <wayfire/toplevel-view.hpp>
+#include <wayfire/util.hpp>
+#include <wayfire/view.hpp>
+#include <wayfire/nonstd/wlroots-full.hpp>
+#include <linux/input-event-codes.h>
+#include <sys/syscall.h>
+#include <xkbcommon/xkbcommon.h>
+#include <algorithm>
+#include <chrono>
+#include <csignal>
+#include <cstring>
+#include <unistd.h>
+
+namespace
+{
+double now()
+{
+ return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
+}
+
+// The sheet. `pyr keys` and the session launcher exec in place, so the pid
+// Wayfire returns becomes pyr-keys itself, which quits cleanly on SIGTERM.
+const char *sheet_command = "pyr keys";
+const char *sheet_app_id = "wf-keys"; // its layer-shell namespace
+
+// A key that types Super_L or Super_R in the keyboard's layout, so a key
+// remapped to Super (caps:super) counts too.
+bool is_super(wlr_keyboard *kbd, uint32_t key)
+{
+ if (!kbd || !kbd->xkb_state)
+ {
+ return (key == KEY_LEFTMETA) || (key == KEY_RIGHTMETA);
+ }
+
+ const xkb_keysym_t *syms;
+ int count = xkb_state_key_get_syms(kbd->xkb_state, key + 8, &syms);
+ return std::any_of(syms, syms + std::max(0, count),
+ [] (xkb_keysym_t s) { return (s == XKB_KEY_Super_L) || (s == XKB_KEY_Super_R); });
+}
+
+class hold_super_t : public wf::plugin_interface_t
+{
+ wf::option_wrapper_t<bool> show_shortcuts{"hold-super/show_shortcuts"};
+ wf::option_wrapper_t<int> delay{"hold-super/delay"};
+
+ hold_super::hold_detector_t hold;
+ wf::wl_timer<false> timer;
+ int sheet = -1; // pidfd of the sheet this plugin started
+
+ static bool locked()
+ {
+ for (auto o : wf::get_core().output_layout->get_outputs())
+ {
+ if (o->is_inhibited())
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // Games and videos, as in find-pointer; the sheet would also take the
+ // game's keyboard.
+ static bool fullscreen_focus()
+ {
+ auto view = wf::toplevel_cast(wf::get_core().seat->get_active_view());
+ return view && view->pending_fullscreen();
+ }
+
+ // The focused app gets every key, Super holds included (a virtual
+ // machine or remote desktop with compositor shortcuts inhibited).
+ static bool shortcuts_inhibited()
+ {
+ wlr_surface *focus = wf::get_core().get_current_seat()->keyboard_state.focused_surface;
+ if (!focus || !focus->resource)
+ {
+ return false;
+ }
+
+ std::pair<wlr_surface*, bool> found{focus, false};
+ wl_client_for_each_resource(wl_resource_get_client(focus->resource), [] (wl_resource *r, void *data)
+ {
+ auto *found = static_cast<std::pair<wlr_surface*, bool>*>(data);
+ if (!std::strcmp(wl_resource_get_class(r), "zwp_keyboard_shortcuts_inhibitor_v1"))
+ {
+ auto *inhibitor = static_cast<wlr_keyboard_shortcuts_inhibitor_v1*>(wl_resource_get_user_data(r));
+ found->second |= inhibitor && inhibitor->active && (inhibitor->surface == found->first);
+ }
+
+ return found->second ? WL_ITERATOR_STOP : WL_ITERATOR_CONTINUE;
+ }, &found);
+ return found.second;
+ }
+
+ // Already open from Super+K: starting it again would close it.
+ static bool sheet_open()
+ {
+ for (auto& view : wf::get_core().get_all_views())
+ {
+ if (view->is_mapped() && (view->get_app_id() == sheet_app_id))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ void show()
+ {
+ if ((sheet >= 0) || locked() || fullscreen_focus() || shortcuts_inhibited() || sheet_open())
+ {
+ return;
+ }
+
+ pid_t pid = wf::get_core().run(sheet_command);
+ sheet = (pid > 0) ? (int)syscall(SYS_pidfd_open, pid, 0) : -1;
+ }
+
+ // The pidfd signals only that process, even if it has exited already
+ // (Super+K closed it, or it quit by itself) and its pid was reused. Raw syscalls:
+ // glibc 2.44's <sys/pidfd.h> lacks extern "C".
+ void hide()
+ {
+ if (sheet >= 0)
+ {
+ syscall(SYS_pidfd_send_signal, sheet, SIGTERM, nullptr, 0);
+ close(sheet);
+ sheet = -1;
+ }
+ }
+
+ void other()
+ {
+ hold.other();
+ if (hold.state != hold.ARMED)
+ {
+ timer.disconnect();
+ }
+ }
+
+ template<class T>
+ using input_t = wf::signal::connection_t<wf::input_event_signal<T>>;
+
+ // Observed, never consumed.
+ input_t<wlr_keyboard_key_event> on_key = [this] (wf::input_event_signal<wlr_keyboard_key_event> *ev)
+ {
+ auto kbd = (ev->device && (ev->device->type == WLR_INPUT_DEVICE_KEYBOARD)) ?
+ wlr_keyboard_from_input_device(ev->device) : nullptr;
+ if (!is_super(kbd, ev->event->keycode))
+ {
+ other();
+ return;
+ }
+
+ if (ev->event->state != WL_KEYBOARD_KEY_STATE_PRESSED)
+ {
+ timer.disconnect();
+ if (hold.release())
+ {
+ hide();
+ }
+
+ return;
+ }
+
+ // Super alone: no other key held on this keyboard, no button held.
+ bool alone = show_shortcuts && !wf::get_core().get_current_seat()->pointer_state.button_count;
+ for (size_t i = 0; kbd && (i < kbd->num_keycodes); i++)
+ {
+ alone &= is_super(kbd, kbd->keycodes[i]);
+ }
+
+ int ms = std::clamp((int)delay, 500, 5000);
+ hold.delay = ms / 1000.0;
+ if (hold.press(now(), alone))
+ {
+ timer.set_timeout(ms, [this]
+ {
+ if (hold.due(now()))
+ {
+ show();
+ }
+ });
+ }
+ };
+
+ input_t<wlr_pointer_button_event> on_button = [this] (auto) { other(); };
+ input_t<wlr_pointer_axis_event> on_axis = [this] (auto) { other(); };
+ input_t<wlr_touch_down_event> on_touch = [this] (auto) { other(); };
+
+ public:
+ void init() override
+ {
+ auto& core = wf::get_core();
+ core.connect(&on_key);
+ core.connect(&on_button);
+ core.connect(&on_axis);
+ core.connect(&on_touch);
+ }
+
+ void fini() override
+ {
+ timer.disconnect();
+ hide();
+ }
+};
+}
+
+DECLARE_WAYFIRE_PLUGIN(hold_super_t);
diff --git a/plugins/hold-super/tests/hold-test.cpp b/plugins/hold-super/tests/hold-test.cpp
new file mode 100644
index 0000000..cb3221d
--- /dev/null
+++ b/plugins/hold-super/tests/hold-test.cpp
@@ -0,0 +1,61 @@
+// Unit tests for the hold-super hold detector.
+#include "../src/hold.hpp"
+#include <cassert>
+#include <cstdio>
+
+using namespace hold_super;
+
+int main()
+{
+ // Held alone past the delay: due once, and its release ends it.
+ hold_detector_t d;
+ assert(d.press(1.0, true));
+ assert(!d.due(1.5));
+ assert(d.due(1.8) && d.state == hold_detector_t::DUE);
+ assert(!d.due(1.9));
+ assert(d.release() && d.state == hold_detector_t::IDLE);
+ // A timer rounded a few milliseconds early still counts; much earlier doesn't.
+ d = {};
+ d.press(1.0, true);
+ assert(d.due(1.797));
+ d = {};
+ d.press(1.0, true);
+ assert(!d.due(1.79));
+ // A short tap: released before the delay, nothing to end; a late timer finds nothing.
+ d = {};
+ assert(d.press(1.0, true));
+ assert(!d.release() && !d.due(1.8));
+ // Another key, button or scroll first (Super+Space, Super+K, Super+drag)
+ // cancels, until Super is released.
+ d = {};
+ d.press(1.0, true);
+ d.other();
+ assert(!d.due(1.8) && !d.due(3.0));
+ assert(!d.press(1.9, true)); // the other Super key does not re-arm it
+ assert(!d.release());
+ assert(d.press(4.0, true) && d.due(4.8)); // the next press is a new hold
+ // Super pressed as part of a chord (Shift held, or a button held) arms nothing,
+ // and stays so while Super is held.
+ d = {};
+ assert(!d.press(1.0, false) && !d.due(2.0));
+ assert(!d.press(1.2, true) && !d.due(2.0));
+ assert(!d.release());
+ // A repeated press or the other Super key keeps the first press's time.
+ d = {};
+ d.press(1.0, true);
+ assert(!d.press(1.5, true));
+ assert(d.due(1.8));
+ // Once due, other keys don't end it (Super+D while looking); only the release does.
+ d.other();
+ assert(d.state == hold_detector_t::DUE && d.release());
+ // A different delay.
+ d = {};
+ d.delay = 0.3;
+ d.press(1.0, true);
+ assert(!d.due(1.2) && d.due(1.3));
+ // A release with nothing pressed (focus or device changes) is harmless.
+ d = {};
+ assert(!d.release() && d.state == hold_detector_t::IDLE);
+ std::puts("hold-super hold: all tests passed");
+ return 0;
+}
|