breadbox: fix spurious close via transparent backdrop window

EventControllerFocus::connect_leave was firing whenever the pointer left
the launcher surface because Hyprland's focus-follows-mouse hands keyboard
focus back to the window under the cursor (OnDemand mode).

Fix: introduce a full-screen transparent backdrop window at Layer::Top.
The launcher sits at Layer::Overlay (above it), so the backdrop never
intercepts launcher clicks. Clicking anywhere outside the launcher hits
the backdrop and closes both windows via a shared Rc<dyn Fn()> callback.
The launcher returns to KeyboardMode::Exclusive so the compositor can no
longer steal focus on pointer leave. EventControllerFocus is removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Breadway 2026-05-23 12:31:14 +08:00
parent f665320634
commit afd1f8f9e2

View file

@ -5,6 +5,7 @@ use std::{
io::{BufRead, BufReader, Write}, io::{BufRead, BufReader, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Command, Stdio}, process::{Command, Stdio},
rc::Rc,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
@ -21,7 +22,10 @@ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
const CACHE_TIMEOUT_SECS: u64 = 86400; const CACHE_TIMEOUT_SECS: u64 = 86400;
const CSS: &str = " const CSS: &str = "
window, .background { window {
background-color: transparent;
}
.launcher-bg {
background-color: #1e1e2e; background-color: #1e1e2e;
} }
searchentry { searchentry {
@ -324,18 +328,45 @@ fn run_ui(entries: Vec<(String, String)>) {
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );
// Full-screen transparent backdrop at Layer::Top — catches clicks
// outside the launcher and closes it. Sits below the launcher
// (Layer::Overlay) so it never intercepts clicks on the UI itself.
let backdrop = ApplicationWindow::builder()
.application(app)
.build();
backdrop.init_layer_shell();
backdrop.set_layer(Layer::Top);
backdrop.set_keyboard_mode(KeyboardMode::None);
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
backdrop.set_anchor(edge, true);
}
// Main launcher window
let window = ApplicationWindow::builder() let window = ApplicationWindow::builder()
.application(app) .application(app)
.default_width(700) .default_width(700)
.build(); .build();
window.init_layer_shell(); window.init_layer_shell();
window.set_layer(Layer::Overlay); window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::OnDemand); // Exclusive: compositor won't hand focus to another window on pointer
// leave, so the backdrop (not a focus event) handles click-outside.
window.set_keyboard_mode(KeyboardMode::Exclusive);
window.set_anchor(Edge::Top, true); window.set_anchor(Edge::Top, true);
window.set_exclusive_zone(-1); window.set_exclusive_zone(-1);
// Shared close: dismisses both windows and cleans up the PID file.
let close_all: Rc<dyn Fn()> = Rc::new({
let w = window.clone();
let b = backdrop.clone();
move || {
cleanup_pid();
w.close();
b.close();
}
});
let vbox = GBox::new(Orientation::Vertical, 0); let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("launcher-bg");
let search = SearchEntry::new(); let search = SearchEntry::new();
search.set_placeholder_text(Some("breadbox")); search.set_placeholder_text(Some("breadbox"));
@ -406,14 +437,13 @@ fn run_ui(entries: Vec<(String, String)>) {
// intercept before SearchEntry's own handlers consume them // intercept before SearchEntry's own handlers consume them
let key_ctrl = EventControllerKey::new(); let key_ctrl = EventControllerKey::new();
key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture);
let window_k = window.clone(); let close_k = Rc::clone(&close_all);
let list_k = list.clone(); let list_k = list.clone();
key_ctrl.connect_key_pressed(move |_, key, _, _| { key_ctrl.connect_key_pressed(move |_, key, _, _| {
use gtk4::gdk::Key; use gtk4::gdk::Key;
match key { match key {
Key::Escape => { Key::Escape => {
cleanup_pid(); close_k();
window_k.close();
glib::Propagation::Stop glib::Propagation::Stop
} }
Key::Return | Key::KP_Enter => { Key::Return | Key::KP_Enter => {
@ -421,8 +451,7 @@ fn run_ui(entries: Vec<(String, String)>) {
let action = get_row_data(&row, "action"); let action = get_row_data(&row, "action");
if !action.is_empty() { if !action.is_empty() {
do_launch(&action); do_launch(&action);
cleanup_pid(); close_k();
window_k.close();
} }
} }
glib::Propagation::Stop glib::Propagation::Stop
@ -465,29 +494,26 @@ fn run_ui(entries: Vec<(String, String)>) {
}); });
window.add_controller(key_ctrl); window.add_controller(key_ctrl);
// Click to launch // Row click / Enter activates launch
let window_a = window.clone(); let close_a = Rc::clone(&close_all);
list.connect_row_activated(move |_, row| { list.connect_row_activated(move |_, row| {
let action = get_row_data(row, "action"); let action = get_row_data(row, "action");
if !action.is_empty() { if !action.is_empty() {
do_launch(&action); do_launch(&action);
cleanup_pid(); close_a();
window_a.close();
} }
}); });
// Close when focus leaves the window (click outside, alt-tab, etc.) // Backdrop click: user clicked outside the launcher
let window_foc = window.clone(); let close_bd = Rc::clone(&close_all);
let focus_ctrl = gtk4::EventControllerFocus::new(); let click = gtk4::GestureClick::new();
focus_ctrl.connect_leave(move |_| { click.connect_released(move |_, _, _, _| close_bd());
cleanup_pid(); backdrop.add_controller(click);
window_foc.close();
});
window.add_controller(focus_ctrl);
// Cleanup pid when window is destroyed for any reason // Safety net: clean up PID if the window is destroyed by the compositor
window.connect_destroy(|_| cleanup_pid()); window.connect_destroy(|_| cleanup_pid());
backdrop.present();
window.present(); window.present();
search.grab_focus(); search.grab_focus();
}); });