panel: scope the capsule's click-away dead zone to its own column
"it only sometimes is dismissed when you click somewhere else": the dismiss scrim's clickable region started at a fixed offset below the screen top (the capsule row plus the drawer's max possible height, kept generous on purpose so an animating drawer never has its result rows swallowed) but via a plain layer-shell margin, which pushes the scrim's *entire width* down by that much — not just the strip under the capsule. That left a full-screen-wide dead band above it (near 470px tall on a 1200px-tall display) where a click neither dismissed the capsule nor hit anything else, since breadbar-dismiss sits on the overlay layer above the bar's own top layer and nothing else was there to catch it either. show_capsule_dismiss now also takes an optional (x, width) hole, computed from the capsule's own real on-screen column — queried live from `hyprctl layers -j` (ground truth; the layer-shell protocol never hands a client its own assigned position back) rather than assumed — and punches exactly that column out of the scrim's input region via a custom cairo Region, leaving the same vertical safety margin in place but no longer swallowing clicks beside the capsule. Falls back to the old full-width margin if the geometry query fails for any reason. Added capsule_dismiss_hole (pure coordinate math, unit tested) and hypr_capsule_center_x (the live query) to main.rs.
This commit is contained in:
parent
fecac7671a
commit
55d0e399bd
2 changed files with 216 additions and 7 deletions
106
src/main.rs
106
src/main.rs
|
|
@ -1060,6 +1060,7 @@ impl SimpleComponent for App {
|
|||
let panels = panels.clone();
|
||||
let idle_width = launcher_cfg.width;
|
||||
let search_width = launcher_cfg.search_width;
|
||||
let monitor_name_for_dismiss = monitor_name.clone();
|
||||
move || {
|
||||
if !launcher_open.get() {
|
||||
launcher_open.set(true);
|
||||
|
|
@ -1081,8 +1082,20 @@ impl SimpleComponent for App {
|
|||
// offset (`capsule_dismiss_margin`), never the live
|
||||
// drawer height — see that constant's own doc comment
|
||||
// for why a live-tracking offset would risk swallowing
|
||||
// clicks meant for a result row.
|
||||
panels.show_capsule_dismiss(capsule_dismiss_margin);
|
||||
// clicks meant for a result row. `hole` scopes that
|
||||
// dead zone to the capsule's own column instead of the
|
||||
// full screen width ("it only sometimes is dismissed
|
||||
// when you click somewhere else") — see
|
||||
// `capsule_dismiss_hole`'s doc comment. Falls back to
|
||||
// the old full-width dead zone if the live geometry
|
||||
// query fails for any reason.
|
||||
let hole = hypr_capsule_center_x(&monitor_name_for_dismiss).and_then(
|
||||
|center_x| {
|
||||
let (origin_x, _) = hypr_monitor_origin(&monitor_name_for_dismiss)?;
|
||||
Some(capsule_dismiss_hole(center_x, origin_x, search_width))
|
||||
},
|
||||
);
|
||||
panels.show_capsule_dismiss(capsule_dismiss_margin, hole);
|
||||
}
|
||||
let target = drawer_target_height(&drawer_box);
|
||||
let current = drawer_box.size_request().1;
|
||||
|
|
@ -2975,6 +2988,64 @@ fn hypr_monitor_origin(name: &str) -> Option<(i32, i32)> {
|
|||
.map(|m| (m.x, m.y))
|
||||
}
|
||||
|
||||
/// The bar/capsule's own layer-surface geometry for `monitor`, straight
|
||||
/// from the compositor (`hyprctl layers -j`, ground truth — not derived
|
||||
/// from anything GTK/gtk4-layer-shell reports client-side, since the
|
||||
/// wlr-layer-shell protocol never hands a client its own assigned x/y back;
|
||||
/// only width/height come through `configure`). Matched by `namespace`
|
||||
/// ("breadbar", set via `root.set_namespace` above), which is unique per
|
||||
/// output since each monitor gets its own bound `App` instance/window.
|
||||
/// Returns the surface's horizontal center in Hyprland's global coordinate
|
||||
/// space. `None` on any parse/lookup failure — callers must fall back to
|
||||
/// the pre-existing, safe-but-broader dead-zone behaviour rather than
|
||||
/// guess.
|
||||
fn hypr_capsule_center_x(monitor: &str) -> Option<i32> {
|
||||
let output = std::process::Command::new("hyprctl")
|
||||
.args(["layers", "-j"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let root: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
|
||||
let levels = root.get(monitor)?.get("levels")?.as_object()?;
|
||||
for arr in levels.values() {
|
||||
let Some(items) = arr.as_array() else {
|
||||
continue;
|
||||
};
|
||||
for item in items {
|
||||
if item.get("namespace").and_then(|v| v.as_str()) != Some("breadbar") {
|
||||
continue;
|
||||
}
|
||||
let x = item.get("x")?.as_i64()? as i32;
|
||||
let w = item.get("w")?.as_i64()? as i32;
|
||||
return Some(x + w / 2);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The click-away scrim's capsule-column hole, in coordinates local to the
|
||||
/// `breadbar-dismiss` surface (see `PanelSet::show_capsule_dismiss`) —
|
||||
/// pure arithmetic, split out for unit testing. `capsule_center_global` and
|
||||
/// `monitor_origin_x` are both in Hyprland's global compositor space
|
||||
/// (`hypr_capsule_center_x`/`hypr_monitor_origin`); `column_width` is the
|
||||
/// capsule's own *configured* search-state width
|
||||
/// (`[launcher].search_width`), not a live-queried one — this fires right
|
||||
/// as `open_fn` starts the width-animation from idle to search width, so a
|
||||
/// live query at that exact instant would catch it mid-transition. Using
|
||||
/// the wider, settled target here (like `DRAWER_MAX_HEIGHT_PX` already does
|
||||
/// for the vertical bound) means the hole is never narrower than the
|
||||
/// capsule ever actually gets while the scrim is showing.
|
||||
fn capsule_dismiss_hole(
|
||||
capsule_center_global: i32,
|
||||
monitor_origin_x: i32,
|
||||
column_width: i32,
|
||||
) -> (i32, i32) {
|
||||
let local_center = capsule_center_global - monitor_origin_x;
|
||||
(local_center - column_width / 2, column_width)
|
||||
}
|
||||
|
||||
/// Hyprland connector names and GDK connector names can disagree after a
|
||||
/// hotplug (`DVI-I-1` vs `DVI-I-2`). Match the connector first, then the
|
||||
/// output's origin — transform swaps width/height so size is not reliable.
|
||||
|
|
@ -3091,6 +3162,37 @@ mod launcher_route_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod capsule_dismiss_hole_tests {
|
||||
use super::capsule_dismiss_hole;
|
||||
|
||||
#[test]
|
||||
fn centered_capsule_on_primary_monitor_at_origin() {
|
||||
// A 520px-wide capsule centered on a 1920px-wide monitor at global
|
||||
// origin (0,0): global center x = 960, monitor origin x = 0.
|
||||
let (x, w) = capsule_dismiss_hole(960, 0, 520);
|
||||
assert_eq!((x, w), (960 - 260, 520));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_origin_secondary_monitor_converts_to_local() {
|
||||
// This machine's own DVI-I-1 (`hyprctl layers -j`, quoted in this
|
||||
// module's doc comments): monitor origin x = -1080. A capsule
|
||||
// centered on that output's own 1080px-wide span sits at global
|
||||
// center x = -1080 + 540 = -540.
|
||||
let (x, w) = capsule_dismiss_hole(-540, -1080, 520);
|
||||
// Local center is 540 (origin subtracted back out); hole starts
|
||||
// 260px to either side of it, independent of the monitor's sign.
|
||||
assert_eq!((x, w), (540 - 260, 520));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_width_always_matches_requested_column_width() {
|
||||
let (_, w) = capsule_dismiss_hole(100, 0, 480);
|
||||
assert_eq!(w, 480);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod url_open_target_tests {
|
||||
use super::url_open_target;
|
||||
|
|
|
|||
117
src/panel.rs
117
src/panel.rs
|
|
@ -5,7 +5,7 @@
|
|||
//! *below* the exclusive zone, and Hyprland slides `breadbar-panel` in from
|
||||
//! the right.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::gdk::Key;
|
||||
|
|
@ -14,10 +14,26 @@ use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell};
|
|||
|
||||
use crate::{bind_layer_monitor, theme};
|
||||
|
||||
/// Outside this rectangle's local x/y span, the dismiss window's own real
|
||||
/// size (Wayland clips an input region to the surface's actual bounds, same
|
||||
/// as `surface::click_through`'s empty-region trick) — big enough to cover
|
||||
/// any realistic monitor layout, including a negative-origin secondary
|
||||
/// output (`hyprctl layers -j` reported `x: -1080` for this machine's own
|
||||
/// DVI-I-1). Centered on the origin so it's safe regardless of which way a
|
||||
/// hole's coordinates end up signed.
|
||||
const HOLE_CANVAS_SPAN: i32 = 20_000;
|
||||
|
||||
/// A boxed, ref-counted, optionally-unset click-away callback — see
|
||||
/// `PanelSet::on_dismiss`'s own doc comment.
|
||||
type DismissCallback = Rc<RefCell<Option<Rc<dyn Fn()>>>>;
|
||||
|
||||
/// The capsule-column hole punched in the dismiss scrim's input region —
|
||||
/// local x, y, width, height — see `PanelSet::dismiss_hole`'s own doc
|
||||
/// comment. Shared (not just passed by value) so `make_dismiss`'s
|
||||
/// `connect_map` hook and every `show_capsule_dismiss`/`reset_dismiss_margin`
|
||||
/// call agree on the current value.
|
||||
type DismissHole = Rc<Cell<Option<(i32, i32, i32, i32)>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PanelSet {
|
||||
pub connectivity: gtk4::Window,
|
||||
|
|
@ -30,6 +46,16 @@ pub struct PanelSet {
|
|||
// capsule's drawer — see `show_capsule_dismiss`/`hide_dismiss` and
|
||||
// `set_on_dismiss`. `None` under every other theme (never set).
|
||||
on_dismiss: DismissCallback,
|
||||
// The rectangle (local to `dismiss`'s own surface coordinates) that
|
||||
// should stay click-through even while the scrim otherwise covers the
|
||||
// screen — `show_capsule_dismiss`'s own doc comment explains why this
|
||||
// exists and how it's computed. `None` = no hole, the plain
|
||||
// margin-based popover behaviour applies instead. Read inside
|
||||
// `dismiss`'s own `connect_map` (the input region can only be set once
|
||||
// the surface is real — see `surface::click_through`'s doc comment for
|
||||
// the same constraint) and, for the case where `dismiss` is already
|
||||
// mapped from a prior show, applied immediately too.
|
||||
dismiss_hole: DismissHole,
|
||||
}
|
||||
|
||||
impl PanelSet {
|
||||
|
|
@ -42,7 +68,8 @@ impl PanelSet {
|
|||
let connectivity = make_panel("wifi-popover", connectivity_child, monitor);
|
||||
let control = make_panel("control-panel", control_child, monitor);
|
||||
let media = make_panel("media-popover", media_child, monitor);
|
||||
let dismiss = make_dismiss(monitor);
|
||||
let dismiss_hole: DismissHole = Rc::new(Cell::new(None));
|
||||
let dismiss = make_dismiss(monitor, &dismiss_hole);
|
||||
|
||||
let set = Self {
|
||||
connectivity,
|
||||
|
|
@ -50,6 +77,7 @@ impl PanelSet {
|
|||
media,
|
||||
dismiss,
|
||||
on_dismiss: Rc::new(RefCell::new(None)),
|
||||
dismiss_hole,
|
||||
};
|
||||
set.wire_dismiss();
|
||||
set.wire_escape();
|
||||
|
|
@ -106,8 +134,31 @@ impl PanelSet {
|
|||
/// (`top`), so if its clickable region ever reached up into where the
|
||||
/// drawer is actually drawn, it would swallow clicks meant for a
|
||||
/// result row instead of forwarding them.
|
||||
pub fn show_capsule_dismiss(&self, top_margin: i32) {
|
||||
self.dismiss.set_margin(Edge::Top, top_margin);
|
||||
///
|
||||
/// Before this fix, that safety was bought with a `set_margin` that
|
||||
/// pushed the scrim's *entire width* down by `top_margin` — leaving a
|
||||
/// full-screen-wide dead band above it (up to ~470px on a 1200px-tall
|
||||
/// display) where a click neither dismissed nor hit anything else, the
|
||||
/// "it only sometimes is dismissed when you click somewhere else"
|
||||
/// report. `hole`, when known (local-to-this-surface x-start/width, in
|
||||
/// `main.rs`'s `capsule_dismiss_hole`), keeps exactly the same
|
||||
/// vertical safety margin but scopes the dead band to the capsule's
|
||||
/// own column instead of the full width, so everywhere else in that
|
||||
/// band is dismiss-clickable too. `None` (geometry unavailable, e.g.
|
||||
/// `hyprctl` failed) falls back to the old full-width behaviour rather
|
||||
/// than risk a hole in the wrong place.
|
||||
pub fn show_capsule_dismiss(&self, top_margin: i32, hole: Option<(i32, i32)>) {
|
||||
match hole {
|
||||
Some((x, w)) if w > 0 => {
|
||||
self.dismiss.set_margin(Edge::Top, 0);
|
||||
self.dismiss_hole.set(Some((x, 0, w, top_margin)));
|
||||
}
|
||||
_ => {
|
||||
self.dismiss.set_margin(Edge::Top, top_margin);
|
||||
self.dismiss_hole.set(None);
|
||||
}
|
||||
}
|
||||
apply_dismiss_hole(&self.dismiss, &self.dismiss_hole);
|
||||
self.dismiss.set_visible(true);
|
||||
self.dismiss.present();
|
||||
}
|
||||
|
|
@ -126,6 +177,10 @@ impl PanelSet {
|
|||
let top = surf.offset.first().copied().unwrap_or(0.0) as i32;
|
||||
self.dismiss.set_margin(Edge::Top, top);
|
||||
}
|
||||
// A stale capsule-shaped hole must not leak into a popover's own
|
||||
// full-width dead zone.
|
||||
self.dismiss_hole.set(None);
|
||||
apply_dismiss_hole(&self.dismiss, &self.dismiss_hole);
|
||||
}
|
||||
|
||||
fn wire_dismiss(&self) {
|
||||
|
|
@ -180,7 +235,7 @@ fn make_panel(class: &str, child: &impl IsA<gtk4::Widget>, monitor: &str) -> gtk
|
|||
window
|
||||
}
|
||||
|
||||
fn make_dismiss(monitor: &str) -> gtk4::Window {
|
||||
fn make_dismiss(monitor: &str, hole: &DismissHole) -> gtk4::Window {
|
||||
let window = gtk4::Window::new();
|
||||
window.add_css_class("breadbar-dismiss");
|
||||
window.init_layer_shell();
|
||||
|
|
@ -206,5 +261,57 @@ fn make_dismiss(monitor: &str) -> gtk4::Window {
|
|||
bind_layer_monitor(&window, monitor);
|
||||
theme::bind_output(&window, monitor);
|
||||
window.set_visible(false);
|
||||
// The underlying `GdkSurface` (and therefore `window.surface()`, which
|
||||
// `apply_dismiss_hole` needs) doesn't exist until the window is mapped
|
||||
// — same constraint `surface::click_through` documents. This surface
|
||||
// gets hidden/shown repeatedly (every popover open/close, every
|
||||
// capsule search), and GTK4 unmaps-then-remaps a toplevel each time
|
||||
// its visibility toggles off then on, so re-applying here on every
|
||||
// `map` (not just the first) is what keeps a freshly (re)shown surface
|
||||
// honouring whatever hole was set before this particular `present()`.
|
||||
{
|
||||
let hole = Rc::clone(hole);
|
||||
window.connect_map(move |win| apply_dismiss_hole(win, &hole));
|
||||
}
|
||||
window
|
||||
}
|
||||
|
||||
/// Sets `dismiss`'s click-away input region to "everywhere" minus `hole`
|
||||
/// (if any) — see `PanelSet::show_capsule_dismiss`'s doc comment for why.
|
||||
/// Only takes effect once `dismiss.surface()` is real, i.e. the window is
|
||||
/// currently mapped; harmlessly no-ops otherwise (the `connect_map` hook in
|
||||
/// `make_dismiss` re-runs this the moment that stops being true).
|
||||
fn apply_dismiss_hole(dismiss: >k4::Window, hole: &DismissHole) {
|
||||
let Some(surface) = dismiss.surface() else {
|
||||
return;
|
||||
};
|
||||
match hole.get() {
|
||||
Some((x, y, w, h)) => {
|
||||
let canvas = gtk4::cairo::RectangleInt::new(
|
||||
-HOLE_CANVAS_SPAN,
|
||||
-HOLE_CANVAS_SPAN,
|
||||
HOLE_CANVAS_SPAN * 2,
|
||||
HOLE_CANVAS_SPAN * 2,
|
||||
);
|
||||
let region = gtk4::cairo::Region::create_rectangle(&canvas);
|
||||
let punch = gtk4::cairo::RectangleInt::new(x, y, w, h);
|
||||
if region.subtract_rectangle(&punch).is_ok() {
|
||||
surface.set_input_region(Some(®ion));
|
||||
} else {
|
||||
// Punching the hole failed for some reason (an invalid
|
||||
// cairo status on a plain rectangle op, effectively
|
||||
// unreachable in practice) — falling back to `None` (the
|
||||
// protocol's documented "no input region set: whole
|
||||
// surface hits") is still safer than leaving whatever
|
||||
// region predates this call in place, which could be
|
||||
// stale from a completely different mode (e.g. an old
|
||||
// popover-shaped margin-only region with no hole at all).
|
||||
eprintln!(
|
||||
"breadbar: could not punch capsule hole in dismiss scrim's input region"
|
||||
);
|
||||
surface.set_input_region(None);
|
||||
}
|
||||
}
|
||||
None => surface.set_input_region(None),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue