feat: picker windows, layout, state and icons
This commit is contained in:
1 parent
3f529f3a65
commit
c6736b0ac3
8 files changed
+780
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user