Daylight bar chrome + embedded launcher, v0.7.5 pins #5
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;
|
||||
|
|
|
|||
115
src/panel.rs
115
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) {
|
||||
///
|
||||
/// 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