feat(panel): added support for clicking outside the panel to close

This commit is contained in:
Lemmy
2026-04-28 19:17:09 -04:00
parent f83f15322d
commit c05e04cef2
14 changed files with 889 additions and 4 deletions
+4
View File
@@ -265,6 +265,8 @@ foreach _p : [
'protocols/wlr-foreign-toplevel-management-unstable-v1.xml'],
['wlr-data-control-unstable-v1',
'protocols/wlr-data-control-unstable-v1.xml'],
['hyprland-focus-grab-v1',
'protocols/hyprland-focus-grab-v1.xml'],
]
_name = _p[0]
_xml = _p[1]
@@ -428,6 +430,8 @@ _noctalia_sources = files(
'src/shell/osd/audio_osd.cpp',
'src/shell/osd/brightness_osd.cpp',
'src/shell/osd/osd_overlay.cpp',
'src/shell/panel/panel_click_shield.cpp',
'src/shell/panel/panel_focus_grab.cpp',
'src/shell/panel/panel_manager.cpp',
'src/shell/polkit/polkit_panel.cpp',
'src/shell/session/session_panel.cpp',
+128
View File
@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="hyprland_focus_grab_v1">
<copyright>
Copyright © 2024 outfoxxed
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</copyright>
<description summary="limit input focus to a set of surfaces">
This protocol allows clients to limit input focus to a specific set
of surfaces and receive a notification when the limiter is removed as
detailed below.
</description>
<interface name="hyprland_focus_grab_manager_v1" version="1">
<description summary="manager for focus grab objects">
This interface allows a client to create surface grab objects.
</description>
<request name="create_grab">
<description summary="create a focus grab object">
Create a surface grab object.
</description>
<arg name="grab" type="new_id" interface="hyprland_focus_grab_v1"/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the focus grab manager">
Destroy the focus grab manager.
This doesn't destroy existing focus grab objects.
</description>
</request>
</interface>
<interface name="hyprland_focus_grab_v1" version="1">
<description summary="input focus limiter">
This interface restricts input focus to a specified whitelist of
surfaces as long as the focus grab object exists and has at least
one comitted surface.
Mouse and touch events inside a whitelisted surface will be passed
to the surface normally, while events outside of a whitelisted surface
will clear the grab object. Keyboard events will be passed to the client
and a compositor-picked surface in the whitelist will receive a
wl_keyboard::enter event if a whitelisted surface is not already entered.
Upon meeting implementation-defined criteria usually meaning a mouse or
touch input outside of any whitelisted surfaces, the compositor will
clear the whitelist, rendering the grab inert and sending the cleared
event. The same will happen if another focus grab or similar action
is started at the compositor's discretion.
</description>
<request name="add_surface">
<description summary="add a surface to the focus whitelist">
Add a surface to the whitelist. Destroying the surface is treated the
same as an explicit call to remove_surface and duplicate additions are
ignored.
Does not take effect until commit is called.
</description>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<request name="remove_surface">
<description summary="remove a surface from the focus whitelist">
Remove a surface from the whitelist. Destroying the surface is treated
the same as an explicit call to this function.
If the grab was active and the removed surface was entered by the
keyboard, another surface will be entered on commit.
Does not take effect until commit is called.
</description>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<request name="commit">
<description summary="commit the focus whitelist">
Commit pending changes to the surface whitelist.
If the list previously had no entries and now has at least one, the grab
will start. If it previously had entries and now has none, the grab will
become inert.
</description>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the focus grab">
Destroy the grab object and remove the grab if active.
</description>
</request>
<event name="cleared">
<description summary="the focus grab was cleared">
Sent when an active grab is cancelled by the compositor,
regardless of cause.
</description>
</event>
</interface>
</protocol>
+3
View File
@@ -833,6 +833,9 @@ void Application::initUi() {
[this](wl_output* output, std::optional<AttachedPanelGeometry> geometry) {
m_bar.setAttachedPanelGeometry(output, geometry);
});
m_panelManager.setClickShieldExcludeRectsProvider(
[this](wl_output* output) { return m_bar.surfaceRectsForOutput(output); });
m_panelManager.setFocusGrabBarSurfacesProvider([this]() { return m_bar.allBarSurfaces(); });
m_bar.setAutoHideSuppressionCallback([this]() { return m_trayMenu.isOpen() || m_panelManager.isAttachedOpen(); });
// When config reloads, refresh any open panel: bar-driven attached decoration restyle and
// shell-driven compositor blur.
+81
View File
@@ -571,6 +571,87 @@ std::optional<LayerPopupParentContext> Bar::preferredPopupParentContext(wl_outpu
: std::nullopt;
}
std::vector<InputRect> Bar::surfaceRectsForOutput(wl_output* output) const {
std::vector<InputRect> rects;
if (m_wayland == nullptr || output == nullptr) {
return rects;
}
const WaylandOutput* wlOutput = m_wayland->findOutputByWl(output);
if (wlOutput == nullptr) {
return rects;
}
// logicalWidth/Height become valid only after xdg_output.done; before that
// we cannot accurately place a bottom/right anchored bar.
if (wlOutput->logicalWidth <= 0 || wlOutput->logicalHeight <= 0) {
return rects;
}
const std::int32_t outputW = wlOutput->logicalWidth;
const std::int32_t outputH = wlOutput->logicalHeight;
for (const auto& instance : m_instances) {
if (instance == nullptr || instance->output != output || instance->surface == nullptr) {
continue;
}
const auto* surface = instance->surface.get();
const std::uint32_t anchor = surface->anchor();
const bool aTop = (anchor & LayerShellAnchor::Top) != 0;
const bool aBottom = (anchor & LayerShellAnchor::Bottom) != 0;
const bool aLeft = (anchor & LayerShellAnchor::Left) != 0;
const bool aRight = (anchor & LayerShellAnchor::Right) != 0;
const std::int32_t mTop = surface->marginTop();
const std::int32_t mRight = surface->marginRight();
const std::int32_t mBottom = surface->marginBottom();
const std::int32_t mLeft = surface->marginLeft();
// surface->width()/height() may be 0 before configure; fall back to BarConfig
// thickness so we still publish a sensible exclusion for fresh surfaces.
const std::int32_t surfW = static_cast<std::int32_t>(surface->width());
const std::int32_t surfH = static_cast<std::int32_t>(surface->height());
std::int32_t rectW = surfW;
std::int32_t rectH = surfH;
std::int32_t rectX = 0;
std::int32_t rectY = 0;
if (aLeft && aRight) {
rectW = std::max(0, outputW - mLeft - mRight);
rectX = mLeft;
} else if (aRight) {
rectX = std::max(0, outputW - mRight - rectW);
} else {
rectX = mLeft;
}
if (aTop && aBottom) {
rectH = std::max(0, outputH - mTop - mBottom);
rectY = mTop;
} else if (aBottom) {
rectY = std::max(0, outputH - mBottom - rectH);
} else {
rectY = mTop;
}
if (rectW > 0 && rectH > 0) {
rects.push_back(InputRect{rectX, rectY, rectW, rectH});
}
}
return rects;
}
std::vector<wl_surface*> Bar::allBarSurfaces() const {
std::vector<wl_surface*> surfaces;
surfaces.reserve(m_instances.size());
for (const auto& instance : m_instances) {
if (instance != nullptr && instance->surface != nullptr) {
if (wl_surface* s = instance->surface->wlSurface(); s != nullptr) {
surfaces.push_back(s);
}
}
}
return surfaces;
}
void Bar::setAttachedPanelGeometry(wl_output* output, std::optional<AttachedPanelGeometry> geometry) {
BarInstance* instance = instanceForOutput(output);
if (instance == nullptr) {
+8
View File
@@ -4,6 +4,7 @@
#include "shell/bar/widget_factory.h"
#include "shell/panel/attached_panel_context.h"
#include "ui/dialogs/layer_popup_host.h"
#include "wayland/surface.h"
#include <functional>
#include <memory>
@@ -68,6 +69,13 @@ public:
[[nodiscard]] bool isRunning() const noexcept;
[[nodiscard]] std::optional<LayerPopupParentContext> popupParentContextForSurface(wl_surface* surface) const noexcept;
[[nodiscard]] std::optional<LayerPopupParentContext> preferredPopupParentContext(wl_output* output) const noexcept;
// Returns the bar surface rects on the given output in output-local logical
// coordinates. Used by the panel click shield to keep clicks on bar widgets
// flowing to the bar instead of dismissing the active panel.
[[nodiscard]] std::vector<InputRect> surfaceRectsForOutput(wl_output* output) const;
// Returns every bar wl_surface across all outputs. Used as the focus-grab
// whitelist on Hyprland so bar widgets keep receiving clicks.
[[nodiscard]] std::vector<wl_surface*> allBarSurfaces() const;
void setAttachedPanelGeometry(wl_output* output, std::optional<AttachedPanelGeometry> geometry);
void beginAttachedPopup(wl_surface* surface);
void endAttachedPopup(wl_surface* surface);
+289
View File
@@ -0,0 +1,289 @@
#include "shell/panel/panel_click_shield.h"
#include "compositors/compositor_detect.h"
#include "core/log.h"
#include "viewporter-client-protocol.h"
#include "wayland/wayland_connection.h"
#include "wlr-layer-shell-unstable-v1-client-protocol.h"
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <wayland-client.h>
namespace {
constexpr Logger kLog("panel-click-shield");
// Anonymous file backing for a tiny SHM pool. We use memfd_create so the fd
// is never visible on the filesystem.
int createAnonFd(std::size_t size) {
int fd = memfd_create("noctalia-click-shield", MFD_CLOEXEC);
if (fd < 0) {
return -1;
}
if (ftruncate(fd, static_cast<off_t>(size)) != 0) {
close(fd);
return -1;
}
return fd;
}
const zwlr_layer_surface_v1_listener kLayerSurfaceListener = {
.configure = &PanelClickShield::handleConfigure,
.closed = &PanelClickShield::handleClosed,
};
// Hyprland refuses to deliver pointer events to layer-shell surfaces with
// keyboard_interactivity == None. Exclusive is what unlocks pointer delivery
// there. Bar exclusion via input region doesn't actually work on Hyprland
// (clicks on bars still hit the shield); on Hyprland we prefer the
// hyprland_focus_grab_v1 protocol when available and only fall back to the
// shield path here.
LayerShellKeyboard shieldKeyboardMode() {
return compositors::isHyprland() ? LayerShellKeyboard::Exclusive : LayerShellKeyboard::None;
}
} // namespace
PanelClickShield::~PanelClickShield() {
deactivate();
if (m_buffer != nullptr) {
wl_buffer_destroy(m_buffer);
m_buffer = nullptr;
}
}
void PanelClickShield::initialize(WaylandConnection& wayland) { m_wayland = &wayland; }
bool PanelClickShield::ensureSharedBuffer() {
if (m_buffer != nullptr) {
return true;
}
if (m_wayland == nullptr || m_wayland->shm() == nullptr) {
return false;
}
// 1×1 ARGB8888 — 4 bytes total. Stretched to fullscreen via wp_viewport.
constexpr std::int32_t kWidth = 1;
constexpr std::int32_t kHeight = 1;
constexpr std::int32_t kStride = kWidth * 4;
constexpr std::size_t kSize = static_cast<std::size_t>(kStride * kHeight);
int fd = createAnonFd(kSize);
if (fd < 0) {
kLog.warn("failed to create shm fd: {}", std::strerror(errno));
return false;
}
// ftruncate already zero-fills (transparent ARGB8888).
wl_shm_pool* pool = wl_shm_create_pool(m_wayland->shm(), fd, static_cast<std::int32_t>(kSize));
close(fd);
if (pool == nullptr) {
return false;
}
m_buffer = wl_shm_pool_create_buffer(pool, 0, kWidth, kHeight, kStride, WL_SHM_FORMAT_ARGB8888);
wl_shm_pool_destroy(pool);
return m_buffer != nullptr;
}
void PanelClickShield::activate(const std::vector<wl_output*>& outputs, LayerShellLayer layer,
ExcludeProvider excludeProvider) {
if (m_wayland == nullptr) {
return;
}
// Tear down any previous shields and recreate. Cheaper to refresh than to
// diff outputs/exclude rects, and keeps the dispatch order deterministic.
deactivate();
if (!ensureSharedBuffer()) {
kLog.warn("disabled: shared shm buffer unavailable");
return;
}
if (m_wayland->layerShell() == nullptr || m_wayland->compositor() == nullptr) {
return;
}
if (m_wayland->viewporter() == nullptr) {
// Without viewporter we'd have to allocate a fullscreen-sized buffer to
// make the surface logically fullscreen. Skip the shield rather than burn
// tens of MB of SHM. Most modern compositors support viewporter.
kLog.warn("disabled: wp_viewporter not available");
return;
}
for (wl_output* output : outputs) {
if (output == nullptr || m_shields.find(output) != m_shields.end()) {
continue;
}
std::vector<InputRect> excludeRects;
if (excludeProvider) {
excludeRects = excludeProvider(output);
}
auto shield = createShield(output, layer, std::move(excludeRects));
if (shield) {
m_shields.emplace(output, std::move(shield));
}
}
}
std::unique_ptr<PanelClickShield::Shield> PanelClickShield::createShield(wl_output* output, LayerShellLayer layer,
std::vector<InputRect> excludeRects) {
auto shield = std::make_unique<Shield>();
shield->owner = this;
shield->output = output;
shield->excludeRects = std::move(excludeRects);
shield->surface = wl_compositor_create_surface(m_wayland->compositor());
if (shield->surface == nullptr) {
return nullptr;
}
shield->viewport = wp_viewporter_get_viewport(m_wayland->viewporter(), shield->surface);
shield->layerSurface =
zwlr_layer_shell_v1_get_layer_surface(m_wayland->layerShell(), shield->surface, output,
static_cast<std::uint32_t>(layer), "noctalia-panel-click-shield");
if (shield->layerSurface == nullptr) {
if (shield->viewport != nullptr) {
wp_viewport_destroy(shield->viewport);
}
wl_surface_destroy(shield->surface);
return nullptr;
}
zwlr_layer_surface_v1_add_listener(shield->layerSurface, &kLayerSurfaceListener, shield.get());
zwlr_layer_surface_v1_set_anchor(shield->layerSurface, LayerShellAnchor::Top | LayerShellAnchor::Bottom |
LayerShellAnchor::Left | LayerShellAnchor::Right);
zwlr_layer_surface_v1_set_size(shield->layerSurface, 0, 0);
zwlr_layer_surface_v1_set_exclusive_zone(shield->layerSurface, -1);
zwlr_layer_surface_v1_set_keyboard_interactivity(shield->layerSurface,
static_cast<std::uint32_t>(shieldKeyboardMode()));
// Empty input region until we receive a configure with the actual surface
// size. Until then any click would land on the 1×1 buffer at (0,0) before
// the viewport applies, which we don't want.
if (wl_region* emptyRegion = wl_compositor_create_region(m_wayland->compositor()); emptyRegion != nullptr) {
wl_surface_set_input_region(shield->surface, emptyRegion);
wl_region_destroy(emptyRegion);
}
// Initial commit (no buffer) — required by layer-shell to enter the
// configure round-trip. The buffer is attached on the first configure.
wl_surface_commit(shield->surface);
return shield;
}
void PanelClickShield::deactivate() {
for (auto& [output, shield] : m_shields) {
if (shield) {
destroyShield(*shield);
}
}
m_shields.clear();
}
void PanelClickShield::destroyShield(Shield& shield) {
if (shield.viewport != nullptr) {
wp_viewport_destroy(shield.viewport);
shield.viewport = nullptr;
}
if (shield.layerSurface != nullptr) {
zwlr_layer_surface_v1_destroy(shield.layerSurface);
shield.layerSurface = nullptr;
}
if (shield.surface != nullptr) {
wl_surface_destroy(shield.surface);
shield.surface = nullptr;
}
}
bool PanelClickShield::ownsSurface(wl_surface* surface) const noexcept {
if (surface == nullptr) {
return false;
}
for (const auto& [output, shield] : m_shields) {
if (shield && shield->surface == surface) {
return true;
}
}
return false;
}
void PanelClickShield::handleConfigure(void* data, zwlr_layer_surface_v1* layerSurface, std::uint32_t serial,
std::uint32_t width, std::uint32_t height) {
zwlr_layer_surface_v1_ack_configure(layerSurface, serial);
auto* shield = static_cast<Shield*>(data);
if (shield == nullptr || shield->owner == nullptr) {
return;
}
shield->owner->applyConfigured(*shield, width, height);
}
void PanelClickShield::handleClosed(void* data, zwlr_layer_surface_v1* /*layerSurface*/) {
auto* shield = static_cast<Shield*>(data);
if (shield == nullptr || shield->owner == nullptr) {
return;
}
// Compositor closed our surface. Drop just this shield; the others (and
// the next activate()) keep working.
PanelClickShield* owner = shield->owner;
wl_output* output = shield->output;
auto it = owner->m_shields.find(output);
if (it != owner->m_shields.end() && it->second.get() == shield) {
owner->destroyShield(*it->second);
owner->m_shields.erase(it);
}
}
void PanelClickShield::applyConfigured(Shield& shield, std::uint32_t width, std::uint32_t height) {
if (shield.surface == nullptr) {
return;
}
shield.width = static_cast<std::int32_t>(width);
shield.height = static_cast<std::int32_t>(height);
if (!shield.bufferAttached && m_buffer != nullptr) {
wl_surface_attach(shield.surface, m_buffer, 0, 0);
wl_surface_set_buffer_scale(shield.surface, 1);
wl_surface_damage_buffer(shield.surface, 0, 0, 1, 1);
shield.bufferAttached = true;
}
if (shield.viewport != nullptr && width > 0 && height > 0) {
wp_viewport_set_destination(shield.viewport, static_cast<std::int32_t>(width), static_cast<std::int32_t>(height));
}
shield.configured = true;
applyInputRegion(shield);
wl_surface_commit(shield.surface);
}
void PanelClickShield::applyInputRegion(Shield& shield) {
if (m_wayland == nullptr || shield.surface == nullptr) {
return;
}
if (shield.width <= 0 || shield.height <= 0) {
return;
}
wl_region* region = wl_compositor_create_region(m_wayland->compositor());
if (region == nullptr) {
return;
}
wl_region_add(region, 0, 0, shield.width, shield.height);
for (const auto& r : shield.excludeRects) {
wl_region_subtract(region, r.x, r.y, r.width, r.height);
}
wl_surface_set_input_region(shield.surface, region);
wl_region_destroy(region);
}
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "wayland/layer_surface.h"
#include "wayland/surface.h"
#include <cstdint>
#include <functional>
#include <memory>
#include <unordered_map>
#include <vector>
class WaylandConnection;
struct PointerEvent;
struct wl_buffer;
struct wl_output;
struct wl_surface;
struct wp_viewport;
struct zwlr_layer_surface_v1;
// Transparent fullscreen layer-shell surfaces (one per output) that catch
// clicks landing outside the active panel and outside the bars. Used to
// dismiss panels when the user clicks on an xdg_toplevel app window — Wayland
// routes that click to the app's surface, so without a catcher the panel
// never sees it.
//
// Ordering: shields are mapped on the same layer as the panel; activate()
// must be called BEFORE the panel surface is committed so that the panel
// ends up on top of its co-output shield within the layer (wlroots stacks
// within-layer surfaces in mapping order).
//
// Each shield's input region is fullscreen MINUS the rects returned by the
// exclude provider for that output, so clicks on bar widgets keep flowing
// to the bar.
//
// Keyboard interactivity:
// - Hyprland gates pointer delivery on keyboard_interactivity: layer-shell
// surfaces declared as None never receive pointer events, so the shield
// uses Exclusive there. The panel is also Exclusive and is mapped after
// the shield, so per the layer-shell spec the panel still wins keyboard
// focus.
// - On every other compositor we tested (niri, wlroots vanilla, sway), None
// works fine and avoids touching keyboard focus at all, so we keep that
// as the default.
//
// Buffer strategy: a single shared 1×1 fully-transparent SHM buffer is
// stretched to the per-shield surface size via wp_viewport. Cost is ~4 bytes
// regardless of resolution or output count.
class PanelClickShield {
public:
using ExcludeProvider = std::function<std::vector<InputRect>(wl_output*)>;
PanelClickShield() = default;
~PanelClickShield();
PanelClickShield(const PanelClickShield&) = delete;
PanelClickShield& operator=(const PanelClickShield&) = delete;
void initialize(WaylandConnection& wayland);
// Map a fullscreen shield on each of the given outputs.
void activate(const std::vector<wl_output*>& outputs, LayerShellLayer layer, ExcludeProvider excludeProvider);
// Tear down all shields. Idempotent.
void deactivate();
[[nodiscard]] bool isActive() const noexcept { return !m_shields.empty(); }
[[nodiscard]] bool ownsSurface(wl_surface* surface) const noexcept;
// Public so the C-callback bridge in panel_click_shield.cpp can dispatch.
static void handleConfigure(void* data, zwlr_layer_surface_v1* layerSurface, std::uint32_t serial,
std::uint32_t width, std::uint32_t height);
static void handleClosed(void* data, zwlr_layer_surface_v1* layerSurface);
private:
struct Shield {
PanelClickShield* owner = nullptr;
wl_output* output = nullptr;
wl_surface* surface = nullptr;
zwlr_layer_surface_v1* layerSurface = nullptr;
wp_viewport* viewport = nullptr;
std::int32_t width = 0;
std::int32_t height = 0;
bool configured = false;
bool bufferAttached = false;
std::vector<InputRect> excludeRects;
};
bool ensureSharedBuffer();
std::unique_ptr<Shield> createShield(wl_output* output, LayerShellLayer layer, std::vector<InputRect> excludeRects);
void destroyShield(Shield& shield);
void applyConfigured(Shield& shield, std::uint32_t width, std::uint32_t height);
void applyInputRegion(Shield& shield);
WaylandConnection* m_wayland = nullptr;
wl_buffer* m_buffer = nullptr;
std::unordered_map<wl_output*, std::unique_ptr<Shield>> m_shields;
};
+81
View File
@@ -0,0 +1,81 @@
#include "shell/panel/panel_focus_grab.h"
#include "core/log.h"
#include "hyprland-focus-grab-v1-client-protocol.h"
#include "wayland/wayland_connection.h"
namespace {
constexpr Logger kLog("panel-focus-grab");
const hyprland_focus_grab_v1_listener kFocusGrabListener = {
.cleared = &PanelFocusGrab::handleCleared,
};
} // namespace
PanelFocusGrab::~PanelFocusGrab() { deactivate(); }
void PanelFocusGrab::initialize(WaylandConnection& wayland) { m_wayland = &wayland; }
void PanelFocusGrab::setOnCleared(ClearedCallback callback) { m_onCleared = std::move(callback); }
bool PanelFocusGrab::available() const noexcept {
return m_wayland != nullptr && m_wayland->hyprlandFocusGrabManager() != nullptr;
}
void PanelFocusGrab::activate(const std::vector<wl_surface*>& surfaces) {
if (!available()) {
return;
}
// Replace any existing grab. We don't try to diff: panels open infrequently
// enough that recreating the grab keeps the lifecycle obvious.
deactivate();
auto* manager = m_wayland->hyprlandFocusGrabManager();
m_grab = hyprland_focus_grab_manager_v1_create_grab(manager);
if (m_grab == nullptr) {
kLog.warn("create_grab returned null");
return;
}
hyprland_focus_grab_v1_add_listener(m_grab, &kFocusGrabListener, this);
std::size_t added = 0;
for (wl_surface* surface : surfaces) {
if (surface == nullptr) {
continue;
}
hyprland_focus_grab_v1_add_surface(m_grab, surface);
++added;
}
if (added == 0) {
// Per protocol, committing an empty whitelist is inert. Tear down so we
// don't leak the proxy, and so available()/isActive() stay consistent.
hyprland_focus_grab_v1_destroy(m_grab);
m_grab = nullptr;
return;
}
hyprland_focus_grab_v1_commit(m_grab);
}
void PanelFocusGrab::deactivate() {
if (m_grab == nullptr) {
return;
}
hyprland_focus_grab_v1_destroy(m_grab);
m_grab = nullptr;
}
void PanelFocusGrab::handleCleared(void* data, hyprland_focus_grab_v1* /*grab*/) {
auto* self = static_cast<PanelFocusGrab*>(data);
if (self == nullptr) {
return;
}
if (self->m_onCleared) {
self->m_onCleared();
}
}
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <functional>
#include <vector>
class WaylandConnection;
struct hyprland_focus_grab_v1;
struct wl_surface;
// Thin wrapper around hyprland_focus_grab_v1. While active, the compositor
// dispatches pointer/touch events landing inside any whitelisted surface to
// that surface as usual; a click that lands outside the whitelist clears the
// grab and fires `cleared`. We use this on Hyprland in lieu of the click
// shield: same outcome (panel dismisses on outside click) without fighting
// Hyprland's layer-shell input quirks.
//
// Lifecycle:
// - activate(...) creates a fresh grab, adds the given surfaces, commits.
// - deactivate() destroys the grab.
// - On `cleared` the wrapper invokes onCleared (if set). The wrapper does
// NOT auto-deactivate; the owner (PanelManager) decides what to do.
//
// `available()` is true only when the compositor advertises the protocol —
// i.e. on Hyprland builds that support it. Callers should check this and
// fall back to PanelClickShield otherwise.
class PanelFocusGrab {
public:
using ClearedCallback = std::function<void()>;
PanelFocusGrab() = default;
~PanelFocusGrab();
PanelFocusGrab(const PanelFocusGrab&) = delete;
PanelFocusGrab& operator=(const PanelFocusGrab&) = delete;
void initialize(WaylandConnection& wayland);
void setOnCleared(ClearedCallback callback);
[[nodiscard]] bool available() const noexcept;
[[nodiscard]] bool isActive() const noexcept { return m_grab != nullptr; }
// Create a grab and seed its whitelist with `surfaces`. Any surface that
// is null is skipped. Replaces a previous grab if one is already active.
void activate(const std::vector<wl_surface*>& surfaces);
// Destroy the grab if active. Idempotent.
void deactivate();
// Public so the C-callback bridge in panel_focus_grab.cpp can dispatch.
static void handleCleared(void* data, hyprland_focus_grab_v1* grab);
private:
WaylandConnection* m_wayland = nullptr;
hyprland_focus_grab_v1* m_grab = nullptr;
ClearedCallback m_onCleared;
};
+96 -4
View File
@@ -68,6 +68,13 @@ void PanelManager::initialize(WaylandConnection& wayland, ConfigService* config,
m_wayland = &wayland;
m_config = config;
m_renderContext = renderContext;
m_clickShield.initialize(wayland);
m_focusGrab.initialize(wayland);
m_focusGrab.setOnCleared([this]() {
if (isOpen() && !m_closing) {
closePanel();
}
});
}
void PanelManager::setOpenSettingsWindowCallback(std::function<void()> callback) {
@@ -88,6 +95,14 @@ void PanelManager::setAttachedPanelGeometryCallback(
m_attachedPanelGeometryCallback = std::move(callback);
}
void PanelManager::setClickShieldExcludeRectsProvider(std::function<std::vector<InputRect>(wl_output*)> provider) {
m_clickShieldExcludeRectsProvider = std::move(provider);
}
void PanelManager::setFocusGrabBarSurfacesProvider(std::function<std::vector<wl_surface*>()> provider) {
m_focusGrabBarSurfacesProvider = std::move(provider);
}
void PanelManager::registerPanel(const std::string& id, std::unique_ptr<Panel> content) {
m_panels[id] = std::move(content);
}
@@ -117,6 +132,11 @@ void PanelManager::openPanel(const std::string& panelId, wl_output* output, floa
m_activePanel->setContentScale(resolvePanelContentScale(m_config));
m_pendingOpenContext = std::string(context);
// Map shields BEFORE the panel surface is created/committed. Within a
// single layer, wlroots stacks surfaces by mapping order — the shields
// need to be mapped first so the panel ends up on top of them.
activateClickShield();
const auto panelWidth = static_cast<std::uint32_t>(m_activePanel->preferredWidth());
const auto panelHeight = static_cast<std::uint32_t>(m_activePanel->preferredHeight());
const auto barConfig = resolvePanelBarConfig(m_config, m_wayland, output);
@@ -200,6 +220,7 @@ void PanelManager::openPanel(const std::string& panelId, wl_output* output, floa
};
const auto resetPanelOpenState = [this]() {
deactivateOutsideClickHandlers();
m_surface.reset();
m_layerSurface = nullptr;
m_output = nullptr;
@@ -353,10 +374,13 @@ void PanelManager::openPanel(const std::string& panelId, wl_output* output, floa
.marginRight = 0,
.marginBottom = 0,
.marginLeft = surfaceX,
// Force exclusive keyboard so panels with text inputs (search field, etc.) work without
// a prior click — matches the previous subsurface behavior where the bar borrowed
// exclusive focus on the panel's behalf.
.keyboard = LayerShellKeyboard::Exclusive,
// Default: force exclusive keyboard so panels with text inputs (search field, etc.) work
// without a prior click — matches the previous subsurface behavior where the bar
// borrowed exclusive focus on the panel's behalf. On Hyprland, Exclusive on a layer
// surface also grabs the pointer (any click anywhere reports on this surface), which
// breaks outside-click dismissal. When the focus_grab protocol is available we drop to
// OnDemand and let the grab grant keyboard focus to the panel per the spec.
.keyboard = m_focusGrab.available() ? LayerShellKeyboard::OnDemand : LayerShellKeyboard::Exclusive,
.defaultWidth = surfaceWidth,
.defaultHeight = surfaceHeight,
};
@@ -378,6 +402,15 @@ void PanelManager::openPanel(const std::string& panelId, wl_output* output, floa
applyPanelCompositorBlur();
publishAttachedPanelGeometry(m_attachedRevealProgress);
m_surface->requestRedraw();
// Defer the focus grab to the next tick: Hyprland's focus_grab seems to
// need the whitelisted surfaces to actually be mapped, which only
// happens after the configure round-trip completes.
const std::uint64_t gen = m_destroyGeneration;
DeferredCall::callLater([this, gen]() {
if (m_destroyGeneration == gen) {
activateFocusGrab();
}
});
kLog.debug("panel manager: opened \"{}\" as attached layer-shell", panelId);
return;
}
@@ -429,9 +462,61 @@ void PanelManager::openPanel(const std::string& panelId, wl_output* output, floa
m_output = output;
m_wlSurface = m_surface->wlSurface();
applyPanelCompositorBlur();
// Defer the focus grab to the next tick — see attached-path comment above.
const std::uint64_t gen = m_destroyGeneration;
DeferredCall::callLater([this, gen]() {
if (m_destroyGeneration == gen) {
activateFocusGrab();
}
});
kLog.debug("panel manager: opened \"{}\"", panelId);
}
void PanelManager::activateClickShield() {
if (m_activePanel == nullptr || m_wayland == nullptr) {
return;
}
// Hyprland: prefer the native focus-grab path; the shield can't reliably
// exclude bar surfaces there (input region exclusion isn't honored when
// keyboard_interactivity is Exclusive, which is what unlocks pointer
// delivery). Skip the shield and let activateFocusGrab() handle it later.
if (m_focusGrab.available()) {
return;
}
std::vector<wl_output*> outputs;
outputs.reserve(m_wayland->outputs().size());
for (const auto& wlOutput : m_wayland->outputs()) {
if (wlOutput.output != nullptr) {
outputs.push_back(wlOutput.output);
}
}
m_clickShield.activate(outputs, m_activePanel->layer(), m_clickShieldExcludeRectsProvider);
}
void PanelManager::activateFocusGrab() {
if (!m_focusGrab.available() || m_wlSurface == nullptr) {
return;
}
// Whitelist the panel + every bar surface. Clicks on whitelisted surfaces
// pass through normally so bar widgets can toggle the next panel; clicks
// anywhere else clear the grab and we close the panel via the `cleared`
// event handler. The panel uses OnDemand keyboard mode on Hyprland (the
// focus_grab grants keyboard focus to the panel on its own) so the panel
// surface no longer grabs the pointer the way Exclusive does.
std::vector<wl_surface*> whitelist;
whitelist.push_back(m_wlSurface);
if (m_focusGrabBarSurfacesProvider) {
auto bars = m_focusGrabBarSurfacesProvider();
whitelist.insert(whitelist.end(), bars.begin(), bars.end());
}
m_focusGrab.activate(whitelist);
}
void PanelManager::deactivateOutsideClickHandlers() {
m_clickShield.deactivate();
m_focusGrab.deactivate();
}
void PanelManager::closePanel() {
if (!isOpen() || m_inTransition || m_closing) {
return;
@@ -439,6 +524,10 @@ void PanelManager::closePanel() {
kLog.debug("panel manager: closing \"{}\"", m_activePanelId);
// Drop the outside-click handlers as soon as close starts. During the close
// animation we want clicks on apps to behave normally, not re-trigger close.
deactivateOutsideClickHandlers();
// Disable input during close animation
m_inputDispatcher.setSceneRoot(nullptr);
m_closing = true;
@@ -486,6 +575,9 @@ void PanelManager::destroyPanel() {
if (m_attachedToBar && m_attachedPanelGeometryCallback && m_output != nullptr) {
m_attachedPanelGeometryCallback(m_output, std::nullopt);
}
// Defensive: closePanel deactivates first, but destroyPanel can also be
// reached directly (e.g. when openPanel preempts an open panel).
deactivateOutsideClickHandlers();
m_animations.cancelAll();
m_closing = false;
m_pointerInside = false;
+20
View File
@@ -5,6 +5,8 @@
#include "render/scene/node.h"
#include "shell/panel/attached_panel_context.h"
#include "shell/panel/panel.h"
#include "shell/panel/panel_click_shield.h"
#include "shell/panel/panel_focus_grab.h"
#include "ui/dialogs/layer_popup_host.h"
#include "wayland/layer_surface.h"
#include "wayland/surface.h"
@@ -44,6 +46,13 @@ public:
void setOpenSettingsWindowCallback(std::function<void()> callback);
void openSettingsWindow();
void setAttachedPanelGeometryCallback(std::function<void(wl_output*, std::optional<AttachedPanelGeometry>)> callback);
// Callback to query the bar surface rects on a given output, in output-local
// coordinates. The click shield's input region excludes these rects so
// clicks on bar widgets keep flowing to the bar while a panel is open.
void setClickShieldExcludeRectsProvider(std::function<std::vector<InputRect>(wl_output*)> provider);
// Callback returning every bar wl_surface. Used to seed the Hyprland focus
// grab whitelist so bar widgets keep receiving clicks while a panel is open.
void setFocusGrabBarSurfacesProvider(std::function<std::vector<wl_surface*>()> provider);
void registerPanel(const std::string& id, std::unique_ptr<Panel> content);
@@ -88,6 +97,13 @@ private:
void buildScene(std::uint32_t width, std::uint32_t height);
void prepareFrame(bool needsUpdate, bool needsLayout);
void destroyPanel();
// Called BEFORE the panel surface commits so shields sit below the panel
// within the layer-shell layer. No-op when the focus-grab path is in use.
void activateClickShield();
// Called AFTER the panel surface is mapped so the panel wl_surface is
// available for the whitelist. No-op when focus-grab is unavailable.
void activateFocusGrab();
void deactivateOutsideClickHandlers();
void applyAttachedReveal(float progress);
void publishAttachedPanelGeometry(float revealProgress);
// Restyle the attached-panel decoration nodes (bg fill, drop shadow, contact shadow)
@@ -104,6 +120,10 @@ private:
RenderContext* m_renderContext = nullptr;
std::function<void()> m_openSettingsWindow;
std::function<void(wl_output*, std::optional<AttachedPanelGeometry>)> m_attachedPanelGeometryCallback;
std::function<std::vector<InputRect>(wl_output*)> m_clickShieldExcludeRectsProvider;
std::function<std::vector<wl_surface*>()> m_focusGrabBarSurfacesProvider;
PanelClickShield m_clickShield;
PanelFocusGrab m_focusGrab;
std::unique_ptr<Surface> m_surface;
LayerSurface* m_layerSurface = nullptr;
+5
View File
@@ -60,6 +60,11 @@ public:
void setClickThrough(bool clickThrough);
void setKeyboardInteractivity(LayerShellKeyboard mode);
[[nodiscard]] LayerShellKeyboard keyboardInteractivity() const noexcept { return m_config.keyboard; }
[[nodiscard]] std::uint32_t anchor() const noexcept { return m_config.anchor; }
[[nodiscard]] std::int32_t marginTop() const noexcept { return m_config.marginTop; }
[[nodiscard]] std::int32_t marginRight() const noexcept { return m_config.marginRight; }
[[nodiscard]] std::int32_t marginBottom() const noexcept { return m_config.marginBottom; }
[[nodiscard]] std::int32_t marginLeft() const noexcept { return m_config.marginLeft; }
static void handleConfigure(void* data, zwlr_layer_surface_v1* layerSurface, std::uint32_t serial,
std::uint32_t width, std::uint32_t height);
+18
View File
@@ -12,6 +12,7 @@
#include "ext-session-lock-v1-client-protocol.h"
#include "ext-workspace-v1-client-protocol.h"
#include "fractional-scale-v1-client-protocol.h"
#include "hyprland-focus-grab-v1-client-protocol.h"
#include "idle-inhibit-unstable-v1-client-protocol.h"
#include "viewporter-client-protocol.h"
#include "virtual-keyboard-unstable-v1-client-protocol.h"
@@ -46,6 +47,7 @@ namespace {
constexpr std::uint32_t kIdleInhibitManagerVersion = 1;
constexpr std::uint32_t kExtBackgroundEffectManagerVersion = 1;
constexpr std::uint32_t kFractionalScaleManagerVersion = 1;
constexpr std::uint32_t kHyprlandFocusGrabManagerVersion = 1;
constexpr std::uint32_t kViewporterVersion = 1;
constexpr std::uint32_t kOutputVersion = 4;
constexpr std::uint32_t kVirtualKeyboardManagerVersion = 1;
@@ -535,6 +537,10 @@ ext_background_effect_manager_v1* WaylandConnection::backgroundEffectManager() c
wp_fractional_scale_manager_v1* WaylandConnection::fractionalScaleManager() const noexcept {
return m_fractionalScaleManager;
}
hyprland_focus_grab_manager_v1* WaylandConnection::hyprlandFocusGrabManager() const noexcept {
return m_hyprlandFocusGrabManager;
}
wp_viewporter* WaylandConnection::viewporter() const noexcept { return m_viewporter; }
void WaylandConnection::onBackgroundEffectCapabilities(std::uint32_t capabilities) noexcept {
@@ -725,6 +731,13 @@ void WaylandConnection::bindGlobal(wl_registry* registry, std::uint32_t name, co
return;
}
if (interfaceName == hyprland_focus_grab_manager_v1_interface.name) {
const auto bindVersion = std::min(version, kHyprlandFocusGrabManagerVersion);
m_hyprlandFocusGrabManager = static_cast<hyprland_focus_grab_manager_v1*>(
wl_registry_bind(registry, name, &hyprland_focus_grab_manager_v1_interface, bindVersion));
return;
}
if (interfaceName == ext_data_control_manager_v1_interface.name) {
if (m_dataControlManager != nullptr && m_dataControlOps != extDataControlOps()) {
m_dataControlOps->destroyManager(m_dataControlManager);
@@ -861,6 +874,11 @@ void WaylandConnection::cleanup() {
m_fractionalScaleManager = nullptr;
}
if (m_hyprlandFocusGrabManager != nullptr) {
hyprland_focus_grab_manager_v1_destroy(m_hyprlandFocusGrabManager);
m_hyprlandFocusGrabManager = nullptr;
}
if (m_viewporter != nullptr) {
wp_viewporter_destroy(m_viewporter);
m_viewporter = nullptr;
+3
View File
@@ -36,6 +36,7 @@ struct zwlr_foreign_toplevel_manager_v1;
struct zwlr_foreign_toplevel_handle_v1;
struct zdwl_ipc_manager_v2;
struct zwp_virtual_keyboard_manager_v1;
struct hyprland_focus_grab_manager_v1;
struct wp_fractional_scale_manager_v1;
struct wp_viewporter;
class ClipboardService;
@@ -109,6 +110,7 @@ public:
[[nodiscard]] bool hasBackgroundEffectBlur() const noexcept;
[[nodiscard]] ext_background_effect_manager_v1* backgroundEffectManager() const noexcept;
[[nodiscard]] wp_fractional_scale_manager_v1* fractionalScaleManager() const noexcept;
[[nodiscard]] hyprland_focus_grab_manager_v1* hyprlandFocusGrabManager() const noexcept;
[[nodiscard]] wp_viewporter* viewporter() const noexcept;
[[nodiscard]] wl_display* display() const noexcept;
[[nodiscard]] wl_compositor* compositor() const noexcept;
@@ -190,6 +192,7 @@ private:
zwp_idle_inhibit_manager_v1* m_idleInhibitManager = nullptr;
ext_background_effect_manager_v1* m_backgroundEffectManager = nullptr;
wp_fractional_scale_manager_v1* m_fractionalScaleManager = nullptr;
hyprland_focus_grab_manager_v1* m_hyprlandFocusGrabManager = nullptr;
wp_viewporter* m_viewporter = nullptr;
bool m_backgroundEffectBlurSupported = false;
void* m_dataControlManager = nullptr;