diff --git a/modules/applications/niri/home/nixos.nix b/modules/applications/niri/home/nixos.nix index 8c381e1..13f2a3e 100644 --- a/modules/applications/niri/home/nixos.nix +++ b/modules/applications/niri/home/nixos.nix @@ -43,13 +43,17 @@ in "Mod+Tab" = { repeat = false; - action.spawn = [ "window-overview" ]; + action.spawn = [ + "window-overview" + "--hold" + ]; hotkey-overlay.title = "Window Overview: Next"; }; "Mod+Shift+Tab" = { repeat = false; action.spawn = [ "window-overview" + "--hold" "--reverse" ]; hotkey-overlay.title = "Window Overview: Previous"; diff --git a/modules/applications/window-overview/package.nix b/modules/applications/window-overview/package.nix index cc45895..a18b0a7 100644 --- a/modules/applications/window-overview/package.nix +++ b/modules/applications/window-overview/package.nix @@ -4,10 +4,12 @@ libgbm, libglvnd, libxkbcommon, + jq, makeWrapper, pkg-config, rustPlatform, wayland, + wl-clipboard, }: rustPlatform.buildRustPackage rec { pname = "window-overview"; @@ -21,6 +23,7 @@ rustPlatform.buildRustPackage rec { patches = [ ./patches/window-overview.patch + ./patches/niri-backend.patch ]; cargoHash = "sha256-KBLgtS8ULrmsOf6BN5SlkVQyXB12utvr/KonnKnCTCM="; @@ -46,6 +49,12 @@ rustPlatform.buildRustPackage rec { 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 diff --git a/modules/applications/window-overview/patches/niri-backend.patch b/modules/applications/window-overview/patches/niri-backend.patch new file mode 100644 index 0000000..7ce3e3c --- /dev/null +++ b/modules/applications/window-overview/patches/niri-backend.patch @@ -0,0 +1,431 @@ +--- 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/window-overview.patch b/modules/applications/window-overview/patches/window-overview.patch index f1d2e22..c2d6a62 100644 --- a/modules/applications/window-overview/patches/window-overview.patch +++ b/modules/applications/window-overview/patches/window-overview.patch @@ -229,8 +229,24 @@ + full_area.center(), + full_area.size() * self.overview_scale, + ); -@@ -1076,0 +1195,8 @@ -+ +@@ -1048,0 +1167 @@ ++ let selected = *i == self.selected; +@@ -1062,9 +1181,3 @@ +- 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) ++ * if selected { 1.04 } else { 1.0 }, +@@ -1071,0 +1185 @@ ++ self.paint_expose_tile(ui, s, scaled, selected, resp.hovered(), ease); +@@ -1077,0 +1192,8 @@ + ui.painter().text( + egui::pos2(full_area.right(), full_area.top()), + egui::Align2::RIGHT_TOP, @@ -238,3 +254,83 @@ + egui::FontId::proportional(14.0), + self.theme.text_dim, + ); ++ +@@ -1107 +1229,14 @@ +- let white = egui::Color32::WHITE.gamma_multiply(a); ++ let accent = if s.is_window { ++ t.window_accent ++ } else { ++ t.screen_accent ++ }; ++ let content_alpha = if selected || hovered { 1.0 } else { 0.55 }; ++ let white = egui::Color32::WHITE.gamma_multiply(a * content_alpha); ++ if selected { ++ p.rect_filled( ++ rect.expand(8.0), ++ radius + 8.0, ++ fade(accent).gamma_multiply(0.35), ++ ); ++ } +@@ -1127,0 +1263,7 @@ ++ if !selected && !hovered { ++ p.rect_filled( ++ rect, ++ radius, ++ egui::Color32::from_black_alpha(72).gamma_multiply(a), ++ ); ++ } +@@ -1136 +1278,5 @@ +- egui::Color32::from_black_alpha(160).gamma_multiply(a), ++ if selected { ++ fade(accent).gamma_multiply(0.9) ++ } else { ++ egui::Color32::from_black_alpha(160).gamma_multiply(a) ++ }, +@@ -1175,9 +1321,22 @@ +- let accent = if s.is_window { +- t.window_accent +- } else { +- t.screen_accent +- }; +- let (sw, col) = if selected { +- (3.0, accent) +- } else if hovered { +- (2.0, accent) ++ if selected { ++ p.rect_stroke( ++ rect.expand(4.0), ++ radius + 4.0, ++ egui::Stroke::new(3.0, fade(egui::Color32::WHITE)), ++ egui::StrokeKind::Inside, ++ ); ++ p.rect_stroke( ++ rect, ++ radius, ++ egui::Stroke::new(6.0, fade(accent)), ++ egui::StrokeKind::Inside, ++ ); ++ let marker = egui::pos2(rect.right() - 18.0, rect.top() + 18.0); ++ p.circle_filled(marker, 13.0, fade(accent)); ++ p.text( ++ marker, ++ egui::Align2::CENTER_CENTER, ++ "✓", ++ egui::FontId::proportional(18.0), ++ fade(egui::Color32::WHITE), ++ ); +@@ -1185,8 +1344,7 @@ +- (1.0, t.thumb) +- }; +- p.rect_stroke( +- rect, +- radius, +- egui::Stroke::new(sw, fade(col)), +- egui::StrokeKind::Inside, +- ); ++ p.rect_stroke( ++ rect, ++ radius, ++ egui::Stroke::new(if hovered { 2.0 } else { 1.0 }, fade(accent)), ++ egui::StrokeKind::Inside, ++ ); ++ }