Files
valw/docs/superpowers/plans/2026-10-03-valw-theme.md
T

469 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# valw theme round Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A shared paint shader and palettes (T1, with a themed region dim), `valw backdrop` behind niri's overview (T2), and an Alt+Tab-style window picker (T3).
**Architecture:** Pure pieces first and tested (`theme` palettes, `render::tint`, backdrop timing, picker layout/navigation/state, icon lookup), then the GL renderer shared by backdrop and picker (`theme::gl`, reusing zoom's EGL setup), then the two surfaces.
**Tech Stack:** Rust 2024, SCTK 0.21, EGL + glow (GLES 3), tiny-skia, `resvg` 0.45-ish (whatever `cargo add resvg` resolves to; built on tiny-skia), `noctalia msg` at run time.
**Spec:** `docs/superpowers/specs/2026-10-03-valw-theme-design.md`
## Global Constraints
- jj only; `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`.
- No Vulkan/wgpu. Shader code is our own (from the spike), not Balatro's.
- Default palette (no Noctalia): base `#1b1f2a`, body `#2e3a52`, highlight `#8fa6c9`.
- Themed dim: 55 % towards `base`. Backdrop: flow, slow (speed 0.35), re-read wallpaper after a > 2 s pause, colours ease over 1 s. Picker: icons 96 logical px, selected card 110 % with a 2 px border, colour ease 300 ms, motion alternates per run (state `$XDG_STATE_HOME/valw/picker`, missing/broken → swirl).
- Noctalia is optional everywhere; failures fall back to the default palette and log at debug.
- Tell the user before showing surfaces on the live session.
## Review Focus
1. **Noctalia missing / `noctalia msg` hanging**: the region overlay and picker must not wait on it. `noctalia msg` runs with a 1 s timeout (Task 1).
2. **The backdrop must cost nothing while hidden** and survive output hotplug and niri restarts (Task 4).
3. **Icon lookup edge cases**: app ids with dots/case differences, `StartupWMClass`, absolute `Icon=` paths, missing icons (Task 5).
4. **The picker must never end up in the shot** (same destroy/flush/round-trip as the toolbar) (Task 6).
5. **Many windows** (20+) still fit: rows wrap and shrink (Task 5 layout tests).
---
## File Structure
| File | Task |
|---|---|
| `src/theme/mod.rs` (Palette, sources) | 1 |
| `src/render.rs` (`tint`), `src/region.rs` | 2 |
| `src/theme/paint.frag`, `src/theme/gl.rs`; `src/zoom/gl.rs` (`open`, `config` → `pub(crate)`) | 3 |
| `src/backdrop.rs`, `src/main.rs`, `src/wayland.rs` | 4 |
| `src/niri.rs` (`windows`), `src/picker/{layout,icons,state}.rs` | 5 |
| `src/picker/mod.rs`, `src/main.rs`, `src/wayland.rs` | 6 |
| README, checklist, copland | 7 |
---
### Task 1: Palettes
**Files:** create `src/theme/mod.rs`; `src/main.rs`: `mod theme;` (+ temporary dead-code allowance until Task 2).
**Produces:** `theme::Palette { base: [f32; 3], body: [f32; 3], highlight: [f32; 3] }` with `Palette::DEFAULT`, `Palette::lerp(&self, other, t)`; `theme::hex(&str) -> Option<[f32; 3]>`; `theme::from_noctalia_file(text: &str, mode: &str) -> Option<Palette>`; `theme::from_image(img: &RgbaImage, icon: bool) -> Palette`; `theme::current() -> Palette` (Noctalia or default); `theme::wallpaper(connector: &str) -> Option<PathBuf>`; private `noctalia(args) -> Option<String>` (1 s timeout).
- [ ] **Step 1: Failing tests**
```rust
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgba, RgbaImage};
const FILE: &str = r##"{"dark":{"mPrimary":"#cabaaa","mSecondary":"#73685F","mSurface":"#1e1d1b"},
"light":{"mPrimary":"#262524","mSecondary":"#736B5E","mSurface":"#D4C1A8"}}"##;
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() < 0.01)
}
#[test]
fn hex_parses() {
assert!(close(hex("#ff8000").unwrap(), [1.0, 0.502, 0.0]));
assert!(close(hex("D4C1A8").unwrap(), [0.831, 0.757, 0.659]));
assert_eq!(hex("#12"), None);
}
#[test]
fn noctalia_palette_per_mode() {
let light = from_noctalia_file(FILE, "light").unwrap();
assert!(close(light.base, hex("#D4C1A8").unwrap()));
assert!(close(light.body, hex("#736B5E").unwrap()));
assert!(close(light.highlight, hex("#262524").unwrap()));
let dark = from_noctalia_file(FILE, "dark").unwrap();
assert!(close(dark.base, hex("#1e1d1b").unwrap()));
assert_eq!(from_noctalia_file(FILE, "sepia"), None);
assert_eq!(from_noctalia_file("{}", "light"), None);
assert_eq!(from_noctalia_file("not json", "light"), None);
}
#[test]
fn image_palette_is_dark_to_light() {
let img = RgbaImage::from_fn(30, 30, |x, _| match x / 10 {
0 => Rgba([250, 240, 20, 255]),
1 => Rgba([20, 30, 160, 255]),
_ => Rgba([200, 30, 30, 255]),
});
let p = from_image(&img, false);
let lum = |c: [f32; 3]| c[0] + c[1] + c[2];
assert!(lum(p.base) < lum(p.body) && lum(p.body) < lum(p.highlight), "{p:?}");
}
#[test]
fn icon_palettes_ignore_transparent_and_grey() {
let img = RgbaImage::from_fn(30, 30, |x, y| {
if x < 15 { Rgba([0, 0, 0, 0]) } else if y < 10 { Rgba([128, 128, 128, 255]) } else { Rgba([30, 160, 220, 255]) }
});
let p = from_image(&img, true);
for c in [p.base, p.body, p.highlight] {
assert!(c[2] > c[0], "blue family, not grey or black: {p:?}");
}
}
#[test]
fn lerp_eases() {
let a = Palette::DEFAULT;
let b = Palette { base: [1.0; 3], body: [1.0; 3], highlight: [1.0; 3] };
assert_eq!(a.lerp(&b, 0.0), a);
assert_eq!(a.lerp(&b, 1.0), b);
}
}
```
Run → compile errors.
- [ ] **Step 2: Implement** (`serde_json` is already a dependency):
```rust
//! valw's colours: palettes for the paint shader and the overlays, from
//! Noctalia's theme, from an image, or a built-in default.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use image::RgbaImage;
/// Three colours, dark to light, as linear 0–1 RGB.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palette {
pub base: [f32; 3],
pub body: [f32; 3],
pub highlight: [f32; 3],
}
impl Palette {
pub const DEFAULT: Palette = Palette {
base: [0.106, 0.122, 0.165],
body: [0.180, 0.227, 0.322],
highlight: [0.561, 0.651, 0.788],
};
pub fn lerp(&self, other: &Palette, t: f32) -> Palette {
let t = t.clamp(0.0, 1.0);
let mix = |a: [f32; 3], b: [f32; 3]| [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t);
Palette { base: mix(self.base, other.base), body: mix(self.body, other.body), highlight: mix(self.highlight, other.highlight) }
}
}
pub fn hex(s: &str) -> Option<[f32; 3]> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 {
return None;
}
let v = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok().map(|v| v as f32 / 255.0);
Some([v(0)?, v(2)?, v(4)?])
}
/// A Noctalia community palette file at `mode`.
pub fn from_noctalia_file(text: &str, mode: &str) -> Option<Palette> {
let json: serde_json::Value = serde_json::from_str(text).ok()?;
let m = json.get(mode)?;
let get = |k: &str| hex(m.get(k)?.as_str()?);
Some(Palette { base: get("mSurface")?, body: get("mSecondary")?, highlight: get("mPrimary")? })
}
/// The dominant colours of `img`, dark to light. Icons skip transparent and
/// near-grey pixels so the brand colour wins.
pub fn from_image(img: &RgbaImage, icon: bool) -> Palette {
let step = ((img.width() * img.height()) as usize / 4096).max(1);
let mut buckets = std::collections::HashMap::<(u8, u8, u8), (u32, [u64; 3])>::new();
for p in img.pixels().step_by(step) {
let [r, g, b, a] = p.0;
if icon {
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
if a < 128 || max - min < 24 {
continue;
}
}
let e = buckets.entry((r >> 5, g >> 5, b >> 5)).or_default();
e.0 += 1;
for (s, v) in e.1.iter_mut().zip([r, g, b]) {
*s += v as u64;
}
}
let mut groups: Vec<_> = buckets.into_values().collect();
groups.sort_by(|a, b| b.0.cmp(&a.0));
let mut pick: Vec<[f32; 3]> = Vec::new();
for (n, sum) in groups {
let c = sum.map(|s| s as f32 / n as f32 / 255.0);
if pick.iter().all(|p| (0..3).map(|i| (p[i] - c[i]).abs()).sum::<f32>() > 0.3) {
pick.push(c);
}
if pick.len() == 3 {
break;
}
}
match pick.len() {
0 => return Palette::DEFAULT,
1 => {
let c = pick[0];
pick = vec![c.map(|v| v * 0.45), c, c.map(|v| v + (1.0 - v) * 0.5)];
}
2 => {
let c = pick[1];
pick.push(c.map(|v| v + (1.0 - v) * 0.5));
}
_ => {}
}
pick.sort_by(|a, b| (a[0] + a[1] + a[2]).total_cmp(&(b[0] + b[1] + b[2])));
Palette { base: pick[0], body: pick[1], highlight: pick[2] }
}
/// `noctalia msg <args>`'s output, or None (not installed, failing, or
/// slower than a second).
fn noctalia(args: &[&str]) -> Option<String> {
let mut child = Command::new("noctalia")
.arg("msg")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(1);
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(Some(_)) | Err(_) => return None,
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
}
}
let mut out = String::new();
std::io::Read::read_to_string(&mut child.stdout.take()?, &mut out).ok()?;
Some(out.trim().to_string())
}
/// Noctalia's current palette, or the default.
pub fn current() -> Palette {
noctalia_palette().unwrap_or_else(|| {
tracing::debug!("using the default palette");
Palette::DEFAULT
})
}
fn noctalia_palette() -> Option<Palette> {
let scheme = noctalia(&["color-scheme-get"])?; // "<source> <name>"
let (source, name) = scheme.split_once(' ')?;
if source != "community" {
return None;
}
let mode = noctalia(&["theme-mode-get"])?;
let dir = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| crate::config::home().join(".local/state"));
let file = dir.join("noctalia/community-palettes").join(format!("{}.json", name.replace(' ', "%20")));
from_noctalia_file(&std::fs::read_to_string(file).ok()?, &mode)
}
/// The wallpaper Noctalia shows on `connector`.
pub fn wallpaper(connector: &str) -> Option<PathBuf> {
noctalia(&["wallpaper-get", connector]).map(PathBuf::from).filter(|p| p.exists())
}
```
(Check `noctalia msg color-scheme-get`'s exact output on this machine and adapt the split; ledger what it printed.)
- [ ] **Step 3: Run**, clippy, fmt. **Step 4: Commit** `feat: theme palettes`.
---
### Task 2: Themed region dim
**Files:** `src/render.rs`, `src/region.rs`.
**Produces:** `render::tint(pixels: &[u8], colour: [f32; 3], amount: f32) -> Vec<u8>` (BGRX bytes; X untouched-ish); `render::DIM` stays for tests only or is removed if unused.
- [ ] **Step 1: Failing test** (`render.rs`):
```rust
#[test]
fn tint_blends_towards_the_colour() {
// B, G, R, X = 200, 100, 0, 255; colour pure red (R = 1).
let out = tint(&[200, 100, 0, 255], [1.0, 0.0, 0.0], 0.55);
assert_eq!(out[0], (200.0 * 0.45f32).round() as u8, "blue fades");
assert_eq!(out[1], (100.0 * 0.45f32).round() as u8);
assert_eq!(out[2], (255.0 * 0.55f32).round() as u8, "red rises");
assert_eq!(tint(&[10, 20, 30, 0], [0.0; 3], 0.0), vec![10, 20, 30, 0]);
}
```
- [ ] **Step 2: Implement**:
```rust
/// B, G, R, X pixels blended `amount` of the way towards `colour` (RGB 0–1).
/// Runs once per output before the overlay shows, like `dim` did.
pub fn tint(pixels: &[u8], colour: [f32; 3], amount: f32) -> Vec<u8> {
let keep = 1.0 - amount;
let [r, g, b] = colour.map(|c| c * 255.0 * amount);
let add = [b, g, r, 0.0];
pixels
.chunks_exact(4)
.flat_map(|p| [0, 1, 2, 3].map(|i| if i == 3 { p[3] } else { (p[i] as f32 * keep + add[i]).round() as u8 }))
.collect()
}
```
(If clippy prefers `as_chunks`, follow it.) `region.rs`: `select` computes `let palette = crate::theme::current();` once and `Surface::new` uses `render::tint(frame.pixels.as_raw(), palette.base, TINT)` with `const TINT: f32 = 0.55;` in `render.rs`; drop `dim`/`DIM` if nothing else uses them (update its test accordingly). Remove Task 1's allowance.
Note the performance: `tint` is a float pass over every pixel; on 1920×1080 that is ~8 M bytes — keep it a tight loop (it replaced `dim`'s integer pass). If it adds more than ~10 ms in release (measure with the existing "overlay buffers ready … after start" log line), switch to an integer form `(p * keep256 + add256) >> 8`.
- [ ] **Step 3: Run**, clippy, fmt. **Step 4: Commit** `feat: theme-coloured region dim`.
---
### Task 3: The paint renderer
**Files:** create `src/theme/paint.frag` (the spike's final `paint()` and `warp()`, with uniforms `res`, `t`, `motion`, `speed`, `scale`, `c1`, `c2`, `c3`, plus an optional overlay texture `overlay` and `use_overlay`), `src/theme/gl.rs`; `src/zoom/gl.rs`: `open`, `config`, `Egl` become `pub(crate)`.
**Produces:** `theme::gl::Paint::new(conn, surface: &WlSurface, size_px: (u32, u32)) -> Result<Paint>`; `Paint::resize(size_px)`; `Paint::draw(&self, frame: &Frame) -> Result<()>` with `pub struct Frame { pub time: f32, pub motion: f32, pub speed: f32, pub scale: f32, pub palette: Palette }`; `Paint::set_overlay(&mut self, pixmap: Option<&tiny_skia::Pixmap>)` (premultiplied RGBA, drawn over the paint with `ONE, ONE_MINUS_SRC_ALPHA` blending). Drop order as in `zoom::gl::Renderer` (EGL surface before `wl_egl_window`).
Fragment shader (final):
```glsl
#version 300 es
precision highp float;
uniform vec2 res;
uniform float t;
uniform float motion; // 0 flow, 1 swirl
uniform float speed;
uniform float scale; // blob size: 1 = the spike's
uniform vec3 c1;
uniform vec3 c2;
uniform vec3 c3;
uniform sampler2D overlay;
uniform bool use_overlay;
out vec4 color;
vec2 warp(vec2 p, float time) {
for (int i = 0; i < 6; i++) {
float fi = float(i);
p += 0.42 * sin(p.yx * vec2(1.55, 1.28) + time * vec2(0.27, 0.33) + fi * vec2(1.3, 2.1));
p = mat2(0.82, -0.57, 0.57, 0.82) * p;
}
return p;
}
vec3 paint(vec2 frag) {
vec2 uv = (frag - 0.5 * res) / res.y;
float time = t * speed;
if (motion > 0.0) {
float r = length(uv);
float a = atan(uv.y, uv.x) + motion * (2.2 / (r + 0.35)) + time * 0.25 * motion;
uv = r * vec2(cos(a), sin(a));
}
vec2 p = warp(uv * 4.0 / scale, time);
p = warp(p * 1.7, time * 1.25 + 3.0);
float v = sin(p.x) * cos(p.y);
float w = sin(length(p) * 0.6 - time * 0.8);
float m = v * 0.75 + w * 0.25;
vec3 col = c1;
col = mix(col, c2, smoothstep(-0.32, -0.18, m));
col = mix(col, c3, smoothstep(0.38, 0.5, m) * 0.85);
float rim = 1.0 - smoothstep(0.0, 0.06, abs(m + 0.25));
col *= 1.0 - 0.3 * rim;
float gloss = pow(abs(cos(p.x * 1.3 + p.y)), 36.0);
col += gloss * 0.18 * (c3 + 0.25);
col *= 1.0 - 0.3 * dot(uv, uv);
return col;
}
void main() {
vec2 frag = vec2(gl_FragCoord.x, res.y - gl_FragCoord.y);
vec3 col = paint(frag);
if (use_overlay) {
vec4 o = texture(overlay, frag / res);
col = o.rgb + col * (1.0 - o.a);
}
color = vec4(col, 1.0);
}
```
(One pass: the overlay is composited in the shader, no blending state needed.)
- [ ] **Step 1** (no unit test for GL; the spike proved the shader on this GPU): implement, `cargo clippy`, and a compile-only test that the shader source contains every uniform `Paint` sets (`#[test] fn shader_declares_its_uniforms`).
- [ ] **Step 2: Commit** `feat: paint shader renderer`.
---
### Task 4: `valw backdrop`
**Files:** create `src/backdrop.rs`; `src/main.rs` (`Command::Backdrop`), `src/wayland.rs` (routing).
**Produces:** pure `backdrop::should_refresh(last_frame: Option<Instant>, now: Instant) -> bool` (true when no frame yet or more than 2 s since the last) and `backdrop::ease(from: &Palette, to: &Palette, since: Duration) -> Palette` (linear over 1 s); `backdrop::run() -> Result<()>`.
- [ ] **Step 1: Failing tests** for `should_refresh` (None → true, 1 s → false, 3 s → true) and `ease` (0 → from, 0.5 s → halfway, 2 s → to).
- [ ] **Step 2: Implement** `run`:
- Single instance: `Lock::acquire(lock::default_path().with_file_name("valw-backdrop.lock"))`, else log and exit 0.
- `Wayland::connect`, one `Backdrop` per output in `State::backdrops: Vec<Backdrop>`: background-layer surface, `Anchor::all()`, exclusive zone -1 (cover panels too — niri draws it inside the backdrop), `KeyboardInteractivity::None`, empty input region, namespace `valw-backdrop`, a `theme::gl::Paint` sized to the output's physical size, a palette (current/target/eased since), `last_frame`.
- Draw on configure and on each frame callback (request the next one before swapping). In the frame handler: if `should_refresh(last_frame, now)`, read `theme::wallpaper(name)` → `image` decode (downscale to ~256 px with `thumbnail`) → `from_image(.., false)` as the new target (keep the old if anything fails).
- Outputs: new outputs get a surface; destroyed ones drop theirs (SCTK `OutputHandler::new_output` / `output_destroyed`).
- Dispatch forever (`dispatch_blocking` loop); exit when the compositor goes away.
- [ ] **Step 3: Run tests**, clippy, fmt. **Step 4: Smoke** (tell the user: without the niri layer rule the backdrop shows over the wallpaper for a few seconds): `timeout -s INT 4 valw backdrop`, grim shot, check the log. **Step 5: Commit** `feat: valw backdrop`.
---
### Task 5: Picker data — windows, icons, layout, state
**Files:** `src/niri.rs` (`windows() -> Result<Vec<Window>>`), create `src/picker/mod.rs` (module list), `src/picker/layout.rs`, `src/picker/icons.rs`, `src/picker/state.rs`; `Cargo.toml`: `resvg`.
**Produces:**
- `niri::windows()`; `picker::mru(windows) -> Vec<Window>` (focused first, then by `focus_timestamp` descending, windows without a timestamp last by id).
- `layout::cards(n: usize, output: (f32, f32), selected: usize) -> Vec<Rect>` (logical px; card base 200 × 200: 96 px icon + title + app name; 24 px gaps; rows wrap to the output width minus 10 % margins; when rows exceed 60 % of the height the whole grid scales down to fit; the selected card scaled 1.1 about its centre); `layout::hit(cards, p) -> Option<usize>`; `layout::step(selected, n, delta: i32) -> usize` (wraps).
- `state::next_motion(path) -> f32` (reads the last motion, returns the other, writes it; missing/broken → swirl = 1.0 and writes it).
- `icons::find(app_id: &str, dirs: &Dirs) -> Option<PathBuf>`; `Dirs { data: Vec<PathBuf>, theme: String }` from `$XDG_DATA_HOME`, `$XDG_DATA_DIRS`, and `gtk-icon-theme-name` in `~/.config/gtk-3.0/settings.ini` (default `hicolor`); `icons::load(path, size_px) -> Option<RgbaImage>` (PNG via `image`, SVG via `resvg`).
- [ ] **Step 1: Failing tests**
- `mru`: focused first, then newest timestamps, then no-timestamp by id.
- `cards`: 1 card centred; 5 cards in one row on 1920×1080; 20 cards on 1366×768 wrap into rows that fit inside 90 % of the width and 60 % of the height (scaled), no overlaps; the selected card is 1.1× and still centred on its slot; `hit` on a card centre and in a gap.
- `step`: +1 at the end wraps to 0, −1 at 0 wraps to n−1, n = 0 → 0.
- `next_motion`: missing → 1.0 then 0.0 then 1.0; garbage file → 1.0.
- `icons::find` against a temp data dir:
- `applications/org.telegram.desktop.desktop` with `Icon=telegram` and `icons/hicolor/256x256/apps/telegram.png` → found for app id `org.telegram.desktop`;
- a desktop file `foo.desktop` with `StartupWMClass=FooApp` → found for `FooApp`;
- theme `mytheme` with `index.theme` `Inherits=hicolor` and the icon only in hicolor → found;
- `Icon=/abs/path.png` → that path;
- prefers `scalable/apps/x.svg` over smaller PNGs;
- unknown app id → None.
- `icons::load`: a tiny SVG (`<svg …><rect fill="#f00" …/></svg>`) renders to the requested size with red pixels; a PNG loads and resizes.
- [ ] **Step 2: Implement.** Desktop lookup: for each data dir `applications/`, try `<app_id>.desktop`, then case-insensitive file name match, then scan for `StartupWMClass=<app_id>` (case-insensitive). Icon theme lookup: for theme, then its `Inherits` chain, then `hicolor`: directories in order `scalable/apps`, `256x256/apps`, `128x128/apps`, `96x96/apps`, `64x64/apps`, `48x48/apps`, and any `*/apps` under the theme (first hit), extensions `svg` then `png`; also `pixmaps/<name>.(png|svg)`. Bound the search (no recursion beyond `theme/size/apps`).
- [ ] **Step 3: Run**, clippy, fmt. **Step 4: Commit** `feat: picker windows, layout, state and icons`.
---
### Task 6: The picker
**Files:** `src/picker/mod.rs`, `src/main.rs`, `src/wayland.rs`.
**Produces:** `picker::run(config: &Config) -> Result<u64>` (the chosen window id; `Cancelled` on Esc / click outside); `Command::Window { pick: bool, common }` — `--pick` keeps the old niri pick; without it, `picker::run` chooses and the existing `window_shot` path captures that id (`window::capture_id(id, cursor)` split out of `window::capture`).
- Surface: overlay layer on the focused output, `Anchor::all()`, exclusive keyboard, namespace `valw-picker`; `theme::gl::Paint` with `motion = state::next_motion(..)`, `speed = 0.6`, `scale = 1.0`; palette eases (300 ms) towards `from_image(icon, true)` of the selected window (icons loaded once at start, at 96 × scale px; a missing icon → the default palette and a letter tile).
- Cards are drawn with tiny-skia into a full-output `Pixmap` (rounded panel `#000000` alpha 90 with 1 px `#ffffff` alpha 40 border; selected panel alpha 140 and a 2 px border in the palette's highlight; the icon; title 14 px white ellipsised to the card width; app name 12 px white alpha 170) and uploaded as the overlay only when the selection or hover changes.
- Frames: frame-callback driven while open (the shader animates), the overlay re-uploaded only on change.
- Input as the spec says (Tab/Shift+Tab/arrows, Enter, Esc, hover, click, click outside).
- On choice: drop the picker (surface destroyed), flush, round-trip, return the id. The caller (`capture`) then runs `window_shot` for that id.
- Tests: `main` CLI (`window`, `window --pick`), and the mapping in `capture` stays covered by existing tests; the picker's own logic is covered by Task 5.
- Smoke (tell the user): open the picker for a few seconds, grim shot, check the cards and the shader.
- Commit `feat: window picker`.
---
### Task 7: Wiring, docs, checks
- [ ] README: the picker (`valw window`, `--pick`), `valw backdrop` with the niri layer rule and Noctalia's `[backdrop] enabled = false`.
- [ ] Checklist section "Theme".
- [ ] copland (with the user's approval): `spawn-at-startup` `valw backdrop`, the niri `layer-rule`, Noctalia backdrop off (wherever copland sets Noctalia's config; if it is not managed by copland, tell the user to toggle it in Noctalia's settings), `nix flake update valw`.
- [ ] `nix flake check`, review, push.