feat: picker windows, layout, state and icons

This commit is contained in:
elwrcl committed 2026-10-03 20:55:09 +03:00
1 parent 3f529f3a65
commit c6736b0ac3
8 files changed
+780

No files matched your search

Generated
+207
View File
@@ -189,6 +189,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.10.0" version = "0.10.0"
@@ -436,6 +442,12 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -528,6 +540,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@@ -819,6 +837,12 @@ dependencies = [
"zlib-rs", "zlib-rs",
] ]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -840,6 +864,28 @@ dependencies = [
"bytemuck", "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]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -924,6 +970,16 @@ dependencies = [
"r-efi 6.0.0", "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]] [[package]]
name = "gl_generator" name = "gl_generator"
version = "0.14.0" version = "0.14.0"
@@ -1130,6 +1186,22 @@ dependencies = [
"tiff", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.2" version = "2.14.2"
@@ -1912,6 +1984,12 @@ dependencies = [
"indexmap", "indexmap",
] ]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.13" version = "1.1.13"
@@ -2134,6 +2212,48 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" 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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@@ -2319,6 +2439,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 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]] [[package]]
name = "skrifa" name = "skrifa"
version = "0.44.0" version = "0.44.0"
@@ -2461,6 +2596,9 @@ name = "strict-num"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
@@ -2468,6 +2606,16 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 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]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -2592,6 +2740,12 @@ dependencies = [
"strict-num", "strict-num",
] ]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.1"
@@ -2740,6 +2894,12 @@ dependencies = [
"rustc-hash 2.1.3", "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]] [[package]]
name = "unicode-general-category" name = "unicode-general-category"
version = "1.1.0" version = "1.1.0"
@@ -2752,18 +2912,58 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.3" version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 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]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@@ -2797,6 +2997,7 @@ dependencies = [
"libc", "libc",
"libloading", "libloading",
"niri-ipc", "niri-ipc",
"resvg",
"rustix 1.1.5", "rustix 1.1.5",
"serde", "serde",
"serde_ignored", "serde_ignored",
@@ -3601,6 +3802,12 @@ version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.59" version = "0.8.59"
+1
View File
@@ -16,6 +16,7 @@ image = { version = "0.25.10", default-features = false, features = ["png"] }
khronos-egl = { version = "6.0.0", features = ["dynamic"] } khronos-egl = { version = "6.0.0", features = ["dynamic"] }
libc = "0.2.189" libc = "0.2.189"
libloading = "0.8" libloading = "0.8"
resvg = "0.48.1"
niri-ipc = "=26.4.0" niri-ipc = "=26.4.0"
rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] } rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
+3
View File
@@ -13,6 +13,9 @@ mod lock;
mod log; mod log;
mod niri; mod niri;
mod output; mod output;
// The picker lands in pieces; Task 6 of the theme plan removes this.
#[allow(dead_code)]
mod picker;
mod region; mod region;
mod render; mod render;
mod selection; mod selection;
+56
View File
@@ -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) 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<Vec<Window>> {
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<Window>) -> Vec<Window> {
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. /// The output showing the workspace a window is on, if niri knows it.
pub fn output_of(workspace_id: Option<u64>, workspaces: &[Workspace]) -> Option<String> { pub fn output_of(workspace_id: Option<u64>, workspaces: &[Workspace]) -> Option<String> {
let id = workspace_id?; let id = workspace_id?;
@@ -132,6 +152,42 @@ pub fn parse_version(v: &str) -> Option<(u32, u32)> {
mod tests { mod tests {
use super::*; use super::*;
fn window(id: u64, focused: bool, at: Option<u64>) -> 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<u64> = 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 { fn ws(id: u64, output: Option<&str>) -> Workspace {
Workspace { Workspace {
id, id,
+300
View File
@@ -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<PathBuf>,
/// 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<PathBuf> {
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<String> {
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<String> {
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<PathBuf> {
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 <size>/apps or apps/<size>.
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<String> {
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<RgbaImage> {
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##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>"##;
#[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());
}
}
+163
View File
@@ -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<Rect> {
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<usize> {
let mut order: Vec<usize> = (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);
}
}
+5
View File
@@ -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;
+45
View File
@@ -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);
}
}