--- a/src/chooser_cli.rs +++ b/src/chooser_cli.rs @@ -79,0 +80,4 @@ + app_id_filter: None, + initial_cycle: None, + snapshot: false, + cycle_socket: None, --- a/src/lib.rs +++ b/src/lib.rs @@ -33,3 +33,3 @@ -/// Returns the held lock file (keep it alive), or `None` if another instance owns -/// it — sway processes its own keybinding even over our exclusive keyboard grab, -/// so re-pressing the bind would otherwise stack overlays. +/// Returns the held lock and command socket for the first instance. Later +/// invocations forward their requested cycle direction through the socket and +/// return `None`, which also supports compositors that keep handling the binding. @@ -36 +36,3 @@ -pub fn acquire_switch_lock() -> Option { +pub fn acquire_switch_lock( + forward: bool, +) -> Option<(std::fs::File, std::os::unix::net::UnixDatagram)> { @@ -38,0 +41 @@ + let socket_path = dir.join("wlr-switcher.sock"); @@ -45,2 +49,10 @@ - flock(&f, FlockOperation::NonBlockingLockExclusive).ok()?; - Some(f) + if flock(&f, FlockOperation::NonBlockingLockExclusive).is_err() { + let socket = std::os::unix::net::UnixDatagram::unbound().ok()?; + let command = if forward { b"next" } else { b"prev" }; + let _ = socket.send_to(command, socket_path); + return None; + } + let _ = std::fs::remove_file(&socket_path); + let socket = std::os::unix::net::UnixDatagram::bind(socket_path).ok()?; + socket.set_nonblocking(true).ok()?; + Some((f, socket)) @@ -56 +68,2 @@ - std::thread::spawn(move || ui::capture_thread(tx)); + let snapshot = opts.snapshot; + std::thread::spawn(move || ui::capture_thread(tx, snapshot)); --- a/src/shell.rs +++ b/src/shell.rs @@ -617,0 +618,3 @@ + Keysym::plus | Keysym::KP_Add => Key::Plus, + Keysym::equal => Key::Equals, + Keysym::minus | Keysym::KP_Subtract => Key::Minus, --- a/src/switcher_cli.rs +++ b/src/switcher_cli.rs @@ -75,0 +76,6 @@ + /// Show only windows whose Wayland app-id exactly matches this value. + #[arg(long)] + app_id: Option, + /// Select the previous window when the overview first opens. + #[arg(long)] + reverse: bool, @@ -96,2 +102,2 @@ - let _lock = match acquire_switch_lock() { - Some(lock) => lock, + let (_lock, cycle_socket) = match acquire_switch_lock(!cli.reverse) { + Some(instance) => instance, @@ -121,0 +128,4 @@ + app_id_filter: cli.app_id, + initial_cycle: Some(!cli.reverse), + snapshot: true, + cycle_socket: Some(cycle_socket), --- a/src/ui.rs +++ b/src/ui.rs @@ -8,0 +9 @@ +use std::path::PathBuf; @@ -41,0 +43,32 @@ +const DEFAULT_OVERVIEW_SCALE: f32 = 0.85; +const MIN_OVERVIEW_SCALE: f32 = 0.55; +const MAX_OVERVIEW_SCALE: f32 = 1.0; + +fn overview_scale_path() -> Option { + if let Some(path) = std::env::var_os("XDG_STATE_HOME") { + return Some(PathBuf::from(path).join("window-overview/scale")); + } + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join(".local/state/window-overview/scale")) +} + +fn load_overview_scale() -> f32 { + overview_scale_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(DEFAULT_OVERVIEW_SCALE) + .clamp(MIN_OVERVIEW_SCALE, MAX_OVERVIEW_SCALE) +} + +fn save_overview_scale(scale: f32) { + let Some(path) = overview_scale_path() else { + return; + }; + let Some(parent) = path.parent() else { + return; + }; + if std::fs::create_dir_all(parent).is_ok() { + let _ = std::fs::write(path, format!("{scale:.2}\n")); + } +} @@ -177 +210 @@ -pub fn capture_thread(tx: Sender) { +pub fn capture_thread(tx: Sender, snapshot: bool) { @@ -189,0 +223 @@ + let mut captured: HashSet = HashSet::new(); @@ -236,0 +271 @@ + captured.retain(|key| present.contains(key.as_str())); @@ -255 +290 @@ - if sessions.contains_key(&s.key) { + if sessions.contains_key(&s.key) || (snapshot && captured.contains(&s.key)) { @@ -295 +330,3 @@ - let Some(key) = by_id.get(&id) else { continue }; + let Some(key) = by_id.get(&id).cloned() else { + continue; + }; @@ -313,0 +351,6 @@ + if snapshot { + captured.insert(key.clone()); + sessions.remove(&key); + by_id.remove(&id); + client.close_session(&id); + } @@ -508,0 +552,8 @@ + /// Exact Wayland app-id filter, used by taskbar launches. + pub app_id_filter: Option, + /// Initial cycle direction. `None` leaves the first item selected. + pub initial_cycle: Option, + /// Capture each window once instead of continuously refreshing previews. + pub snapshot: bool, + /// Receives next/previous commands from repeated compositor keybind launches. + pub cycle_socket: Option, @@ -533,0 +585,5 @@ + app_id_filter: Option, + initial_forward: bool, + overview_scale: f32, + cycle_socket: Option, + queued_cycles: isize, @@ -567 +623 @@ - pending_initial_select: false, + pending_initial_select: opts.initial_cycle.is_some(), @@ -571,0 +628,5 @@ + app_id_filter: opts.app_id_filter, + initial_forward: opts.initial_cycle.unwrap_or(true), + overview_scale: load_overview_scale(), + cycle_socket: opts.cycle_socket, + queued_cycles: 0, @@ -604 +665,4 @@ - return; // nothing to cycle yet; keep the pending initial jump + self.queued_cycles = self + .queued_cycles + .saturating_add(if forward { 1 } else { -1 }); + return; @@ -639,0 +704,12 @@ + loop { + let command = self.cycle_socket.as_ref().and_then(|socket| { + let mut buf = [0_u8; 8]; + socket.recv(&mut buf).ok().map(|len| (buf, len)) + }); + match command { + Some((buf, len)) if &buf[..len] == b"next" => self.cycle(true), + Some((buf, len)) if &buf[..len] == b"prev" => self.cycle(false), + Some(_) => {} + None => break, + } + } @@ -698,0 +775,5 @@ + .filter(|s| { + self.app_id_filter + .as_ref() + .is_none_or(|app_id| s.app_id.eq_ignore_ascii_case(app_id)) + }) @@ -713,2 +793,0 @@ - // Exposé covers the whole screen: dim almost to opaque so the real windows - // behind are hidden (a client can't move them; this hides them instead). @@ -716 +795 @@ - c[3] = c[3].max(0.96); + c[3] = c[3].max(0.88); @@ -734 +813,5 @@ - self.selected = if n > 1 { 1 } else { 0 }; + self.selected = if n > 1 { + if self.initial_forward { 1 } else { n - 1 } + } else { + 0 + }; @@ -737,0 +821,8 @@ + if self.queued_cycles != 0 { + let n = self.visible().len(); + if n > 0 { + self.selected = (self.selected as isize + self.queued_cycles) + .rem_euclid(n as isize) as usize; + self.queued_cycles = 0; + } + } @@ -745 +836 @@ - let (esc, next, prev, enter) = ctx.input(|i| { + let (esc, next, prev, enter, zoom_in, zoom_out, wheel_zoom) = ctx.input(|i| { @@ -746,0 +838,8 @@ + let wheel_zoom = i + .events + .iter() + .filter_map(|event| match event { + egui::Event::MouseWheel { delta, .. } => Some(delta.y), + _ => None, + }) + .sum::(); @@ -755,0 +855,3 @@ + i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals), + i.key_pressed(egui::Key::Minus), + wheel_zoom, @@ -771,0 +874,12 @@ + if self.view == View::Grid && (zoom_in || zoom_out || wheel_zoom != 0.0) { + let delta = if zoom_in { + 0.05 + } else if zoom_out { + -0.05 + } else { + wheel_zoom.signum() * 0.05 + }; + self.overview_scale = + (self.overview_scale + delta).clamp(MIN_OVERVIEW_SCALE, MAX_OVERVIEW_SCALE); + save_overview_scale(self.overview_scale); + } @@ -1020 +1134,5 @@ - let area = ctx.content_rect().shrink(24.0); + let full_area = ctx.content_rect().shrink(24.0); + let area = egui::Rect::from_center_size( + full_area.center(), + full_area.size() * self.overview_scale, + ); @@ -1048,0 +1167 @@ + let selected = *i == self.selected; @@ -1062,9 +1181 @@ - rect.size() * (0.86 + 0.14 * ease), - ); - self.paint_expose_tile( - ui, - s, - scaled, - *i == self.selected, - resp.hovered(), - ease, + rect.size() * (0.86 + 0.14 * ease), @@ -1071,0 +1185 @@ + self.paint_expose_tile(ui, s, scaled, selected, resp.hovered(), ease); @@ -1107 +1229,3 @@ - let white = egui::Color32::WHITE.gamma_multiply(a); + let white = egui::Color32::WHITE.gamma_multiply( + a * if selected || hovered { 1.0 } else { 0.7 }, + );