diff --git a/README.md b/README.md index 170dbe4..b2d567d 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ in Wayfire 0.11.0-r19 or newer. Unpatched upstream Wayfire is not supported. | `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` | +| `raise-on-release` | Clicking a background window raises it on release, so you can drag out of it | Wayfire | -All seven build by default. Building a module does not enable it in Wayfire. +All eight 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 @@ -42,6 +43,7 @@ The installed libraries: - `libfind-pointer.so`. - `libclipboard-keys.so`. - `libhold-super.so`. +- `libraise-on-release.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 e98a7dd..f3a7239 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') and not get_option('hold-super') +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') and not get_option('raise-on-release') error('Enable at least one plugin') endif -foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver', 'find-pointer', 'clipboard-keys', 'hold-super'] +foreach plugin : ['window-actions', 'panel-desktop', 'workspace-backgrounds', 'screensaver', 'find-pointer', 'clipboard-keys', 'hold-super', 'raise-on-release'] if get_option(plugin) subdir('plugins' / plugin) endif @@ -18,5 +18,6 @@ summary({ 'find-pointer': get_option('find-pointer'), 'clipboard-keys': get_option('clipboard-keys'), 'hold-super': get_option('hold-super'), + 'raise-on-release': get_option('raise-on-release'), 'tests': get_option('tests'), }, section: 'Custom Wayfire plugins') diff --git a/meson_options.txt b/meson_options.txt index aa4b6d2..1b03ef1 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -7,3 +7,4 @@ option('screensaver', type: 'boolean', value: true, description: 'Per-monitor id 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') +option('raise-on-release', type: 'boolean', value: true, description: 'Clicking a background window raises it on release, so a drag out of it stays behind') diff --git a/plugins/raise-on-release/LICENSE b/plugins/raise-on-release/LICENSE new file mode 100644 index 0000000..d610e4f --- /dev/null +++ b/plugins/raise-on-release/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 raise-on-release 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/raise-on-release/README.md b/plugins/raise-on-release/README.md new file mode 100644 index 0000000..11093f0 --- /dev/null +++ b/plugins/raise-on-release/README.md @@ -0,0 +1,62 @@ +# raise-on-release + +Clicking a window that is behind others gives it keyboard focus at once, but +brings it to the front only when you let go of the button. If the press turns +into a drag, the window stays where it is, so you can drag a file or text out +of a background window and drop it on the window in front. macOS works this +way, and KDE does too since Scott Jenson pointed it out. + +## Setup + +Add `raise-on-release` to `[core] plugins` and leave the left button out of +core's own click-to-focus: + +```ini +[core] +focus_buttons = BTN_MIDDLE | BTN_RIGHT +``` + +The plugin takes the left button only when `core/focus_buttons` leaves it out. +With Wayfire's default (`BTN_LEFT | BTN_MIDDLE | BTN_RIGHT`), core focuses and +raises on press as before, and the plugin does nothing, so a click is never +handled twice. Do not remove `BTN_LEFT` from `focus_buttons` without loading +the plugin: left clicks would then stop focusing windows. + +## Behaviour + +Left-button presses on the app's own surface (its content, or the titlebar it +draws itself) of an ordinary window: + +- **Press:** the window gets keyboard focus at once, and the press reaches the + app as usual. It stays behind any window in front of it. +- **Release:** the window comes to the front, so a plain click looks as it + always did; only the raise happens a moment later. +- **Drag:** the window is not raised if the pointer moves more than 8 pixels + with the button down, or if the app starts a drag-and-drop. After a drop, + focus is wherever the drop leaves it; the plugin changes nothing. +- **Moving or resizing:** when the app asks to move or resize the window (a + drag on its own titlebar or edge), it is raised as the move starts. +- **Focus moves elsewhere** while the button is down (for example the click + opened another window): the window is not raised. + +Everything else behaves exactly as core's click-to-focus: + +- Server-side frames (pixdecor titlebars and borders), panels, docks and + other layer-shell surfaces, popups, and windows that take no keyboard + focus are focused and raised on press. A titlebar drag raises as it starts. +- Clicks with a modifier held (Super+drag moves) are left alone unless + `core/focus_button_with_modifiers` is on; then they raise on press. +- With `core/focus_buttons_passthrough` off, a click that changes focus is + kept from the app (so no drag can start) and raises on press. +- The middle and right buttons, touch and tablet follow `core/focus_buttons` + as before. Keyboard focus changes, Alt+Tab and new windows are untouched. + +While no button is held the plugin only watches button presses. It has no +options of its own, so it can be enabled in a running session. + +## Tests + +`meson test` runs `raise-on-release-press`. It covers which presses wait for +the release (frames, panels, modifiers, passthrough, core still owning the +button) and when a held press stops being a click (threshold, drag-and-drop, +window moves). diff --git a/plugins/raise-on-release/meson.build b/plugins/raise-on-release/meson.build new file mode 100644 index 0000000..7e80efe --- /dev/null +++ b/plugins/raise-on-release/meson.build @@ -0,0 +1,6 @@ +shared_module('raise-on-release', 'src/plugin.cpp', dependencies: wf, + install: true, install_dir: wf.get_variable(pkgconfig: 'plugindir')) +install_data('raise-on-release.xml', install_dir: wf.get_variable(pkgconfig: 'metadatadir')) +if get_option('tests') + test('raise-on-release-press', executable('raise-on-release-test', 'tests/press-test.cpp'), timeout: 30) +endif diff --git a/plugins/raise-on-release/raise-on-release.xml b/plugins/raise-on-release/raise-on-release.xml new file mode 100644 index 0000000..46c85e9 --- /dev/null +++ b/plugins/raise-on-release/raise-on-release.xml @@ -0,0 +1,8 @@ + + + + <_short>Raise on Release + <_long>Clicking a window behind others gives it keyboard focus at once and raises it when the button comes up. If the press becomes a drag, the window stays behind, so you can drag something from it onto the window in front. Takes the left button when Mouse buttons to focus views (core) leaves it out. + Window Management + + diff --git a/plugins/raise-on-release/src/plugin.cpp b/plugins/raise-on-release/src/plugin.cpp new file mode 100644 index 0000000..cf05209 --- /dev/null +++ b/plugins/raise-on-release/src/plugin.cpp @@ -0,0 +1,214 @@ +// raise-on-release: clicking a window behind others gives it keyboard focus +// at once but raises it only when the button comes up, and not at all if the +// press became a drag, so something can be dragged out of a background window +// onto the one in front, as on macOS. It takes the left button only when +// core/focus_buttons leaves it out; idle, it only watches button presses. +#include "press.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using raise_on_release::action_t; +using button_signal = wf::input_event_signal; + +// One app window: a view and its dialogs share their topmost parent. +bool same_window(wayfire_view a, wayfire_view b) +{ + auto ta = wf::toplevel_cast(a), tb = wf::toplevel_cast(b); + return ta && tb && (wf::find_topmost_parent(ta) == wf::find_topmost_parent(tb)); +} + +class raise_on_release_plugin_t : public wf::plugin_interface_t +{ + wf::option_wrapper_t focus_buttons{"core/focus_buttons"}; + wf::option_wrapper_t focus_modifiers{"core/focus_button_with_modifiers"}; + wf::option_wrapper_t passthrough{"core/focus_buttons_passthrough"}; + + raise_on_release::tracker_t tracker; + std::weak_ptr pressed; + wf::wl_listener_wrapper on_start_drag; + + bool core_focuses(uint32_t button) + { + wf::option_sptr_t buttons = focus_buttons; + return buttons->get_value().has_match(wf::buttonbinding_t(0, button)); + } + + // Exactly what core's click-to-focus does. + void focus_now(wayfire_view view, button_signal *ev) + { + auto& seat = wf::get_core().seat; + if (!view->get_keyboard_focus_surface()) + { + wf::view_bring_to_front(view); + return; + } + + auto old_focus = seat->get_active_view(); + wf::get_core().default_wm->focus_raise_view(view); + if (!passthrough && (seat->get_active_view() != old_focus)) + { + ev->mode = wf::input_event_processing_mode_t::NO_CLIENT; + } + } + + // Focus without raising, and watch the press until the button comes up. + void focus_and_wait(wayfire_view view) + { + auto& core = wf::get_core(); + core.seat->focus_output(view->get_output()); + core.seat->focus_view(view); + auto at = core.get_cursor_position(); + tracker.press(at.x, at.y); + pressed = view->weak_from_this(); + core.connect(&on_motion); + core.connect(&on_motion_absolute); + view->get_output()->connect(&on_move_request); + view->get_output()->connect(&on_resize_request); + on_start_drag.connect(&core.get_current_seat()->events.start_drag); + } + + wayfire_view pressed_view() + { + auto view = pressed.lock(); + return (view && view->is_mapped()) ? wayfire_view(view.get()) : nullptr; + } + + void stop() + { + tracker.state = raise_on_release::tracker_t::IDLE; + pressed.reset(); + on_motion.disconnect(); + on_motion_absolute.disconnect(); + on_move_request.disconnect(); + on_resize_request.disconnect(); + on_start_drag.disconnect(); + } + + // Raise, unless focus has meanwhile gone to another window. + void raise(wayfire_view view) + { + if (view && same_window(view, wf::get_core().seat->get_active_view())) + { + wf::view_bring_to_front(view); + } + } + + wf::signal::connection_t on_button = [this] (button_signal *ev) + { + if (ev->event->button != BTN_LEFT) + { + return; + } + + if (ev->event->state == WL_POINTER_BUTTON_STATE_RELEASED) + { + bool click = tracker.release(); + auto view = pressed_view(); + stop(); + if (click) + { + raise(view); + } + + return; + } + + stop(); // a second press without a release (two mice) starts over + if (ev->mode == wf::input_event_processing_mode_t::IGNORE) + { + return; + } + + auto& core = wf::get_core(); + auto view = core.get_cursor_focus_view(); + auto output = core.seat->get_active_output(); + raise_on_release::press_t press; + press.core_focuses = core_focuses(ev->event->button); + press.modifiers = core.seat->get_keyboard_modifiers(); + press.focus_with_modifiers = focus_modifiers; + press.focusable = view && view->is_mapped() && view->get_output() && output && + output->can_activate_plugin(wf::CAPABILITY_MANAGE_DESKTOP); + press.app_window = wf::toplevel_cast(view) && (view->role == wf::VIEW_ROLE_TOPLEVEL); + // The app's own surface, not a server-side frame (pixdecor). + auto node = core.get_cursor_focus(); + press.on_app_surface = dynamic_cast(node.get()) != nullptr; + press.keyboard_focus = view && view->get_keyboard_focus_surface(); + press.passthrough = passthrough; + press.active = view && (core.seat->get_active_view() == view); + switch (raise_on_release::classify(press)) + { + case action_t::NONE: + return; + + case action_t::CORE: + focus_now(view, ev); + return; + + case action_t::DEFER: + focus_and_wait(view); + return; + } + }; + + void moved() + { + auto at = wf::get_core().get_cursor_position(); + tracker.motion(at.x, at.y); + } + + wf::signal::connection_t> on_motion = + [this] (auto) { moved(); }; + wf::signal::connection_t> + on_motion_absolute = [this] (auto) { moved(); }; + + // A client-side titlebar or edge drag: raise as the move or resize starts. + void grabbed(wayfire_view target) + { + auto view = pressed_view(); + if (view && same_window(view, target) && tracker.window_grabbed()) + { + raise(view); + } + } + + wf::signal::connection_t on_move_request = + [this] (wf::view_move_request_signal *ev) { grabbed(ev->view); }; + wf::signal::connection_t on_resize_request = + [this] (wf::view_resize_request_signal *ev) { grabbed(ev->view); }; + + public: + void init() override + { + on_start_drag.set_callback([this] (void*) { tracker.drag_started(); }); + wf::get_core().connect(&on_button); + if (core_focuses(BTN_LEFT)) + { + LOGI("raise-on-release: core/focus_buttons has BTN_LEFT, so core raises on press; " + "leave it out (e.g. BTN_MIDDLE | BTN_RIGHT) to raise on release"); + } + } + + void fini() override + { + stop(); + on_button.disconnect(); + } +}; +} + +DECLARE_WAYFIRE_PLUGIN(raise_on_release_plugin_t); diff --git a/plugins/raise-on-release/src/press.hpp b/plugins/raise-on-release/src/press.hpp new file mode 100644 index 0000000..7d8cf90 --- /dev/null +++ b/plugins/raise-on-release/src/press.hpp @@ -0,0 +1,99 @@ +#pragma once +// Pure raise-on-release logic, no Wayfire dependencies: what to do with a +// left-button press, and whether the held press is still a click. +#include + +namespace raise_on_release +{ +enum class action_t +{ + NONE, // leave the press alone: core focuses it, or nothing would + CORE, // focus and raise now, as core's click-to-focus does + DEFER, // focus now, raise on release unless the press becomes a drag +}; + +// What is known about a left press when it arrives. +struct press_t +{ + bool core_focuses = false; // core/focus_buttons has this button + bool modifiers = false; // a keyboard modifier is held + bool focus_with_modifiers = false; // core/focus_button_with_modifiers + bool focusable = false; // a mapped view is under the pointer and no plugin holds the desktop + bool app_window = false; // that view is an ordinary toplevel (not a panel, popup or desktop) + bool on_app_surface = false; // the press lands on the app's own surface, not a server-side frame + bool keyboard_focus = false; // the view can take keyboard focus + bool passthrough = true; // core/focus_buttons_passthrough + bool active = false; // the view already has keyboard focus +}; + +inline action_t classify(const press_t& p) +{ + if (p.core_focuses || (p.modifiers && !p.focus_with_modifiers) || !p.focusable) + { + return action_t::NONE; + } + + // Frames (so a titlebar drag raises as it starts), panels, popups, + // modifier clicks, and clicks that core would keep from the app. + if (p.modifiers || !p.app_window || !p.on_app_surface || !p.keyboard_focus || + (!p.passthrough && !p.active)) + { + return action_t::CORE; + } + + return action_t::DEFER; +} + +/* A deferred press, from button down to button up. Moving the pointer + * further than `threshold` makes it a drag (no raise on release); an app + * starting drag-and-drop does too, for good. The window manager moving or + * resizing the window (a client-side titlebar drag) raises it at once. */ +struct tracker_t +{ + enum state_t { IDLE, CLICK, MOVED, DND } state = IDLE; + double threshold = 8; // logical pixels; GTK's drag threshold + double x = 0, y = 0; // where the press was + + void press(double px, double py) + { + state = CLICK, x = px, y = py; + } + + bool held() const + { + return state != IDLE; + } + + void motion(double px, double py) + { + if ((state == CLICK) && (std::hypot(px - x, py - y) > threshold)) + { + state = MOVED; + } + } + + void drag_started() + { + if (held()) + { + state = DND; + } + } + + // True if the window should be raised now. + bool window_grabbed() + { + bool raise = (state == CLICK) || (state == MOVED); + state = raise ? IDLE : state; + return raise; + } + + // True if the window should be raised now. + bool release() + { + bool raise = (state == CLICK); + state = IDLE; + return raise; + } +}; +} diff --git a/plugins/raise-on-release/tests/press-test.cpp b/plugins/raise-on-release/tests/press-test.cpp new file mode 100644 index 0000000..9d4c907 --- /dev/null +++ b/plugins/raise-on-release/tests/press-test.cpp @@ -0,0 +1,124 @@ +// Unit tests for raise-on-release: which presses wait for the release, and +// when a held press stops being a click. +#include "../src/press.hpp" +#include +#include + +using namespace raise_on_release; + +// A left press on the client area of a background app window, with Pyr's +// defaults (core/focus_buttons without BTN_LEFT, passthrough on). +static press_t background_click() +{ + press_t p; + p.focusable = p.app_window = p.on_app_surface = p.keyboard_focus = true; + return p; +} + +int main() +{ + // The client area of a background window: focus now, raise on release. + press_t p = background_click(); + assert(classify(p) == action_t::DEFER); + // The active window too (it may sit behind after a drag-out). + p.active = true; + assert(classify(p) == action_t::DEFER); + + // core/focus_buttons still has the button (an existing config): core + // focuses and raises; the plugin does nothing, so nothing happens twice. + p = background_click(); + p.core_focuses = true; + assert(classify(p) == action_t::NONE); + p.modifiers = p.focus_with_modifiers = true; + assert(classify(p) == action_t::NONE); + + // Modifier clicks (Super+drag moves) behave as core: ignored unless + // core/focus_button_with_modifiers, and then raised at once. + p = background_click(); + p.modifiers = true; + assert(classify(p) == action_t::NONE); + p.focus_with_modifiers = true; + assert(classify(p) == action_t::CORE); + + // Nothing to focus, or a plugin holds the desktop: nothing. + p = background_click(); + p.focusable = false; + assert(classify(p) == action_t::NONE); + + // Server-side frames (titlebar, borders) raise at once, so a titlebar + // drag raises as it starts; so do panels, popups and the desktop. + p = background_click(); + p.on_app_surface = false; + assert(classify(p) == action_t::CORE); + p = background_click(); + p.app_window = false; + assert(classify(p) == action_t::CORE); + + // A window that takes no keyboard focus is raised at once, as by core. + p = background_click(); + p.keyboard_focus = false; + assert(classify(p) == action_t::CORE); + + // Without passthrough core keeps a focusing click from the app, so no + // drag can start: behave as core. A click in the active window passes. + p = background_click(); + p.passthrough = false; + assert(classify(p) == action_t::CORE); + p.active = true; + assert(classify(p) == action_t::DEFER); + + // A click, even a slightly shaky one, raises on release. + tracker_t t; + assert(!t.held() && !t.release()); + t.press(100, 100); + assert(t.held()); + t.motion(103, 104); // 5 px + t.motion(108, 100); // 8 px: still a click + assert(t.release() && !t.held()); + assert(!t.release()); // only once + + // Moving further than the threshold makes it a drag: no raise, even if + // the pointer comes back. + t.press(100, 100); + t.motion(106, 106); // 8.5 px + t.motion(100, 100); + assert(t.held() && !t.release()); + + // An app starting drag-and-drop: no raise, whether it started before or + // after the pointer passed the threshold. + t.press(10, 10); + t.drag_started(); + assert(!t.release()); + t.press(10, 10); + t.motion(40, 10); + t.drag_started(); + assert(!t.release()); + // ... and a drop moving nothing afterwards does not bring it back. + t.press(10, 10); + t.drag_started(); + t.motion(10, 10); + assert(!t.window_grabbed() && !t.release()); + // Drag-and-drop outside a press is not ours. + t.drag_started(); + assert(!t.held()); + + // A client-side titlebar drag: the window manager starts moving it. + // Raise at once, before or after the threshold, and not again on release. + t.press(0, 0); + assert(t.window_grabbed() && !t.held() && !t.release()); + t.press(0, 0); + t.motion(0, 30); + assert(t.window_grabbed() && !t.release()); + // A move without a press (keyboard move) is not ours. + assert(!t.window_grabbed()); + + // The threshold follows its setting. + t = {}; + t.threshold = 2; + t.press(0, 0); + t.motion(2, 1); + assert(!t.release()); + + std::puts("raise-on-release press tests passed"); + return 0; +}