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

+3
View File
@@ -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;
+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)
}
/// 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.
pub fn output_of(workspace_id: Option<u64>, workspaces: &[Workspace]) -> Option<String> {
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<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 {
Workspace {
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);
}
}