diff --git a/src/main.rs b/src/main.rs index 8295e7c..f0cf010 100644 --- a/src/main.rs +++ b/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 { + 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; diff --git a/src/panel.rs b/src/panel.rs index b9982ed..cca16c5 100644 --- a/src/panel.rs +++ b/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>>>; +/// 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>>; + #[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, 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), + } +}