From 0b1ae13048d410431522e40338f9dcfe482bba93 Mon Sep 17 00:00:00 2001 From: moons Date: Sat, 8 Aug 2026 04:15:35 +0900 Subject: [PATCH] window-overview --- flake.lock | 23 +- flake.nix | 5 + .../patches/taskbar-overview-hook.patch | 2 +- .../window-overview/home/nixos.nix | 9 +- .../applications/window-overview/package.nix | 78 ---- .../patches/niri-backend.patch | 431 ------------------ .../patches/niri-hold-release.patch | 69 --- .../patches/window-overview.patch | 251 ---------- 8 files changed, 32 insertions(+), 836 deletions(-) delete mode 100644 modules/applications/window-overview/package.nix delete mode 100644 modules/applications/window-overview/patches/niri-backend.patch delete mode 100644 modules/applications/window-overview/patches/niri-hold-release.patch delete mode 100644 modules/applications/window-overview/patches/window-overview.patch diff --git a/flake.lock b/flake.lock index 6cf6d0b..ab25398 100644 --- a/flake.lock +++ b/flake.lock @@ -1227,7 +1227,8 @@ "systems": "systems_8", "treefmt-nix": "treefmt-nix_3", "vicinae": "vicinae", - "vicinae-extensions": "vicinae-extensions" + "vicinae-extensions": "vicinae-extensions", + "window-overview": "window-overview" } }, "rust-overlay": { @@ -1683,6 +1684,26 @@ "type": "github" } }, + "window-overview": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786126873, + "narHash": "sha256-ALECKOKBXGfzfTkZuGIAKKwkFII3NWpf9mnmKIfGOG4=", + "owner": "moons-14", + "repo": "window-overview", + "rev": "7565da153bd7cb1bddce87c23667a41775f7ca74", + "type": "github" + }, + "original": { + "owner": "moons-14", + "repo": "window-overview", + "type": "github" + } + }, "xwayland-satellite-stable": { "flake": false, "locked": { diff --git a/flake.nix b/flake.nix index 96cd7cf..69800a0 100644 --- a/flake.nix +++ b/flake.nix @@ -23,6 +23,11 @@ # Desktop niri-flake.url = "github:sodiboo/niri-flake"; + window-overview = { + url = "github:moons-14/window-overview"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + nix-hazkey = { url = "github:aster-void/nix-hazkey"; inputs.nixpkgs.follows = "nixpkgs"; diff --git a/modules/applications/noctalia/patches/taskbar-overview-hook.patch b/modules/applications/noctalia/patches/taskbar-overview-hook.patch index 57e1793..c0f85cf 100644 --- a/modules/applications/noctalia/patches/taskbar-overview-hook.patch +++ b/modules/applications/noctalia/patches/taskbar-overview-hook.patch @@ -14,7 +14,7 @@ diff --git a/src/shell/bar/widgets/taskbar_widget.cpp b/src/shell/bar/widgets/ta namespace { + [[nodiscard]] bool launchTaskbarOverview(const std::string& appId) { -+ constexpr const char* command = "noctalia-taskbar-overview"; ++ constexpr const char* command = "window-overview"; + if (appId.empty() || !process::commandExists(command)) { + return false; + } diff --git a/modules/applications/window-overview/home/nixos.nix b/modules/applications/window-overview/home/nixos.nix index 4898c03..6b1cfaf 100644 --- a/modules/applications/window-overview/home/nixos.nix +++ b/modules/applications/window-overview/home/nixos.nix @@ -1,7 +1,6 @@ -{ pkgs, ... }: -let - windowOverview = pkgs.callPackage ../package.nix { }; -in +{ inputs, pkgs, ... }: { - home.packages = [ windowOverview ]; + home.packages = [ + inputs.window-overview.packages.${pkgs.stdenv.hostPlatform.system}.window-overview + ]; } diff --git a/modules/applications/window-overview/package.nix b/modules/applications/window-overview/package.nix deleted file mode 100644 index 4a57e04..0000000 --- a/modules/applications/window-overview/package.nix +++ /dev/null @@ -1,78 +0,0 @@ -{ - lib, - fetchCrate, - libgbm, - libglvnd, - libxkbcommon, - jq, - makeWrapper, - pkg-config, - rustPlatform, - wayland, - wl-clipboard, -}: -rustPlatform.buildRustPackage rec { - pname = "window-overview"; - version = "1.5.0"; - - src = fetchCrate { - pname = "wlr-chooser"; - inherit version; - hash = "sha256-Jb59m1z+2G5istcbC0sg9jRVcC/bQh/OY0ny9OdTsdw="; - }; - - patches = [ - ./patches/window-overview.patch - ./patches/niri-backend.patch - ./patches/niri-hold-release.patch - ]; - - cargoHash = "sha256-KBLgtS8ULrmsOf6BN5SlkVQyXB12utvr/KonnKnCTCM="; - - nativeBuildInputs = [ - makeWrapper - pkg-config - ]; - - buildInputs = [ - libgbm - libglvnd - libxkbcommon - wayland - ]; - - cargoBuildFlags = [ - "--bin" - "wlr-switcher" - ]; - - postInstall = '' - mv "$out/bin/wlr-switcher" "$out/bin/.window-overview-wrapped" - makeWrapper "$out/bin/.window-overview-wrapped" "$out/bin/window-overview" \ - --add-flags "--layout grid" \ - --prefix PATH : "${ - lib.makeBinPath [ - jq - wl-clipboard - ] - }" \ - --prefix LD_LIBRARY_PATH : "${ - lib.makeLibraryPath [ - libgbm - libglvnd - ] - }:/run/opengl-driver/lib" - ln -s window-overview "$out/bin/noctalia-taskbar-overview" - ''; - - meta = { - description = "Fullscreen Wayland window overview with captured previews"; - homepage = "https://github.com/sjourdois/wlr-utils"; - license = with lib.licenses; [ - asl20 - mit - ]; - mainProgram = "window-overview"; - platforms = lib.platforms.linux; - }; -} diff --git a/modules/applications/window-overview/patches/niri-backend.patch b/modules/applications/window-overview/patches/niri-backend.patch deleted file mode 100644 index 7ce3e3c..0000000 --- a/modules/applications/window-overview/patches/niri-backend.patch +++ /dev/null @@ -1,431 +0,0 @@ ---- a/src/chooser_cli.rs -+++ b/src/chooser_cli.rs -@@ -81,6 +81,7 @@ - initial_cycle: None, - snapshot: false, - cycle_socket: None, -+ niri_backend: false, - }; - - match run_overlay(opts, t0) { ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -65,7 +65,14 @@ - // must connect, enumerate, and open sessions before any thumbnail appears. - let (tx, rx) = mpsc::channel(); - let snapshot = opts.snapshot; -- std::thread::spawn(move || ui::capture_thread(tx, snapshot)); -+ let niri_backend = opts.niri_backend; -+ std::thread::spawn(move || { -+ if niri_backend { -+ ui::niri_capture_thread(tx); -+ } else { -+ ui::capture_thread(tx, snapshot); -+ } -+ }); - shell::tlog(t0, "capture-thread spawned"); - - let out: ui::Outcome = Arc::new(Mutex::new(None)); ---- a/src/switcher_cli.rs -+++ b/src/switcher_cli.rs -@@ -88,6 +88,7 @@ - let t0 = Instant::now(); - let cli = Cli::parse(); - i18n::init(); -+ let niri_backend = std::env::var_os("NIRI_SOCKET").is_some(); - - if cli.doctor { - if let Err(e) = wlr_capture::doctor::report("wlr-switcher", env!("CARGO_PKG_VERSION")) { -@@ -129,32 +130,50 @@ - initial_cycle: Some(!cli.reverse), - snapshot: true, - cycle_socket: Some(cycle_socket), -+ niri_backend, - }; - - // Pre-flight: wlr-switcher switches *windows*, which need the foreign-toplevel - // capture source (wlroots >= 0.20 / Sway >= 1.12). On older compositors connect() - // now succeeds for screen-only capture, but there are no windows to offer — so say - // so clearly and exit, instead of showing an empty dimmed overlay (issue #1). -- match wl::Client::connect() { -- Ok(client) if !client.can_capture_windows() => { -- eprintln!("{}", tr!("capture-no-window")); -- std::process::exit(2); -- } -- Ok(_) => {} -- Err(e) => { -- eprintln!("{}", tr!("error", error = format!("{e:#}"))); -- std::process::exit(2); -+ if !niri_backend { -+ match wl::Client::connect() { -+ Ok(client) if !client.can_capture_windows() => { -+ eprintln!("{}", tr!("capture-no-window")); -+ std::process::exit(2); -+ } -+ Ok(_) => {} -+ Err(e) => { -+ eprintln!("{}", tr!("error", error = format!("{e:#}"))); -+ std::process::exit(2); -+ } - } - } - - match run_overlay(opts, t0) { - Ok(Some(sel)) => { - // Focus the picked window (outputs aren't focusable, so ignore them). -- if sel.is_window -- && let Err(e) = wl::activate_window(&sel.app_id, &sel.title, sel.dup_index) -- { -- eprintln!("{}", tr!("error", error = format!("{e:#}"))); -- std::process::exit(2); -+ if sel.is_window { -+ let result: anyhow::Result<()> = if niri_backend { -+ let output = std::process::Command::new("niri") -+ .args(["msg", "action", "focus-window", "--id", &sel.identifier]) -+ .output(); -+ match output { -+ Ok(output) if output.status.success() => Ok(()), -+ Ok(output) => Err(anyhow::anyhow!( -+ "{}", -+ String::from_utf8_lossy(&output.stderr).trim() -+ )), -+ Err(e) => Err(e.into()), -+ } -+ } else { -+ wl::activate_window(&sel.app_id, &sel.title, sel.dup_index).map_err(Into::into) -+ }; -+ if let Err(e) = result { -+ eprintln!("{}", tr!("error", error = format!("{e:#}"))); -+ std::process::exit(2); -+ } - } - } - Ok(None) => std::process::exit(1), // cancelled ---- a/src/ui.rs -+++ b/src/ui.rs -@@ -6,7 +6,9 @@ - - use crate::tr; - use std::collections::{HashMap, HashSet}; -+use std::io::Write; - use std::path::PathBuf; -+use std::process::{Command, Stdio}; - use std::sync::mpsc::{Receiver, Sender}; - use std::sync::{Arc, Mutex}; - use std::time::{Duration, Instant}; -@@ -366,6 +368,288 @@ - } - } - -+#[derive(Clone)] -+struct NiriWindow { -+ id: String, -+ app_id: String, -+ title: String, -+} -+ -+enum ClipboardSnapshot { -+ Empty, -+ Content { mime: String, data: Vec }, -+ Unavailable, -+} -+ -+fn save_clipboard() -> ClipboardSnapshot { -+ let Ok(types) = Command::new("wl-paste").arg("--list-types").output() else { -+ return ClipboardSnapshot::Unavailable; -+ }; -+ if !types.status.success() { -+ return ClipboardSnapshot::Empty; -+ } -+ let types = String::from_utf8_lossy(&types.stdout); -+ let offered: Vec<&str> = types.lines().filter(|line| !line.is_empty()).collect(); -+ let Some(mime) = offered -+ .iter() -+ .copied() -+ .find(|mime| *mime == "text/plain;charset=utf-8") -+ .or_else(|| offered.first().copied()) -+ else { -+ return ClipboardSnapshot::Empty; -+ }; -+ let Ok(data) = Command::new("wl-paste") -+ .args(["--type", mime]) -+ .output() -+ else { -+ return ClipboardSnapshot::Unavailable; -+ }; -+ if !data.status.success() { -+ return ClipboardSnapshot::Unavailable; -+ } -+ ClipboardSnapshot::Content { -+ mime: mime.to_owned(), -+ data: data.stdout, -+ } -+} -+ -+fn restore_clipboard(snapshot: ClipboardSnapshot) { -+ match snapshot { -+ ClipboardSnapshot::Empty => { -+ let _ = Command::new("wl-copy").arg("--clear").status(); -+ } -+ ClipboardSnapshot::Content { mime, data } => { -+ let Ok(mut copy) = Command::new("wl-copy") -+ .args(["--type", &mime]) -+ .stdin(Stdio::piped()) -+ .spawn() -+ else { -+ return; -+ }; -+ if let Some(mut stdin) = copy.stdin.take() { -+ let _ = stdin.write_all(&data); -+ } -+ let _ = copy.wait(); -+ } -+ ClipboardSnapshot::Unavailable => {} -+ } -+} -+ -+/// Decode one field emitted by jq's `@tsv` formatter. It escapes the only -+/// characters that would otherwise make the line-oriented transport ambiguous. -+fn unescape_tsv(value: &str) -> String { -+ let mut decoded = String::with_capacity(value.len()); -+ let mut chars = value.chars(); -+ while let Some(ch) = chars.next() { -+ if ch != '\\' { -+ decoded.push(ch); -+ continue; -+ } -+ match chars.next() { -+ Some('t') => decoded.push('\t'), -+ Some('r') => decoded.push('\r'), -+ Some('n') => decoded.push('\n'), -+ Some('\\') => decoded.push('\\'), -+ Some(other) => { -+ decoded.push('\\'); -+ decoded.push(other); -+ } -+ None => decoded.push('\\'), -+ } -+ } -+ decoded -+} -+ -+/// Ask niri for its toplevel list. niri does not implement the -+/// ext-image-copy-capture window protocol, so its IPC is the source of both the -+/// stable window id and the metadata used by this backend. -+fn niri_windows() -> Result, String> { -+ let response = Command::new("niri") -+ .args(["msg", "-j", "windows"]) -+ .output() -+ .map_err(|e| format!("could not run niri msg: {e}"))?; -+ if !response.status.success() { -+ return Err(String::from_utf8_lossy(&response.stderr).trim().to_owned()); -+ } -+ -+ let mut jq = Command::new("jq") -+ .args([ -+ "-r", -+ ".[] | [(.id | tostring), (.app_id // \"\"), (.title // \"\")] | @tsv", -+ ]) -+ .stdin(Stdio::piped()) -+ .stdout(Stdio::piped()) -+ .stderr(Stdio::piped()) -+ .spawn() -+ .map_err(|e| format!("could not run jq: {e}"))?; -+ jq.stdin -+ .take() -+ .ok_or_else(|| String::from("jq stdin was unavailable"))? -+ .write_all(&response.stdout) -+ .map_err(|e| format!("could not pass niri window list to jq: {e}"))?; -+ let output = jq -+ .wait_with_output() -+ .map_err(|e| format!("could not read jq output: {e}"))?; -+ if !output.status.success() { -+ return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()); -+ } -+ -+ let text = String::from_utf8(output.stdout) -+ .map_err(|e| format!("niri window list was not UTF-8: {e}"))?; -+ let mut windows = Vec::new(); -+ for line in text.lines() { -+ let mut fields = line.splitn(3, '\t'); -+ let Some(id) = fields.next() else { continue }; -+ let Some(app_id) = fields.next() else { continue }; -+ let Some(title) = fields.next() else { continue }; -+ windows.push(NiriWindow { -+ id: id.to_owned(), -+ app_id: unescape_tsv(app_id), -+ title: unescape_tsv(title), -+ }); -+ } -+ windows.sort_by(|a, b| { -+ a.app_id -+ .to_lowercase() -+ .cmp(&b.app_id.to_lowercase()) -+ .then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) -+ }); -+ Ok(windows) -+} -+ -+fn niri_window_source(w: &NiriWindow, dup_index: usize) -> Source { -+ let is_system = w.app_id.is_empty(); -+ let (title, subtitle) = if is_system { -+ (w.title.clone(), String::new()) -+ } else { -+ (w.app_id.clone(), w.title.clone()) -+ }; -+ Source { -+ key: w.id.clone(), -+ token: format!("Window: {}", w.id), -+ filter: format!("{} {}", w.app_id, w.title).to_lowercase(), -+ title, -+ subtitle, -+ is_window: true, -+ is_system, -+ app_id: w.app_id.clone(), -+ win_title: w.title.clone(), -+ dup_index, -+ } -+} -+ -+/// Capture a static overview through niri's IPC. This is deliberately separate -+/// from the generic Wayland backend: niri can render any toplevel by id (even an -+/// occluded one), but does not advertise ext-image-copy-capture. -+pub fn niri_capture_thread(tx: Sender) { -+ let windows = match niri_windows() { -+ Ok(windows) => windows, -+ Err(e) => { -+ eprintln!("niri overview backend: {e}"); -+ return; -+ } -+ }; -+ -+ let mut dup: HashMap<(String, String), usize> = HashMap::new(); -+ let mut current = Vec::with_capacity(windows.len()); -+ for window in windows { -+ let e = dup -+ .entry((window.app_id.clone(), window.title.clone())) -+ .or_insert(0); -+ let source = niri_window_source(&window, *e); -+ *e += 1; -+ current.push((source, window)); -+ } -+ if tx -+ .send(Msg::Sources( -+ current.iter().map(|(source, _)| source.clone()).collect(), -+ )) -+ .is_err() -+ { -+ return; -+ } -+ -+ let capture_dir = wlr_capture::paths::runtime_dir().join(format!( -+ "window-overview-niri-{}", -+ std::process::id() -+ )); -+ if let Err(e) = std::fs::create_dir_all(&capture_dir) { -+ eprintln!("niri overview backend: could not create capture directory: {e}"); -+ return; -+ } -+ // niri's screenshot action also sets the clipboard. Preserve the existing -+ // selection so opening the overview does not unexpectedly replace it. -+ let clipboard = save_clipboard(); -+ -+ for (source, window) in current { -+ if let Some(path) = icons::resolve(&window.app_id) -+ && let Some((w, h, rgba)) = icons::load(&path, 128) -+ && tx -+ .send(Msg::Icon { -+ key: source.key.clone(), -+ w: w as usize, -+ h: h as usize, -+ rgba, -+ }) -+ .is_err() -+ { -+ break; -+ } -+ -+ let path = capture_dir.join(format!("{}.png", window.id)); -+ let command = Command::new("niri") -+ .args(["msg", "action", "screenshot-window", "--id"]) -+ .arg(&window.id) -+ .args(["--write-to-disk", "true", "--show-pointer", "false", "--path"]) -+ .arg(&path) -+ .output(); -+ let Ok(output) = command else { -+ continue; -+ }; -+ if !output.status.success() { -+ eprintln!( -+ "niri overview backend: could not capture window {}: {}", -+ window.id, -+ String::from_utf8_lossy(&output.stderr).trim() -+ ); -+ continue; -+ } -+ -+ // niri encodes PNGs off the compositor thread, after acknowledging the -+ // IPC action. Wait briefly for that completion without delaying the UI. -+ let mut bytes = None; -+ for _ in 0..200 { -+ match std::fs::read(&path) { -+ Ok(data) => { -+ bytes = Some(data); -+ break; -+ } -+ Err(_) => std::thread::sleep(Duration::from_millis(10)), -+ } -+ } -+ let Some(bytes) = bytes else { continue }; -+ let Ok(image) = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png) else { -+ continue; -+ }; -+ let image = image.into_rgba8(); -+ let (w, h, rgba) = thumbnail_rgba(image.width(), image.height(), image.into_raw()); -+ if tx -+ .send(Msg::Thumb { -+ key: source.key, -+ w, -+ h, -+ rgba, -+ }) -+ .is_err() -+ { -+ break; -+ } -+ let _ = std::fs::remove_file(path); -+ } -+ let _ = std::fs::remove_dir(capture_dir); -+ restore_clipboard(clipboard); -+} -+ - /// Cheap content fingerprint of a frame (subsampled FNV-1a), to tell whether a - /// capture actually changed between rounds — used by the headless bench. - fn quick_hash(rgba: &[u8]) -> u64 { -@@ -515,11 +799,14 @@ - - /// Downscale a capture to a thumbnail (max side `THUMB_MAX`), never upscaling. - fn thumbnail(img: wl::CapturedImage) -> (usize, usize, Vec) { -- let (w, h) = (img.width, img.height); -+ thumbnail_rgba(img.width, img.height, img.rgba) -+} -+ -+fn thumbnail_rgba(w: u32, h: u32, rgba: Vec) -> (usize, usize, Vec) { - let scale = (THUMB_MAX as f32 / w as f32) - .min(THUMB_MAX as f32 / h as f32) - .min(1.0); -- let src = match image::RgbaImage::from_raw(w, h, img.rgba) { -+ let src = match image::RgbaImage::from_raw(w, h, rgba) { - Some(s) => s, - None => return (0, 0, Vec::new()), - }; -@@ -557,6 +844,9 @@ - pub snapshot: bool, - /// Receives next/previous commands from repeated compositor keybind launches. - pub cycle_socket: Option, -+ /// Use niri's IPC for window enumeration and snapshots instead of the -+ /// ext-image-copy-capture protocol that niri does not implement. -+ pub niri_backend: bool, - } - - pub struct App { diff --git a/modules/applications/window-overview/patches/niri-hold-release.patch b/modules/applications/window-overview/patches/niri-hold-release.patch deleted file mode 100644 index 798d1bc..0000000 --- a/modules/applications/window-overview/patches/niri-hold-release.patch +++ /dev/null @@ -1,69 +0,0 @@ -diff --git a/src/shell.rs b/src/shell.rs -index 54fc2f0..dee4def 100644 ---- a/src/shell.rs -+++ b/src/shell.rs -@@ -83,2 +83 @@ struct State { -- /// We've painted at least one frame with the modifier genuinely held; gates the -- /// confirm-on-release so the launching chord's tail can't trigger it. -+ /// A rendered hold after sources arrived, enabling immediate confirmation on release. -@@ -85,0 +85,2 @@ struct State { -+ /// A release received before source enumeration completes, to confirm after render. -+ pending_release: bool, -@@ -179,0 +181 @@ pub fn run(app: App, t0: Instant) -> anyhow::Result<()> { -+ pending_release: false, -@@ -205,5 +207,2 @@ impl State { -- // Record that we've shown at least one frame with the modifier held; this -- // gates confirm-on-release (see `reconcile`). -- if self.armed && self.any_armed_held() { -- self.armed_rendered = true; -- } -+ // Record a frame with the modifier held after source enumeration completes. -+ let armed_held = self.armed && self.any_armed_held(); -@@ -234,0 +234,7 @@ impl State { -+ if armed_held && self.app.release_ready() { -+ self.armed_rendered = true; -+ self.pending_release = false; -+ } else if self.pending_release && self.app.release_ready() { -+ self.pending_release = false; -+ self.app.confirm_release(); -+ } -@@ -485,2 +491,2 @@ impl State { -- // Confirm on release — but only after we've painted a frame with the -- // modifier genuinely held, so the launching chord's tail can't fire it. -+ // Confirm on release. Before the UI is ready, latch the edge and consume it -+ // only after `run_ui` has received sources and the frame has rendered. -@@ -488,2 +494,12 @@ impl State { -- if self.armed && self.armed_rendered && self.prev_held && !held { -- self.app.confirm_release(); -+ // A new hold represents a new switching gesture, so do not consume a -+ // release that belonged to an earlier gesture. -+ if held { -+ self.pending_release = false; -+ } -+ if self.armed && self.prev_held && !held { -+ if self.armed_rendered && self.app.release_ready() { -+ self.pending_release = false; -+ self.app.confirm_release(); -+ } else { -+ self.pending_release = true; -+ } -diff --git a/src/ui.rs b/src/ui.rs -index c821d66..854f6ad 100644 ---- a/src/ui.rs -+++ b/src/ui.rs -@@ -854,0 +855 @@ pub struct App { -+ sources_received: bool, -@@ -899,0 +901 @@ impl App { -+ sources_received: false, -@@ -1008 +1010,4 @@ impl App { -- Msg::Sources(s) => self.sources = s, -+ Msg::Sources(s) => { -+ self.sources = s; -+ self.sources_received = true; -+ } -@@ -1080,0 +1086,5 @@ impl App { -+ /// True once `run_ui` has received the initial source enumeration. -+ pub fn release_ready(&self) -> bool { -+ self.sources_received -+ } -+ diff --git a/modules/applications/window-overview/patches/window-overview.patch b/modules/applications/window-overview/patches/window-overview.patch deleted file mode 100644 index 58d2c0f..0000000 --- a/modules/applications/window-overview/patches/window-overview.patch +++ /dev/null @@ -1,251 +0,0 @@ ---- 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 }, -+ );