From c6736b0ac3d537e3731af19841e58489a97e8f4c Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 20:46:09 +0300 Subject: [PATCH] feat: picker windows, layout, state and icons --- Cargo.lock | 207 +++++++++++++++++++++++++++++ Cargo.toml | 1 + src/main.rs | 3 + src/niri.rs | 56 ++++++++ src/picker/icons.rs | 300 +++++++++++++++++++++++++++++++++++++++++++ src/picker/layout.rs | 163 +++++++++++++++++++++++ src/picker/mod.rs | 5 + src/picker/state.rs | 45 +++++++ 8 files changed, 780 insertions(+) create mode 100644 src/picker/icons.rs create mode 100644 src/picker/layout.rs create mode 100644 src/picker/mod.rs create mode 100644 src/picker/state.rs diff --git a/Cargo.lock b/Cargo.lock index c67f0e1..436297e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,6 +189,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.10.0" @@ -436,6 +442,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -528,6 +540,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dispatch" version = "0.2.0" @@ -819,6 +837,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "foldhash" version = "0.1.5" @@ -840,6 +864,28 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -924,6 +970,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1130,6 +1186,22 @@ dependencies = [ "tiff", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "indexmap" version = "2.14.2" @@ -1912,6 +1984,12 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -2134,6 +2212,48 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2319,6 +2439,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "skrifa" version = "0.44.0" @@ -2461,6 +2596,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "strsim" @@ -2468,6 +2606,16 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.119" @@ -2592,6 +2740,12 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" @@ -2740,6 +2894,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -2752,18 +2912,58 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "simplecss", + "siphasher", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8parse" version = "0.2.2" @@ -2797,6 +2997,7 @@ dependencies = [ "libc", "libloading", "niri-ipc", + "resvg", "rustix 1.1.5", "serde", "serde_ignored", @@ -3601,6 +3802,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "zerocopy" version = "0.8.59" diff --git a/Cargo.toml b/Cargo.toml index e3c1a3f..10c397b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ image = { version = "0.25.10", default-features = false, features = ["png"] } khronos-egl = { version = "6.0.0", features = ["dynamic"] } libc = "0.2.189" libloading = "0.8" +resvg = "0.48.1" niri-ipc = "=26.4.0" rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] } serde = { version = "1.0.229", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 347b8f1..0ef54ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,9 @@ mod lock; mod log; mod niri; mod output; +// The picker lands in pieces; Task 6 of the theme plan removes this. +#[allow(dead_code)] +mod picker; mod region; mod render; mod selection; diff --git a/src/niri.rs b/src/niri.rs index d972042..3122c51 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -107,6 +107,26 @@ pub fn is_our_capture(event: &Event, path: &Path) -> bool { matches!(event, Event::ScreenshotCaptured { path: Some(p) } if Path::new(p) == path) } +/// Every window niri has. +#[allow(dead_code)] // the picker (theme plan Task 6) removes this +pub fn windows() -> Result> { + match request(Request::Windows)? { + Response::Windows(windows) => Ok(windows), + other => Err(anyhow!("unexpected niri reply: {other:?}")), + } +} + +/// `windows` most recently used first: the focused one, then by focus +/// time, then those never focused, by id. +#[allow(dead_code)] // the picker (theme plan Task 6) removes this +pub fn mru(mut windows: Vec) -> Vec { + windows.sort_by_key(|w| { + let at = w.focus_timestamp.map(|t| (t.secs, t.nanos)); + (!w.is_focused, std::cmp::Reverse(at), w.id) + }); + windows +} + /// The output showing the workspace a window is on, if niri knows it. pub fn output_of(workspace_id: Option, workspaces: &[Workspace]) -> Option { let id = workspace_id?; @@ -132,6 +152,42 @@ pub fn parse_version(v: &str) -> Option<(u32, u32)> { mod tests { use super::*; + fn window(id: u64, focused: bool, at: Option) -> Window { + Window { + id, + title: None, + app_id: None, + pid: None, + workspace_id: None, + is_focused: focused, + is_floating: false, + is_urgent: false, + layout: niri_ipc::WindowLayout { + pos_in_scrolling_layout: None, + tile_size: (0.0, 0.0), + window_size: (0, 0), + tile_pos_in_workspace_view: None, + window_offset_in_tile: (0.0, 0.0), + }, + focus_timestamp: at.map(|secs| niri_ipc::Timestamp { secs, nanos: 0 }), + } + } + + #[test] + fn most_recently_used_first() { + let order: Vec = mru(vec![ + window(1, false, Some(10)), + window(2, false, None), + window(3, true, Some(5)), + window(4, false, Some(20)), + window(5, false, None), + ]) + .iter() + .map(|w| w.id) + .collect(); + assert_eq!(order, [3, 4, 1, 2, 5]); + } + fn ws(id: u64, output: Option<&str>) -> Workspace { Workspace { id, diff --git a/src/picker/icons.rs b/src/picker/icons.rs new file mode 100644 index 0000000..8df39a0 --- /dev/null +++ b/src/picker/icons.rs @@ -0,0 +1,300 @@ +//! Finding and loading a window's app icon: app id → desktop file → +//! `Icon=` → the icon theme (with its `Inherits`), then hicolor. + +use std::path::{Path, PathBuf}; + +use image::RgbaImage; + +/// Where to look. +#[derive(Debug, Clone)] +pub struct Dirs { + /// `$XDG_DATA_HOME` then `$XDG_DATA_DIRS`. + pub data: Vec, + /// The icon theme's directory name. + pub theme: String, +} + +impl Dirs { + pub fn from_env() -> Dirs { + let home = crate::config::home(); + let mut data = vec![ + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/share")), + ]; + let dirs = + std::env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into()); + data.extend(dirs.split(':').filter(|d| !d.is_empty()).map(PathBuf::from)); + let theme = std::fs::read_to_string(home.join(".config/gtk-3.0/settings.ini")) + .ok() + .and_then(|t| { + t.lines().find_map(|l| { + let (k, v) = l.split_once('=')?; + (k.trim() == "gtk-icon-theme-name").then(|| v.trim().to_string()) + }) + }) + .unwrap_or_else(|| "hicolor".into()); + Dirs { data, theme } + } +} + +const SIZES: [&str; 6] = ["scalable", "256x256", "128x128", "96x96", "64x64", "48x48"]; + +/// The icon file for `app_id`, if any. +pub fn find(app_id: &str, dirs: &Dirs) -> Option { + let icon = desktop_icon(app_id, dirs).unwrap_or_else(|| app_id.to_string()); + let path = Path::new(&icon); + if path.is_absolute() { + return path.exists().then(|| path.to_path_buf()); + } + themed(&icon, dirs) +} + +/// `Icon=` from the app's desktop file. +fn desktop_icon(app_id: &str, dirs: &Dirs) -> Option { + let wanted = app_id.to_lowercase(); + let mut by_class = None; + for dir in &dirs.data { + let apps = dir.join("applications"); + let exact = apps.join(format!("{app_id}.desktop")); + if let Some(icon) = icon_line(&exact) { + return Some(icon); + } + let Ok(entries) = std::fs::read_dir(&apps) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + if path.extension().is_some_and(|e| e == "desktop") { + if stem == wanted { + if let Some(icon) = icon_line(&path) { + return Some(icon); + } + } else if by_class.is_none() { + let text = std::fs::read_to_string(&path).unwrap_or_default(); + let class = text.lines().find_map(|l| l.strip_prefix("StartupWMClass=")); + if class.is_some_and(|c| c.trim().to_lowercase() == wanted) { + by_class = icon_line(&path); + } + } + } + } + } + by_class +} + +fn icon_line(desktop: &Path) -> Option { + let text = std::fs::read_to_string(desktop).ok()?; + text.lines() + .find_map(|l| l.strip_prefix("Icon=")) + .map(|i| i.trim().to_string()) + .filter(|i| !i.is_empty()) +} + +/// `name` in the theme, its parents, hicolor, then pixmaps. +fn themed(name: &str, dirs: &Dirs) -> Option { + let mut themes = vec![dirs.theme.clone()]; + let mut i = 0; + while i < themes.len() && i < 8 { + for parent in inherits(&themes[i], dirs) { + if !themes.contains(&parent) { + themes.push(parent); + } + } + i += 1; + } + if !themes.iter().any(|t| t == "hicolor") { + themes.push("hicolor".into()); + } + for theme in &themes { + for dir in &dirs.data { + let root = dir.join("icons").join(theme); + if !root.is_dir() { + continue; + } + for size in SIZES { + for ext in ["svg", "png"] { + let p = root.join(size).join("apps").join(format!("{name}.{ext}")); + if p.exists() { + return Some(p); + } + } + } + // Other layouts: any /apps or apps/. + if let Ok(entries) = std::fs::read_dir(&root) { + for entry in entries.flatten() { + for sub in [entry.path().join("apps"), entry.path()] { + for ext in ["svg", "png"] { + let p = sub.join(format!("{name}.{ext}")); + if p.exists() { + return Some(p); + } + } + } + } + } + } + } + dirs.data.iter().find_map(|dir| { + ["svg", "png"] + .iter() + .map(|ext| dir.join("pixmaps").join(format!("{name}.{ext}"))) + .find(|p| p.exists()) + }) +} + +fn inherits(theme: &str, dirs: &Dirs) -> Vec { + dirs.data + .iter() + .find_map(|d| std::fs::read_to_string(d.join("icons").join(theme).join("index.theme")).ok()) + .and_then(|t| { + t.lines() + .find_map(|l| l.strip_prefix("Inherits=").map(str::to_string)) + }) + .map(|v| { + v.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +/// The icon at `size` × `size` px (straight alpha). +pub fn load(path: &Path, size: u32) -> Option { + if path.extension().is_some_and(|e| e == "svg") { + let data = std::fs::read(path).ok()?; + let tree = resvg::usvg::Tree::from_data(&data, &resvg::usvg::Options::default()).ok()?; + let mut pixmap = resvg::tiny_skia::Pixmap::new(size, size)?; + let s = tree.size(); + let k = size as f32 / s.width().max(s.height()); + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(k, k), + &mut pixmap.as_mut(), + ); + let mut img = RgbaImage::new(size, size); + for (dst, src) in img.pixels_mut().zip(pixmap.pixels()) { + let c = src.demultiply(); + dst.0 = [c.red(), c.green(), c.blue(), c.alpha()]; + } + Some(img) + } else { + let img = image::ImageReader::open(path) + .ok()? + .with_guessed_format() + .ok()? + .decode() + .ok()?; + Some(image::imageops::resize( + &img.to_rgba8(), + size, + size, + image::imageops::FilterType::Triangle, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn png(path: &std::path::Path, rgba: [u8; 4]) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + image::RgbaImage::from_pixel(8, 8, image::Rgba(rgba)) + .save(path) + .unwrap(); + } + + fn write(path: &std::path::Path, text: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + + const SVG: &str = r##""##; + + #[test] + fn finds_icons_through_desktop_files_and_themes() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("share"); + let dirs = Dirs { + data: vec![data.clone()], + theme: "mytheme".into(), + }; + // By desktop file name, icon in hicolor. + write( + &data.join("applications/org.telegram.desktop.desktop"), + "[Desktop Entry]\nName=Telegram\nIcon=telegram\n", + ); + png( + &data.join("icons/hicolor/256x256/apps/telegram.png"), + [0, 0, 255, 255], + ); + assert_eq!( + find("org.telegram.desktop", &dirs), + Some(data.join("icons/hicolor/256x256/apps/telegram.png")) + ); + // By StartupWMClass, icon in a theme that inherits hicolor. + write( + &data.join("applications/foo.desktop"), + "[Desktop Entry]\nIcon=foo-icon\nStartupWMClass=FooApp\n", + ); + write( + &data.join("icons/mytheme/index.theme"), + "[Icon Theme]\nName=My\nInherits=hicolor\n", + ); + png( + &data.join("icons/hicolor/48x48/apps/foo-icon.png"), + [0, 255, 0, 255], + ); + assert_eq!( + find("FooApp", &dirs), + Some(data.join("icons/hicolor/48x48/apps/foo-icon.png")) + ); + // The theme wins over hicolor, scalable over bitmaps. + write(&data.join("icons/mytheme/scalable/apps/telegram.svg"), SVG); + assert_eq!( + find("org.telegram.desktop", &dirs), + Some(data.join("icons/mytheme/scalable/apps/telegram.svg")) + ); + // An absolute Icon= path. + let abs = tmp.path().join("abs.png"); + png(&abs, [1, 2, 3, 255]); + write( + &data.join("applications/bar.desktop"), + &format!("[Desktop Entry]\nIcon={}\n", abs.display()), + ); + assert_eq!(find("bar", &dirs), Some(abs)); + // No desktop file: the app id itself as the icon name. + png( + &data.join("icons/hicolor/64x64/apps/kitty.png"), + [9, 9, 9, 255], + ); + assert_eq!( + find("kitty", &dirs), + Some(data.join("icons/hicolor/64x64/apps/kitty.png")) + ); + assert_eq!(find("unknown.app", &dirs), None); + } + + #[test] + fn loads_svg_and_png_at_the_asked_size() { + let tmp = tempfile::tempdir().unwrap(); + let svg = tmp.path().join("x.svg"); + fs::write(&svg, SVG).unwrap(); + let img = load(&svg, 48).unwrap(); + assert_eq!(img.dimensions(), (48, 48)); + assert_eq!(img.get_pixel(24, 24).0, [255, 0, 0, 255]); + let p = tmp.path().join("x.png"); + png(&p, [0, 0, 255, 255]); + let img = load(&p, 32).unwrap(); + assert_eq!(img.dimensions(), (32, 32)); + assert_eq!(img.get_pixel(5, 5).0, [0, 0, 255, 255]); + assert!(load(&tmp.path().join("none.png"), 32).is_none()); + } +} diff --git a/src/picker/layout.rs b/src/picker/layout.rs new file mode 100644 index 0000000..e2af496 --- /dev/null +++ b/src/picker/layout.rs @@ -0,0 +1,163 @@ +//! Where the picker's cards go, in logical px of the output. + +/// A card at rest: icon, title and app name. +pub const CARD: (f32, f32) = (200.0, 200.0); +pub const GAP: f32 = 24.0; +/// The selected card's size relative to the others. +pub const GROW: f32 = 1.1; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl Rect { + pub fn contains(&self, p: (f32, f32)) -> bool { + p.0 >= self.x && p.0 < self.x + self.w && p.1 >= self.y && p.1 < self.y + self.h + } +} + +/// `n` cards centred on an output of `output` logical px: rows wrap inside +/// 90 % of the width, and the grid shrinks to fit 60 % of the height. The +/// `selected` card grows about its centre. +pub fn cards(n: usize, output: (f32, f32), selected: usize) -> Vec { + if n == 0 { + return Vec::new(); + } + let (max_w, max_h) = (output.0 * 0.9, output.1 * 0.6); + // Shrink until the grid fits both ways. + let mut scale = 1.0f32; + let (per_row, rows) = loop { + let (cw, g) = (CARD.0 * scale, GAP * scale); + let per_row = (((max_w + g) / (cw + g)).floor() as usize).clamp(1, n); + let rows = n.div_ceil(per_row); + let grid_h = rows as f32 * CARD.1 * scale + (rows - 1) as f32 * g; + if grid_h <= max_h || scale < 0.2 { + break (per_row, rows); + } + scale *= 0.95; + }; + let (cw, ch, g) = (CARD.0 * scale, CARD.1 * scale, GAP * scale); + let grid_h = rows as f32 * ch + (rows - 1) as f32 * g; + let top = (output.1 - grid_h) / 2.0; + (0..n) + .map(|i| { + let (row, col) = (i / per_row, i % per_row); + let in_row = per_row.min(n - row * per_row); + let row_w = in_row as f32 * cw + (in_row - 1) as f32 * g; + let r = Rect { + x: (output.0 - row_w) / 2.0 + col as f32 * (cw + g), + y: top + row as f32 * (ch + g), + w: cw, + h: ch, + }; + if i == selected { grow(r) } else { r } + }) + .collect() +} + +fn grow(r: Rect) -> Rect { + let (w, h) = (r.w * GROW, r.h * GROW); + Rect { + x: r.x - (w - r.w) / 2.0, + y: r.y - (h - r.h) / 2.0, + w, + h, + } +} + +/// The card under `p`, the selected (grown) one first. +pub fn hit(cards: &[Rect], p: (f32, f32)) -> Option { + let mut order: Vec = (0..cards.len()).collect(); + order.sort_by(|a, b| (cards[*b].w).total_cmp(&cards[*a].w)); + order.into_iter().find(|i| cards[*i].contains(p)) +} + +/// `selected` moved by `delta`, wrapping around `n` cards. +pub fn step(selected: usize, n: usize, delta: i32) -> usize { + if n == 0 { + return 0; + } + (selected as i64 + delta as i64).rem_euclid(n as i64) as usize +} + +#[cfg(test)] +mod tests { + use super::*; + + fn overlap(a: &Rect, b: &Rect) -> bool { + a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h + } + + #[test] + fn one_card_is_centred() { + let c = cards(1, (1920.0, 1080.0), 0); + let r = c[0]; + assert!( + ((r.x + r.w / 2.0) - 960.0).abs() < 0.01 && ((r.y + r.h / 2.0) - 540.0).abs() < 0.01 + ); + } + + #[test] + fn five_cards_fit_one_row_on_1080p() { + let c = cards(5, (1920.0, 1080.0), 2); + let unselected: Vec<_> = c + .iter() + .enumerate() + .filter(|(i, _)| *i != 2) + .map(|(_, r)| r.y) + .collect(); + assert!(unselected.windows(2).all(|w| w[0] == w[1]), "one row"); + for i in 0..5 { + for j in i + 1..5 { + assert!(!overlap(&c[i], &c[j]), "{i} and {j} overlap"); + } + } + } + + #[test] + fn many_cards_wrap_and_shrink_to_fit() { + let (w, h) = (1366.0, 768.0); + let c = cards(20, (w, h), 0); + assert_eq!(c.len(), 20); + let left = c.iter().map(|r| r.x).fold(f32::MAX, f32::min); + let right = c.iter().map(|r| r.x + r.w).fold(0.0, f32::max); + let top = c.iter().skip(1).map(|r| r.y).fold(f32::MAX, f32::min); + let bottom = c.iter().skip(1).map(|r| r.y + r.h).fold(0.0, f32::max); + assert!(right - left <= w * 0.9 + 1.0, "{left}..{right}"); + assert!(bottom - top <= h * 0.6 + 1.0, "{top}..{bottom}"); + for i in 1..20 { + for j in i + 1..20 { + assert!(!overlap(&c[i], &c[j]), "{i} and {j} overlap"); + } + } + } + + #[test] + fn the_selected_card_grows_about_its_centre() { + let plain = cards(3, (1920.0, 1080.0), 0); + let chosen = cards(3, (1920.0, 1080.0), 1); + let (a, b) = (plain[1], chosen[1]); + assert!((b.w - a.w * 1.1).abs() < 0.01); + assert!(((a.x + a.w / 2.0) - (b.x + b.w / 2.0)).abs() < 0.01); + } + + #[test] + fn hits_and_gaps() { + let c = cards(3, (1920.0, 1080.0), 0); + let mid = |r: Rect| (r.x + r.w / 2.0, r.y + r.h / 2.0); + assert_eq!(hit(&c, mid(c[2])), Some(2)); + assert_eq!(hit(&c, (5.0, 5.0)), None); + } + + #[test] + fn steps_wrap() { + assert_eq!(step(4, 5, 1), 0); + assert_eq!(step(0, 5, -1), 4); + assert_eq!(step(2, 5, 1), 3); + assert_eq!(step(0, 0, 1), 0); + } +} diff --git a/src/picker/mod.rs b/src/picker/mod.rs new file mode 100644 index 0000000..5cb19c9 --- /dev/null +++ b/src/picker/mod.rs @@ -0,0 +1,5 @@ +//! `valw window`: an Alt+Tab-style window picker over the paint shader. + +pub mod icons; +pub mod layout; +pub mod state; diff --git a/src/picker/state.rs b/src/picker/state.rs new file mode 100644 index 0000000..66ebf28 --- /dev/null +++ b/src/picker/state.rs @@ -0,0 +1,45 @@ +//! The picker remembers which motion it used last, to alternate them. + +use std::path::{Path, PathBuf}; + +pub const SWIRL: f32 = 1.0; +pub const FLOW: f32 = 0.0; + +/// `$XDG_STATE_HOME/valw/picker`. +pub fn default_path() -> PathBuf { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| crate::config::home().join(".local/state")) + .join("valw/picker") +} + +/// The motion for this run: the other one than last time (a swirl the +/// first time, or when the file is unreadable). Remembers it. +pub fn next_motion(path: &Path) -> f32 { + let last = std::fs::read_to_string(path).ok(); + let next = match last.as_deref().map(str::trim) { + Some("swirl") => FLOW, + _ => SWIRL, + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(path, if next == SWIRL { "swirl\n" } else { "flow\n" }); + next +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn motion_alternates_and_starts_with_a_swirl() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("deep/picker"); + assert_eq!(next_motion(&path), SWIRL); + assert_eq!(next_motion(&path), FLOW); + assert_eq!(next_motion(&path), SWIRL); + std::fs::write(&path, "garbage").unwrap(); + assert_eq!(next_motion(&path), SWIRL); + } +}