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;
+}