launcher: open the capsule on the currently-focused monitor

launcher_command::spawn() only runs for the primary App instance, and
AppInput::OpenLauncher always grabbed focus on that instance's own
capsule — but self.monitor is whichever output was focused ONCE, at
that instance's own init(), not re-resolved on every keybind press.
Start breadbar while eDP-1 is focused, move to DVI-I-1, press the
launcher keybind, and the capsule opened on eDP-1, off-screen from
where the user was looking.

Re-resolve the focused Hyprland monitor at fire-time via
primary_hypr_monitor() (already used for the same purpose at startup)
and route to the right instance: the primary's own capsule if the
focused monitor is its own, or forward to that monitor's tracked
Controller<App> in self.satellites otherwise. Falls back to the local
capsule when there's no focused monitor to resolve (screenshot mode,
hyprctl unavailable) or no tracked satellite for it yet (a very recent
hotplug reconcile hasn't caught up with).

Split the routing decision into a pure resolve_launcher_route()
helper so it's unit-testable without a live App/GTK/Hyprland stack;
covered by four new tests.
This commit is contained in:
Breadway 2026-08-25 16:20:59 +08:00
parent 110ad2c6f9
commit 2cbdb58f37

View file

@ -1705,7 +1705,31 @@ impl SimpleComponent for App {
self.panels.hide_all();
}
AppInput::OpenLauncher => {
self.launcher_entry.grab_focus();
// Only the primary instance subscribes to the open command
// (`launcher_command::spawn`), but `self.monitor` here is
// whichever output was focused ONCE, at this instance's own
// `init()` — baked in at process start, not re-resolved on
// every keybind press. If the user has since moved focus to
// a different monitor, blindly grabbing focus on `self`
// would open the capsule on the wrong screen. Re-resolve
// the focused monitor now and route to whichever instance
// actually owns it.
let satellite_names: Vec<&str> =
self.satellites.iter().map(|(n, _)| n.as_str()).collect();
let focused = primary_hypr_monitor();
match resolve_launcher_route(focused.as_deref(), &self.monitor, &satellite_names)
{
LauncherRoute::Satellite(name) => {
// resolve_launcher_route only returns a name present in
// satellite_names, so this lookup cannot miss.
if let Some((_, ctrl)) = self.satellites.iter().find(|(n, _)| *n == name) {
ctrl.sender().emit(AppInput::OpenLauncher);
}
}
LauncherRoute::Local => {
self.launcher_entry.grab_focus();
}
}
}
}
}
@ -2847,6 +2871,35 @@ fn primary_hypr_monitor() -> Option<String> {
.map(|m| m.name.clone())
}
/// Where `AppInput::OpenLauncher` should be actually handled: locally (this
/// instance grabs its own capsule's focus), or forwarded to a specific
/// satellite instance. Pure decision logic, split out of the `update` match
/// arm so it's unit-testable without a live `App`/GTK/Hyprland stack.
#[derive(Debug, Clone, PartialEq, Eq)]
enum LauncherRoute {
Local,
Satellite(String),
}
/// `focused`: the currently-focused Hyprland monitor, re-queried at
/// keybind-fire time (`None` if Hyprland's monitor query failed, e.g.
/// screenshot mode). `own`: this instance's own monitor, fixed at `init()`.
/// `satellites`: names of monitors this (necessarily primary) instance
/// tracks a `Controller<App>` for.
///
/// Falls back to `Local` whenever forwarding isn't possible or isn't
/// needed, so a caller can always make forward progress: no focused
/// monitor, the focused monitor is this instance's own, or the focused
/// monitor has no tracked satellite yet.
fn resolve_launcher_route(focused: Option<&str>, own: &str, satellites: &[&str]) -> LauncherRoute {
match focused {
Some(name) if name != own && satellites.contains(&name) => {
LauncherRoute::Satellite(name.to_string())
}
_ => LauncherRoute::Local,
}
}
fn hypr_monitor_names() -> Vec<String> {
hypr_monitors_live()
.into_iter()
@ -2937,3 +2990,42 @@ fn drop_satellite(satellites: &mut Vec<(String, Controller<App>)>, name: &str) {
}
});
}
#[cfg(test)]
mod launcher_route_tests {
use super::{resolve_launcher_route, LauncherRoute};
#[test]
fn focused_monitor_is_own_stays_local() {
assert_eq!(
resolve_launcher_route(Some("eDP-1"), "eDP-1", &["DVI-I-1"]),
LauncherRoute::Local
);
}
#[test]
fn focused_monitor_is_tracked_satellite_forwards() {
assert_eq!(
resolve_launcher_route(Some("DVI-I-1"), "eDP-1", &["DVI-I-1"]),
LauncherRoute::Satellite("DVI-I-1".to_string())
);
}
#[test]
fn focused_monitor_with_no_tracked_satellite_falls_back_local() {
// e.g. reconcile hasn't caught up with a very recent hotplug yet.
assert_eq!(
resolve_launcher_route(Some("HDMI-A-1"), "eDP-1", &["DVI-I-1"]),
LauncherRoute::Local
);
}
#[test]
fn no_focused_monitor_falls_back_local() {
// Hyprland's monitor query failed (screenshot mode, hyprctl missing).
assert_eq!(
resolve_launcher_route(None, "eDP-1", &["DVI-I-1"]),
LauncherRoute::Local
);
}
}