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 = `, + 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 @@ + + + + <_short>Hold Super + <_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. + Desktop + + + + 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +double now() +{ + return std::chrono::duration(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 show_shortcuts{"hold-super/show_shortcuts"}; + wf::option_wrapper_t delay{"hold-super/delay"}; + + hold_super::hold_detector_t hold; + wf::wl_timer 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 found{focus, false}; + wl_client_for_each_resource(wl_resource_get_client(focus->resource), [] (wl_resource *r, void *data) + { + auto *found = static_cast*>(data); + if (!std::strcmp(wl_resource_get_class(r), "zwp_keyboard_shortcuts_inhibitor_v1")) + { + auto *inhibitor = static_cast(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 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 + using input_t = wf::signal::connection_t>; + + // Observed, never consumed. + input_t on_key = [this] (wf::input_event_signal *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 on_button = [this] (auto) { other(); }; + input_t on_axis = [this] (auto) { other(); }; + input_t 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 +#include + +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; +}