diff --git a/docs/superpowers/plans/2026-09-29-valw-phase-0-1.md b/docs/superpowers/plans/2026-09-29-valw-phase-0-1.md deleted file mode 100644 index 169dfb2..0000000 --- a/docs/superpowers/plans/2026-09-29-valw-phase-0-1.md +++ /dev/null @@ -1,4344 +0,0 @@ -# valw Phase 0 + 1 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:** Ship `valw screen`, `valw region` and `valw doctor`: pixel-exact PNG screenshots on niri that are saved, copied to the clipboard, logged, and explained when they fail. Everything is packaged as a dendritic Nix flake. - -**Architecture:** One short-lived Rust process per command. The process captures every needed output up front through wlr-screencopy and keeps the pixels in the compositor's own byte order (B, G, R, X), so the overlay appears fast even on a slow machine. For `region` it shows the frozen frames in layer-shell overlays and crops in memory; conversion to RGBA happens only for what gets saved. It then writes the PNG and forks a small child that serves the clipboard. Pure logic (pixel formats, selection math, overlay pixels, file names, config, errors) is kept apart from the SCTK/Wayland glue so it can be unit tested without a compositor. - -**Tech Stack:** Rust 2024 (stable, from nixpkgs), smithay-client-toolkit 0.21, wayland-protocols(-wlr), niri-ipc 26.4.0, wl-clipboard-rs, image (png), clap, serde/toml, tracing, rustix/libc, anyhow; Nix flake-parts with `*.ulu.nix` auto-discovery; jj. - -**Spec:** `docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md` (read it first; this plan implements it). - -## Global Constraints - -- Code, comments, error/log messages: English. Comments are plain and short and say *why*. -- Version control is **jj** (the repo is non-colocated; there is no `.git`). Commit with `jj commit -m ""`. Never use git commands. -- Use `cargo clippy` (never `cargo check`) and `cargo nextest run` (never `cargo test`). -- Run every cargo command inside the devShell. direnv loads it automatically once `.envrc` is allowed; otherwise prefix the command with `nix develop -c`. -- `CARGO_TARGET_DIR` is `${XDG_CACHE_HOME:-$HOME/.cache}/valw/target` (set by the devShell). A `target/` directory must never appear in the repo, because the `path:` flake would copy it into the store on every evaluation. -- The flake discovers `nix/**/*.ulu.nix` automatically. Never write a manual `imports = [ ... ]` list. -- Prefer nix3 commands (`nix build`, `nix develop`, `nix flake check`). Don't `find` over `/nix/store`. -- `.gitignore` is a whitelist. Any new top-level file or directory must be added to it, or jj silently leaves it out of commits. -- Dependency versions are exactly those in Task 1's `Cargo.toml`; `niri-ipc` stays pinned at `=26.4.0`. -- Layer-shell namespace: `valw-overlay`. Exit codes: 0 success, 1 error, 2 usage (clap), 3 cancelled. -- Until Task 11, `src/main.rs` starts with `#![allow(dead_code)]` because modules land before the CLI uses them. Task 11 removes it, and clippy must then be clean without it. - -## Review Focus - -These are the input classes most likely to bite a real user that no unit test fully exercises. Each one has a check in the task that owns the code. - -1. **Rotated or flipped outputs** (`transform != Normal`): `screen` and `region` must produce an upright image identical to grim's. Unit test: `frame::tests::transforms_match_grim` (Task 6). Real check: Task 13, step 4. -2. **Fractional scale (1.25)**: the overlay must look sharp (not resampled), and the crop must be pixel-exact against grim. Unit tests: `selection::tests::fractional_scale_rounds_outwards` (Task 7). Real check: Task 13, step 2. -3. **A drag that leaves the anchor output** (press on one monitor, release on another, or outside every monitor): the result must be clipped to the monitor where the drag began and never panic. Unit tests: `selection::tests::drag_across_outputs_is_clipped_to_the_output` and `output_offsets_including_negative` (Task 7). Real check: Task 11, step 7. -4. **Hotkey pressed again while an overlay is open**: the second process exits 1 with "valw is already running" and a hint, and the first keeps working. The same must hold after the first one has exited while its clipboard child is still alive. Unit tests: `lock::tests::second_acquire_fails_until_release`, `detach::tests::child_holds_neither_lock_nor_stdio` (Task 5). Real check: Task 10, step 8. -5. **Unwritable log or save locations**: an unwritable log dir must not block a screenshot (stderr-only fallback). An unwritable save dir must fail with a message that names the path. Unit tests: `log::tests::open_run_log_fails_on_unwritable_dir` (Task 4), `output::tests::write_atomic_reports_unwritable_dir` (Task 8). Real check: Task 13, step 6. - ---- - -## File Structure - -| File | Responsibility | -|---|---| -| `flake.nix` | flake-parts entry; auto-discovers `nix/**/*.ulu.nix` | -| `nix/package.ulu.nix` | `packages.default` via `rustPlatform.buildRustPackage` | -| `nix/devshell.ulu.nix` | `devShells.default`; sets `CARGO_TARGET_DIR` outside the repo | -| `nix/formatter.ulu.nix` | `formatter` (nixfmt over all `.nix` files) | -| `nix/checks.ulu.nix` | `checks`: clippy, fmt, nextest, headless sway | -| `.envrc`, `.gitignore` | direnv hookup; whitelist ignore file | -| `Cargo.toml` | crate manifest, all Phase 1 dependencies | -| `src/main.rs` | clap CLI, run/exit-code plumbing, capture → deliver flow | -| `src/error.rs` | `Hint`, `HintExt`, `Cancelled`, three-part `render` | -| `src/config.rs` | config struct, defaults, TOML parse with unknown-key report | -| `src/log.rs` | per-run log file, pruning, stderr warnings, panic hook | -| `src/lock.rs` | single-instance `flock` | -| `src/detach.rs` | fork a child that outlives valw with no lock and no stdio | -| `src/frame.rs` | `OutputGeom`, `PixelRect`, `Frame` (B,G,R,X pixels), shm format → BGRX, RGBA for saving, output transforms | -| `src/selection.rs` | drag state machine; logical → physical pixel rectangles | -| `src/render.rs` | overlay pixels: dim, paste undimmed selection, outline | -| `src/output.rs` | file names, unique paths, PNG encode, atomic write, stdout, clipboard | -| `src/niri.rs` | niri IPC: focused output, version, version parsing | -| `src/wayland.rs` | SCTK connection, state, handlers, dispatch helpers | -| `src/capture.rs` | `Capturer` trait; wlr-screencopy implementation | -| `src/region.rs` | layer-shell overlay surfaces and pointer/keyboard handling | -| `src/doctor.rs` | `valw doctor` checks and report | - -Unit tests live in a `#[cfg(test)] mod tests` block at the bottom of each file. - ---- - -### Task 1: Flake, devShell and crate skeleton (Phase 0) - -**Files:** -- Create: `flake.nix`, `nix/package.ulu.nix`, `nix/devshell.ulu.nix`, `nix/formatter.ulu.nix`, `.envrc`, `.gitignore`, `Cargo.toml`, `src/main.rs` -- Generated: `flake.lock`, `Cargo.lock` - -**Interfaces:** -- Consumes: nothing. -- Produces: `nix build` → `result/bin/valw`; `nix develop` shell with `cargo`, `clippy`, `rustfmt`, `cargo-nextest`, `rust-analyzer`, `grim`, `imagemagick`, `wayland-info`; `CARGO_TARGET_DIR` set outside the repo. - -- [ ] **Step 1: Write `flake.nix`** - -```nix -{ - description = "valw, macOS-style screenshots for niri"; - - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - - flake-parts = { - url = "github:hercules-ci/flake-parts"; - inputs.nixpkgs-lib.follows = "nixpkgs"; - }; - }; - - outputs = - inputs@{ flake-parts, ... }: - flake-parts.lib.mkFlake { inherit inputs; } ( - { lib, ... }: - let - inherit (lib.filesystem) listFilesRecursive; - inherit (lib.lists) filter; - inherit (lib.strings) hasSuffix; - in - { - systems = [ - "x86_64-linux" - "aarch64-linux" - ]; - imports = filter (hasSuffix ".ulu.nix") (listFilesRecursive ./nix); - } - ); -} -``` - -- [ ] **Step 2: Write `nix/package.ulu.nix`** - -```nix -{ ... }: -{ - perSystem = - { pkgs, lib, ... }: - { - packages.default = pkgs.rustPlatform.buildRustPackage { - pname = "valw"; - version = "0.1.0"; - - src = lib.cleanSourceWith { - src = ../.; - filter = - path: _type: - !(builtins.elem (baseNameOf path) [ - ".direnv" - ".jj" - "result" - "target" - ]); - }; - - cargoLock.lockFile = ../Cargo.lock; - - nativeBuildInputs = [ pkgs.pkg-config ]; - buildInputs = [ - pkgs.libxkbcommon - pkgs.wayland - ]; - - # Tests run through `nix flake check` instead. - doCheck = false; - - meta = { - description = "macOS-style screenshots for niri"; - mainProgram = "valw"; - platforms = lib.platforms.linux; - }; - }; - }; -} -``` - -- [ ] **Step 3: Write `nix/devshell.ulu.nix`** - -```nix -{ ... }: -{ - perSystem = - { pkgs, self', ... }: - { - devShells.default = pkgs.mkShell { - inputsFrom = [ self'.packages.default ]; - - packages = with pkgs; [ - cargo-nextest - clippy - rust-analyzer - rustfmt - # Manual verification: grim is the pixel oracle, magick compares. - grim - imagemagick - wayland-utils - ]; - - # The repo is a path: flake, so Nix copies the whole tree on every - # evaluation. Keep the multi-gigabyte target dir out of it. - shellHook = '' - export CARGO_TARGET_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/valw/target" - ''; - }; - }; -} -``` - -- [ ] **Step 4: Write `nix/formatter.ulu.nix`** - -```nix -{ ... }: -{ - perSystem = - { pkgs, ... }: - { - formatter = pkgs.writeShellApplication { - name = "nixfmt-tree"; - runtimeInputs = [ - pkgs.nixfmt - pkgs.fd - pkgs.findutils - ]; - text = '' - if [ "$#" -eq 0 ]; then - set -- . - fi - fd --type f --extension nix --hidden --exclude .jj . "$@" \ - | xargs -r nixfmt - ''; - }; - }; -} -``` - -- [ ] **Step 5: Write `.envrc` and `.gitignore`** - -`.envrc`: - -```sh -watch_file nix/*.ulu.nix -use flake -``` - -`.gitignore`: - -```gitignore -/* -!/.gitignore -!/.envrc -!/flake.nix -!/flake.lock -!/Cargo.toml -!/Cargo.lock -!/nix/ -!/src/ -!/docs/ -!/README.md -``` - -- [ ] **Step 6: Write `Cargo.toml`** - -```toml -[package] -name = "valw" -version = "0.1.0" -edition = "2024" -description = "macOS-style screenshots for niri" -license = "MIT" - -[dependencies] -anyhow = "1.0.104" -chrono = "0.4.45" -clap = { version = "4.6.7", features = ["derive"] } -image = { version = "0.25.10", default-features = false, features = ["png"] } -libc = "0.2.189" -niri-ipc = "=26.4.0" -rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] } -serde = { version = "1.0.229", features = ["derive"] } -serde_ignored = "0.1.14" -smithay-client-toolkit = "0.21.1" -toml = "1.1.6" -tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } -wayland-client = "0.31.15" -wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } -wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } -wl-clipboard-rs = "0.9.4" - -[dev-dependencies] -tempfile = "3.27.0" - -# Image encoding is painfully slow unoptimised; keep dev builds usable. -[profile.dev.package."*"] -opt-level = 3 -``` - -- [ ] **Step 7: Write `src/main.rs`** - -```rust -// Modules land before the CLI uses them; Task 11 removes this. -#![allow(dead_code)] - -use clap::Parser; - -#[derive(Parser)] -#[command(version, about = "macOS-style screenshots for niri")] -struct Cli {} - -fn main() { - Cli::parse(); -} -``` - -- [ ] **Step 8: Generate the lock files** - -The devShell evaluates the package, and the package reads `Cargo.lock`, so `Cargo.lock` has to exist before `nix develop` can run: - -```sh -nix shell nixpkgs#cargo -c cargo generate-lockfile -nix flake lock -``` - -Expected: `Cargo.lock` and `flake.lock` exist at the repo root. - -- [ ] **Step 9: Enter the devShell and build** - -```sh -direnv allow -cargo clippy --all-targets -- -D warnings -cargo run -- --version -``` - -Expected: clippy prints no warnings, and the last command prints `valw 0.1.0`. `ls` shows **no** `target/` directory in the repo, and `echo $CARGO_TARGET_DIR` prints a path under `~/.cache/valw/`. - -- [ ] **Step 10: Build with Nix and check the flake copy stays small** - -```sh -nix build -./result/bin/valw --version -du -sh "$(nix flake metadata --json | jq -r .path)" -``` - -Expected: `valw 0.1.0`, and the flake source is a few MB at most. If it is much larger, something like `target/` or `result` has leaked into the tree. - -- [ ] **Step 11: Commit** - -```sh -jj commit -m "build: dendritic flake, devShell and crate skeleton" -``` - ---- - -### Task 2: Error type and rendering - -**Files:** -- Create: `src/error.rs` -- Modify: `src/main.rs` (add `error` to the `mod` list) - -**Interfaces:** -- Consumes: nothing. -- Produces: `pub struct Hint(pub String)`; `pub struct Cancelled` (implements `std::error::Error`); `pub trait HintExt { fn hint(self, hint: impl Into) -> anyhow::Result; }` implemented for every `Result>`; `pub fn render(err: &anyhow::Error, log: Option<&Path>) -> String`. - -- [ ] **Step 1: Write the failing tests for `error`** - -Create `src/error.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use anyhow::{Context, anyhow}; - use std::path::PathBuf; - - #[test] - fn renders_all_parts() { - let err = Err::<(), _>(anyhow!("compositor offered only XBGR2101010")) - .context("could not convert the frame") - .hint("run `valw doctor` and include its output in a bug report") - .context("could not capture output HDMI-A-1") - .unwrap_err(); - let log = PathBuf::from("/tmp/valw.log"); - - assert_eq!( - render(&err, Some(&log)), - "error: could not capture output HDMI-A-1\n\ - \x20 cause: could not convert the frame\n\ - \x20 cause: compositor offered only XBGR2101010\n\ - \x20 hint: run `valw doctor` and include its output in a bug report\n\ - \x20 log: /tmp/valw.log\n" - ); - } - - #[test] - fn renders_without_hint_or_log() { - let err = anyhow!("disk full"); - assert_eq!(render(&err, None), "error: disk full\n"); - } - - #[test] - fn hint_as_outermost_layer_is_not_the_headline() { - let err = Err::<(), _>(anyhow!("valw is already running")) - .hint("wait for the other capture to finish") - .unwrap_err(); - assert_eq!( - render(&err, None), - "error: valw is already running\n\ - \x20 hint: wait for the other capture to finish\n" - ); - } - - #[test] - fn cancelled_is_detectable() { - let err = anyhow::Error::new(Cancelled).context("region selection"); - assert!(err.downcast_ref::().is_some()); - } -} -``` - -Add a `mod` list to `src/main.rs`, right after the `#![allow(dead_code)]` line, containing `mod error;`. - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run error::` -Expected: compilation fails, e.g. `` cannot find type `Hint` in this scope ``. - -- [ ] **Step 3: Implement `error`** - -Put this above the test module in `src/error.rs`: - -```rust -use std::fmt; -use std::path::Path; - -/// A concrete next step for the user, attached to an error with [`HintExt::hint`]. -#[derive(Debug)] -pub struct Hint(pub String); - -impl fmt::Display for Hint { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -/// The user cancelled the capture. Not a failure; maps to exit code 3. -#[derive(Debug)] -pub struct Cancelled; - -impl fmt::Display for Cancelled { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("cancelled") - } -} - -impl std::error::Error for Cancelled {} - -pub trait HintExt { - fn hint(self, hint: impl Into) -> anyhow::Result; -} - -impl HintExt for Result -where - E: Into, -{ - fn hint(self, hint: impl Into) -> anyhow::Result { - self.map_err(|e| e.into().context(Hint(hint.into()))) - } -} - -/// Renders an error as `error:` / `cause:` / `hint:` / `log:` lines. -pub fn render(err: &anyhow::Error, log: Option<&Path>) -> String { - let hint = err.downcast_ref::().map(|h| h.0.as_str()); - // Hints live in the context chain too; skip them so they print once. - let mut messages = err - .chain() - .map(|e| e.to_string()) - .filter(|m| Some(m.as_str()) != hint); - - let mut out = format!("error: {}\n", messages.next().unwrap_or_default()); - for cause in messages { - out.push_str(&format!(" cause: {cause}\n")); - } - if let Some(hint) = hint { - out.push_str(&format!(" hint: {hint}\n")); - } - if let Some(log) = log { - out.push_str(&format!(" log: {}\n", log.display())); - } - out -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run error::` -Expected: all 4 tests in `error::tests` pass. - -- [ ] **Step 5: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: three-part error rendering with hints" -``` - ---- - - -### Task 3: Config file - -**Files:** -- Create: `src/config.rs` -- Modify: `src/main.rs` (add `config` to the `mod` list) - -**Interfaces:** -- Consumes: `error::HintExt` (Task 2). -- Produces: `pub struct Config { pub save: Save, pub capture: Capture }` (Default, Deserialize, PartialEq); `pub struct Save { pub directory: String, pub filename: String, pub copy_to_clipboard: bool }`; `pub struct Capture { pub show_cursor: bool }`; `impl Config { pub fn save_dir(&self) -> PathBuf }`; `pub fn home() -> PathBuf`; `pub fn default_path() -> PathBuf`; `pub fn load(path: &Path) -> Result`; `pub fn parse(text: &str) -> Result<(Config, Vec)>`; `pub fn expand_home(path: &str, home: &Path) -> PathBuf`. - -- [ ] **Step 1: Write the failing tests for `config`** - -Create `src/config.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_file_gives_defaults() { - let (config, unknown) = parse("").unwrap(); - assert_eq!(config, Config::default()); - assert!(unknown.is_empty()); - } - - #[test] - fn partial_file_keeps_other_defaults() { - let (config, _) = parse("[save]\ncopy_to_clipboard = false\n").unwrap(); - assert!(!config.save.copy_to_clipboard); - assert_eq!(config.save.directory, "~/Pictures/Screenshots"); - assert!(!config.capture.show_cursor); - } - - #[test] - fn unknown_keys_are_reported_not_rejected() { - let text = "[preview]\nenabled = true\n\n[save]\nfolder = \"x\"\n"; - let (config, unknown) = parse(text).unwrap(); - assert_eq!(config, Config::default()); - assert_eq!( - unknown, - vec!["preview".to_string(), "save.folder".to_string()] - ); - } - - #[test] - fn invalid_toml_names_the_line() { - let err = parse("[save]\ndirectory = \n").unwrap_err(); - assert!(format!("{err:#}").contains("line 2"), "{err:#}"); - } - - #[test] - fn wrong_type_is_an_error() { - assert!(parse("[capture]\nshow_cursor = \"yes\"\n").is_err()); - } - - #[test] - fn missing_file_gives_defaults() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!( - load(&dir.path().join("nope.toml")).unwrap(), - Config::default() - ); - } - - #[test] - fn expands_home() { - let home = Path::new("/home/u"); - assert_eq!( - expand_home("~/Pictures", home), - Path::new("/home/u/Pictures") - ); - assert_eq!( - expand_home("$HOME/Pictures", home), - Path::new("/home/u/Pictures") - ); - assert_eq!(expand_home("~", home), Path::new("/home/u")); - assert_eq!(expand_home("/srv/shots", home), Path::new("/srv/shots")); - assert_eq!(expand_home("~other/x", home), Path::new("~other/x")); - } -} -``` - -Add `mod config;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run config::` -Expected: compilation fails, e.g. `` cannot find function `parse` in this scope ``. - -- [ ] **Step 3: Implement `config`** - -Put this above the test module in `src/config.rs`: - -```rust -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use serde::Deserialize; - -use crate::error::HintExt; - -#[derive(Debug, Clone, PartialEq, Deserialize, Default)] -#[serde(default)] -pub struct Config { - pub save: Save, - pub capture: Capture, -} - -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(default)] -pub struct Save { - pub directory: String, - pub filename: String, - pub copy_to_clipboard: bool, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Default)] -#[serde(default)] -pub struct Capture { - pub show_cursor: bool, -} - -impl Default for Save { - fn default() -> Self { - Self { - directory: "~/Pictures/Screenshots".into(), - filename: "Screenshot %Y-%m-%d at %H.%M.%S.png".into(), - copy_to_clipboard: true, - } - } -} - -impl Config { - /// The save directory with `~` and `$HOME` expanded. - pub fn save_dir(&self) -> PathBuf { - expand_home(&self.save.directory, &home()) - } -} - -pub fn home() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| "/".into()) -} - -/// `$XDG_CONFIG_HOME/valw/config.toml`, falling back to `~/.config`. -pub fn default_path() -> PathBuf { - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home().join(".config")) - .join("valw/config.toml") -} - -/// Loads the config. A missing file means defaults. -pub fn load(path: &Path) -> Result { - let text = match std::fs::read_to_string(path) { - Ok(text) => text, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()), - Err(e) => return Err(e).with_context(|| format!("could not read {}", path.display())), - }; - let (config, unknown) = parse(&text) - .with_context(|| format!("invalid config file {}", path.display())) - .hint("fix the file or delete it to use the defaults")?; - for key in unknown { - tracing::warn!("ignoring unknown config key `{key}` in {}", path.display()); - } - Ok(config) -} - -/// Parses config text. Returns the config and the keys valw doesn't know. -pub fn parse(text: &str) -> Result<(Config, Vec)> { - let de = toml::Deserializer::parse(text)?; - let mut unknown = Vec::new(); - let config = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?; - Ok((config, unknown)) -} - -pub fn expand_home(path: &str, home: &Path) -> PathBuf { - if path == "~" || path == "$HOME" { - return home.to_path_buf(); - } - for prefix in ["~/", "$HOME/"] { - if let Some(rest) = path.strip_prefix(prefix) { - return home.join(rest); - } - } - PathBuf::from(path) -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run config::` -Expected: all 7 tests in `config::tests` pass. - -- [ ] **Step 5: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: config file with defaults and unknown-key warnings" -``` - ---- - - -### Task 4: Per-run logs and panic hook - -**Files:** -- Create: `src/log.rs` -- Modify: `src/main.rs` (add `log` to the `mod` list) - -**Interfaces:** -- Consumes: `config::home` (Task 3); `error::{Hint, render}` (Task 2). -- Produces: `pub fn default_dir() -> PathBuf`; `pub fn init(dir: &Path, now: DateTime) -> Option` (installs the global subscriber, returns this run's log path or `None` if it fell back to stderr); `pub fn since_start() -> Duration`; `pub fn install_panic_hook(log: Option)`. - -- [ ] **Step 1: Write the failing tests for `log`** - -Create `src/log.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - - #[test] - fn log_names_sort_by_time() { - let a = Local.with_ymd_and_hms(2026, 9, 29, 9, 5, 1).unwrap(); - let b = Local.with_ymd_and_hms(2026, 9, 29, 19, 0, 0).unwrap(); - assert_eq!(log_name(a), "2026-09-29T09-05-01.000.log"); - assert!(log_name(a) < log_name(b)); - } - - #[test] - fn prune_keeps_newest() { - let dir = tempfile::tempdir().unwrap(); - for i in 0..12 { - File::create(dir.path().join(format!("2026-09-29T10-00-{i:02}.000.log"))).unwrap(); - } - File::create(dir.path().join("notes.txt")).unwrap(); - - prune(dir.path(), 10).unwrap(); - - let mut left: Vec = fs::read_dir(dir.path()) - .unwrap() - .map(|e| e.unwrap().file_name().into_string().unwrap()) - .collect(); - left.sort(); - assert_eq!(left.len(), 11); - assert_eq!(left[0], "2026-09-29T10-00-02.000.log"); - assert!(left.contains(&"notes.txt".to_string())); - } - - #[test] - fn open_run_log_fails_on_unwritable_dir() { - let dir = tempfile::tempdir().unwrap(); - let blocker = dir.path().join("file"); - File::create(&blocker).unwrap(); - // A regular file where the log dir should be. - assert!(open_run_log(&blocker, Local::now()).is_err()); - } -} -``` - -Add `mod log;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run log::` -Expected: compilation fails, e.g. `` cannot find function `log_name` in this scope ``. - -- [ ] **Step 3: Implement `log`** - -Put this above the test module in `src/log.rs`: - -```rust -use std::fs::{self, File}; -use std::io::IsTerminal; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result}; -use chrono::{DateTime, Local}; -use tracing::Level; -use tracing_subscriber::filter::{EnvFilter, filter_fn}; -use tracing_subscriber::prelude::*; - -use crate::error::{Hint, render}; - -/// Number of run logs kept on disk, including the current one. -const KEEP: usize = 10; - -static START: OnceLock = OnceLock::new(); - -/// Time since logging was set up, i.e. roughly since the process started. -pub fn since_start() -> Duration { - START.get_or_init(Instant::now).elapsed() -} - -/// `$XDG_STATE_HOME/valw/logs`, falling back to `~/.local/state/valw/logs`. -pub fn default_dir() -> PathBuf { - std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| crate::config::home().join(".local/state")) - .join("valw/logs") -} - -/// Sets up tracing: this run's log file at `VALW_LOG` level (default info), -/// plus warnings and errors on stderr. Returns the log file path. -/// -/// If the log file can't be created, logging falls back to stderr only and -/// the returned path is `None`; a broken log dir must not block a screenshot. -pub fn init(dir: &Path, now: DateTime) -> Option { - START.get_or_init(Instant::now); - let file = open_run_log(dir, now); - - // Errors reach stderr through error::render, so only warnings go here. - let stderr = tracing_subscriber::fmt::layer() - .with_writer(std::io::stderr) - .with_ansi(std::io::stderr().is_terminal()) - .without_time() - .with_target(false) - .with_filter(filter_fn(|m| *m.level() == Level::WARN)); - - let (file_layer, path, failure) = match file { - Ok((file, path)) => { - let filter = - EnvFilter::try_from_env("VALW_LOG").unwrap_or_else(|_| EnvFilter::new("info")); - let layer = tracing_subscriber::fmt::layer() - .with_writer(Mutex::new(file)) - .with_ansi(false) - .with_filter(filter); - (Some(layer), Some(path), None) - } - Err(e) => (None, None, Some(e)), - }; - - tracing_subscriber::registry() - .with(stderr) - .with(file_layer) - .init(); - - if let Some(e) = failure { - tracing::warn!("logging to stderr only: {e:#}"); - } - path -} - -fn open_run_log(dir: &Path, now: DateTime) -> Result<(File, PathBuf)> { - fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?; - let path = dir.join(log_name(now)); - let file = - File::create(&path).with_context(|| format!("could not create {}", path.display()))?; - prune(dir, KEEP)?; - Ok((file, path)) -} - -/// Log names sort chronologically, so pruning can compare names. -fn log_name(now: DateTime) -> String { - now.format("%Y-%m-%dT%H-%M-%S%.3f.log").to_string() -} - -/// Deletes all but the `keep` newest `.log` files in `dir`. -fn prune(dir: &Path, keep: usize) -> Result<()> { - let mut logs: Vec = fs::read_dir(dir)? - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| p.extension().is_some_and(|x| x == "log")) - .collect(); - logs.sort(); - let excess = logs.len().saturating_sub(keep); - for old in &logs[..excess] { - fs::remove_file(old)?; - } - Ok(()) -} - -/// Sends panics to the log and prints them in the usual error format. -pub fn install_panic_hook(log: Option) { - std::panic::set_hook(Box::new(move |info| { - let backtrace = std::backtrace::Backtrace::force_capture(); - tracing::error!("panic: {info}\n{backtrace}"); - let err = anyhow::anyhow!("{info}").context(Hint( - "this is a bug in valw; please report it with the log".into(), - )); - eprint!("{}", render(&err, log.as_deref())); - })); -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run log::` -Expected: all 3 tests in `log::tests` pass. - -- [ ] **Step 5: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: per-run log files and panic hook" -``` - ---- - - -### Task 5: Single-instance lock and detached child - -**Files:** -- Create: `src/lock.rs` -- Create: `src/detach.rs` -- Modify: `src/main.rs` (add `lock`, `detach` to the `mod` list) - -**Interfaces:** -- Consumes: `error::HintExt` (Task 2). -- Produces: `pub struct Lock` with `pub fn acquire(path: &Path) -> Result` (busy → error `valw is already running` + hint) and `pub fn release(self)`; `pub fn lock::default_path() -> PathBuf`; `pub fn detach::spawn(lock: Lock, work: impl FnOnce()) -> Result<()>` (releases the lock, forks; the child gets `setsid` and /dev/null stdio, runs `work`, then `_exit(0)`). - -- [ ] **Step 1: Write the failing tests for `lock`** - -Create `src/lock.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn second_acquire_fails_until_release() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("valw.lock"); - - let first = Lock::acquire(&path).unwrap(); - let err = Lock::acquire(&path).unwrap_err(); - assert_eq!( - err.to_string(), - "wait for the other capture to finish, or press Esc in it" - ); - assert_eq!(err.root_cause().to_string(), "valw is already running"); - - first.release(); - Lock::acquire(&path).unwrap(); - } - - #[test] - fn stale_lock_file_is_fine() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("valw.lock"); - File::create(&path).unwrap(); - Lock::acquire(&path).unwrap(); - } -} -``` - -Add `mod lock;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run lock::` -Expected: compilation fails, e.g. `` failed to resolve: use of undeclared type `Lock` ``. - -- [ ] **Step 3: Implement `lock`** - -Put this above the test module in `src/lock.rs`: - -```rust -use std::fs::File; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; -use rustix::fs::{FlockOperation, flock}; -use rustix::io::Errno; - -use crate::error::HintExt; - -/// Held for the whole capture so two captures never overlap. The kernel -/// drops the lock when the process dies, so a crash can't leave it stuck. -#[derive(Debug)] -pub struct Lock { - _file: File, -} - -/// `$XDG_RUNTIME_DIR/valw.lock`, falling back to the temp dir. -pub fn default_path() -> PathBuf { - std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir) - .join("valw.lock") -} - -impl Lock { - pub fn acquire(path: &Path) -> Result { - let file = File::options() - .create(true) - .truncate(false) - .write(true) - .open(path) - .with_context(|| format!("could not open lock file {}", path.display()))?; - match flock(&file, FlockOperation::NonBlockingLockExclusive) { - Ok(()) => Ok(Lock { _file: file }), - Err(Errno::WOULDBLOCK) => Err(anyhow::anyhow!("valw is already running")) - .hint("wait for the other capture to finish, or press Esc in it"), - Err(e) => { - bail!("could not lock {}: {e}", path.display()) - } - } - } - - /// Releases the lock. Must happen before forking, or the child would - /// inherit the file and keep the lock held. - pub fn release(self) {} -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run lock::` -Expected: all 2 tests in `lock::tests` pass. - -- [ ] **Step 5: Write the failing tests for `detach`** - -Create `src/detach.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn child_holds_neither_lock_nor_stdio() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("valw.lock"); - let lock = Lock::acquire(&path).unwrap(); - let (report_r, report_w) = rustix::pipe::pipe().unwrap(); - let (go_r, go_w) = rustix::pipe::pipe().unwrap(); - - spawn(lock, move || { - // Report whether stdout is /dev/null, then wait for the parent. - let null = rustix::fs::stat("/dev/null").unwrap(); - let out = rustix::fs::fstat(rustix::stdio::stdout()).unwrap(); - let is_null = out.st_rdev == null.st_rdev && out.st_ino == null.st_ino; - rustix::io::write(&report_w, &[is_null as u8]).unwrap(); - let _ = rustix::io::read(&go_r, &mut [0u8; 1]); - }) - .unwrap(); - - // The child is still running, yet the lock is free. - Lock::acquire(&path).unwrap(); - - let mut is_null = [0u8; 1]; - rustix::io::read(&report_r, &mut is_null).unwrap(); - assert_eq!(is_null, [1], "child stdout should be /dev/null"); - - // Closing our end lets the child exit. - drop(go_w); - } -} -``` - -Add `mod detach;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 6: Run the tests and watch them fail** - -Run: `cargo nextest run detach::` -Expected: compilation fails, e.g. `` cannot find function `spawn` in this scope ``. - -- [ ] **Step 7: Implement `detach`** - -Put this above the test module in `src/detach.rs`: - -```rust -use std::fs::File; -use std::io; - -use anyhow::{Context, Result}; - -use crate::lock::Lock; - -/// Runs `work` in a forked child that outlives valw, like `wl-copy` does. -/// The parent returns right away. -/// -/// The child drops the capture lock (so the next capture isn't blocked) and -/// points stdio at /dev/null (so `valw ... -o - | x` sees EOF without waiting -/// for the clipboard to be replaced). -pub fn spawn(lock: Lock, work: impl FnOnce()) -> Result<()> { - lock.release(); - // SAFETY: valw is single-threaded at this point, and the child only - // touches its own state before exiting with _exit. - match unsafe { libc::fork() } { - -1 => Err(io::Error::last_os_error()).context("could not fork the clipboard server"), - 0 => { - let _ = rustix::process::setsid(); - let _ = detach_stdio(); - work(); - // SAFETY: skips atexit handlers and destructors that belong to the parent. - unsafe { libc::_exit(0) } - } - _ => Ok(()), - } -} - -fn detach_stdio() -> io::Result<()> { - let null = File::options().read(true).write(true).open("/dev/null")?; - rustix::stdio::dup2_stdin(&null)?; - rustix::stdio::dup2_stdout(&null)?; - rustix::stdio::dup2_stderr(&null)?; - Ok(()) -} -``` - -- [ ] **Step 8: Run the tests and watch them pass** - -Run: `cargo nextest run detach::` -Expected: all 1 tests in `detach::tests` pass. - -- [ ] **Step 9: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 10: Commit** - -```sh -jj commit -m "feat: single-instance lock and detached background child" -``` - ---- - - -### Task 6: Frames, pixel formats and output transforms - -**Files:** -- Create: `src/frame.rs` -- Modify: `src/main.rs` (add `frame` to the `mod` list) - -**Interfaces:** -- Consumes: nothing. -- Produces: `pub struct OutputGeom { pub name: String, pub x: i32, pub y: i32, pub width: i32, pub height: i32 }` (logical, global coordinates); `pub struct PixelRect { pub x: u32, pub y: u32, pub width: u32, pub height: u32 }` (a frame's physical pixels); `pub type Bgrx = RgbaImage` (container whose bytes are B, G, R, X, i.e. XRGB8888 in memory); `pub struct Frame { pub output: OutputGeom, pub pixels: Arc }` with `full(&self) -> PixelRect` and `to_rgba(&self, rect: PixelRect) -> RgbaImage` (opaque); `pub const SUPPORTED: [Format; 4]` (preference order); `pub fn to_bgrx(format: Format, width: u32, height: u32, stride: u32, data: &[u8], y_invert: bool) -> Result`; `pub fn apply_transform(img: Bgrx, transform: Transform) -> Bgrx`. `Format` is `wayland_client::protocol::wl_shm::Format`, `Transform` is `wayland_client::protocol::wl_output::Transform`. - -- [ ] **Step 1: Write the failing tests for `frame`** - -Create `src/frame.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - /// 2x1 buffer: a red pixel then a blue one, in the given byte order. - fn two_pixels(format: Format) -> Vec { - match format { - Format::Xrgb8888 | Format::Argb8888 => vec![0, 0, 255, 0, 255, 0, 0, 0], - _ => vec![255, 0, 0, 0, 0, 0, 255, 0], - } - } - - fn bgr(img: &Bgrx, x: u32, y: u32) -> [u8; 3] { - let p = img.get_pixel(x, y).0; - [p[0], p[1], p[2]] - } - - #[test] - fn reads_all_supported_formats_as_bgrx() { - for format in SUPPORTED { - let img = to_bgrx(format, 2, 1, 8, &two_pixels(format), false).unwrap(); - assert_eq!(bgr(&img, 0, 0), [0, 0, 255], "red, {format:?}"); - assert_eq!(bgr(&img, 1, 0), [255, 0, 0], "blue, {format:?}"); - } - } - - #[test] - fn honours_stride_padding() { - // One pixel per row, 8-byte stride. - let data = [0, 0, 255, 0, 9, 9, 9, 9, 255, 0, 0, 0, 9, 9, 9, 9]; - let img = to_bgrx(Format::Xrgb8888, 1, 2, 8, &data, false).unwrap(); - assert_eq!(img.as_raw().len(), 8); - assert_eq!(bgr(&img, 0, 0), [0, 0, 255]); - assert_eq!(bgr(&img, 0, 1), [255, 0, 0]); - } - - #[test] - fn y_invert_flips_rows() { - let data = [0, 0, 255, 0, 255, 0, 0, 0]; - let img = to_bgrx(Format::Xrgb8888, 1, 2, 4, &data, true).unwrap(); - assert_eq!(bgr(&img, 0, 0), [255, 0, 0]); - assert_eq!(bgr(&img, 0, 1), [0, 0, 255]); - } - - #[test] - fn rejects_unknown_format_by_name() { - let err = to_bgrx(Format::Xbgr2101010, 1, 1, 4, &[0; 4], false).unwrap_err(); - assert_eq!(err.to_string(), "unsupported buffer format Xbgr2101010"); - } - - #[test] - fn rejects_short_buffer() { - assert!(to_bgrx(Format::Xrgb8888, 2, 2, 8, &[0; 8], false).is_err()); - } - - fn frame(width: u32, height: u32, data: Vec) -> Frame { - Frame { - output: OutputGeom { - name: "A".into(), - x: 0, - y: 0, - width: width as i32, - height: height as i32, - }, - pixels: Arc::new(Bgrx::from_raw(width, height, data).unwrap()), - } - } - - #[test] - fn to_rgba_swaps_channels_and_makes_opaque() { - // Red with alpha 0, as an ARGB8888 buffer may deliver it. - let f = frame(1, 1, vec![0, 0, 255, 0]); - assert_eq!(f.to_rgba(f.full()).get_pixel(0, 0).0, [255, 0, 0, 255]); - } - - #[test] - fn to_rgba_crops() { - // 3x2: pixel value = its index, in the blue byte. - let data = (0u8..6).flat_map(|i| [i, 0, 0, 0]).collect(); - let f = frame(3, 2, data); - let rect = PixelRect { - x: 1, - y: 1, - width: 2, - height: 1, - }; - let out = f.to_rgba(rect); - assert_eq!(out.dimensions(), (2, 1)); - assert_eq!(out.get_pixel(0, 0).0, [0, 0, 4, 255]); - assert_eq!(out.get_pixel(1, 0).0, [0, 0, 5, 255]); - } - - fn marked() -> Bgrx { - // 3x2, top-left pixel marked. - let mut img = Bgrx::new(3, 2); - img.put_pixel(0, 0, image::Rgba([255, 255, 255, 255])); - img - } - - fn marked_at(img: &Bgrx) -> (u32, u32, u32, u32) { - let (x, y, _) = img - .enumerate_pixels() - .find(|(_, _, p)| p.0[0] == 255) - .unwrap(); - (img.width(), img.height(), x, y) - } - - #[test] - fn transforms_match_grim() { - use Transform::*; - let cases = [ - (Normal, (3, 2, 0, 0)), - (_90, (2, 3, 1, 0)), - (_180, (3, 2, 2, 1)), - (_270, (2, 3, 0, 2)), - (Flipped, (3, 2, 2, 0)), - (Flipped90, (2, 3, 0, 0)), - (Flipped180, (3, 2, 0, 1)), - (Flipped270, (2, 3, 1, 2)), - ]; - for (t, want) in cases { - assert_eq!(marked_at(&apply_transform(marked(), t)), want, "{t:?}"); - } - } -} -``` - -Add `mod frame;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run frame::` -Expected: compilation fails, e.g. `` cannot find function `to_bgrx` in this scope ``. - -- [ ] **Step 3: Implement `frame`** - -Put this above the test module in `src/frame.rs`: - -```rust -use std::sync::Arc; - -use anyhow::{Result, bail, ensure}; -use image::{RgbaImage, imageops}; -use wayland_client::protocol::{wl_output::Transform, wl_shm::Format}; - -/// An output's place in the global logical coordinate space. -#[derive(Debug, Clone, PartialEq)] -pub struct OutputGeom { - pub name: String, - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, -} - -/// A rectangle in a frame's physical pixels. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PixelRect { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, -} - -/// Pixels in wl_shm XRGB8888 memory order: B, G, R, X. This is what niri -/// hands out and what the overlay shows, so nothing on the way to the first -/// overlay frame has to reorder bytes. `RgbaImage` is only the container; -/// its channels do not mean R, G, B, A here. -pub type Bgrx = RgbaImage; - -/// One captured output: upright pixels at physical resolution. -#[derive(Debug, Clone)] -pub struct Frame { - pub output: OutputGeom, - /// Shared with the overlay, which shows it as the undimmed selection. - pub pixels: Arc, -} - -impl Frame { - /// The whole frame as a rectangle. - pub fn full(&self) -> PixelRect { - PixelRect { - x: 0, - y: 0, - width: self.pixels.width(), - height: self.pixels.height(), - } - } - - /// The `rect` part as opaque RGBA, for saving. - /// - /// Alpha is always 255: a screenshot of what's on screen is opaque, even - /// where the compositor's buffer has alpha 0. - pub fn to_rgba(&self, rect: PixelRect) -> RgbaImage { - let stride = self.pixels.width() as usize * 4; - let raw = self.pixels.as_raw(); - let mut out = RgbaImage::new(rect.width, rect.height); - for (y, row) in out.rows_mut().enumerate() { - let start = (rect.y as usize + y) * stride + rect.x as usize * 4; - let (src, _) = raw[start..start + rect.width as usize * 4].as_chunks::<4>(); - for (px, s) in row.zip(src) { - px.0 = [s[2], s[1], s[0], 255]; - } - } - out - } -} - -/// Shm formats valw can read, in order of preference. -pub const SUPPORTED: [Format; 4] = [ - Format::Xrgb8888, - Format::Argb8888, - Format::Xbgr8888, - Format::Abgr8888, -]; - -/// Copies a screencopy buffer into B, G, R, X order, dropping stride padding. -pub fn to_bgrx( - format: Format, - width: u32, - height: u32, - stride: u32, - data: &[u8], - y_invert: bool, -) -> Result { - let swap = match format { - Format::Xrgb8888 | Format::Argb8888 => false, - Format::Xbgr8888 | Format::Abgr8888 => true, - other => bail!("unsupported buffer format {other:?}"), - }; - ensure!( - stride >= width * 4, - "stride {stride} is too small for width {width}" - ); - ensure!( - data.len() >= (stride * height) as usize, - "buffer is {} bytes, expected at least {}", - data.len(), - stride * height - ); - - let row_len = width as usize * 4; - let mut out = Bgrx::new(width, height); - for (y, dst) in out.chunks_exact_mut(row_len).enumerate() { - let src_y = if y_invert { height as usize - 1 - y } else { y }; - let src = &data[src_y * stride as usize..][..row_len]; - dst.copy_from_slice(src); - if swap { - for px in dst.as_chunks_mut::<4>().0 { - px.swap(0, 2); - } - } - } - Ok(out) -} - -/// Turns a buffer in the output's native orientation upright, the same way -/// grim does: rotate clockwise by the transform's angle, then mirror. -pub fn apply_transform(img: Bgrx, transform: Transform) -> Bgrx { - let rotated = match transform { - Transform::_90 | Transform::Flipped90 => imageops::rotate90(&img), - Transform::_180 | Transform::Flipped180 => imageops::rotate180(&img), - Transform::_270 | Transform::Flipped270 => imageops::rotate270(&img), - _ => img, - }; - match transform { - Transform::Flipped - | Transform::Flipped90 - | Transform::Flipped180 - | Transform::Flipped270 => imageops::flip_horizontal(&rotated), - _ => rotated, - } -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run frame::` -Expected: all 8 tests in `frame::tests` pass. - -- [ ] **Step 5: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: screencopy frames, pixel formats and output transforms" -``` - ---- - - -### Task 7: Selection math and overlay pixels - -**Files:** -- Create: `src/selection.rs` -- Create: `src/render.rs` -- Modify: `src/main.rs` (add `selection`, `render` to the `mod` list) - -**Interfaces:** -- Consumes: `frame::{OutputGeom, PixelRect}` (Task 6). -- Produces: `selection::{MIN_PIXELS, Point { x: f64, y: f64 }, Selection { Idle, Dragging { start, end } }}` with `press`, `motion`, `release(p) -> Option<(Point, Point)>`; `selection::clip(a, b, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option`; `selection::resolve(...)` (same, but `None` under `MIN_PIXELS`); `render::{DIM, dim(pixels: &[u8], brightness: u8) -> Vec, compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option)}` (all buffers are B, G, R, X). - -- [ ] **Step 1: Write the failing tests for `selection`** - -Create `src/selection.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn out(name: &str, x: i32, y: i32, width: i32, height: i32) -> OutputGeom { - OutputGeom { - name: name.into(), - x, - y, - width, - height, - } - } - - fn p(x: f64, y: f64) -> Point { - Point { x, y } - } - - fn rect(x: u32, y: u32, width: u32, height: u32) -> PixelRect { - PixelRect { - x, - y, - width, - height, - } - } - - #[test] - fn scale_one() { - let o = out("A", 0, 0, 1920, 1080); - assert_eq!( - clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080), - Some(rect(10, 20, 100, 50)) - ); - } - - #[test] - fn drag_direction_does_not_matter() { - let o = out("A", 0, 0, 1920, 1080); - assert_eq!( - clip(p(110.0, 70.0), p(10.0, 20.0), &o, 1920, 1080), - clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080) - ); - } - - #[test] - fn fractional_scale_rounds_outwards() { - // 1920x1080 at 1.25 is 1536x864 logical. - let o = out("A", 0, 0, 1536, 864); - // 10.5 * 1.25 = 13.125 -> 13; 20.3 * 1.25 = 25.375 -> 26. - assert_eq!( - clip(p(10.5, 0.0), p(20.3, 8.0), &o, 1920, 1080), - Some(rect(13, 0, 13, 10)) - ); - } - - #[test] - fn scale_one_and_a_half() { - let o = out("A", 0, 0, 1280, 720); - assert_eq!( - clip(p(100.0, 100.0), p(200.0, 150.0), &o, 1920, 1080), - Some(rect(150, 150, 150, 75)) - ); - } - - #[test] - fn output_offsets_including_negative() { - let left = out("L", -1366, 0, 1366, 768); - let right = out("R", 0, 0, 1920, 1080); - assert_eq!( - clip(p(-1356.0, 10.0), p(-1256.0, 60.0), &left, 1366, 768), - Some(rect(10, 10, 100, 50)) - ); - assert_eq!( - clip( - p(1930.0, 10.0), - p(2030.0, 60.0), - &out("R2", 1920, 0, 800, 600), - 800, - 600 - ), - Some(rect(10, 10, 100, 50)) - ); - assert_eq!( - clip(p(-10.0, 10.0), p(10.0, 20.0), &right, 1920, 1080), - Some(rect(0, 10, 10, 10)) - ); - } - - #[test] - fn drag_across_outputs_is_clipped_to_the_output() { - let left = out("L", 0, 0, 1366, 768); - // From (1300, 100) on the left output to (1500, 900) on the right one. - assert_eq!( - clip(p(1300.0, 100.0), p(1500.0, 900.0), &left, 1366, 768), - Some(rect(1300, 100, 66, 668)) - ); - } - - #[test] - fn nothing_left_on_other_output() { - let o = out("A", 0, 0, 100, 100); - assert_eq!(clip(p(200.0, 200.0), p(300.0, 300.0), &o, 100, 100), None); - } - - #[test] - fn click_is_not_a_selection() { - let o = out("A", 0, 0, 1920, 1080); - assert_eq!(resolve(p(10.0, 10.0), p(10.0, 10.0), &o, 1920, 1080), None); - assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None); - assert_eq!( - resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080), - Some(rect(10, 10, 2, 2)) - ); - } - - #[test] - fn state_machine() { - let mut s = Selection::default(); - assert_eq!(s.release(p(1.0, 1.0)), None); - - s.press(p(1.0, 2.0)); - s.motion(p(5.0, 6.0)); - assert_eq!( - s, - Selection::Dragging { - start: p(1.0, 2.0), - end: p(5.0, 6.0) - } - ); - assert_eq!(s.release(p(7.0, 8.0)), Some((p(1.0, 2.0), p(7.0, 8.0)))); - assert_eq!(s, Selection::Idle); - - s.motion(p(9.0, 9.0)); - assert_eq!(s, Selection::Idle); - } -} -``` - -Add `mod selection;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run selection::` -Expected: compilation fails, e.g. `` cannot find struct, variant or union type `Point` in this scope ``. - -- [ ] **Step 3: Implement `selection`** - -Put this above the test module in `src/selection.rs`: - -```rust -use crate::frame::{OutputGeom, PixelRect}; - -/// Selections smaller than this (in physical pixels) count as a click. -pub const MIN_PIXELS: u32 = 2; - -/// A point in global logical coordinates. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Point { - pub x: f64, - pub y: f64, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub enum Selection { - #[default] - Idle, - Dragging { - start: Point, - end: Point, - }, -} - -impl Selection { - pub fn press(&mut self, p: Point) { - *self = Selection::Dragging { start: p, end: p }; - } - - pub fn motion(&mut self, p: Point) { - if let Selection::Dragging { end, .. } = self { - *end = p; - } - } - - /// Ends the drag and returns its start and end. Back to `Idle` either way. - pub fn release(&mut self, p: Point) -> Option<(Point, Point)> { - match std::mem::take(self) { - Selection::Dragging { start, .. } => Some((start, p)), - Selection::Idle => None, - } - } -} - -/// The part of the drag from `a` to `b` that falls on `out`, in the physical -/// pixels of a `frame_w` x `frame_h` frame. `None` if nothing is left. -/// -/// Edges round outwards so the rectangle covers every pixel the user touched. -pub fn clip(a: Point, b: Point, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option { - let sx = frame_w as f64 / out.width as f64; - let sy = frame_h as f64 / out.height as f64; - let to_px = |v: f64, origin: i32, s: f64, max: u32, round: fn(f64) -> f64| { - round((v - origin as f64) * s).clamp(0.0, max as f64) as u32 - }; - let x0 = to_px(a.x.min(b.x), out.x, sx, frame_w, f64::floor); - let x1 = to_px(a.x.max(b.x), out.x, sx, frame_w, f64::ceil); - let y0 = to_px(a.y.min(b.y), out.y, sy, frame_h, f64::floor); - let y1 = to_px(a.y.max(b.y), out.y, sy, frame_h, f64::ceil); - (x1 > x0 && y1 > y0).then(|| PixelRect { - x: x0, - y: y0, - width: x1 - x0, - height: y1 - y0, - }) -} - -/// Like [`clip`], but treats anything under [`MIN_PIXELS`] as a click. -pub fn resolve( - a: Point, - b: Point, - out: &OutputGeom, - frame_w: u32, - frame_h: u32, -) -> Option { - clip(a, b, out, frame_w, frame_h).filter(|r| r.width >= MIN_PIXELS && r.height >= MIN_PIXELS) -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run selection::` -Expected: all 9 tests in `selection::tests` pass. - -- [ ] **Step 5: Write the failing tests for `render`** - -Create `src/render.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dims_every_byte() { - assert_eq!(dim(&[200, 100, 50, 0], 255), vec![200, 100, 50, 0]); - assert_eq!(dim(&[200, 100, 50, 255], DIM), vec![120, 60, 30, 153]); - assert_eq!(dim(&[255, 0, 1, 2], 0), vec![0, 0, 0, 0]); - } - - fn px(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { - let i = ((y * width + x) * 4) as usize; - buf[i..i + 4].try_into().unwrap() - } - - #[test] - fn compose_outline_and_inside() { - let (w, h) = (6, 6); - let dark = vec![1u8; (w * h * 4) as usize]; - let bright = vec![2u8; (w * h * 4) as usize]; - let mut dst = vec![0u8; dark.len()]; - let sel = PixelRect { - x: 1, - y: 1, - width: 4, - height: 4, - }; - - compose(&mut dst, &dark, &bright, w, Some(sel)); - - assert_eq!(px(&dst, w, 0, 0), [1; 4], "outside"); - assert_eq!(px(&dst, w, 1, 1), [255; 4], "corner"); - assert_eq!(px(&dst, w, 4, 2), [255; 4], "right edge"); - assert_eq!(px(&dst, w, 2, 4), [255; 4], "bottom edge"); - assert_eq!(px(&dst, w, 2, 2), [2; 4], "inside"); - assert_eq!(px(&dst, w, 5, 5), [1; 4], "outside"); - } - - #[test] - fn compose_without_selection_is_all_dark() { - let dark = vec![1u8; 16]; - let mut dst = vec![0u8; 16]; - compose(&mut dst, &dark, &[2u8; 16], 2, None); - assert_eq!(dst, dark); - } - - #[test] - fn compose_one_pixel_selection() { - let dark = vec![1u8; 16]; - let mut dst = vec![0u8; 16]; - let sel = PixelRect { - x: 1, - y: 1, - width: 1, - height: 1, - }; - compose(&mut dst, &dark, &[2u8; 16], 2, Some(sel)); - assert_eq!(px(&dst, 2, 1, 1), [255; 4]); - } -} -``` - -Add `mod render;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 6: Run the tests and watch them fail** - -Run: `cargo nextest run render::` -Expected: compilation fails, e.g. `` cannot find function `dim` in this scope ``. - -- [ ] **Step 7: Implement `render`** - -Put this above the test module in `src/render.rs`: - -```rust -use crate::frame::PixelRect; - -/// Overlay brightness outside the selection (60%, i.e. darkened by 40%). -pub const DIM: u8 = 153; - -/// B, G, R, X pixels with every byte scaled by about `brightness / 255`. -/// -/// This runs on every output before the overlay can appear, so it is one -/// branch-free pass the compiler can vectorise. Scaling X too is harmless: -/// the overlay buffer is XRGB8888, which ignores it. -pub fn dim(pixels: &[u8], brightness: u8) -> Vec { - let factor = brightness as u16 + 1; - pixels - .iter() - .map(|&b| ((b as u16 * factor) >> 8) as u8) - .collect() -} - -/// Draws one overlay frame into `dst`: `dark` everywhere, `bright` inside -/// `sel`, and a 1 px white outline on the edge of `sel`. -pub fn compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option) { - dst.copy_from_slice(dark); - let Some(r) = sel else { return }; - let stride = width as usize * 4; - let (x0, x1) = (r.x as usize * 4, (r.x + r.width) as usize * 4); - - for y in r.y..r.y + r.height { - let row = y as usize * stride; - dst[row + x0..row + x1].copy_from_slice(&bright[row + x0..row + x1]); - if y == r.y || y == r.y + r.height - 1 { - dst[row + x0..row + x1].fill(255); - } else { - dst[row + x0..row + x0 + 4].fill(255); - dst[row + x1 - 4..row + x1].fill(255); - } - } -} -``` - -- [ ] **Step 8: Run the tests and watch them pass** - -Run: `cargo nextest run render::` -Expected: all 4 tests in `render::tests` pass. - -- [ ] **Step 9: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 10: Commit** - -```sh -jj commit -m "feat: selection geometry and overlay compositing" -``` - ---- - - -### Task 8: Saving, stdout and clipboard - -**Files:** -- Create: `src/output.rs` -- Modify: `src/main.rs` (add `output` to the `mod` list) - -**Interfaces:** -- Consumes: `error::HintExt` (Task 2); `lock::Lock`, `detach::spawn` (Task 5). -- Produces: `pub fn file_name(template: &str, now: &DateTime, output: Option<&str>) -> Result`; `pub fn unique_path(dir: &Path, name: &str) -> PathBuf`; `pub fn encode_png(img: &RgbaImage) -> Result>`; `pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()>`; `pub fn write_stdout(bytes: &[u8]) -> Result<()>`; `pub fn copy_to_clipboard(png: Vec, lock: Lock) -> Result<()>`; `pub enum Target { Default, Path(PathBuf), Stdout, ClipboardOnly }` with `pub fn from_args(output: Option, clipboard_only: bool) -> Target`. - -- [ ] **Step 1: Write the failing tests for `output`** - -Create `src/output.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - - fn now() -> DateTime { - Local.with_ymd_and_hms(2026, 9, 29, 19, 4, 5).unwrap() - } - - #[test] - fn default_template() { - let name = file_name("Screenshot %Y-%m-%d at %H.%M.%S.png", &now(), None).unwrap(); - assert_eq!(name, "Screenshot 2026-09-29 at 19.04.05.png"); - } - - #[test] - fn output_suffix() { - let name = file_name("shot.png", &now(), Some("HDMI-A-1")).unwrap(); - assert_eq!(name, "shot (HDMI-A-1).png"); - } - - #[test] - fn invalid_pattern_is_an_error_not_a_panic() { - let err = file_name("shot %Q.png", &now(), None).unwrap_err(); - assert!( - format!("{err:#}").contains("invalid strftime pattern"), - "{err:#}" - ); - } - - #[test] - fn rejects_slashes_and_empty_names() { - assert!(file_name("%Y/%m.png", &now(), None).is_err()); - assert!(file_name("", &now(), None).is_err()); - } - - #[test] - fn suffix_placement() { - assert_eq!(with_suffix("a.png", " (2)"), "a (2).png"); - assert_eq!(with_suffix("a.b.png", " (2)"), "a.b (2).png"); - assert_eq!(with_suffix("noext", " (2)"), "noext (2)"); - assert_eq!(with_suffix(".hidden", " (2)"), ".hidden (2)"); - } - - #[test] - fn unique_path_counts_up() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(unique_path(dir.path(), "a.png"), dir.path().join("a.png")); - fs::write(dir.path().join("a.png"), b"").unwrap(); - fs::write(dir.path().join("a (2).png"), b"").unwrap(); - assert_eq!( - unique_path(dir.path(), "a.png"), - dir.path().join("a (3).png") - ); - } - - #[test] - fn write_atomic_creates_dirs_and_leaves_no_temp() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("new/sub/a.png"); - write_atomic(&path, b"png").unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"png"); - let names: Vec<_> = fs::read_dir(path.parent().unwrap()).unwrap().collect(); - assert_eq!(names.len(), 1); - } - - #[test] - fn write_atomic_reports_unwritable_dir() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("file"); - fs::write(&file, b"").unwrap(); - let err = write_atomic(&file.join("a.png"), b"png").unwrap_err(); - assert!(format!("{err:#}").contains("could not create"), "{err:#}"); - } - - #[test] - fn write_atomic_relative_path() { - let dir = tempfile::tempdir().unwrap(); - std::env::set_current_dir(dir.path()).unwrap(); - write_atomic(Path::new("a.png"), b"png").unwrap(); - assert_eq!(fs::read(dir.path().join("a.png")).unwrap(), b"png"); - } - - #[test] - fn png_round_trip() { - let img = RgbaImage::from_raw(1, 1, vec![1, 2, 3, 255]).unwrap(); - let png = encode_png(&img).unwrap(); - let back = image::load_from_memory(&png).unwrap().to_rgba8(); - assert_eq!(back, img); - } - - #[test] - fn targets() { - use Target::*; - assert_eq!(Target::from_args(None, false), Default); - assert_eq!(Target::from_args(None, true), ClipboardOnly); - assert_eq!(Target::from_args(Some("-".into()), false), Stdout); - assert_eq!( - Target::from_args(Some("a.png".into()), false), - Path("a.png".into()) - ); - } -} -``` - -Add `mod output;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run output::` -Expected: compilation fails, e.g. `` cannot find function `file_name` in this scope ``. - -- [ ] **Step 3: Implement `output`** - -Put this above the test module in `src/output.rs`: - -```rust -use std::fmt::Write as _; -use std::fs; -use std::io::{Cursor, Write as _}; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, anyhow}; -use chrono::{DateTime, Local}; -use image::{ImageFormat, RgbaImage}; -use wl_clipboard_rs::copy::{MimeType, Options, Source}; - -use crate::error::HintExt; -use crate::lock::Lock; - -/// Renders the strftime `template`. With `output`, appends ` (NAME)` before -/// the extension, for `valw screen --all`. -pub fn file_name(template: &str, now: &DateTime, output: Option<&str>) -> Result { - let mut name = String::new(); - write!(name, "{}", now.format(template)) - .map_err(|_| anyhow!("invalid strftime pattern in save.filename: {template:?}")) - .hint("see https://docs.rs/chrono/latest/chrono/format/strftime for the syntax")?; - if name.is_empty() || name.contains('/') { - return Err(anyhow!( - "save.filename must render to a plain file name, got {name:?}" - )) - .hint("put folders in save.directory, not save.filename"); - } - if let Some(output) = output { - name = with_suffix(&name, &format!(" ({output})")); - } - Ok(name) -} - -/// Inserts `suffix` before the extension: `a.png` + ` (2)` = `a (2).png`. -fn with_suffix(name: &str, suffix: &str) -> String { - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => format!("{stem}{suffix}.{ext}"), - _ => format!("{name}{suffix}"), - } -} - -/// `dir/name`, or `dir/name (2)`, `(3)`, … if taken. -pub fn unique_path(dir: &Path, name: &str) -> PathBuf { - let first = dir.join(name); - if !first.exists() { - return first; - } - (2..) - .map(|n| dir.join(with_suffix(name, &format!(" ({n})")))) - .find(|p| !p.exists()) - .expect("some suffix is free") -} - -pub fn encode_png(img: &RgbaImage) -> Result> { - let mut png = Vec::new(); - img.write_to(&mut Cursor::new(&mut png), ImageFormat::Png) - .context("could not encode PNG")?; - Ok(png) -} - -/// Writes via a temp file in the same directory and a rename, so a partial -/// screenshot never appears under the final name. -pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - // "a.png" has the parent "", which means the current directory. - let dir = match path.parent() { - Some(p) if !p.as_os_str().is_empty() => p, - _ => Path::new("."), - }; - fs::create_dir_all(dir) - .with_context(|| format!("could not create {}", dir.display())) - .hint("check save.directory in the config")?; - let name = path.file_name().context("path has no file name")?; - let tmp = dir.join(format!(".{}.tmp", name.to_string_lossy())); - let result = fs::write(&tmp, bytes).and_then(|()| fs::rename(&tmp, path)); - if result.is_err() { - let _ = fs::remove_file(&tmp); - } - result.with_context(|| format!("could not write {}", path.display())) -} - -pub fn write_stdout(bytes: &[u8]) -> Result<()> { - let mut out = std::io::stdout().lock(); - out.write_all(bytes) - .and_then(|()| out.flush()) - .context("could not write to stdout") -} - -/// Copies `png` to the clipboard. The data is served by a forked child that -/// lives until something else is copied, so this consumes the lock. -pub fn copy_to_clipboard(png: Vec, lock: Lock) -> Result<()> { - let mut options = Options::new(); - options.foreground(true); - let prepared = options - .prepare_copy( - Source::Bytes(png.into()), - MimeType::Specific("image/png".into()), - ) - .context("could not copy to the clipboard") - .hint("the compositor needs ext-data-control or wlr-data-control; see `valw doctor`")?; - crate::detach::spawn(lock, move || { - let _ = prepared.serve(); - }) -} - -/// Where a capture goes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Target { - /// Save under the config's directory and filename template. - Default, - /// `--output `. - Path(PathBuf), - /// `--output -`. - Stdout, - /// `--clipboard-only`. - ClipboardOnly, -} - -impl Target { - /// clap rejects `--output` together with `--clipboard-only`. - pub fn from_args(output: Option, clipboard_only: bool) -> Target { - match output { - _ if clipboard_only => Target::ClipboardOnly, - Some(p) if p.as_os_str() == "-" => Target::Stdout, - Some(p) => Target::Path(p), - None => Target::Default, - } - } -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run output::` -Expected: all 11 tests in `output::tests` pass. - -- [ ] **Step 5: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: file names, atomic PNG writes and clipboard" -``` - ---- - - -### Task 9: Wayland connection, niri IPC and `valw doctor` (Phase 0 discovery) - -**Files:** -- Create: `src/niri.rs`, `src/wayland.rs`, `src/doctor.rs` -- Replace: `src/main.rs` - -**Interfaces:** -- Consumes: `config::{load, default_path, Config::save_dir}` (Task 3); `log::{init, default_dir, install_panic_hook}` (Task 4); `error::{render, Cancelled, HintExt}` (Task 2); `frame::OutputGeom` (Task 6). -- Produces: - - `niri`: `pub const IPC_VERSION: (u32, u32)`; `pub fn focused_output() -> Result>`; `pub fn version() -> Result`; `pub fn parse_version(v: &str) -> Option<(u32, u32)>`. - - `wayland`: `pub struct Output { pub wl: WlOutput, pub geom: OutputGeom, pub transform: Transform }`; `pub struct Wayland { pub conn, pub queue: EventQueue, pub globals: GlobalList, pub state: State }` with `connect() -> Result`, `outputs(&self) -> Vec`, `global_list(&self) -> Vec<(String, u32)>`, `dispatch_until(&mut self, deadline: Instant, done: impl FnMut(&State) -> bool) -> Result<()>`, `dispatch_blocking(&mut self, done) -> Result<()>`; `pub struct State` (SCTK state plus `layer_shell`, `screencopy`, `viewporter`, `cursor_shape`, `cursor_device`, `keyboard`, `pointer`). - - `doctor`: `pub enum Level { Ok, Warn, Fail }`, `pub struct Check { level, text }` (Display: `"ok text"`), `check_globals`, `check_niri`, `check_config`, `check_save_dir`, `pub fn run() -> Result<()>`. - - CLI: `valw doctor`; exit codes 0/1/3 wired in `main`. - -Background for the Wayland code: SCTK 0.21 dispatches through `delegate_dispatch2!`. Protocol objects that SCTK doesn't wrap (screencopy manager, viewporter) use `wayland_client::delegate_noop!`. The handlers that do nothing yet become meaningful in Tasks 10 and 11. - -- [ ] **Step 1: Write the failing tests for `niri`** - -Create `src/niri.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_versions() { - assert_eq!(parse_version("26.04 (Nixpkgs)"), Some((26, 4))); - assert_eq!(parse_version("25.11"), Some((25, 11))); - assert_eq!(parse_version("26.04.1-dev"), Some((26, 4))); - assert_eq!(parse_version("unknown"), None); - } - - #[test] - fn ipc_version_matches_cargo_toml() { - let (y, m) = IPC_VERSION; - let pin = format!("niri-ipc = \"={y}.{m}.0\""); - assert!( - include_str!("../Cargo.toml").contains(&pin), - "Cargo.toml should pin {pin}" - ); - } -} -``` - -Add `mod niri;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run niri::` -Expected: compilation fails, e.g. `` cannot find function `parse_version` in this scope ``. - -- [ ] **Step 3: Implement `niri`** - -Put this above the test module in `src/niri.rs`: - -```rust -use anyhow::{Context, Result, anyhow}; -use niri_ipc::{Request, Response, socket::Socket}; - -/// The niri release the pinned `niri-ipc` crate belongs to. Keep in sync -/// with the `niri-ipc = "=X.Y.0"` line in Cargo.toml (a test checks this). -pub const IPC_VERSION: (u32, u32) = (26, 4); - -fn request(request: Request) -> Result { - let mut socket = - Socket::connect().context("could not connect to niri (is NIRI_SOCKET set?)")?; - socket - .send(request) - .context("niri IPC request failed")? - .map_err(|e| anyhow!("niri replied with an error: {e}")) -} - -/// Name of the output niri has focused, if any. -pub fn focused_output() -> Result> { - match request(Request::FocusedOutput)? { - Response::FocusedOutput(output) => Ok(output.map(|o| o.name)), - other => Err(anyhow!("unexpected niri reply: {other:?}")), - } -} - -/// niri's version string, e.g. "26.04 (Nixpkgs)". -pub fn version() -> Result { - match request(Request::Version)? { - Response::Version(v) => Ok(v), - other => Err(anyhow!("unexpected niri reply: {other:?}")), - } -} - -/// `(year, month)` from a niri version string like "26.04 (Nixpkgs)". -pub fn parse_version(v: &str) -> Option<(u32, u32)> { - let (year, rest) = v.split_once('.')?; - let month: String = rest.chars().take_while(char::is_ascii_digit).collect(); - Some((year.parse().ok()?, month.parse().ok()?)) -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run niri::` -Expected: all 2 tests in `niri::tests` pass. - -- [ ] **Step 5: Write `src/wayland.rs`** - -This is glue with no unit tests; `valw doctor` exercises it in Step 11. Add `mod wayland;` to the `mod` list in `src/main.rs`. - -```rust -use std::time::Instant; - -use anyhow::{Context, Result, bail}; -use rustix::event::{PollFd, PollFlags, Timespec, poll}; -use smithay_client_toolkit::{ - compositor::{CompositorHandler, CompositorState}, - delegate_registry, - output::{OutputHandler, OutputState}, - registry::{ProvidesRegistryState, RegistryState}, - registry_handlers, - seat::{ - Capability, SeatHandler, SeatState, - keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers}, - pointer::{PointerEvent, PointerHandler, cursor_shape::CursorShapeManager}, - }, - shell::wlr_layer::{LayerShell, LayerShellHandler, LayerSurface, LayerSurfaceConfigure}, - shm::{Shm, ShmHandler}, -}; -use wayland_client::{ - Connection, EventQueue, QueueHandle, - globals::{GlobalList, registry_queue_init}, - protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, -}; -use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; -use wayland_protocols::wp::viewporter::client::{ - wp_viewport::WpViewport, wp_viewporter::WpViewporter, -}; -use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; - -use crate::error::HintExt; -use crate::frame::OutputGeom; - -/// An output as valw sees it. -#[derive(Debug, Clone)] -pub struct Output { - pub wl: wl_output::WlOutput, - pub geom: OutputGeom, - pub transform: wl_output::Transform, -} - -pub struct Wayland { - pub conn: Connection, - pub queue: EventQueue, - pub globals: GlobalList, - pub state: State, -} - -pub struct State { - registry: RegistryState, - pub outputs: OutputState, - pub seat: SeatState, - pub shm: Shm, - pub compositor: CompositorState, - pub layer_shell: Option, - pub screencopy: Option, - pub viewporter: Option, - pub cursor_shape: Option, - pub cursor_device: Option, - pub keyboard: Option, - pub pointer: Option, -} - -impl Wayland { - pub fn connect() -> Result { - let conn = Connection::connect_to_env() - .context("could not connect to the Wayland compositor") - .hint("valw needs a Wayland session; is WAYLAND_DISPLAY set?")?; - let (globals, mut queue) = registry_queue_init::(&conn) - .context("could not read the compositor's globals")?; - let qh = queue.handle(); - - let mut state = State { - registry: RegistryState::new(&globals), - outputs: OutputState::new(&globals, &qh), - seat: SeatState::new(&globals, &qh), - shm: Shm::bind(&globals, &qh).context("compositor has no wl_shm")?, - compositor: CompositorState::bind(&globals, &qh) - .context("compositor has no wl_compositor")?, - layer_shell: LayerShell::bind(&globals, &qh).ok(), - screencopy: globals.bind(&qh, 1..=3, ()).ok(), - viewporter: globals.bind(&qh, 1..=1, ()).ok(), - cursor_shape: CursorShapeManager::bind(&globals, &qh).ok(), - cursor_device: None, - keyboard: None, - pointer: None, - }; - // Two round trips: one for wl_output, one for the xdg-output details. - queue - .roundtrip(&mut state) - .context("Wayland round trip failed")?; - queue - .roundtrip(&mut state) - .context("Wayland round trip failed")?; - - let wl = Wayland { - conn, - queue, - globals, - state, - }; - wl.log_environment(); - Ok(wl) - } - - /// All outputs with a known name and logical geometry. - pub fn outputs(&self) -> Vec { - self.state - .outputs - .outputs() - .filter_map(|wl| { - let info = self.state.outputs.info(&wl)?; - let (x, y) = info.logical_position?; - let (width, height) = info.logical_size?; - Some(Output { - geom: OutputGeom { - name: info - .name - .clone() - .unwrap_or_else(|| format!("output-{}", info.id)), - x, - y, - width, - height, - }, - transform: info.transform, - wl, - }) - }) - .collect() - } - - /// `(interface, version)` of every global, sorted. - pub fn global_list(&self) -> Vec<(String, u32)> { - let mut list: Vec<_> = self - .globals - .contents() - .clone_list() - .into_iter() - .map(|g| (g.interface, g.version)) - .collect(); - list.sort(); - list.dedup(); - list - } - - fn log_environment(&self) { - let globals: Vec = self - .global_list() - .into_iter() - .map(|(interface, version)| format!("{interface} v{version}")) - .collect(); - tracing::info!("globals: {}", globals.join(", ")); - for o in self.outputs() { - let g = &o.geom; - tracing::info!( - "output {} at {},{} size {}x{} (logical) transform {:?}", - g.name, - g.x, - g.y, - g.width, - g.height, - o.transform - ); - } - } - - /// Dispatches events until `done` returns true or `deadline` passes. - pub fn dispatch_until( - &mut self, - deadline: Instant, - mut done: impl FnMut(&State) -> bool, - ) -> Result<()> { - loop { - self.queue - .dispatch_pending(&mut self.state) - .context("Wayland dispatch failed")?; - if done(&self.state) { - return Ok(()); - } - self.queue.flush().context("Wayland flush failed")?; - let Some(guard) = self.queue.prepare_read() else { - continue; - }; - let left = deadline.saturating_duration_since(Instant::now()); - if left.is_zero() { - bail!("the compositor did not answer in time"); - } - let timeout = Timespec::try_from(left).expect("duration fits"); - let fd = guard.connection_fd(); - let mut fds = [PollFd::new(&fd, PollFlags::IN)]; - match poll(&mut fds, Some(&timeout)) { - Ok(0) => continue, - Ok(_) => { - guard.read().context("Wayland read failed")?; - } - Err(rustix::io::Errno::INTR) => continue, - Err(e) => return Err(e).context("poll failed"), - } - } - } - - /// Dispatches events until `done` returns true. Waits as long as it takes. - pub fn dispatch_blocking(&mut self, mut done: impl FnMut(&State) -> bool) -> Result<()> { - while !done(&self.state) { - self.queue - .blocking_dispatch(&mut self.state) - .context("Wayland dispatch failed")?; - } - Ok(()) - } -} - -impl CompositorHandler for State { - fn scale_factor_changed( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_surface::WlSurface, - _: i32, - ) { - } - - fn transform_changed( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_surface::WlSurface, - _: wl_output::Transform, - ) { - } - - fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: u32) {} - - fn surface_enter( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_surface::WlSurface, - _: &wl_output::WlOutput, - ) { - } - - fn surface_leave( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_surface::WlSurface, - _: &wl_output::WlOutput, - ) { - } -} - -impl OutputHandler for State { - fn output_state(&mut self) -> &mut OutputState { - &mut self.outputs - } - - fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} - - fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} - - fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} -} - -impl LayerShellHandler for State { - fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) {} - - fn configure( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &LayerSurface, - _: LayerSurfaceConfigure, - _: u32, - ) { - } -} - -impl SeatHandler for State { - fn seat_state(&mut self) -> &mut SeatState { - &mut self.seat - } - - fn new_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} - - fn new_capability( - &mut self, - _: &Connection, - qh: &QueueHandle, - seat: wl_seat::WlSeat, - capability: Capability, - ) { - if capability == Capability::Keyboard && self.keyboard.is_none() { - self.keyboard = self.seat.get_keyboard(qh, &seat, None).ok(); - } - if capability == Capability::Pointer - && self.pointer.is_none() - && let Ok(pointer) = self.seat.get_pointer(qh, &seat) - { - self.cursor_device = self - .cursor_shape - .as_ref() - .map(|m| m.get_shape_device(&pointer, qh)); - self.pointer = Some(pointer); - } - } - - fn remove_capability( - &mut self, - _: &Connection, - _: &QueueHandle, - _: wl_seat::WlSeat, - capability: Capability, - ) { - if capability == Capability::Keyboard - && let Some(k) = self.keyboard.take() - { - k.release(); - } - if capability == Capability::Pointer { - if let Some(d) = self.cursor_device.take() { - d.destroy(); - } - if let Some(p) = self.pointer.take() { - p.release(); - } - } - } - - fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} -} - -impl KeyboardHandler for State { - fn enter( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: &wl_surface::WlSurface, - _: u32, - _: &[u32], - _: &[Keysym], - ) { - } - - fn leave( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: &wl_surface::WlSurface, - _: u32, - ) { - } - - fn press_key( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: u32, - _: KeyEvent, - ) { - } - - fn repeat_key( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: u32, - _: KeyEvent, - ) { - } - - fn release_key( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: u32, - _: KeyEvent, - ) { - } - - fn update_modifiers( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: u32, - _: Modifiers, - _: RawModifiers, - _: u32, - ) { - } -} - -impl PointerHandler for State { - fn pointer_frame( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_pointer::WlPointer, - _: &[PointerEvent], - ) { - } -} - -impl ShmHandler for State { - fn shm_state(&mut self) -> &mut Shm { - &mut self.shm - } -} - -impl ProvidesRegistryState for State { - fn registry(&mut self) -> &mut RegistryState { - &mut self.registry - } - registry_handlers![OutputState, SeatState]; -} - -delegate_registry!(State); -smithay_client_toolkit::delegate_dispatch2!(State); -wayland_client::delegate_noop!(State: ZwlrScreencopyManagerV1); -wayland_client::delegate_noop!(State: WpViewporter); -wayland_client::delegate_noop!(State: WpViewport); -``` - -- [ ] **Step 6: Write the failing tests for `doctor`** - -Create `src/doctor.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn globals(list: &[(&str, u32)]) -> Vec<(String, u32)> { - list.iter().map(|&(n, v)| (n.to_string(), v)).collect() - } - - fn niri_26_04() -> Vec<(String, u32)> { - globals(&[ - ("wl_compositor", 6), - ("wl_shm", 2), - ("wl_seat", 9), - ("zxdg_output_manager_v1", 3), - ("zwlr_layer_shell_v1", 5), - ("zwlr_screencopy_manager_v1", 3), - ("wp_viewporter", 1), - ("wp_cursor_shape_manager_v1", 2), - ("zwlr_data_control_manager_v1", 2), - ("ext_data_control_manager_v1", 1), - ]) - } - - #[test] - fn all_present_is_all_ok() { - let checks = check_globals(&niri_26_04()); - assert!(checks.iter().all(|c| c.level == Level::Ok), "{checks:#?}"); - assert!( - checks - .iter() - .any(|c| c.text == "ext_data_control_manager_v1 v1 (clipboard copy)") - ); - } - - #[test] - fn missing_required_fails_missing_optional_warns() { - let list: Vec<_> = niri_26_04() - .into_iter() - .filter(|(n, _)| n != "zwlr_screencopy_manager_v1" && n != "wp_cursor_shape_manager_v1") - .collect(); - let checks = check_globals(&list); - assert!(checks.contains(&check( - Level::Fail, - "zwlr_screencopy_manager_v1 missing (screen capture)" - ))); - assert!(checks.contains(&check( - Level::Warn, - "wp_cursor_shape_manager_v1 missing (crosshair cursor in region mode disabled)" - ))); - } - - #[test] - fn old_version_fails() { - let mut list = niri_26_04(); - list.retain(|(n, _)| n != "zxdg_output_manager_v1"); - list.push(("zxdg_output_manager_v1".into(), 1)); - assert!(check_globals(&list).contains(&check( - Level::Fail, - "zxdg_output_manager_v1 v1 is too old, need v2 (output names and positions)" - ))); - } - - #[test] - fn niri_versions() { - assert_eq!(check_niri(Ok("26.04 (Nixpkgs)".into())).level, Level::Ok); - assert_eq!(check_niri(Ok("25.11".into())).level, Level::Warn); - assert_eq!( - check_niri(Err(anyhow::anyhow!("no socket"))).level, - Level::Warn - ); - } - - #[test] - fn save_dir_checks() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(check_save_dir(&dir.path().join("new")).level, Level::Ok); - let file = dir.path().join("file"); - std::fs::write(&file, b"").unwrap(); - assert_eq!(check_save_dir(&file).level, Level::Fail); - } - - #[test] - fn line_format() { - assert_eq!(check(Level::Warn, "x").to_string(), "warn x"); - assert_eq!(check(Level::Ok, "x").to_string(), "ok x"); - } -} -``` - -Add `mod doctor;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 7: Run the tests and watch them fail** - -Run: `cargo nextest run doctor::` -Expected: compilation fails, e.g. `` cannot find function `check_globals` in this scope ``. - -- [ ] **Step 8: Implement `doctor`** - -Put this above the test module in `src/doctor.rs`: - -```rust -use std::fmt; -use std::path::Path; - -use anyhow::{Result, bail}; - -use crate::config; -use crate::niri; -use crate::wayland::Wayland; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Level { - Ok, - Warn, - Fail, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Check { - pub level: Level, - pub text: String, -} - -impl fmt::Display for Check { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let tag = match self.level { - Level::Ok => "ok", - Level::Warn => "warn", - Level::Fail => "fail", - }; - write!(f, "{tag:<5} {}", self.text) - } -} - -fn check(level: Level, text: impl Into) -> Check { - Check { - level, - text: text.into(), - } -} - -/// Globals valw can't work without: (interface, minimum version, used for). -const REQUIRED: [(&str, u32, &str); 7] = [ - ("wl_compositor", 1, "surfaces"), - ("wl_shm", 1, "pixel buffers"), - ("wl_seat", 1, "mouse and keyboard"), - ("zxdg_output_manager_v1", 2, "output names and positions"), - ("zwlr_layer_shell_v1", 1, "the selection overlay"), - ("zwlr_screencopy_manager_v1", 1, "screen capture"), - ("wp_viewporter", 1, "sharp overlays at fractional scale"), -]; - -/// Globals that enable a feature: (any of these interfaces, feature). -const OPTIONAL: [(&[&str], &str); 2] = [ - ( - &["wp_cursor_shape_manager_v1"], - "crosshair cursor in region mode", - ), - ( - &[ - "ext_data_control_manager_v1", - "zwlr_data_control_manager_v1", - ], - "clipboard copy", - ), -]; - -pub fn check_globals(globals: &[(String, u32)]) -> Vec { - let version = |name: &str| globals.iter().find(|(i, _)| i == name).map(|&(_, v)| v); - let mut checks = Vec::new(); - for (name, min, purpose) in REQUIRED { - checks.push(match version(name) { - Some(v) if v >= min => check(Level::Ok, format!("{name} v{v} ({purpose})")), - Some(v) => check( - Level::Fail, - format!("{name} v{v} is too old, need v{min} ({purpose})"), - ), - None => check(Level::Fail, format!("{name} missing ({purpose})")), - }); - } - for (names, feature) in OPTIONAL { - checks.push( - match names.iter().find_map(|&n| version(n).map(|v| (n, v))) { - Some((n, v)) => check(Level::Ok, format!("{n} v{v} ({feature})")), - None => check( - Level::Warn, - format!("{} missing ({feature} disabled)", names.join(" / ")), - ), - }, - ); - } - checks -} - -pub fn check_niri(version: Result) -> Check { - let (y, m) = niri::IPC_VERSION; - match version { - Err(e) => check( - Level::Warn, - format!("niri IPC unavailable ({e:#}); using the first output for `screen`"), - ), - Ok(v) => match niri::parse_version(&v) { - Some(found) if found == niri::IPC_VERSION => check(Level::Ok, format!("niri {v}")), - _ => check( - Level::Warn, - format!( - "niri {v}, but valw was built against niri-ipc {y}.{m}; IPC replies may not parse" - ), - ), - }, - } -} - -pub fn check_config(path: &Path) -> Check { - match config::load(path) { - Ok(_) if path.exists() => check(Level::Ok, format!("config {}", path.display())), - Ok(_) => check( - Level::Ok, - format!("config {} not found, using defaults", path.display()), - ), - Err(e) => check(Level::Fail, format!("config: {e:#}")), - } -} - -pub fn check_save_dir(dir: &Path) -> Check { - let probe = dir.join(".valw-doctor"); - let result = std::fs::create_dir_all(dir) - .and_then(|()| std::fs::write(&probe, b"")) - .and_then(|()| std::fs::remove_file(&probe)); - match result { - Ok(()) => check( - Level::Ok, - format!("save directory {} is writable", dir.display()), - ), - Err(e) => check( - Level::Fail, - format!("save directory {}: {e}", dir.display()), - ), - } -} - -/// Runs every check and prints one line each. Fails if any check failed. -pub fn run() -> Result<()> { - let mut checks = Vec::new(); - match Wayland::connect() { - Ok(wl) => { - checks.extend(check_globals(&wl.global_list())); - for o in wl.outputs() { - let g = &o.geom; - checks.push(check( - Level::Ok, - format!( - "output {}: {}x{} logical at {},{}, transform {:?}", - g.name, g.width, g.height, g.x, g.y, o.transform - ), - )); - } - } - Err(e) => checks.push(check(Level::Fail, format!("wayland: {e:#}"))), - } - checks.push(check_niri(niri::version())); - let config_path = config::default_path(); - checks.push(check_config(&config_path)); - if let Ok(config) = config::load(&config_path) { - checks.push(check_save_dir(&config.save_dir())); - } - - for c in &checks { - println!("{c}"); - } - let failed = checks.iter().filter(|c| c.level == Level::Fail).count(); - if failed > 0 { - bail!("{failed} check(s) failed"); - } - Ok(()) -} -``` - -- [ ] **Step 9: Run the tests and watch them pass** - -Run: `cargo nextest run doctor::` -Expected: all 6 tests in `doctor::tests` pass. - -- [ ] **Step 10: Replace `src/main.rs`** - -```rust -// Modules land before the CLI uses them; Task 11 removes this. -#![allow(dead_code)] - -mod config; -mod detach; -mod doctor; -mod error; -mod frame; -mod lock; -mod log; -mod niri; -mod output; -mod render; -mod selection; -mod wayland; - -use std::process::ExitCode; - -use anyhow::Result; -use chrono::Local; -use clap::{Parser, Subcommand}; - -use crate::error::Cancelled; - -#[derive(Parser)] -#[command(version, about = "macOS-style screenshots for niri")] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Report what the compositor and system support. - Doctor, -} - -fn main() -> ExitCode { - let cli = Cli::parse(); - let log = log::init(&log::default_dir(), Local::now()); - log::install_panic_hook(log.clone()); - tracing::info!( - "valw {} {:?}", - env!("CARGO_PKG_VERSION"), - std::env::args().collect::>() - ); - - match run(cli) { - Ok(()) => ExitCode::SUCCESS, - Err(e) if e.downcast_ref::().is_some() => { - tracing::info!("cancelled by the user"); - ExitCode::from(3) - } - Err(e) => { - tracing::error!("{e:#}"); - eprint!("{}", error::render(&e, log.as_deref())); - ExitCode::FAILURE - } - } -} - -fn run(cli: Cli) -> Result<()> { - match cli.command { - Command::Doctor => doctor::run(), - } -} -``` - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 11: Run `valw doctor` on niri (Phase 0 discovery report)** - -Run: `cargo run --release -- doctor; echo "exit=$?"` -Expected, on the user's machine, is something like the following (the spec's section 2 table lists the same globals): - -``` -ok wl_compositor v6 (surfaces) -ok wl_shm v2 (pixel buffers) -ok wl_seat v9 (mouse and keyboard) -ok zxdg_output_manager_v1 v3 (output names and positions) -ok zwlr_layer_shell_v1 v5 (the selection overlay) -ok zwlr_screencopy_manager_v1 v3 (screen capture) -ok wp_viewporter v1 (sharp overlays at fractional scale) -ok wp_cursor_shape_manager_v1 v2 (crosshair cursor in region mode) -ok ext_data_control_manager_v1 v1 (clipboard copy) -ok output LVDS-1: 1366x768 logical at 0,0, transform Normal -ok output HDMI-A-1: 1920x1080 logical at -277,-1080, transform Normal -ok niri 26.04 (Nixpkgs) -ok config /home/elars/.config/valw/config.toml not found, using defaults -ok save directory /home/elars/Pictures/Screenshots is writable -exit=0 -``` - -Output positions depend on the current monitor layout. Any `fail` line is a blocker; report it rather than working around it. Also check that `~/.local/state/valw/logs/` now holds a log file whose first lines include the valw version and the `globals:` line. - -- [ ] **Step 12: Check the error path** - -Run: `WAYLAND_DISPLAY=nope cargo run --release -- doctor; echo "exit=$?"` -Expected: a `fail wayland: could not connect to the Wayland compositor: ...` line and exit 1. This is the only path where doctor itself fails. - -- [ ] **Step 13: Commit** - -```sh -jj commit -m "feat: wayland connection, niri IPC and valw doctor" -``` - ---- - - -### Task 10: Screen capture and `valw screen` - -**Files:** -- Create: `src/capture.rs` -- Modify: `src/wayland.rs` (screencopy bookkeeping) -- Replace: `src/main.rs` - -**Interfaces:** -- Consumes: `wayland::{Wayland, State, Output}` and `Wayland::dispatch_until` (Task 9); `frame::{Frame, SUPPORTED, to_bgrx, apply_transform, Frame::to_rgba, Frame::full}` (Task 6); `output::*` (Task 8); `lock::Lock` (Task 5); `niri::{focused_output, version}` (Task 9); `config` (Task 3). -- Produces: `pub trait Capturer { fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result>; }` implemented for `Wayland`; `pub struct Pending` (one in-flight screencopy, stored in `State::captures`); CLI `valw screen [--all] [--clipboard-only] [-o PATH|-] [--delay SECS] [--cursor]`. - -Two facts the code depends on, both verified on niri 26.04: -- **niri rejects a screencopy shm buffer unless its `wl_shm_pool` is exactly the buffer's size** (`src/protocols/screencopy.rs`: `shm_len == stride * height`). Every capture therefore gets its own `RawPool`. A shared, larger pool fails with `invalid buffer`. -- **wl-clipboard-rs never forks.** Its background mode is a thread, which dies with the process. `output::copy_to_clipboard` (Task 8) forks through `detach::spawn` instead. - -- [ ] **Step 1: Write `src/capture.rs`** - -Add `mod capture;` to the `mod` list in `src/main.rs`. The pixel logic here was unit-tested in Task 6; this file is protocol plumbing. - -```rust -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result, anyhow}; -use smithay_client_toolkit::shm::raw::RawPool; -use wayland_client::{ - Connection, Dispatch, Proxy, QueueHandle, WEnum, - protocol::{wl_buffer::WlBuffer, wl_shm::Format}, -}; -use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{ - self, ZwlrScreencopyFrameV1, -}; - -use crate::error::HintExt; -use crate::frame::{self, Frame, SUPPORTED}; -use crate::wayland::{Output, State, Wayland}; - -/// How long the compositor gets to deliver all frames. -const TIMEOUT: Duration = Duration::from_secs(5); - -/// Something that captures outputs. Only wlr-screencopy exists today; -/// ext-image-copy-capture would be a second implementation. -pub trait Capturer { - fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result>; -} - -/// A buffer layout the compositor offered. -#[derive(Debug, Clone, Copy)] -struct Layout { - format: Format, - width: u32, - height: u32, - stride: u32, -} - -/// One screencopy request in flight. -pub struct Pending { - output: Output, - offered: Vec, - layout: Option, - // niri rejects a screencopy buffer unless its pool is exactly the - // buffer's size, so every capture gets a pool of its own. - pool: Option, - buffer: Option, - y_invert: bool, - result: Option>, -} - -impl Capturer for Wayland { - fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result> { - let manager = self - .state - .screencopy - .clone() - .context("compositor does not support wlr-screencopy") - .hint("run `valw doctor` to see what the compositor supports")?; - let qh = self.queue.handle(); - - self.state.captures = outputs - .iter() - .map(|o| Pending { - output: o.clone(), - offered: Vec::new(), - layout: None, - pool: None, - buffer: None, - y_invert: false, - result: None, - }) - .collect(); - for (i, o) in outputs.iter().enumerate() { - manager.capture_output(cursor as i32, &o.wl, &qh, i); - } - - let started = Instant::now(); - self.dispatch_until(started + TIMEOUT, |s| { - s.captures.iter().all(|c| c.result.is_some()) - }) - .context("screen capture failed")?; - tracing::info!( - "captured {} output(s) in {:?} ({:?} after start)", - outputs.len(), - started.elapsed(), - crate::log::since_start() - ); - - std::mem::take(&mut self.state.captures) - .into_iter() - .map(|c| { - let name = c.output.geom.name.clone(); - c.result - .expect("checked above") - .with_context(|| format!("could not capture output {name}")) - }) - .collect() - } -} - -impl Dispatch for State { - fn event( - state: &mut Self, - frame: &ZwlrScreencopyFrameV1, - event: zwlr_screencopy_frame_v1::Event, - &i: &usize, - _: &Connection, - qh: &QueueHandle, - ) { - use zwlr_screencopy_frame_v1::Event; - match event { - Event::Buffer { - format: WEnum::Value(format), - width, - height, - stride, - } => { - let c = &mut state.captures[i]; - c.offered.push(format); - let better = c.layout.is_none_or(|l| rank(format) < rank(l.format)); - if SUPPORTED.contains(&format) && better { - c.layout = Some(Layout { - format, - width, - height, - stride, - }); - } - // Before v3 there is no buffer_done; the first buffer event is all we get. - if frame.version() < 3 { - start_copy(state, frame, i, qh); - } - } - Event::BufferDone => start_copy(state, frame, i, qh), - Event::Flags { flags } => { - state.captures[i].y_invert = flags - .into_result() - .is_ok_and(|f| f.contains(zwlr_screencopy_frame_v1::Flags::YInvert)); - } - Event::Ready { .. } => { - let c = &mut state.captures[i]; - c.result = Some(finish(c)); - if let Some(buffer) = c.buffer.take() { - buffer.destroy(); - } - frame.destroy(); - } - Event::Failed => { - state.captures[i].result = Some(Err(anyhow!("the compositor refused the capture"))); - frame.destroy(); - } - _ => {} - } - } -} - -fn rank(format: Format) -> usize { - SUPPORTED - .iter() - .position(|&f| f == format) - .unwrap_or(usize::MAX) -} - -fn start_copy(state: &mut State, frame: &ZwlrScreencopyFrameV1, i: usize, qh: &QueueHandle) { - let c = &mut state.captures[i]; - if c.buffer.is_some() || c.result.is_some() { - return; - } - let Some(l) = c.layout else { - c.result = Some( - Err(anyhow!( - "compositor offered only {:?}, which valw can't convert yet", - c.offered - )) - .hint("run `valw doctor` and include its output in a bug report"), - ); - frame.destroy(); - return; - }; - tracing::info!( - "{}: offered {:?}, using {:?} {}x{}", - c.output.geom.name, - c.offered, - l.format, - l.width, - l.height - ); - let len = (l.stride * l.height) as usize; - match RawPool::new(len, &state.shm) { - Ok(mut pool) => { - let buffer = pool.create_buffer( - 0, - l.width as i32, - l.height as i32, - l.stride as i32, - l.format, - (), - qh, - ); - frame.copy(&buffer); - c.pool = Some(pool); - c.buffer = Some(buffer); - } - Err(e) => { - c.result = Some(Err(e).context("could not allocate a capture buffer")); - frame.destroy(); - } - } -} - -fn finish(c: &mut Pending) -> Result { - let l = c.layout.context("ready without a buffer")?; - let pool = c.pool.as_mut().context("ready without a buffer")?; - let data = &pool.mmap()[..]; - let pixels = frame::to_bgrx(l.format, l.width, l.height, l.stride, data, c.y_invert)?; - Ok(Frame { - pixels: Arc::new(frame::apply_transform(pixels, c.output.transform)), - output: c.output.geom.clone(), - }) -} -``` - -- [ ] **Step 2: Track captures in `State`** - -Apply this change to `src/wayland.rs`: - -```diff ---- a/src/wayland.rs -+++ b/src/wayland.rs -@@ -19,7 +19,7 @@ - use wayland_client::{ - Connection, EventQueue, QueueHandle, - globals::{GlobalList, registry_queue_init}, -- protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, -+ protocol::{wl_buffer::WlBuffer, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, - }; - use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; - use wayland_protocols::wp::viewporter::client::{ -@@ -27,6 +27,7 @@ - }; - use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; - -+use crate::capture::Pending; - use crate::error::HintExt; - use crate::frame::OutputGeom; - -@@ -58,6 +59,8 @@ - pub cursor_device: Option, - pub keyboard: Option, - pub pointer: Option, -+ /// Screencopy requests in flight. -+ pub captures: Vec, - } - - impl Wayland { -@@ -83,6 +86,7 @@ - cursor_device: None, - keyboard: None, - pointer: None, -+ captures: Vec::new(), - }; - // Two round trips: one for wl_output, one for the xdg-output details. - queue -@@ -425,3 +429,4 @@ - wayland_client::delegate_noop!(State: ZwlrScreencopyManagerV1); - wayland_client::delegate_noop!(State: WpViewporter); - wayland_client::delegate_noop!(State: WpViewport); -+wayland_client::delegate_noop!(State: ignore WlBuffer); -``` - -- [ ] **Step 3: Write the failing CLI tests** - -Replace `src/main.rs` with the version below. It adds the `screen` command, the capture → deliver flow, and a test module that pins down the flag conflicts and `--delay` parsing. - -```rust -// Modules land before the CLI uses them; Task 11 removes this. -#![allow(dead_code)] - -mod capture; -mod config; -mod detach; -mod doctor; -mod error; -mod frame; -mod lock; -mod log; -mod niri; -mod output; -mod render; -mod selection; -mod wayland; - -use std::path::PathBuf; -use std::process::ExitCode; -use std::time::Duration; - -use anyhow::{Context, Result}; -use chrono::Local; -use clap::{Args, Parser, Subcommand}; -use image::RgbaImage; - -use crate::capture::Capturer; -use crate::config::Config; -use crate::error::Cancelled; -use crate::lock::Lock; -use crate::output::Target; -use crate::wayland::Wayland; - -#[derive(Parser)] -#[command(version, about = "macOS-style screenshots for niri")] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Capture the focused output (Cmd+Shift+3). - Screen { - /// Capture every output, one file each. - #[arg(long, conflicts_with_all = ["output", "clipboard_only"])] - all: bool, - #[command(flatten)] - common: Common, - }, - /// Report what the compositor and system support. - Doctor, -} - -#[derive(Args)] -struct Common { - /// Copy to the clipboard without saving a file. - #[arg(long, conflicts_with = "output")] - clipboard_only: bool, - /// Write to this path instead of the configured directory; "-" for stdout. - #[arg(short, long, value_name = "PATH")] - output: Option, - /// Wait this many seconds before capturing. - #[arg(long, value_name = "SECS", value_parser = parse_delay)] - delay: Option, - /// Include the mouse cursor. - #[arg(long)] - cursor: bool, -} - -fn parse_delay(s: &str) -> Result { - let secs: f64 = s.parse().map_err(|_| format!("not a number: {s}"))?; - Duration::try_from_secs_f64(secs) - .map_err(|_| format!("must be a non-negative number of seconds: {s}")) -} - -enum Mode { - Screen { all: bool }, -} - -fn main() -> ExitCode { - let cli = Cli::parse(); - let log = log::init(&log::default_dir(), Local::now()); - log::install_panic_hook(log.clone()); - tracing::info!( - "valw {} {:?}", - env!("CARGO_PKG_VERSION"), - std::env::args().collect::>() - ); - - match run(cli) { - Ok(()) => ExitCode::SUCCESS, - Err(e) if e.downcast_ref::().is_some() => { - tracing::info!("cancelled by the user"); - ExitCode::from(3) - } - Err(e) => { - tracing::error!("{e:#}"); - eprint!("{}", error::render(&e, log.as_deref())); - ExitCode::FAILURE - } - } -} - -fn run(cli: Cli) -> Result<()> { - match cli.command { - Command::Doctor => doctor::run(), - Command::Screen { all, common } => capture(Mode::Screen { all }, common), - } -} - -fn capture(mode: Mode, common: Common) -> Result<()> { - let config = config::load(&config::default_path())?; - let lock = Lock::acquire(&lock::default_path())?; - if let Some(delay) = common.delay { - std::thread::sleep(delay); - } - let cursor = common.cursor || config.capture.show_cursor; - let mut wl = Wayland::connect()?; - if let Ok(version) = niri::version() { - tracing::info!("niri {version}"); - } - let outputs = wl.outputs(); - anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); - - // (image, output name for the file name) and which one goes to the clipboard. - let (shots, clip): (Vec<(RgbaImage, Option)>, usize) = match mode { - Mode::Screen { all } => { - let focused = focused_output(&outputs); - if all { - let frames = wl.capture(&outputs, cursor)?; - let shots = frames - .into_iter() - .map(|f| (f.to_rgba(f.full()), Some(f.output.name))) - .collect(); - (shots, focused) - } else { - let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); - (vec![(frame.to_rgba(frame.full()), None)], 0) - } - } - }; - drop(wl); - - let target = Target::from_args(common.output, common.clipboard_only); - deliver(shots, clip, &target, &config, lock) -} - -/// Index of niri's focused output, or 0 if niri can't tell us. -fn focused_output(outputs: &[wayland::Output]) -> usize { - let name = match niri::focused_output() { - Ok(Some(name)) => name, - Ok(None) => return 0, - Err(e) => { - tracing::warn!("using the first output: {e:#}"); - return 0; - } - }; - outputs - .iter() - .position(|o| o.geom.name == name) - .unwrap_or_else(|| { - tracing::warn!("niri's focused output {name} is not among the Wayland outputs"); - 0 - }) -} - -fn deliver( - shots: Vec<(RgbaImage, Option)>, - clip: usize, - target: &Target, - config: &Config, - lock: Lock, -) -> Result<()> { - let now = Local::now(); - let pngs = shots - .iter() - .map(|(img, _)| output::encode_png(img)) - .collect::>>()?; - - match target { - Target::Default => { - let dir = config.save_dir(); - for (png, (_, name)) in pngs.iter().zip(&shots) { - let file = output::file_name(&config.save.filename, &now, name.as_deref())?; - let path = output::unique_path(&dir, &file); - output::write_atomic(&path, png)?; - println!("{}", path.display()); - } - } - Target::Path(path) => { - output::write_atomic(path, &pngs[0])?; - println!("{}", path.display()); - } - Target::Stdout => output::write_stdout(&pngs[0])?, - Target::ClipboardOnly => {} - } - - if *target == Target::ClipboardOnly || config.save.copy_to_clipboard { - let png = pngs - .into_iter() - .nth(clip) - .context("no image for the clipboard")?; - output::copy_to_clipboard(png, lock)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::CommandFactory; - - fn parses(args: &[&str]) -> bool { - Cli::try_parse_from(std::iter::once("valw").chain(args.iter().copied())).is_ok() - } - - #[test] - fn cli_definition_is_valid() { - Cli::command().debug_assert(); - } - - #[test] - fn flag_conflicts() { - assert!(parses(&["screen", "--all"])); - assert!(parses(&["screen", "-o", "-"])); - assert!(parses(&["screen", "--clipboard-only", "--cursor"])); - assert!(!parses(&["screen", "--all", "-o", "-"])); - assert!(!parses(&["screen", "--all", "--clipboard-only"])); - assert!(!parses(&["screen", "--clipboard-only", "-o", "a.png"])); - } - - #[test] - fn delay_values() { - assert_eq!(parse_delay("1.5"), Ok(Duration::from_millis(1500))); - assert_eq!(parse_delay("0"), Ok(Duration::ZERO)); - assert!(parse_delay("-1").is_err()); - assert!(parse_delay("soon").is_err()); - assert!(parse_delay("NaN").is_err()); - } -} -``` - -- [ ] **Step 4: Run the tests** - -Run: `cargo nextest run` -Expected: every test passes, including `tests::flag_conflicts`, `tests::delay_values` and `tests::cli_definition_is_valid`. (The CLI tests were written together with the code they test because clap's derive definitions are the implementation.) - -- [ ] **Step 5: Lint** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings` -Expected: no warnings. - -- [ ] **Step 6: Capture on niri without touching the real clipboard or Pictures** - -```sh -t=$(mktemp -d) -mkdir -p $t/config/valw -printf '[save]\ndirectory = "%s/shots"\ncopy_to_clipboard = false\n' $t > $t/config/valw/config.toml -export XDG_CONFIG_HOME=$t/config XDG_STATE_HOME=$t/state -cargo build --release -V=$CARGO_TARGET_DIR/release/valw -$V screen -o - | file - -$V screen --all && file $t/shots/* -$V screen --delay 0.5 -o $t/one.png && file $t/one.png -``` - -Expected: `PNG image data, 1366 x 768` (or whichever output niri has focused). With `--all`, one file per output named `Screenshot … (LVDS-1).png` / `(HDMI-A-1).png` at each output's physical size. The log in `$t/state/valw/logs/` shows `offered [Xrgb8888], using Xrgb8888 …` and `captured N output(s) in …ms`. - -- [ ] **Step 7: Clipboard and pipes** - -```sh -printf '[save]\ndirectory = "%s/shots"\n' $t > $t/config/valw/config.toml -time ($V screen -o - | wc -c) -wl-paste --list-types -wl-paste --type image/png | file - -``` - -Expected: the pipeline returns immediately (well under a second), even though the clipboard child is still running. `wl-paste --list-types` lists `image/png`, and the pasted data is the PNG. This step overwrites the clipboard, so tell the user before running it. - -- [ ] **Step 8: Hotkey spam (Review Focus 4)** - -```sh -$V screen --delay 3 -o $t/a.png & sleep 0.5; $V screen -o $t/b.png; echo "exit=$?"; wait -$V screen -o $t/c.png; echo "exit=$?" -``` - -Expected: the second command prints `error: valw is already running` with `hint: wait for the other capture to finish, or press Esc in it` and `exit=1`. The first one then succeeds. The third succeeds (`exit=0`) even though the first run's clipboard child is still alive. - -- [ ] **Step 9: Commit** - -```sh -jj commit -m "feat: wlr-screencopy capture and valw screen" -``` - ---- - - -### Task 11: Region selection overlay and `valw region` - -**Files:** -- Create: `src/region.rs` -- Modify: `src/wayland.rs` (route overlay events), `src/main.rs` (add `region`, drop `#![allow(dead_code)]`) - -**Interfaces:** -- Consumes: `selection::{Selection, Point, clip, resolve}` (Task 7); `render::{dim, compose, DIM}` (Task 7); `frame::{Bgrx, Frame, OutputGeom, PixelRect}` (Task 6); `wayland::{Wayland, State, Output}`, `Wayland::dispatch_blocking` (Task 9); `log::since_start` (Task 4); `error::{Cancelled, HintExt}` (Task 2). -- Produces: `pub const NAMESPACE: &str = "valw-overlay"`; `pub type Picked = (usize, PixelRect)`; `pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result` (returns `Cancelled` on Esc or right click); `pub struct Overlay` with `cancel`, `configure`, `frame_done`, `pointer`, called from `State`'s handlers. CLI: `valw region [COMMON]`. - -How it works: one fullscreen layer surface per output shows that output's frozen frame, darkened. The buffer is XRGB8888 (the frame's own byte order, so no conversion) at the frame's physical size, and `wp_viewporter` maps it onto the surface's logical size, so it is shown 1:1 at any scale. The undimmed selection is pasted straight from the shared frame (`Arc`, no copy). Pointer positions are converted to global logical coordinates. The drag is drawn (and later cropped) only on the output where it started. Redraws are paced by frame callbacks. - -- [ ] **Step 1: Write `src/region.rs`** - -Add `mod region;` to the `mod` list in `src/main.rs`. - -```rust -use std::sync::Arc; - -use anyhow::{Context, Result}; -use smithay_client_toolkit::{ - compositor::FrameCallbackData, - seat::pointer::{BTN_LEFT, BTN_RIGHT, PointerEvent, PointerEventKind}, - shell::{ - WaylandSurface, - wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface}, - }, - shm::{ - Shm, - slot::{Buffer, SlotPool}, - }, -}; -use wayland_client::{ - QueueHandle, - protocol::{wl_shm, wl_surface::WlSurface}, -}; -use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{ - Shape, WpCursorShapeDeviceV1, -}; -use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; - -use crate::error::{Cancelled, HintExt}; -use crate::frame::{Bgrx, Frame, OutputGeom, PixelRect}; -use crate::render; -use crate::selection::{self, Point, Selection}; -use crate::wayland::{Output, State, Wayland}; - -pub const NAMESPACE: &str = "valw-overlay"; - -/// What the user picked: an output index and a rectangle in its frame. -pub type Picked = (usize, PixelRect); - -/// One full-screen layer surface per output, showing the frozen frame. -pub struct Overlay { - surfaces: Vec, - selection: Selection, - /// Output where the current drag started; the capture is clipped to it. - anchor: Option, - outcome: Option>, -} - -struct Surface { - layer: LayerSurface, - viewport: WpViewport, - geom: OutputGeom, - width: u32, - height: u32, - dark: Vec, - bright: Arc, - pool: SlotPool, - buffer: Option, - configured: bool, - frame_pending: bool, - dirty: bool, -} - -/// Shows the overlay on every output and waits for a selection. -/// Returns `Cancelled` if the user presses Esc or right-clicks. -pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result { - let qh = wl.queue.handle(); - let s = &wl.state; - let layer_shell = s - .layer_shell - .as_ref() - .context("compositor does not support wlr-layer-shell") - .hint("run `valw doctor` to see what the compositor supports")?; - let viewporter = s - .viewporter - .as_ref() - .context("compositor does not support wp-viewporter") - .hint("run `valw doctor` to see what the compositor supports")?; - - let mut surfaces = Vec::new(); - for (output, frame) in outputs.iter().zip(frames) { - let surface = s.compositor.create_surface(&qh); - let layer = layer_shell.create_layer_surface( - &qh, - surface, - Layer::Overlay, - Some(NAMESPACE), - Some(&output.wl), - ); - layer.set_anchor(Anchor::all()); - layer.set_exclusive_zone(-1); - layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive); - layer.commit(); - let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ()); - surfaces.push(Surface::new(layer, viewport, frame, &s.shm)?); - } - - tracing::debug!( - "overlay buffers ready {:?} after start", - crate::log::since_start() - ); - wl.state.overlay = Some(Overlay { - surfaces, - selection: Selection::default(), - anchor: None, - outcome: None, - }); - let result = wl.dispatch_blocking(|s| s.overlay.as_ref().is_some_and(|o| o.outcome.is_some())); - let outcome = wl.state.overlay.take().and_then(|o| o.outcome); - // Dropping the overlay destroys the surfaces; make that visible right away. - let _ = wl.conn.flush(); - result?; - outcome.flatten().ok_or_else(|| Cancelled.into()) -} - -impl Surface { - fn new(layer: LayerSurface, viewport: WpViewport, frame: &Frame, shm: &Shm) -> Result { - let (width, height) = frame.pixels.dimensions(); - let len = (width * height * 4) as usize; - Ok(Surface { - layer, - viewport, - geom: frame.output.clone(), - width, - height, - dark: render::dim(frame.pixels.as_raw(), render::DIM), - bright: Arc::clone(&frame.pixels), - // Room for two buffers so one can be on screen while we draw the next. - pool: SlotPool::new(len * 2, shm).context("could not create shm pool")?, - buffer: None, - configured: false, - frame_pending: false, - dirty: true, - }) - } - - fn draw(&mut self, sel: Option, qh: &QueueHandle) { - let stride = self.width as i32 * 4; - let Ok((buffer, canvas)) = self.pool.create_buffer( - self.width as i32, - self.height as i32, - stride, - wl_shm::Format::Xrgb8888, - ) else { - tracing::warn!("could not allocate an overlay buffer"); - return; - }; - render::compose(canvas, &self.dark, self.bright.as_raw(), self.width, sel); - - let surface = self.layer.wl_surface(); - surface.damage_buffer(0, 0, self.width as i32, self.height as i32); - surface.frame(qh, FrameCallbackData(surface.clone())); - if buffer.attach_to(surface).is_err() { - tracing::warn!("could not attach the overlay buffer"); - return; - } - self.layer.commit(); - self.buffer = Some(buffer); - self.frame_pending = true; - self.dirty = false; - } -} - -impl Overlay { - pub fn cancel(&mut self) { - self.outcome.get_or_insert(None); - } - - fn index_of(&self, surface: &WlSurface) -> Option { - self.surfaces - .iter() - .position(|s| s.layer.wl_surface() == surface) - } - - /// The selection as drawn on surface `i`: only the anchor output shows it. - fn selection_on(&self, i: usize) -> Option { - let Selection::Dragging { start, end } = self.selection else { - return None; - }; - let s = &self.surfaces[i]; - (self.anchor == Some(i)) - .then(|| selection::clip(start, end, &s.geom, s.width, s.height)) - .flatten() - } - - fn redraw(&mut self, i: usize, qh: &QueueHandle) { - let sel = self.selection_on(i); - let s = &mut self.surfaces[i]; - s.dirty = true; - if s.configured && !s.frame_pending { - s.draw(sel, qh); - } - } - - pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle) { - let Some(i) = self.index_of(layer.wl_surface()) else { - return; - }; - let s = &mut self.surfaces[i]; - // Show the physical-size buffer 1:1 on the logical-size surface. - s.viewport.set_destination(w as i32, h as i32); - if !s.configured { - s.configured = true; - let name = s.geom.name.clone(); - self.redraw(i, qh); - tracing::info!( - "overlay on {name} committed {:?} after start", - crate::log::since_start() - ); - } - } - - pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle) { - let Some(i) = self.index_of(surface) else { - return; - }; - self.surfaces[i].frame_pending = false; - if self.surfaces[i].dirty { - self.redraw(i, qh); - } - } - - pub fn pointer( - &mut self, - events: &[PointerEvent], - cursor: Option<&WpCursorShapeDeviceV1>, - qh: &QueueHandle, - ) { - for event in events { - let Some(i) = self.index_of(&event.surface) else { - continue; - }; - let g = &self.surfaces[i].geom; - let p = Point { - x: g.x as f64 + event.position.0, - y: g.y as f64 + event.position.1, - }; - match event.kind { - PointerEventKind::Enter { serial } => { - if let Some(cursor) = cursor { - cursor.set_shape(serial, Shape::Crosshair); - } - } - PointerEventKind::Press { - button: BTN_LEFT, .. - } => { - self.selection.press(p); - self.anchor = Some(i); - } - PointerEventKind::Press { - button: BTN_RIGHT, .. - } => self.cancel(), - PointerEventKind::Motion { .. } => { - self.selection.motion(p); - if let Some(a) = self.anchor { - self.redraw(a, qh); - } - } - PointerEventKind::Release { - button: BTN_LEFT, .. - } => { - let (Some((start, end)), Some(a)) = (self.selection.release(p), self.anchor) - else { - continue; - }; - let s = &self.surfaces[a]; - match selection::resolve(start, end, &s.geom, s.width, s.height) { - Some(rect) => { - tracing::info!( - "selected {rect:?} on {} (logical {start:?} to {end:?})", - s.geom.name - ); - self.outcome = Some(Some((a, rect))); - } - // A click, not a drag: clear it and keep waiting. - None => self.redraw(a, qh), - } - } - _ => {} - } - } - } -} -``` - -- [ ] **Step 2: Route overlay events from `State`** - -Apply this change to `src/wayland.rs`: - -```diff ---- a/src/wayland.rs -+++ b/src/wayland.rs -@@ -30,6 +30,7 @@ - use crate::capture::Pending; - use crate::error::HintExt; - use crate::frame::OutputGeom; -+use crate::region::Overlay; - - /// An output as valw sees it. - #[derive(Debug, Clone)] -@@ -61,6 +62,8 @@ - pub pointer: Option, - /// Screencopy requests in flight. - pub captures: Vec, -+ /// The region selection overlay, while it is open. -+ pub overlay: Option, - } - - impl Wayland { -@@ -87,6 +90,7 @@ - keyboard: None, - pointer: None, - captures: Vec::new(), -+ overlay: None, - }; - // Two round trips: one for wl_output, one for the xdg-output details. - queue -@@ -233,7 +237,17 @@ - ) { - } - -- fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, _: u32) {} -+ fn frame( -+ &mut self, -+ _: &Connection, -+ qh: &QueueHandle, -+ surface: &wl_surface::WlSurface, -+ _: u32, -+ ) { -+ if let Some(overlay) = &mut self.overlay { -+ overlay.frame_done(surface, qh); -+ } -+ } - - fn surface_enter( - &mut self, -@@ -267,16 +281,23 @@ - } - - impl LayerShellHandler for State { -- fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) {} -+ fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) { -+ if let Some(overlay) = &mut self.overlay { -+ overlay.cancel(); -+ } -+ } - - fn configure( - &mut self, - _: &Connection, -- _: &QueueHandle, -- _: &LayerSurface, -- _: LayerSurfaceConfigure, -- _: u32, -- ) { -+ qh: &QueueHandle, -+ layer: &LayerSurface, -+ configure: LayerSurfaceConfigure, -+ _: u32, -+ ) { -+ if let Some(overlay) = &mut self.overlay { -+ overlay.configure(layer, configure.new_size, qh); -+ } - } - } - -@@ -363,8 +384,13 @@ - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: u32, -- _: KeyEvent, -- ) { -+ event: KeyEvent, -+ ) { -+ if event.keysym == Keysym::Escape -+ && let Some(overlay) = &mut self.overlay -+ { -+ overlay.cancel(); -+ } - } - - fn repeat_key( -@@ -404,10 +430,13 @@ - fn pointer_frame( - &mut self, - _: &Connection, -- _: &QueueHandle, -+ qh: &QueueHandle, - _: &wl_pointer::WlPointer, -- _: &[PointerEvent], -- ) { -+ events: &[PointerEvent], -+ ) { -+ if let Some(overlay) = &mut self.overlay { -+ overlay.pointer(events, self.cursor_device.as_ref(), qh); -+ } - } - } - -``` - -- [ ] **Step 3: Add the `region` command and remove the dead-code allowance** - -Apply this change to `src/main.rs`: - -```diff ---- a/src/main.rs -+++ b/src/main.rs -@@ -1,6 +1,3 @@ --// Modules land before the CLI uses them; Task 11 removes this. --#![allow(dead_code)] -- - mod capture; - mod config; - mod detach; -@@ -11,6 +8,7 @@ - mod log; - mod niri; - mod output; -+mod region; - mod render; - mod selection; - mod wayland; -@@ -45,6 +43,11 @@ - /// Capture every output, one file each. - #[arg(long, conflicts_with_all = ["output", "clipboard_only"])] - all: bool, -+ #[command(flatten)] -+ common: Common, -+ }, -+ /// Drag to select a region (Cmd+Shift+4). -+ Region { - #[command(flatten)] - common: Common, - }, -@@ -76,6 +79,7 @@ - - enum Mode { - Screen { all: bool }, -+ Region, - } - - fn main() -> ExitCode { -@@ -106,6 +110,7 @@ - match cli.command { - Command::Doctor => doctor::run(), - Command::Screen { all, common } => capture(Mode::Screen { all }, common), -+ Command::Region { common } => capture(Mode::Region, common), - } - } - -@@ -139,6 +144,11 @@ - (vec![(frame.to_rgba(frame.full()), None)], 0) - } - } -+ Mode::Region => { -+ let frames = wl.capture(&outputs, cursor)?; -+ let (i, r) = region::select(&mut wl, &outputs, &frames)?; -+ (vec![(frames[i].to_rgba(r), None)], 0) -+ } - }; - drop(wl); - -@@ -224,10 +234,10 @@ - fn flag_conflicts() { - assert!(parses(&["screen", "--all"])); - assert!(parses(&["screen", "-o", "-"])); -- assert!(parses(&["screen", "--clipboard-only", "--cursor"])); -+ assert!(parses(&["region", "--clipboard-only", "--cursor"])); - assert!(!parses(&["screen", "--all", "-o", "-"])); - assert!(!parses(&["screen", "--all", "--clipboard-only"])); -- assert!(!parses(&["screen", "--clipboard-only", "-o", "a.png"])); -+ assert!(!parses(&["region", "--clipboard-only", "-o", "a.png"])); - } - - #[test] -``` - -- [ ] **Step 4: Run tests and lint without the allowance** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings (in particular, no `dead_code`); every test passes. - -- [ ] **Step 5: Select a region on niri** - -Use the throwaway config from Task 10 (`copy_to_clipboard = false`): - -```sh -cargo build --release -$V region -o $t/r.png; echo "exit=$?"; file $t/r.png -grep -h "committed\|selected" $t/state/valw/logs/*.log | tail -3 -``` - -Ask the user to drag over a static area. Expected: every output freezes and dims, the cursor becomes a crosshair, the selection shows at full brightness with a white 1 px outline, and the overlay disappears on release. The command prints `exit=0`, and `r.png` has the logged pixel size. The log shows `overlay on … committed …ms after start` and `selected PixelRect { … } on …`. - -- [ ] **Step 6: Cancel paths** - -Ask the user to run `$V region -o $t/x.png; echo "exit=$?"` three times: press Esc, then right-click, then click once without dragging and press Esc afterwards. -Expected: `exit=3` each time, and no `x.png`. A plain click does **not** end the selection; the overlay stays until Esc. - -- [ ] **Step 7: Drag across monitors (Review Focus 3)** - -Ask the user to start a drag on one monitor and release it on the other, and once more to release outside every monitor's area (for example past a screen edge). -Expected: no panic. The result is clipped to the monitor where the drag started, and the log's `selected` line shows a rectangle inside that monitor. - -- [ ] **Step 8: No stuck overlay** - -```sh -$V region -o $t/k.png & sleep 1; kill -9 $! -$V screen -o /dev/null; echo "exit=$?" -``` - -Expected: the overlay vanishes when the process is killed, and the next command gets the lock (`exit=0`). - -- [ ] **Step 9: Commit** - -```sh -jj commit -m "feat: frozen-frame region selection overlay" -``` - ---- - - -### Task 12: Nix checks (clippy, fmt, nextest, headless sway) - -**Files:** -- Create: `nix/checks.ulu.nix` - -**Interfaces:** -- Consumes: `packages.default` (Task 1); `valw doctor` and `valw screen -o -` (Tasks 9–10). -- Produces: `checks..{clippy,fmt,nextest,headless}`, run by `nix flake check`. - -Notes: -- `cargoCheck` reuses the package derivation, so dependencies are vendored once. Setting `buildPhase` and `installPhase` replaces cargo's build and install hooks. -- `fmt` calls `rustfmt` directly because `cargo fmt` would try to resolve dependencies offline. -- The headless check must use `sway-unwrapped`: nixpkgs' `sway` wrapper starts through `dbus-run-session`, which does not exist in the sandbox. -- `valw screen -o - | cat` runs under `timeout 10`, so a clipboard child that kept the pipe open would fail the check. - -- [ ] **Step 1: Write `nix/checks.ulu.nix`** - -```nix -{ ... }: -{ - perSystem = - { pkgs, self', ... }: - let - valw = self'.packages.default; - - # Reuses the package's vendored dependencies, runs `cmd` instead of the build. - cargoCheck = - name: tools: cmd: - valw.overrideAttrs (old: { - pname = "valw-${name}"; - nativeBuildInputs = old.nativeBuildInputs ++ tools; - buildPhase = cmd; - installPhase = "touch $out"; - }); - in - { - checks = { - clippy = cargoCheck "clippy" [ pkgs.clippy ] "cargo clippy --all-targets --offline -- -D warnings"; - nextest = cargoCheck "nextest" [ pkgs.cargo-nextest ] "cargo nextest run --offline"; - - fmt = pkgs.runCommand "valw-fmt" { nativeBuildInputs = [ pkgs.rustfmt ]; } '' - rustfmt --check --edition 2024 ${valw.src}/src/main.rs - touch $out - ''; - - # Runs the real screencopy path against a headless sway. This is - # wlroots, not niri, but it catches capture and format bugs on every build. - headless = - pkgs.runCommand "valw-headless" - { - nativeBuildInputs = [ - pkgs.file - pkgs.sway-unwrapped - valw - ]; - } - '' - export HOME=$TMPDIR/home XDG_RUNTIME_DIR=$TMPDIR/run - mkdir -p $HOME - mkdir -m 700 $XDG_RUNTIME_DIR - export WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 - - echo 'output HEADLESS-1 resolution 1280x720' > sway.conf - sway -c sway.conf > sway.log 2>&1 & - - for _ in $(seq 100); do - socket=$(ls $XDG_RUNTIME_DIR | grep -m1 '^wayland-[0-9]*$' || true) - [ -n "$socket" ] && break - sleep 0.1 - done - [ -n "$socket" ] || { cat sway.log; exit 1; } - export WAYLAND_DISPLAY=$socket - - valw doctor | tee doctor.txt - ! grep -q '^fail' doctor.txt - - # Through a pipe: the clipboard child must not keep it open. - timeout 10 valw screen -o - | cat > shot.png - file shot.png | tee file.txt - grep -q 'PNG image data, 1280 x 720' file.txt - - touch $out - ''; - }; - }; -} -``` - -- [ ] **Step 2: Run every check** - -Run: `nix flake check -L --keep-going` -Expected: all four checks build. The `valw-headless` log shows the doctor lines, all `ok` except `warn niri IPC unavailable …` (sway is not niri), plus `shot.png: PNG image data, 1280 x 720`. - -- [ ] **Step 3: Format the Nix files** - -Run: `nix fmt` -Expected: no changes beyond whitespace. If anything changed, rerun Step 2. - -- [ ] **Step 4: Commit** - -```sh -jj commit -m "test: nix flake checks with headless sway capture" -``` - ---- - - -### Task 13: Acceptance on niri - -**Files:** none (verification only). If a check fails, fix the owning task's code, add a unit test that reproduces the failure where possible, and commit the fix separately. - -**Interfaces:** -- Consumes: the release binary from `nix build`. -- Produces: confirmation that Phase 0 + 1 meets the spec's sections 10.3 and 11. - -Before starting, tell the user this task temporarily changes the HDMI output's scale and rotation through `niri msg`. That is runtime only and doesn't touch the niri config, and every step restores it. Use the throwaway config from Task 10. - -- [ ] **Step 1: Build the real package** - -```sh -nix build && V=$PWD/result/bin/valw -``` - -- [ ] **Step 2: Pixel exactness at scale 1 and 1.25 (Review Focus 2)** - -For each output, and then again after `niri msg output HDMI-A-1 scale 1.25`: - -```sh -$V region -o $t/a.png # user drags over a static area -grep -h selected $t/state/valw/logs/*.log | tail -1 -# -> selected PixelRect { x: X, y: Y, width: W, height: H } on NAME ... -grim -o NAME $t/full.png -magick $t/full.png -crop WxH+X+Y +repage $t/b.png -magick compare -metric AE $t/a.png $t/b.png null:; echo -``` - -Expected: `0` every time. At 1.25 the overlay must also look sharp, with text in it as crisp as on the live screen. Restore with `niri msg output HDMI-A-1 scale 1`. - -- [ ] **Step 3: Latency** - -Run `$V region -o /dev/null` and press Esc; repeat three times. -Expected: every `overlay on … committed …ms after start` log line is under 150 ms. (Prototype measurements: 38–42 ms warm and 90 ms cold on a headless 1080p output; 270 ms before frames were kept in B, G, R, X order.) If it's over, the `captured … (… after start)` and `overlay buffers ready …` lines (`VALW_LOG=debug`) show which phase is slow. - -- [ ] **Step 4: Rotated output (Review Focus 1)** - -```sh -niri msg output HDMI-A-1 transform 90 -$V screen --all && grim -o HDMI-A-1 $t/g.png -magick compare -metric AE "$(ls $t/shots/*HDMI-A-1*.png | tail -1)" $t/g.png null:; echo -niri msg output HDMI-A-1 transform normal -``` - -Expected: `0`. If the images are rotated relative to each other, the mapping in `frame::apply_transform` is wrong for niri. Fix the table in `transforms_match_grim` and the match arms together. - -- [ ] **Step 5: Noctalia** - -Run `$V region -o /dev/null` and have the user confirm the dimmed overlay covers the Noctalia bar, then press Esc. - -- [ ] **Step 6: Unwritable log directory (Review Focus 5)** - -```sh -XDG_STATE_HOME=/proc $V screen -o $t/nolog.png; echo "exit=$?"; file $t/nolog.png -$V screen -o /proc/nope.png; echo "exit=$?" -``` - -Expected: the first prints a `WARN logging to stderr only: …` line, `exit=0`, and a valid PNG. The second prints `error: could not write /proc/nope.png`-style output with `cause:` lines and the `log:` path, and `exit=1`. - -- [ ] **Step 7: Full check suite** - -Run: `nix flake check -L --keep-going` -Expected: every check passes. - -- [ ] **Step 8: Report** - -Summarise the results of Steps 2–6 to the user, including exact numbers (AE values and latencies). There is nothing to commit unless a fix was needed. diff --git a/docs/superpowers/plans/2026-09-30-valw-phase-2-preview.md b/docs/superpowers/plans/2026-09-30-valw-phase-2-preview.md deleted file mode 100644 index 9ff8b45..0000000 --- a/docs/superpowers/plans/2026-09-30-valw-phase-2-preview.md +++ /dev/null @@ -1,2655 +0,0 @@ -# valw Phase 2 (floating preview) 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:** After every capture that writes a file, a thumbnail slides in at the bottom right. Clicking it opens Satty, swiping it right dismisses it, and it slides away after `timeout_secs`. It never takes focus and never shows up in a screenshot. valw is then wired into copland for a day of real use. - -**Architecture:** One on-demand **preview host** process (`valw __preview-host`) owns every thumbnail. It listens on `$XDG_RUNTIME_DIR/valw.sock` (one JSON message per line, one reply each) and runs a calloop loop over Wayland, the socket and timers. A capture holds a `HideGuard` connection from just before its screencopy until it has saved the file. While any such connection is open, the host draws every thumbnail fully transparent and click-through. When the connection closes (for any reason), the thumbnails come back. - -**Tech Stack:** Rust 2024; smithay-client-toolkit 0.21 (its re-exported calloop and calloop-wayland-source); serde_json (new); image (resize); Satty 0.22 (via the Nix wrapper); flake-parts; jj. - -**Spec:** `docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md` (read it first). Phase 1's spec, `docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md`, still applies to everything this plan doesn't change. - -## Global Constraints - -- Code, comments, error/log messages: English. Comments are plain and short and say *why*. -- Version control is **jj**. Commit with `jj commit -m ""`; never use git commands in the valw repo. copland (Task 9) is also a jj repo. -- Use `cargo clippy` (never `cargo check`) and `cargo nextest run` (never `cargo test`), inside the devShell (direnv, or prefix with `nix develop -c`). -- A background `jj util snapshot` in the user's session can race with `jj commit` and leave the working copy stale or divergent. If `jj` reports a stale working copy, **back up the changed files first**, then run `jj workspace update-stale`, restore the newer files from the backup, and abandon an identical divergent twin (`jj diff --from A --to B` shows nothing). -- Never write a manual `imports = [ ... ]` list for flake modules; `nix/**/*.ulu.nix` is discovered automatically. -- Until Task 6, `src/main.rs` starts with `#![allow(dead_code)]`, because the new modules land before the CLI uses them. Task 6 removes it, and clippy must then be clean without it. -- Socket `$XDG_RUNTIME_DIR/valw.sock`, host lock `$XDG_RUNTIME_DIR/valw-preview.lock`, layer namespace `valw-preview`, hidden subcommand `__preview-host`. -- Thumbnail: long edge 220 logical px (never upscaled), 16 px from the screen edges, 12 px gap, at most 5 per output, slide-in 200 ms ease-out, slide-out 150 ms ease-in, click under 4 px of movement, dismissing swipe at least 40% of the width. -- Config: `[preview] enabled = true, timeout_secs = 5`; `timeout_secs = 0` is a config error. -- Never unmap a thumbnail to hide it: niri 26.04 doesn't send a new configure afterwards (verified). Hide means a transparent buffer plus an empty input region. -- The `HideGuard` must be dropped **before** the clipboard fork; the forked child would otherwise inherit the connection and keep thumbnails hidden. - -## Review Focus - -These are the inputs and failure modes a real user will hit that no automated test fully exercises. Each one is pinned in the task that owns the code. - -1. **The capture dies while thumbnails are hidden** (Esc, an error, `kill -9` during region selection): the thumbnails must come back. Tests: `ipc::tests::guard_hides_until_dropped_and_adds_on_the_same_connection` (the host sees EOF when the guard drops, Task 3) and `host::tests::hidden_until_every_hider_leaves` (Task 5). Real check: Task 8, step 5. -2. **A stale socket file after a host crash:** the next capture must neither hang nor fail. It skips hiding, and a new host replaces the file. Test: `ipc::tests::stale_socket_is_ignored` (Task 3). Real check: Task 8, step 5. -3. **Two captures overlapping** (the hotkey pressed during region selection, or `--delay` runs): thumbnails stay hidden until the *last* hider leaves. Tests: `host::tests::hidden_until_every_hider_leaves` and `a_client_that_never_hid_does_not_show` (Task 5). -4. **Satty missing** (valw run without the Nix wrapper, e.g. `cargo run`): a click must only log a warning and close the thumbnail, never crash the host. Real check: Task 8, step 5. -5. **The preview can't be shown** (the file was removed before the host loaded it, or the host fails to start): the capture still succeeds (exit 0) and logs `no preview: …`. Test: `ipc::tests::host_errors_reach_the_caller` and `host_that_never_comes_up_is_an_error` (Task 3). Real check: Task 6, step 6. - ---- - -## File Structure - -| File | Responsibility | Task | -|---|---|---| -| `src/config.rs` | `[preview]` section, `timeout_secs` validation | 1 | -| `src/stack.rs` | Pure: thumbnail size, stack margins, eviction, click/swipe decision, easing, `Anim` | 2 | -| `src/ipc.rs` | Protocol types, JSON lines, `HideGuard`, `start_host` | 3 | -| `src/wayland.rs` | `Output::scale`, `State::output_list`, `State::preview` and event routing | 4, 5 | -| `src/thumbnail.rs` | One thumbnail: layer surface, `downscale`/`paint`, animation, gestures, hide/show | 4 | -| `src/host.rs` | Pure `Core` (hide counter, exit rule); socket server; calloop loop; `add`; Satty launch | 5 | -| `src/main.rs` | `__preview-host`, `--no-preview`, `HideGuard` around the capture, `save` split | 1, 5, 6 | -| `nix/package.ulu.nix` | Satty on PATH via `wrapProgram` | 7 | -| `nix/checks.ulu.nix` | Headless preview checks | 7 | -| copland: `flake.nix`, `modules/home/home-valw.ulu.nix`, `hosts/copland.ulu.nix`, `modules/home/niri/desktop-niri-keybinds.ulu.nix` | Test-day wiring | 8 | - ---- - -### Task 1: `[preview]` config section - -**Files:** -- Modify: `src/main.rs` (dead-code allowance), `src/config.rs` - -**Interfaces:** -- Consumes: Phase 1's `config::parse`/`load` and `Config`. -- Produces: `pub struct Preview { pub enabled: bool, pub timeout_secs: u64 }` (defaults `true`, `5`) as `Config::preview`; `parse` fails with `preview.timeout_secs must be at least 1` when it is 0. - -- [ ] **Step 1: Add the dead-code allowance** - -Put these lines at the very top of `src/main.rs`, above the `mod` list: - -```rust -// Phase 2 modules land before the CLI uses them; Task 6 removes this. -#![allow(dead_code)] -``` - -- [ ] **Step 2: Write the failing tests** - -Apply this change to the test module of `src/config.rs`. It adds two tests and moves the unknown-key example from `[preview]` (a real section now) to `[zoom]`. Unknown keys are reported in alphabetical order, so the expected list is reordered too. - -```diff ---- a/src/config.rs -+++ b/src/config.rs -@@ -19,13 +19,10 @@ - - #[test] - fn unknown_keys_are_reported_not_rejected() { -- let text = "[preview]\nenabled = true\n\n[save]\nfolder = \"x\"\n"; -+ let text = "[zoom]\nscroll_step = 1.15\n\n[save]\nfolder = \"x\"\n"; - let (config, unknown) = parse(text).unwrap(); - assert_eq!(config, Config::default()); -- assert_eq!( -- unknown, -- vec!["preview".to_string(), "save.folder".to_string()] -- ); -+ assert_eq!(unknown, vec!["save.folder".to_string(), "zoom".to_string()]); - } - - #[test] -@@ -37,6 +34,27 @@ - #[test] - fn wrong_type_is_an_error() { - assert!(parse("[capture]\nshow_cursor = \"yes\"\n").is_err()); -+ } -+ -+ #[test] -+ fn preview_defaults_and_overrides() { -+ assert_eq!( -+ Config::default().preview, -+ Preview { -+ enabled: true, -+ timeout_secs: 5 -+ } -+ ); -+ let (config, _) = parse("[preview]\ntimeout_secs = 2\n").unwrap(); -+ assert!(config.preview.enabled); -+ assert_eq!(config.preview.timeout_secs, 2); -+ } -+ -+ #[test] -+ fn preview_timeout_must_be_positive() { -+ let err = parse("[preview]\ntimeout_secs = 0\n").unwrap_err(); -+ assert_eq!(err.to_string(), "preview.timeout_secs must be at least 1"); -+ assert!(parse("[preview]\ntimeout_secs = -3\n").is_err()); - } - - #[test] -``` - -- [ ] **Step 3: Run the tests and watch them fail** - -Run: `cargo nextest run config::` -Expected: compilation fails, e.g. `` no field `preview` on type `Config` ``. - -- [ ] **Step 4: Implement** - -Apply this change above the test module: - -```diff ---- a/src/config.rs -+++ b/src/config.rs -@@ -1,6 +1,6 @@ - use std::path::{Path, PathBuf}; - --use anyhow::{Context, Result}; -+use anyhow::{Context, Result, bail}; - use serde::Deserialize; - - use crate::error::HintExt; -@@ -10,6 +10,7 @@ - pub struct Config { - pub save: Save, - pub capture: Capture, -+ pub preview: Preview, - } - - #[derive(Debug, Clone, PartialEq, Deserialize)] -@@ -24,6 +25,22 @@ - #[serde(default)] - pub struct Capture { - pub show_cursor: bool, -+} -+ -+#[derive(Debug, Clone, PartialEq, Deserialize)] -+#[serde(default)] -+pub struct Preview { -+ pub enabled: bool, -+ pub timeout_secs: u64, -+} -+ -+impl Default for Preview { -+ fn default() -> Self { -+ Self { -+ enabled: true, -+ timeout_secs: 5, -+ } -+ } - } - - impl Default for Save { -@@ -77,7 +94,10 @@ - pub fn parse(text: &str) -> Result<(Config, Vec)> { - let de = toml::Deserializer::parse(text)?; - let mut unknown = Vec::new(); -- let config = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?; -+ let config: Config = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?; -+ if config.preview.timeout_secs == 0 { -+ bail!("preview.timeout_secs must be at least 1"); -+ } - Ok((config, unknown)) - } - -``` - -- [ ] **Step 5: Run the tests and watch them pass** - -Run: `cargo nextest run config::` -Expected: all 9 tests in `config::tests` pass. - -- [ ] **Step 6: Lint and run the whole suite** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 7: Commit** - -```sh -jj commit -m "feat: [preview] config section" -``` - ---- - -### Task 2: Stack layout, gestures and animation (pure) - -**Files:** -- Create: `src/stack.rs` -- Modify: `src/main.rs` (`mod stack;`) - -**Interfaces:** -- Consumes: nothing. -- Produces: consts `LONG_EDGE = 220`, `EDGE_MARGIN = 16`, `GAP = 12`, `MAX = 5`, `CLICK_SLOP = 4.0`, `SWIPE_FRACTION = 0.4`, `SLIDE_IN_MS = 200`, `SLIDE_OUT_MS = 150`; `thumb_size(width: u32, height: u32) -> (u32, u32)`; `bottom_margins(heights_newest_first: &[u32]) -> Vec`; `excess(count: usize) -> usize`; `enum Release { Click, Dismiss, SnapBack }` and `release(dx: f64, dy: f64, width: f64) -> Release`; `drag_offset(dx: f64) -> f64`; `enum Ease { OutCubic, InCubic }` with `apply(t)`; `struct Anim { from, to, duration_ms, ease }` with `slide_in(travel)`, `slide_out(from, travel)`, `snap_back(from)` and `at(elapsed_ms: f64) -> (f64, bool)`. - -- [ ] **Step 1: Write the failing tests for `stack`** - -Create `src/stack.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn landscape_and_portrait_fit_the_long_edge() { - assert_eq!(thumb_size(1920, 1080), (220, 124)); - assert_eq!(thumb_size(1080, 1920), (124, 220)); - assert_eq!(thumb_size(1366, 768), (220, 124)); - } - - #[test] - fn small_images_are_not_scaled_up() { - assert_eq!(thumb_size(100, 50), (100, 50)); - assert_eq!(thumb_size(220, 10), (220, 10)); - } - - #[test] - fn extreme_aspect_ratio_keeps_one_pixel() { - assert_eq!(thumb_size(10_000, 2), (220, 1)); - } - - #[test] - fn stack_margins() { - assert_eq!(bottom_margins(&[]), Vec::::new()); - assert_eq!(bottom_margins(&[124]), vec![16]); - assert_eq!(bottom_margins(&[124, 124, 220]), vec![16, 152, 288]); - } - - #[test] - fn eviction_beyond_five() { - assert_eq!(excess(5), 0); - assert_eq!(excess(6), 1); - assert_eq!(excess(0), 0); - } - - #[test] - fn release_decisions() { - let w = 220.0; - assert_eq!(release(0.0, 0.0, w), Release::Click); - assert_eq!(release(2.0, 3.0, w), Release::Click); - assert_eq!(release(0.39 * w, 0.0, w), Release::SnapBack); - assert_eq!(release(0.40 * w, 0.0, w), Release::Dismiss); - assert_eq!(release(-100.0, 0.0, w), Release::SnapBack); - assert_eq!(release(0.0, 50.0, w), Release::SnapBack); - } - - #[test] - fn drag_follows_right_only() { - assert_eq!(drag_offset(30.0), 30.0); - assert_eq!(drag_offset(-30.0), 0.0); - } - - #[test] - fn easing_endpoints_and_monotonic() { - for ease in [Ease::OutCubic, Ease::InCubic] { - assert_eq!(ease.apply(0.0), 0.0); - assert_eq!(ease.apply(1.0), 1.0); - assert_eq!(ease.apply(2.0), 1.0, "clamped"); - let samples: Vec = (0..=10).map(|i| ease.apply(i as f64 / 10.0)).collect(); - assert!( - samples.windows(2).all(|w| w[0] <= w[1]), - "{ease:?} {samples:?}" - ); - } - } - - #[test] - fn slide_in_runs_from_travel_to_rest() { - let a = Anim::slide_in(236.0); - assert_eq!(a.at(0.0), (236.0, false)); - assert_eq!(a.at(200.0), (0.0, true)); - assert_eq!(a.at(500.0), (0.0, true)); - let (mid, done) = a.at(100.0); - assert!(mid > 0.0 && mid < 118.0, "ease-out is past halfway: {mid}"); - assert!(!done); - } - - #[test] - fn slide_out_and_snap_back() { - let out = Anim::slide_out(50.0, 236.0); - assert_eq!(out.at(0.0), (50.0, false)); - assert_eq!(out.at(150.0), (236.0, true)); - assert_eq!(Anim::snap_back(40.0).at(150.0), (0.0, true)); - } -} -``` - -Add `mod stack;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run stack::` -Expected: compilation fails, e.g. `` cannot find function `thumb_size` in this scope ``. - -- [ ] **Step 3: Implement `stack`** - -Put this above the test module in `src/stack.rs`: - -```rust -//! Pure layout, gesture and animation rules for the preview thumbnails. - -/// Long edge of a thumbnail, in logical pixels. -pub const LONG_EDGE: u32 = 220; -/// Distance between a thumbnail and the screen's right and bottom edges. -pub const EDGE_MARGIN: u32 = 16; -/// Vertical gap between stacked thumbnails. -pub const GAP: u32 = 12; -/// Thumbnails per output; a new one beyond this evicts the oldest. -pub const MAX: usize = 5; -/// Pointer movement below this (logical px) still counts as a click. -pub const CLICK_SLOP: f64 = 4.0; -/// A swipe of at least this fraction of the image width dismisses it. -pub const SWIPE_FRACTION: f64 = 0.4; -pub const SLIDE_IN_MS: u32 = 200; -pub const SLIDE_OUT_MS: u32 = 150; - -/// Logical thumbnail size for an image of `width` x `height`: long edge -/// `LONG_EDGE`, aspect ratio kept, never scaled up. -pub fn thumb_size(width: u32, height: u32) -> (u32, u32) { - let long = width.max(height); - if long <= LONG_EDGE { - return (width.max(1), height.max(1)); - } - let scale = LONG_EDGE as f64 / long as f64; - let fit = |v: u32| ((v as f64 * scale).round() as u32).max(1); - (fit(width), fit(height)) -} - -/// Bottom margin of each thumbnail, given their heights newest first. -/// The newest sits `EDGE_MARGIN` above the screen edge, older ones stack up. -pub fn bottom_margins(heights_newest_first: &[u32]) -> Vec { - let mut next = EDGE_MARGIN; - heights_newest_first - .iter() - .map(|h| { - let margin = next; - next += h + GAP; - margin - }) - .collect() -} - -/// How many of the oldest thumbnails to evict when there are `count`. -pub fn excess(count: usize) -> usize { - count.saturating_sub(MAX) -} - -/// What a pointer release on a thumbnail means. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Release { - /// Barely moved: open the editor. - Click, - /// Dragged far enough right: slide out and close. - Dismiss, - /// Dragged, but not far enough: animate back. - SnapBack, -} - -/// Classifies a release after a press-drag of (`dx`, `dy`) on an image -/// `width` logical px wide. -pub fn release(dx: f64, dy: f64, width: f64) -> Release { - if dx.hypot(dy) < CLICK_SLOP { - Release::Click - } else if dx >= SWIPE_FRACTION * width { - Release::Dismiss - } else { - Release::SnapBack - } -} - -/// Horizontal image offset while dragging: follows the pointer, right only. -pub fn drag_offset(dx: f64) -> f64 { - dx.max(0.0) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Ease { - OutCubic, - InCubic, -} - -impl Ease { - /// Maps linear progress `t` in 0..=1 to eased progress in 0..=1. - pub fn apply(self, t: f64) -> f64 { - let t = t.clamp(0.0, 1.0); - match self { - Ease::OutCubic => 1.0 - (1.0 - t).powi(3), - Ease::InCubic => t.powi(3), - } - } -} - -/// An animation of the image's horizontal offset. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Anim { - pub from: f64, - pub to: f64, - pub duration_ms: u32, - pub ease: Ease, -} - -impl Anim { - /// Slides in from fully off-screen (`travel` px to the right) to rest. - pub fn slide_in(travel: f64) -> Anim { - Anim { - from: travel, - to: 0.0, - duration_ms: SLIDE_IN_MS, - ease: Ease::OutCubic, - } - } - - /// Slides out from the current offset to fully off-screen. - pub fn slide_out(from: f64, travel: f64) -> Anim { - Anim { - from, - to: travel, - duration_ms: SLIDE_OUT_MS, - ease: Ease::InCubic, - } - } - - /// Animates back to rest after a short drag. - pub fn snap_back(from: f64) -> Anim { - Anim { - from, - to: 0.0, - duration_ms: SLIDE_OUT_MS, - ease: Ease::OutCubic, - } - } - - /// Offset after `elapsed_ms`, and whether the animation has finished. - pub fn at(&self, elapsed_ms: f64) -> (f64, bool) { - let t = elapsed_ms / self.duration_ms as f64; - let offset = self.from + (self.to - self.from) * self.ease.apply(t); - (offset, t >= 1.0) - } -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run stack::` -Expected: all 10 tests in `stack::tests` pass. - -- [ ] **Step 5: Lint and run the whole suite** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: preview stack layout, gestures and easing" -``` - ---- - -### Task 3: IPC protocol and client (`HideGuard`) - -**Files:** -- Modify: `Cargo.toml` (`serde_json`), `src/main.rs` (`mod ipc;`) -- Create: `src/ipc.rs` - -**Interfaces:** -- Consumes: `lock::default_path()` (Phase 1). -- Produces: `enum Request { Hide, Add { path: PathBuf, output: String } }` (serde tag `cmd`, lowercase); `struct Reply { ok: bool, error: Option }` with `Reply::ok()`, `Reply::error(msg)`; `encode(&T) -> String` (JSON + newline); `parse_request(&str) -> Result`; `socket_path()`, `host_lock_path()`; `struct HideGuard` with `HideGuard::new(socket: &Path) -> HideGuard` and `add(&mut self, socket, path, output, start: impl FnOnce() -> Result<()>) -> Result<()>`; `start_host() -> Result<()>` (runs `current_exe() __preview-host` in its own session); consts `HIDE_TIMEOUT` 300 ms, `ADD_TIMEOUT` 2 s, `START_TIMEOUT` 1 s. - -The tests run a fake host in a thread over a real Unix socket, so the client is tested without Wayland. `add` takes the host-starting function as a parameter for the same reason. - -- [ ] **Step 1: Add the dependency** - -In `Cargo.toml`, under `[dependencies]`, right after `serde_ignored`: - -```toml -serde_json = "1.0.151" -``` - -- [ ] **Step 2: Write the failing tests for `ipc`** - -Create `src/ipc.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use std::os::unix::net::UnixListener; - use std::sync::{Arc, Mutex}; - use std::thread; - - #[test] - fn requests_as_json_lines() { - assert_eq!(encode(&Request::Hide), "{\"cmd\":\"hide\"}\n"); - let add = Request::Add { - path: "/tmp/a.png".into(), - output: "HDMI-A-1".into(), - }; - assert_eq!( - encode(&add), - "{\"cmd\":\"add\",\"path\":\"/tmp/a.png\",\"output\":\"HDMI-A-1\"}\n" - ); - assert_eq!(parse_request(&encode(&add)), Ok(add)); - } - - #[test] - fn replies_as_json_lines() { - assert_eq!(encode(&Reply::ok()), "{\"ok\":true}\n"); - assert_eq!( - encode(&Reply::error("no")), - "{\"ok\":false,\"error\":\"no\"}\n" - ); - } - - #[test] - fn bad_requests_are_errors() { - assert!(parse_request("nonsense").is_err()); - assert!(parse_request("{\"cmd\":\"launch\"}").is_err()); - assert!(parse_request("{\"cmd\":\"add\"}").is_err()); - } - - /// A fake host: answers every request with `reply` (or never, if None) - /// and records the lines it received, plus "EOF" when the client leaves. - fn fake_host(socket: &Path, reply: Option) -> Arc>> { - let listener = UnixListener::bind(socket).unwrap(); - let seen = Arc::new(Mutex::new(Vec::new())); - let log = Arc::clone(&seen); - thread::spawn(move || { - for stream in listener.incoming() { - let mut stream = stream.unwrap(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - loop { - let mut line = String::new(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - log.lock().unwrap().push("EOF".into()); - break; - } - log.lock().unwrap().push(line.trim().to_string()); - match &reply { - Some(r) => stream.write_all(encode(r).as_bytes()).unwrap(), - None => thread::sleep(Duration::from_secs(5)), - } - } - } - }); - seen - } - - fn wait_for(seen: &Arc>>, n: usize) -> Vec { - let deadline = Instant::now() + Duration::from_secs(2); - while seen.lock().unwrap().len() < n && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - seen.lock().unwrap().clone() - } - - #[test] - fn no_host_means_no_hiding() { - let dir = tempfile::tempdir().unwrap(); - let guard = HideGuard::new(&dir.path().join("valw.sock")); - assert!(guard.conn.is_none()); - } - - #[test] - fn stale_socket_is_ignored() { - // A crashed host leaves its socket file behind with nobody listening. - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("valw.sock"); - drop(UnixListener::bind(&socket).unwrap()); - assert!(socket.exists()); - - let started = Instant::now(); - let guard = HideGuard::new(&socket); - assert!(guard.conn.is_none()); - assert!(started.elapsed() < Duration::from_millis(100)); - } - - #[test] - fn guard_hides_until_dropped_and_adds_on_the_same_connection() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("valw.sock"); - let seen = fake_host(&socket, Some(Reply::ok())); - - let mut guard = HideGuard::new(&socket); - assert!(guard.conn.is_some()); - guard - .add(&socket, Path::new("/tmp/a.png"), "DP-1", || { - panic!("host is running") - }) - .unwrap(); - drop(guard); - - assert_eq!( - wait_for(&seen, 3), - vec![ - "{\"cmd\":\"hide\"}".to_string(), - "{\"cmd\":\"add\",\"path\":\"/tmp/a.png\",\"output\":\"DP-1\"}".to_string(), - "EOF".to_string(), - ] - ); - } - - #[test] - fn silent_host_does_not_block_the_capture() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("valw.sock"); - let _seen = fake_host(&socket, None); - - let started = Instant::now(); - let guard = HideGuard::new(&socket); - assert!(guard.conn.is_none()); - assert!(started.elapsed() < Duration::from_secs(1)); - } - - #[test] - fn add_starts_a_host_when_none_runs() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("valw.sock"); - let mut guard = HideGuard::new(&socket); - - let seen = Arc::new(Mutex::new(None)); - let slot = Arc::clone(&seen); - let start_socket = socket.clone(); - guard - .add(&socket, Path::new("/tmp/b.png"), "DP-1", move || { - *slot.lock().unwrap() = Some(fake_host(&start_socket, Some(Reply::ok()))); - Ok(()) - }) - .unwrap(); - - let seen = seen.lock().unwrap().clone().expect("start was called"); - assert_eq!( - wait_for(&seen, 1)[0], - "{\"cmd\":\"add\",\"path\":\"/tmp/b.png\",\"output\":\"DP-1\"}" - ); - } - - #[test] - fn host_errors_reach_the_caller() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("valw.sock"); - let _seen = fake_host(&socket, Some(Reply::error("could not read /x.png"))); - let mut guard = HideGuard { conn: None }; - let err = guard - .add(&socket, Path::new("/x.png"), "DP-1", || Ok(())) - .unwrap_err(); - assert_eq!(err.to_string(), "could not read /x.png"); - } - - #[test] - fn host_that_never_comes_up_is_an_error() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("valw.sock"); - let mut guard = HideGuard { conn: None }; - let err = guard - .add(&socket, Path::new("/x.png"), "DP-1", || Ok(())) - .unwrap_err(); - assert_eq!(err.to_string(), "the preview host did not come up"); - } -} -``` - -Add `mod ipc;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 3: Run the tests and watch them fail** - -Run: `cargo nextest run ipc::` -Expected: compilation fails, e.g. `` cannot find function `encode` in this scope ``. - -- [ ] **Step 4: Implement `ipc`** - -Put this above the test module in `src/ipc.rs`: - -```rust -//! The line-based JSON protocol between captures and the preview host, and -//! its client side. - -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result, anyhow, bail}; -use serde::{Deserialize, Serialize}; - -/// How long a capture waits for the host to hide its thumbnails. -pub const HIDE_TIMEOUT: Duration = Duration::from_millis(300); -/// How long a capture waits for the host to load and show a thumbnail. -pub const ADD_TIMEOUT: Duration = Duration::from_secs(2); -/// How long a capture keeps retrying to reach a host it just started. -pub const START_TIMEOUT: Duration = Duration::from_secs(1); - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "cmd", rename_all = "lowercase")] -pub enum Request { - /// Hide every thumbnail until this connection closes. - Hide, - /// Show a thumbnail for the screenshot at `path` on `output`. - Add { path: PathBuf, output: String }, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Reply { - pub ok: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl Reply { - pub fn ok() -> Reply { - Reply { - ok: true, - error: None, - } - } - - pub fn error(message: impl Into) -> Reply { - Reply { - ok: false, - error: Some(message.into()), - } - } -} - -/// One message as a JSON line. -pub fn encode(message: &T) -> String { - let mut line = serde_json::to_string(message).expect("messages always serialize"); - line.push('\n'); - line -} - -pub fn parse_request(line: &str) -> Result { - serde_json::from_str(line.trim()).map_err(|e| format!("bad request: {e}")) -} - -/// `$XDG_RUNTIME_DIR/valw.sock`, next to the capture lock. -pub fn socket_path() -> PathBuf { - crate::lock::default_path().with_file_name("valw.sock") -} - -/// Held by the running host so only one exists at a time. -pub fn host_lock_path() -> PathBuf { - crate::lock::default_path().with_file_name("valw-preview.lock") -} - -/// One request/reply connection to the host. -struct Conn { - reader: BufReader, - writer: UnixStream, -} - -impl Conn { - fn new(stream: UnixStream) -> Result { - Ok(Conn { - writer: stream - .try_clone() - .context("could not clone the host socket")?, - reader: BufReader::new(stream), - }) - } - - fn request(&mut self, request: &Request, timeout: Duration) -> Result<()> { - self.writer - .write_all(encode(request).as_bytes()) - .context("could not send to the preview host")?; - self.reader.get_ref().set_read_timeout(Some(timeout))?; - let mut line = String::new(); - self.reader - .read_line(&mut line) - .context("the preview host did not answer")?; - if line.is_empty() { - bail!("the preview host closed the connection"); - } - let reply: Reply = - serde_json::from_str(&line).context("bad reply from the preview host")?; - match reply { - Reply { ok: true, .. } => Ok(()), - Reply { error, .. } => Err(anyhow!(error.unwrap_or_else(|| "unknown error".into()))), - } - } -} - -/// Keeps the host's thumbnails hidden for as long as it lives, so they never -/// end up in a screenshot. Closing the connection, however the capture ends, -/// shows them again. -pub struct HideGuard { - conn: Option, -} - -impl HideGuard { - /// Asks a running host to hide. Without a host this does nothing; if the - /// host doesn't answer in time the capture goes ahead anyway. - pub fn new(socket: &Path) -> HideGuard { - let Ok(stream) = UnixStream::connect(socket) else { - return HideGuard { conn: None }; - }; - let conn = Conn::new(stream).and_then(|mut conn| { - conn.request(&Request::Hide, HIDE_TIMEOUT)?; - Ok(conn) - }); - match conn { - Ok(conn) => HideGuard { conn: Some(conn) }, - Err(e) => { - tracing::warn!("capturing without hiding the previews: {e:#}"); - HideGuard { conn: None } - } - } - } - - /// Shows a thumbnail for `path` on `output`. Uses this guard's - /// connection if there is one, otherwise calls `start` to launch a host - /// and connects to it. - pub fn add( - &mut self, - socket: &Path, - path: &Path, - output: &str, - start: impl FnOnce() -> Result<()>, - ) -> Result<()> { - let request = Request::Add { - path: path.to_path_buf(), - output: output.to_string(), - }; - if let Some(conn) = &mut self.conn { - return conn.request(&request, ADD_TIMEOUT); - } - let mut conn = connect_or_start(socket, start)?; - conn.request(&request, ADD_TIMEOUT) - } -} - -fn connect_or_start(socket: &Path, start: impl FnOnce() -> Result<()>) -> Result { - if let Ok(stream) = UnixStream::connect(socket) { - return Conn::new(stream); - } - start().context("could not start the preview host")?; - let deadline = Instant::now() + START_TIMEOUT; - loop { - match UnixStream::connect(socket) { - Ok(stream) => return Conn::new(stream), - Err(_) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), - Err(e) => return Err(e).context("the preview host did not come up"), - } - } -} - -/// Starts `valw __preview-host` in its own session, detached from our stdio. -/// A fresh exec inherits nothing from the capture (its Wayland connection, -/// lock or threads). -pub fn start_host() -> Result<()> { - use std::os::unix::process::CommandExt; - use std::process::{Command, Stdio}; - - let exe = std::env::current_exe().context("could not find the valw binary")?; - let mut command = Command::new(exe); - command - .arg("__preview-host") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - // SAFETY: setsid is async-signal-safe. - unsafe { - command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); - } - command - .spawn() - .context("could not run valw __preview-host")?; - Ok(()) -} -``` - -- [ ] **Step 5: Run the tests and watch them pass** - -Run: `cargo nextest run ipc::` -Expected: all 10 tests in `ipc::tests` pass. - -- [ ] **Step 6: Lint and run the whole suite** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 7: Commit** - -```sh -jj commit -m "feat: preview host protocol and hide guard" -``` - ---- - -### Task 4: Thumbnail surface and pixels - -**Files:** -- Modify: `src/wayland.rs` (`Output::scale`, `State::output_list`), `src/main.rs` (`mod thumbnail;`) -- Create: `src/thumbnail.rs` - -**Interfaces:** -- Consumes: `stack::*` (Task 2); `wayland::{Output, State}`. -- Produces: `Output::scale: f64` (physical / logical width from the current mode, orientation-aware); `State::output_list(&self) -> Vec` (what `Wayland::outputs` now calls); `thumbnail::{NAMESPACE, downscale(img, scale) -> (RgbaImage, (u32, u32)), paint(canvas, canvas_width, small, x0), Action::OpenEditor, Parts { compositor, layer_shell, viewporter, shm }, Thumbnail}`. `Thumbnail` has `new(id, path, image, output, parts, qh) -> Result` plus `is`, `is_layer`, `is_closing`, `is_finished`, `hide(qh)`, `show(qh)`, `set_margin`, `configure`, `frame_done`, `expire`, `slide_out`, `press`, `motion`, `release(x, y, qh) -> Option`, and the pub fields `id`, `output`, `path`, `size`. - -Why hide/show work this way (spec 4.6): a hidden thumbnail commits a transparent buffer and an empty input region. Unmapping is off limits, because niri 26.04 doesn't send a configure on the next commit, so the thumbnail would never return. `draw(qh, force)` bypasses a pending frame callback for hide and show, so a mid-animation thumbnail is gone before the capture starts. - -- [ ] **Step 1: Output scale and an output list on `State`** - -The host (Task 5) has to look outputs up from inside the event loop, where it only has `State`, and it needs each output's scale to render sharp thumbnails. Apply to `src/wayland.rs`: - -```diff ---- a/src/wayland.rs -+++ b/src/wayland.rs -@@ -38,6 +38,8 @@ - pub wl: wl_output::WlOutput, - pub geom: OutputGeom, - pub transform: wl_output::Transform, -+ /// Physical pixels per logical pixel, e.g. 1.25. -+ pub scale: f64, - } - - pub struct Wayland { -@@ -112,29 +114,7 @@ - - /// All outputs with a known name and logical geometry. - pub fn outputs(&self) -> Vec { -- self.state -- .outputs -- .outputs() -- .filter_map(|wl| { -- let info = self.state.outputs.info(&wl)?; -- let (x, y) = info.logical_position?; -- let (width, height) = info.logical_size?; -- Some(Output { -- geom: OutputGeom { -- name: info -- .name -- .clone() -- .unwrap_or_else(|| format!("output-{}", info.id)), -- x, -- y, -- width, -- height, -- }, -- transform: info.transform, -- wl, -- }) -- }) -- .collect() -+ self.state.output_list() - } - - /// `(interface, version)` of every global, sorted. -@@ -218,6 +198,57 @@ - } - } - -+impl State { -+ /// All outputs with a known name and logical geometry. -+ pub fn output_list(&self) -> Vec { -+ self.outputs -+ .outputs() -+ .filter_map(|wl| { -+ let info = self.outputs.info(&wl)?; -+ let (x, y) = info.logical_position?; -+ let (width, height) = info.logical_size?; -+ Some(Output { -+ scale: output_scale(&info, width), -+ geom: OutputGeom { -+ name: info -+ .name -+ .clone() -+ .unwrap_or_else(|| format!("output-{}", info.id)), -+ x, -+ y, -+ width, -+ height, -+ }, -+ transform: info.transform, -+ wl, -+ }) -+ }) -+ .collect() -+ } -+} -+ -+/// Physical pixels per logical pixel: the current mode's width (turned to -+/// match the logical orientation) over the logical width. -+fn output_scale(info: &smithay_client_toolkit::output::OutputInfo, logical_width: i32) -> f64 { -+ let Some(mode) = info.modes.iter().find(|m| m.current) else { -+ return info.scale_factor as f64; -+ }; -+ let (w, h) = mode.dimensions; -+ let rotated = matches!( -+ info.transform, -+ wl_output::Transform::_90 -+ | wl_output::Transform::_270 -+ | wl_output::Transform::Flipped90 -+ | wl_output::Transform::Flipped270 -+ ); -+ let physical_width = if rotated { h } else { w }; -+ if logical_width > 0 { -+ physical_width as f64 / logical_width as f64 -+ } else { -+ 1.0 -+ } -+} -+ - impl CompositorHandler for State { - fn scale_factor_changed( - &mut self, -``` - -Run: `cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test still passes (no behaviour change yet). - -- [ ] **Step 2: Write the failing tests for `thumbnail`** - -Create `src/thumbnail.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn downscale_fits_long_edge_at_scale_one() { - let img = RgbaImage::new(1920, 1080); - let (small, logical) = downscale(&img, 1.0); - assert_eq!(logical, (220, 124)); - assert_eq!(small.dimensions(), (220, 124)); - } - - #[test] - fn downscale_renders_physical_pixels_at_fractional_scale() { - let img = RgbaImage::new(1920, 1080); - let (small, logical) = downscale(&img, 1.25); - assert_eq!(logical, (220, 124)); - assert_eq!(small.dimensions(), (275, 155)); - } - - #[test] - fn downscale_leaves_small_images_alone() { - let img = RgbaImage::from_pixel(100, 50, image::Rgba([1, 2, 3, 255])); - let (small, logical) = downscale(&img, 1.0); - assert_eq!(logical, (100, 50)); - assert_eq!(small, img); - } - - fn pixel(canvas: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { - let i = ((y * width + x) * 4) as usize; - canvas[i..i + 4].try_into().unwrap() - } - - /// 4x3 red image, so the inner pixels are (1,1) and (2,1). - fn red() -> RgbaImage { - RgbaImage::from_pixel(4, 3, image::Rgba([255, 0, 0, 255])) - } - - #[test] - fn paint_at_rest_has_border_image_and_transparent_padding() { - let mut canvas = vec![9u8; 6 * 3 * 4]; - paint(&mut canvas, 6, &red(), 0); - assert_eq!(pixel(&canvas, 6, 0, 0), BORDER); - assert_eq!(pixel(&canvas, 6, 1, 1), [0, 0, 255, 255], "BGRA red"); - assert_eq!(pixel(&canvas, 6, 3, 1), BORDER); - assert_eq!(pixel(&canvas, 6, 4, 1), [0; 4], "padding is transparent"); - assert_eq!(pixel(&canvas, 6, 5, 2), [0; 4]); - } - - #[test] - fn paint_with_offset_shifts_and_clips() { - let mut canvas = vec![0u8; 6 * 3 * 4]; - paint(&mut canvas, 6, &red(), 3); - assert_eq!(pixel(&canvas, 6, 2, 1), [0; 4], "left of the image"); - assert_eq!(pixel(&canvas, 6, 3, 1), BORDER); - assert_eq!(pixel(&canvas, 6, 4, 1), [0, 0, 255, 255]); - assert_eq!( - pixel(&canvas, 6, 5, 1), - [0, 0, 255, 255], - "clipped at the edge" - ); - } - - #[test] - fn paint_fully_off_screen_is_empty() { - let mut canvas = vec![7u8; 6 * 3 * 4]; - paint(&mut canvas, 6, &red(), 6); - assert!(canvas.iter().all(|&b| b == 0)); - } - - #[test] - fn canvas_includes_padding() { - assert_eq!(canvas_width(220, 1.0), 236); - assert_eq!(canvas_width(220, 1.25), 295); - assert_eq!(travel(220), 236.0); - } -} -``` - -Add `mod thumbnail;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 3: Run the tests and watch them fail** - -Run: `cargo nextest run thumbnail::` -Expected: compilation fails, e.g. `` cannot find function `downscale` in this scope ``. - -- [ ] **Step 4: Implement `thumbnail`** - -Put this above the test module in `src/thumbnail.rs`: - -```rust -//! One preview thumbnail: its layer surface, pixels, animation and gestures. - -use std::path::PathBuf; -use std::time::Instant; - -use anyhow::{Context, Result}; -use image::{RgbaImage, imageops}; -use smithay_client_toolkit::{ - compositor::{CompositorState, FrameCallbackData, Region}, - shell::{ - WaylandSurface, - wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerShell, LayerSurface}, - }, - shm::{ - Shm, - slot::{Buffer, SlotPool}, - }, -}; -use wayland_client::{QueueHandle, protocol::wl_shm}; -use wayland_protocols::wp::viewporter::client::{ - wp_viewport::WpViewport, wp_viewporter::WpViewporter, -}; - -use crate::stack::{self, Anim, EDGE_MARGIN, Release}; -use crate::wayland::{Output, State}; - -pub const NAMESPACE: &str = "valw-preview"; - -/// Premultiplied BGRA of the 1 px border: #e0e0e0 at 80% opacity. -const BORDER: [u8; 4] = [179, 179, 179, 204]; - -/// The thumbnail image for `img` on an output with `scale` physical pixels -/// per logical pixel: returns the physical-size image and its logical size. -pub fn downscale(img: &RgbaImage, scale: f64) -> (RgbaImage, (u32, u32)) { - let logical = stack::thumb_size(img.width(), img.height()); - let physical = ( - ((logical.0 as f64 * scale).round() as u32).max(1), - ((logical.1 as f64 * scale).round() as u32).max(1), - ); - let small = if physical == img.dimensions() { - img.clone() - } else { - imageops::resize( - img, - physical.0, - physical.1, - imageops::FilterType::CatmullRom, - ) - }; - (small, logical) -} - -/// Paints `small` into a transparent ARGB8888 `canvas` that is `canvas_width` -/// px wide and as tall as `small`, starting at column `x0` and clipped to the -/// canvas, with a 1 px border on the image's edges. -pub fn paint(canvas: &mut [u8], canvas_width: u32, small: &RgbaImage, x0: i64) { - canvas.fill(0); - let (w, h) = small.dimensions(); - for y in 0..h { - for x in 0..w { - let cx = x0 + x as i64; - if cx < 0 || cx >= canvas_width as i64 { - continue; - } - let edge = x == 0 || y == 0 || x == w - 1 || y == h - 1; - let px = if edge { - BORDER - } else { - let [r, g, b, _] = small.get_pixel(x, y).0; - [b, g, r, 255] - }; - let i = ((y * canvas_width) as usize + cx as usize) * 4; - canvas[i..i + 4].copy_from_slice(&px); - } - } -} - -/// What the host should do after a pointer release. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Action { - OpenEditor, -} - -pub struct Thumbnail { - pub id: u64, - pub output: String, - pub path: PathBuf, - layer: LayerSurface, - viewport: WpViewport, - pool: SlotPool, - buffer: Option, - small: RgbaImage, - /// Logical size of the image (not the surface, which has padding). - pub size: (u32, u32), - scale: f64, - /// How far right of its resting place the image is drawn, logical px. - offset: f64, - anim: Option<(Anim, Instant)>, - drag: Option<(f64, f64)>, - margin: u32, - /// Input region while visible (the image) and while hidden (nothing). - input: Region, - no_input: Region, - /// Hidden for a capture: drawn fully transparent, clicks pass through. - hidden: bool, - configured: bool, - frame_pending: bool, - started: bool, - closing: bool, - expired: bool, -} - -/// The Wayland objects a new thumbnail needs. -pub struct Parts<'a> { - pub compositor: &'a CompositorState, - pub layer_shell: &'a LayerShell, - pub viewporter: &'a WpViewporter, - pub shm: &'a Shm, -} - -impl Thumbnail { - pub fn new( - id: u64, - path: PathBuf, - image: &RgbaImage, - output: &Output, - parts: &Parts, - qh: &QueueHandle, - ) -> Result { - let (small, size) = downscale(image, output.scale); - let surface = parts.compositor.create_surface(qh); - let layer = parts.layer_shell.create_layer_surface( - qh, - surface, - Layer::Overlay, - Some(NAMESPACE), - Some(&output.wl), - ); - layer.set_anchor(Anchor::BOTTOM | Anchor::RIGHT); - // Never take keyboard focus away from what the user is typing in. - layer.set_keyboard_interactivity(KeyboardInteractivity::None); - layer.set_size(size.0 + EDGE_MARGIN, size.1); - layer.set_margin(0, 0, EDGE_MARGIN as i32, 0); - // Clicks on the transparent padding go through to what's below. - let input = Region::new(parts.compositor).context("could not create an input region")?; - input.add(0, 0, size.0 as i32, size.1 as i32); - let no_input = Region::new(parts.compositor).context("could not create an input region")?; - layer.wl_surface().set_input_region(Some(input.wl_region())); - let viewport = parts.viewporter.get_viewport(layer.wl_surface(), qh, ()); - viewport.set_destination((size.0 + EDGE_MARGIN) as i32, size.1 as i32); - // The first commit has no buffer; the compositor answers with a - // configure, and the first draw starts the slide-in. - layer.commit(); - - let buffer_len = (small.height() * canvas_width(size.0, output.scale) * 4) as usize; - Ok(Thumbnail { - id, - output: output.geom.name.clone(), - path, - layer, - viewport, - pool: SlotPool::new(buffer_len * 2, parts.shm).context("could not create shm pool")?, - buffer: None, - small, - size, - scale: output.scale, - offset: travel(size.0), - anim: None, - drag: None, - margin: EDGE_MARGIN, - input, - no_input, - hidden: false, - configured: false, - frame_pending: false, - started: false, - closing: false, - expired: false, - }) - } - - pub fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { - self.layer.wl_surface() == surface - } - - pub fn is_layer(&self, layer: &LayerSurface) -> bool { - self.layer.wl_surface() == layer.wl_surface() - } - - pub fn is_closing(&self) -> bool { - self.closing - } - - /// The slide-out has finished (or is invisible anyway). - pub fn is_finished(&self) -> bool { - self.closing && (self.anim.is_none() || self.hidden) - } - - /// Makes the thumbnail invisible and click-through. The surface stays - /// mapped: remapping would need a configure that niri doesn't send. - pub fn hide(&mut self, qh: &QueueHandle) { - if self.hidden { - return; - } - self.hidden = true; - self.layer - .wl_surface() - .set_input_region(Some(self.no_input.wl_region())); - self.draw(qh, true); - } - - pub fn show(&mut self, qh: &QueueHandle) { - if !self.hidden { - return; - } - self.hidden = false; - self.layer - .wl_surface() - .set_input_region(Some(self.input.wl_region())); - self.draw(qh, true); - } - - pub fn set_margin(&mut self, margin: u32) { - if margin == self.margin { - return; - } - self.margin = margin; - self.layer.set_margin(0, 0, margin as i32, 0); - self.layer.commit(); - } - - pub fn configure(&mut self, qh: &QueueHandle) { - self.configured = true; - if !self.started { - self.started = true; - self.anim = Some((Anim::slide_in(travel(self.size.0)), Instant::now())); - } - self.draw(qh, false); - } - - pub fn frame_done(&mut self, qh: &QueueHandle) { - self.frame_pending = false; - if self.anim.is_some() || self.drag.is_some() { - self.draw(qh, false); - } - } - - /// The timeout fired: slide out, unless the user is dragging it. - pub fn expire(&mut self, qh: &QueueHandle) { - self.expired = true; - if self.drag.is_none() { - self.slide_out(qh); - } - } - - pub fn slide_out(&mut self, qh: &QueueHandle) { - if self.closing { - return; - } - self.closing = true; - self.anim = Some(( - Anim::slide_out(self.offset, travel(self.size.0)), - Instant::now(), - )); - self.draw(qh, false); - } - - pub fn press(&mut self, x: f64, y: f64) { - if !self.closing { - self.anim = None; - self.drag = Some((x, y)); - } - } - - pub fn motion(&mut self, x: f64, qh: &QueueHandle) { - if let Some((x0, _)) = self.drag { - self.offset = stack::drag_offset(x - x0); - self.draw(qh, false); - } - } - - pub fn release(&mut self, x: f64, y: f64, qh: &QueueHandle) -> Option { - let (x0, y0) = self.drag.take()?; - match stack::release(x - x0, y - y0, self.size.0 as f64) { - Release::Click => { - self.slide_out(qh); - Some(Action::OpenEditor) - } - Release::Dismiss => { - self.slide_out(qh); - None - } - Release::SnapBack if self.expired => { - self.slide_out(qh); - None - } - Release::SnapBack => { - self.anim = Some((Anim::snap_back(self.offset), Instant::now())); - self.draw(qh, false); - None - } - } - } - - fn draw(&mut self, qh: &QueueHandle, force: bool) { - if let Some((anim, start)) = self.anim { - let (offset, done) = anim.at(start.elapsed().as_secs_f64() * 1000.0); - self.offset = offset; - if done { - self.anim = None; - } - } - // Hide and show must reach the screen now, even mid-animation. - if !self.configured || (self.frame_pending && !force) { - return; - } - let width = canvas_width(self.size.0, self.scale); - let height = self.small.height(); - let Ok((buffer, canvas)) = self.pool.create_buffer( - width as i32, - height as i32, - width as i32 * 4, - wl_shm::Format::Argb8888, - ) else { - tracing::warn!("could not allocate a thumbnail buffer"); - return; - }; - if self.hidden { - canvas.fill(0); - } else { - paint( - canvas, - width, - &self.small, - (self.offset * self.scale).round() as i64, - ); - } - let surface = self.layer.wl_surface(); - surface.damage_buffer(0, 0, width as i32, height as i32); - // Keep drawing while something moves; motion that arrives while a - // frame is pending is picked up by the next frame callback. - if self.anim.is_some() || self.drag.is_some() { - surface.frame(qh, FrameCallbackData(surface.clone())); - self.frame_pending = true; - } - if buffer.attach_to(surface).is_err() { - tracing::warn!("could not attach a thumbnail buffer"); - return; - } - self.layer.commit(); - self.buffer = Some(buffer); - } -} - -impl Drop for Thumbnail { - fn drop(&mut self) { - self.viewport.destroy(); - } -} - -/// How far right the image moves to be fully off-screen. -fn travel(image_width: u32) -> f64 { - (image_width + EDGE_MARGIN) as f64 -} - -/// Physical width of the surface: the image plus the edge padding. -fn canvas_width(image_width: u32, scale: f64) -> u32 { - (((image_width + EDGE_MARGIN) as f64 * scale).round() as u32).max(1) -} -``` - -- [ ] **Step 5: Run the tests and watch them pass** - -Run: `cargo nextest run thumbnail::` -Expected: all 7 tests in `thumbnail::tests` pass. - -- [ ] **Step 6: Lint and run the whole suite** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 7: Commit** - -```sh -jj commit -m "feat: preview thumbnail surface and pixels" -``` - ---- - -### Task 5: Preview host - -**Files:** -- Create: `src/host.rs` -- Modify: `src/wayland.rs` (`State::preview`, event routing), `src/main.rs` (`mod host;`, `__preview-host`) - -**Interfaces:** -- Consumes: `ipc::{socket_path, host_lock_path, parse_request, encode, Reply, Request}` (Task 3); `thumbnail::*` (Task 4); `stack::{bottom_margins, excess}` (Task 2); `lock::Lock`, `config::load` (Phase 1). -- Produces: `host::run() -> Result<()>`; `host::Core` (pure: `new(now)`, `connect(id)`, `hide(id) -> bool`, `disconnect(id) -> bool`, `hidden()`, `should_exit(thumbnails, now)`); `host::Host` with `configure`, `frame_done`, `closed`, `pointer` (called from `State`'s handlers); `State::preview: Option`; the hidden CLI subcommand `valw __preview-host`. - -How it runs: the host takes `valw-preview.lock` (a second host exits 0 quietly), removes any stale socket, binds `valw.sock`, connects to Wayland, and runs a calloop loop. The loop covers the Wayland source, the listener, one line-buffered source per client, and one timer per thumbnail. After every dispatch it drops finished thumbnails, restacks the rest, and exits once there are no thumbnails and no clients. A new host that nobody connects to within 2 s also exits. `hide` from the first hider hides every thumbnail and does a Wayland round trip before replying. A hider's connection closing (EOF) shows them again when it was the last hider. - -- [ ] **Step 1: Write the failing tests for `host`** - -Create `src/host.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hidden_until_every_hider_leaves() { - let mut core = Core::new(Instant::now()); - core.connect(1); - core.connect(2); - assert!(core.hide(1), "first hide hides"); - assert!(!core.hide(2), "second hide changes nothing"); - assert!(!core.disconnect(1), "still hidden by 2"); - assert!(core.hidden()); - assert!(core.disconnect(2), "last hider leaving shows"); - assert!(!core.hidden()); - } - - #[test] - fn a_client_that_never_hid_does_not_show() { - let mut core = Core::new(Instant::now()); - core.connect(1); - core.connect(2); - core.hide(1); - assert!(!core.disconnect(2)); - assert!(core.hidden()); - } - - #[test] - fn exits_only_when_empty_and_alone() { - let now = Instant::now(); - let mut core = Core::new(now); - assert!(!core.should_exit(0, now), "waits for its first client"); - assert!( - core.should_exit(0, now + Duration::from_secs(3)), - "gives up after the grace period" - ); - - core.connect(1); - assert!(!core.should_exit(0, now), "a client is connected"); - core.disconnect(1); - assert!(!core.should_exit(1, now), "a thumbnail is up"); - assert!(core.should_exit(0, now)); - } - - #[test] - fn a_second_host_gives_up_quietly() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("valw-preview.lock"); - let _first = Lock::acquire(&path).unwrap(); - assert!(Lock::acquire(&path).is_err()); - } -} -``` - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run host::` -Expected: compilation fails, e.g. `` cannot find type `Core` in this scope ``. - -- [ ] **Step 3: Implement `host`** - -Put this above the test module in `src/host.rs`: - -```rust -//! The preview host: one process that owns every thumbnail, started on -//! demand by a capture and gone once its last thumbnail is. - -use std::collections::BTreeSet; -use std::io::{ErrorKind, Read, Write}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::Path; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result}; -use smithay_client_toolkit::reexports::calloop::{ - EventLoop, Interest, LoopHandle, Mode, PostAction, - generic::Generic, - timer::{TimeoutAction, Timer}, -}; -use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; -use smithay_client_toolkit::seat::pointer::{BTN_LEFT, PointerEvent, PointerEventKind}; -use smithay_client_toolkit::shell::wlr_layer::LayerSurface; -use wayland_client::{Connection, QueueHandle, protocol::wl_surface::WlSurface}; -use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{ - Shape, WpCursorShapeDeviceV1, -}; - -use crate::ipc::{self, Reply, Request}; -use crate::lock::Lock; -use crate::stack; -use crate::thumbnail::{Action, Parts, Thumbnail}; -use crate::wayland::{State, Wayland}; - -/// A host nobody connects to within this time gives up. -const STARTUP_GRACE: Duration = Duration::from_secs(2); - -/// Connection bookkeeping and the exit rule, without any Wayland. -#[derive(Debug)] -pub struct Core { - clients: BTreeSet, - hiding: BTreeSet, - seen_client: bool, - started: Instant, -} - -impl Core { - pub fn new(now: Instant) -> Core { - Core { - clients: BTreeSet::new(), - hiding: BTreeSet::new(), - seen_client: false, - started: now, - } - } - - pub fn connect(&mut self, id: u64) { - self.clients.insert(id); - self.seen_client = true; - } - - /// Returns true if this request is what hides the thumbnails. - pub fn hide(&mut self, id: u64) -> bool { - let was_hidden = self.hidden(); - self.hiding.insert(id); - !was_hidden - } - - /// Returns true if this disconnect is what shows the thumbnails again. - pub fn disconnect(&mut self, id: u64) -> bool { - self.clients.remove(&id); - self.hiding.remove(&id) && self.hiding.is_empty() - } - - pub fn hidden(&self) -> bool { - !self.hiding.is_empty() - } - - /// Nothing to show and nobody talking to us. A fresh host waits a little - /// for the capture that started it to connect. - pub fn should_exit(&self, thumbnails: usize, now: Instant) -> bool { - thumbnails == 0 - && self.clients.is_empty() - && (self.seen_client || now.duration_since(self.started) > STARTUP_GRACE) - } -} - -pub struct Host { - core: Core, - /// Oldest first. - thumbs: Vec, - next_id: u64, - timeout: Duration, - conn: Connection, - handle: LoopHandle<'static, State>, - qh: QueueHandle, -} - -/// Runs the host until its last thumbnail closes. A second host exits -/// right away: the first one owns the socket. -pub fn run() -> Result<()> { - let Ok(_lock) = Lock::acquire(&ipc::host_lock_path()) else { - tracing::info!("another preview host is running"); - return Ok(()); - }; - let socket = ipc::socket_path(); - // Safe under the lock: whatever is there is left over from a crash. - let _ = std::fs::remove_file(&socket); - let listener = UnixListener::bind(&socket) - .with_context(|| format!("could not listen on {}", socket.display()))?; - listener.set_nonblocking(true)?; - - let config = crate::config::load(&crate::config::default_path())?; - let Wayland { - conn, - queue, - mut state, - .. - } = Wayland::connect()?; - let mut event_loop: EventLoop<'static, State> = - EventLoop::try_new().context("could not create the event loop")?; - let handle = event_loop.handle(); - let qh = queue.handle(); - WaylandSource::new(conn.clone(), queue) - .insert(handle.clone()) - .map_err(|e| anyhow::anyhow!("could not watch the Wayland socket: {e}"))?; - - state.preview = Some(Host { - core: Core::new(Instant::now()), - thumbs: Vec::new(), - next_id: 0, - timeout: Duration::from_secs(config.preview.timeout_secs), - conn, - handle: handle.clone(), - qh, - }); - - let mut next_client = 0u64; - handle - .insert_source( - Generic::new(listener, Interest::READ, Mode::Level), - move |_, listener, state| { - while let Ok((stream, _)) = listener.accept() { - next_client += 1; - if let Some(host) = &mut state.preview { - host.add_client(stream, next_client); - } - } - Ok(PostAction::Continue) - }, - ) - .map_err(|e| anyhow::anyhow!("could not watch the socket: {e}"))?; - - tracing::info!("preview host listening on {}", socket.display()); - let signal = event_loop.get_signal(); - event_loop - .run(Duration::from_millis(500), &mut state, |state| { - let host = state.preview.as_mut().expect("host state"); - host.tidy(); - if host.core.should_exit(host.thumbs.len(), Instant::now()) { - signal.stop(); - } - }) - .context("preview host event loop failed")?; - - let _ = std::fs::remove_file(&socket); - tracing::info!("preview host done"); - Ok(()) -} - -impl Host { - fn add_client(&mut self, stream: UnixStream, id: u64) { - if stream.set_nonblocking(true).is_err() { - return; - } - self.core.connect(id); - let mut buf = Vec::new(); - let inserted = self.handle.insert_source( - Generic::new(stream, Interest::READ, Mode::Level), - move |_, stream, state| { - let mut chunk = [0u8; 4096]; - loop { - let mut reader: &UnixStream = stream; - match reader.read(&mut chunk) { - Ok(0) => { - disconnect(state, id); - return Ok(PostAction::Remove); - } - Ok(n) => buf.extend_from_slice(&chunk[..n]), - Err(e) if e.kind() == ErrorKind::WouldBlock => break, - Err(_) => { - disconnect(state, id); - return Ok(PostAction::Remove); - } - } - } - while let Some(end) = buf.iter().position(|&b| b == b'\n') { - let line: Vec = buf.drain(..=end).collect(); - let reply = match ipc::parse_request(&String::from_utf8_lossy(&line)) { - Ok(request) => handle(state, id, request), - Err(e) => Reply::error(e), - }; - let mut writer: &UnixStream = stream; - let _ = writer.write_all(ipc::encode(&reply).as_bytes()); - } - Ok(PostAction::Continue) - }, - ); - if inserted.is_err() { - self.core.disconnect(id); - } - } - - /// Drops finished thumbnails and stacks the rest per output. - fn tidy(&mut self) { - let before = self.thumbs.len(); - self.thumbs.retain(|t| !t.is_finished()); - if self.thumbs.len() != before { - self.restack(); - let _ = self.conn.flush(); - } - } - - fn restack(&mut self) { - let outputs: BTreeSet = self.thumbs.iter().map(|t| t.output.clone()).collect(); - for output in outputs { - let newest_first: Vec = (0..self.thumbs.len()) - .rev() - .filter(|&i| self.thumbs[i].output == output) - .collect(); - let heights: Vec = newest_first - .iter() - .map(|&i| self.thumbs[i].size.1) - .collect(); - for (&i, margin) in newest_first.iter().zip(stack::bottom_margins(&heights)) { - self.thumbs[i].set_margin(margin); - } - } - } - - fn thumb(&mut self, surface: &WlSurface) -> Option<&mut Thumbnail> { - self.thumbs.iter_mut().find(|t| t.is(surface)) - } - - pub fn configure(&mut self, layer: &LayerSurface, qh: &QueueHandle) { - if let Some(t) = self.thumbs.iter_mut().find(|t| t.is_layer(layer)) { - t.configure(qh); - } - } - - pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle) { - if let Some(t) = self.thumb(surface) { - t.frame_done(qh); - } - } - - pub fn closed(&mut self, layer: &LayerSurface) { - self.thumbs.retain(|t| !t.is_layer(layer)); - } - - pub fn pointer( - &mut self, - events: &[PointerEvent], - cursor: Option<&WpCursorShapeDeviceV1>, - qh: &QueueHandle, - ) { - for event in events { - let Some(t) = self.thumb(&event.surface) else { - continue; - }; - let (x, y) = event.position; - match event.kind { - PointerEventKind::Enter { serial } => { - if let Some(cursor) = cursor { - cursor.set_shape(serial, Shape::Pointer); - } - } - PointerEventKind::Press { - button: BTN_LEFT, .. - } => t.press(x, y), - PointerEventKind::Motion { .. } => t.motion(x, qh), - PointerEventKind::Release { - button: BTN_LEFT, .. - } => { - let action = t.release(x, y, qh); - if action == Some(Action::OpenEditor) { - open_editor(&t.path); - } - } - _ => {} - } - } - } - - fn expire(&mut self, id: u64) { - let qh = self.qh.clone(); - if let Some(t) = self.thumbs.iter_mut().find(|t| t.id == id) { - t.expire(&qh); - } - } - - fn set_hidden(&mut self, hidden: bool) { - let qh = self.qh.clone(); - for t in &mut self.thumbs { - if hidden { - t.hide(&qh); - } else { - t.show(&qh); - } - } - } -} - -fn disconnect(state: &mut State, id: u64) { - let Some(host) = &mut state.preview else { - return; - }; - if host.core.disconnect(id) { - host.set_hidden(false); - let _ = host.conn.flush(); - } -} - -fn handle(state: &mut State, id: u64, request: Request) -> Reply { - match request { - Request::Hide => { - let host = state.preview.as_mut().expect("host state"); - if host.core.hide(id) { - host.set_hidden(true); - // The capture must not start until the compositor has - // actually taken the thumbnails off the screen. - if let Err(e) = host.conn.roundtrip() { - return Reply::error(format!("Wayland round trip failed: {e}")); - } - } - Reply::ok() - } - Request::Add { path, output } => match add(state, &path, &output) { - Ok(()) => Reply::ok(), - Err(e) => { - tracing::warn!("could not show a preview: {e:#}"); - Reply::error(format!("{e:#}")) - } - }, - } -} - -fn add(state: &mut State, path: &Path, output_name: &str) -> Result<()> { - let image = image::open(path) - .with_context(|| format!("could not read {}", path.display()))? - .to_rgba8(); - let outputs = state.output_list(); - let output = outputs - .iter() - .find(|o| o.geom.name == output_name) - .or_else(|| outputs.first()) - .context("the compositor reported no outputs")?; - let parts = Parts { - compositor: &state.compositor, - layer_shell: state - .layer_shell - .as_ref() - .context("compositor has no wlr-layer-shell")?, - viewporter: state - .viewporter - .as_ref() - .context("compositor has no wp-viewporter")?, - shm: &state.shm, - }; - let host = state.preview.as_mut().expect("host state"); - host.next_id += 1; - let id = host.next_id; - let mut thumb = Thumbnail::new(id, path.to_path_buf(), &image, output, &parts, &host.qh)?; - if host.core.hidden() { - thumb.hide(&host.qh); - } - host.thumbs.push(thumb); - - // Beyond MAX on this output, the oldest slide away. - let qh = host.qh.clone(); - let open: Vec = (0..host.thumbs.len()) - .filter(|&i| host.thumbs[i].output == output.geom.name && !host.thumbs[i].is_closing()) - .collect(); - for &i in open.iter().take(stack::excess(open.len())) { - host.thumbs[i].slide_out(&qh); - } - host.restack(); - - host.handle - .insert_source(Timer::from_duration(host.timeout), move |_, _, state| { - if let Some(host) = &mut state.preview { - host.expire(id); - } - TimeoutAction::Drop - }) - .map_err(|e| anyhow::anyhow!("could not start the preview timer: {e}"))?; - let _ = host.conn.flush(); - Ok(()) -} - -/// Opens the screenshot in Satty; saving there overwrites the file. -fn open_editor(path: &Path) { - use std::os::unix::process::CommandExt; - use std::process::{Command, Stdio}; - - let mut command = Command::new("satty"); - command - .arg("--filename") - .arg(path) - .arg("--output-filename") - .arg(path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - // SAFETY: setsid is async-signal-safe. - unsafe { - command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); - } - if let Err(e) = command.spawn() { - tracing::warn!("could not start satty: {e}"); - } -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run host::` -Expected: all 4 tests in `host::tests` pass. - -- [ ] **Step 5: Route Wayland events to the host** - -Apply to `src/wayland.rs`: - -```diff ---- a/src/wayland.rs -+++ b/src/wayland.rs -@@ -30,6 +30,7 @@ - use crate::capture::Pending; - use crate::error::HintExt; - use crate::frame::OutputGeom; -+use crate::host::Host; - use crate::region::Overlay; - - /// An output as valw sees it. -@@ -66,6 +67,8 @@ - pub captures: Vec, - /// The region selection overlay, while it is open. - pub overlay: Option, -+ /// The preview thumbnails, in the preview host process. -+ pub preview: Option, - } - - impl Wayland { -@@ -93,6 +96,7 @@ - pointer: None, - captures: Vec::new(), - overlay: None, -+ preview: None, - }; - // Two round trips: one for wl_output, one for the xdg-output details. - queue -@@ -278,6 +282,9 @@ - if let Some(overlay) = &mut self.overlay { - overlay.frame_done(surface, qh); - } -+ if let Some(preview) = &mut self.preview { -+ preview.frame_done(surface, qh); -+ } - } - - fn surface_enter( -@@ -312,9 +319,12 @@ - } - - impl LayerShellHandler for State { -- fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) { -+ fn closed(&mut self, _: &Connection, _: &QueueHandle, layer: &LayerSurface) { - if let Some(overlay) = &mut self.overlay { - overlay.cancel(); -+ } -+ if let Some(preview) = &mut self.preview { -+ preview.closed(layer); - } - } - -@@ -328,6 +338,9 @@ - ) { - if let Some(overlay) = &mut self.overlay { - overlay.configure(layer, configure.new_size, qh); -+ } -+ if let Some(preview) = &mut self.preview { -+ preview.configure(layer, qh); - } - } - } -@@ -468,6 +481,9 @@ - if let Some(overlay) = &mut self.overlay { - overlay.pointer(events, self.cursor_device.as_ref(), qh); - } -+ if let Some(preview) = &mut self.preview { -+ preview.pointer(events, self.cursor_device.as_ref(), qh); -+ } - } - } - -``` - -- [ ] **Step 6: Add the hidden subcommand** - -Apply to `src/main.rs`. This also adds `mod host;` and a test that the subcommand parses but stays out of `--help`: - -```diff ---- a/src/main.rs -+++ b/src/main.rs -@@ -7,6 +7,7 @@ - mod doctor; - mod error; - mod frame; -+mod host; - mod ipc; - mod lock; - mod log; -@@ -59,6 +60,9 @@ - }, - /// Report what the compositor and system support. - Doctor, -+ /// Internal: the process that shows preview thumbnails. -+ #[command(name = "__preview-host", hide = true)] -+ PreviewHost, - } - - #[derive(Args)] -@@ -115,6 +119,7 @@ - fn run(cli: Cli) -> Result<()> { - match cli.command { - Command::Doctor => doctor::run(), -+ Command::PreviewHost => host::run(), - Command::Screen { all, common } => capture(Mode::Screen { all }, common), - Command::Region { common } => capture(Mode::Region, common), - } -@@ -247,6 +252,13 @@ - } - - #[test] -+ fn preview_host_is_hidden_from_help() { -+ assert!(parses(&["__preview-host"])); -+ let help = Cli::command().render_help().to_string(); -+ assert!(!help.contains("preview-host"), "{help}"); -+ } -+ -+ #[test] - fn delay_values() { - assert_eq!(parse_delay("1.5"), Ok(Duration::from_millis(1500))); - assert_eq!(parse_delay("0"), Ok(Duration::ZERO)); -``` - -- [ ] **Step 7: Lint and run the whole suite** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 8: Smoke-test the host on its own** - -```sh -cargo build --release -V=$CARGO_TARGET_DIR/release/valw -time $V __preview-host; echo "exit=$?" -``` - -Expected: after about 2 s (nobody connected, so the startup grace period ends), `exit=0`. The newest log in `~/.local/state/valw/logs/` has `preview host listening on …/valw.sock` and `preview host done`, and `$XDG_RUNTIME_DIR/valw.sock` is gone afterwards. - -```sh -$V __preview-host & sleep 0.3; $V __preview-host; echo "second exit=$?"; wait -``` - -Expected: `second exit=0` right away (the log says `another preview host is running`); the first exits about 2 s later. - -- [ ] **Step 9: Commit** - -```sh -jj commit -m "feat: preview host process" -``` - ---- - -### Task 6: Capture integration - -**Files:** -- Modify: `src/main.rs` - -**Interfaces:** -- Consumes: `ipc::{HideGuard, socket_path, start_host}` (Task 3); `config.preview` (Task 1); `host::run` via `__preview-host` (Task 5). -- Produces: `--no-preview` on `screen` and `region`; `save(shots, clip, target, config) -> Result<(Vec>, Option)>`, which replaces Phase 1's `deliver`. The returned path is the canonical path of the clipboard shot when it went to a regular file, and it's what gets previewed. - -The order in `capture` matters (spec section 5): -1. `HideGuard::new` right before the screencopy. -2. Capture (and region selection). -3. `save`. -4. `hide.add(…)` if the preview is enabled and a regular file was written. -5. `drop(hide)`, which shows the stack. -6. Only then the clipboard fork. - -The previewed output is the anchor output for `region` and the focused output for `screen` and `--all`. - -- [ ] **Step 1: Write the failing CLI tests and the integration** - -Apply to `src/main.rs`. This removes the dead-code allowance, adds `--no-preview`, splits `deliver` into `save` plus the clipboard step, and wraps the capture in the `HideGuard`: - -```diff ---- a/src/main.rs -+++ b/src/main.rs -@@ -1,6 +1,3 @@ --// Phase 2 modules land before the CLI uses them; Task 6 removes this. --#![allow(dead_code)] -- - mod capture; - mod config; - mod detach; -@@ -79,6 +76,9 @@ - /// Include the mouse cursor. - #[arg(long)] - cursor: bool, -+ /// Don't show the preview thumbnail. -+ #[arg(long)] -+ no_preview: bool, - } - - fn parse_delay(s: &str) -> Result { -@@ -138,33 +138,57 @@ - } - let outputs = wl.outputs(); - anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); -- -- // (image, output name for the file name) and which one goes to the clipboard. -- let (shots, clip): (Vec<(RgbaImage, Option)>, usize) = match mode { -+ // Thumbnails from earlier captures must not end up in this one. -+ let mut hide = ipc::HideGuard::new(&ipc::socket_path()); -+ -+ // (image, output name for the file name), which one goes to the -+ // clipboard, and the output the preview appears on. -+ let (shots, clip, source): (Vec<(RgbaImage, Option)>, usize, String) = match mode { - Mode::Screen { all } => { - let focused = focused_output(&outputs); -+ let source = outputs[focused].geom.name.clone(); - if all { - let frames = wl.capture(&outputs, cursor)?; - let shots = frames - .into_iter() - .map(|f| (f.to_rgba(f.full()), Some(f.output.name))) - .collect(); -- (shots, focused) -+ (shots, focused, source) - } else { - let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); -- (vec![(frame.to_rgba(frame.full()), None)], 0) -+ (vec![(frame.to_rgba(frame.full()), None)], 0, source) - } - } - Mode::Region => { - let frames = wl.capture(&outputs, cursor)?; - let (i, r) = region::select(&mut wl, &outputs, &frames)?; -- (vec![(frames[i].to_rgba(r), None)], 0) -+ let source = frames[i].output.name.clone(); -+ (vec![(frames[i].to_rgba(r), None)], 0, source) - } - }; - drop(wl); - - let target = Target::from_args(common.output, common.clipboard_only); -- deliver(shots, clip, &target, &config, lock) -+ let (pngs, saved) = save(&shots, clip, &target, &config)?; -+ if config.preview.enabled -+ && !common.no_preview -+ && let Some(file) = saved -+ && let Err(e) = hide.add(&ipc::socket_path(), &file, &source, ipc::start_host) -+ { -+ tracing::warn!("no preview: {e:#}"); -+ } -+ // Show the thumbnails again before forking the clipboard server; the -+ // child would inherit this connection and keep them hidden. -+ drop(hide); -+ -+ if target == Target::ClipboardOnly || config.save.copy_to_clipboard { -+ let png = pngs -+ .into_iter() -+ .nth(clip) -+ .context("no image for the clipboard")?; -+ output::copy_to_clipboard(png, lock)?; -+ } -+ Ok(()) - } - - /// Index of niri's focused output, or 0 if niri can't tell us. -@@ -186,45 +210,50 @@ - }) - } - --fn deliver( -- shots: Vec<(RgbaImage, Option)>, -+/// Encodes and writes the shots. Returns the PNGs and, when the clipboard -+/// shot went to a regular file, that file's absolute path for the preview. -+fn save( -+ shots: &[(RgbaImage, Option)], - clip: usize, - target: &Target, - config: &Config, -- lock: Lock, --) -> Result<()> { -+) -> Result<(Vec>, Option)> { - let now = Local::now(); - let pngs = shots - .iter() - .map(|(img, _)| output::encode_png(img)) - .collect::>>()?; - -- match target { -+ let saved = match target { - Target::Default => { - let dir = config.save_dir(); -- for (png, (_, name)) in pngs.iter().zip(&shots) { -+ let mut paths = Vec::new(); -+ for (png, (_, name)) in pngs.iter().zip(shots) { - let file = output::file_name(&config.save.filename, &now, name.as_deref())?; - let path = output::unique_path(&dir, &file); - output::write_atomic(&path, png)?; - println!("{}", path.display()); -+ paths.push(path); - } -+ paths.into_iter().nth(clip) - } - Target::Path(path) => { - output::write_atomic(path, &pngs[0])?; - println!("{}", path.display()); -- } -- Target::Stdout => output::write_stdout(&pngs[0])?, -- Target::ClipboardOnly => {} -- } -- -- if *target == Target::ClipboardOnly || config.save.copy_to_clipboard { -- let png = pngs -- .into_iter() -- .nth(clip) -- .context("no image for the clipboard")?; -- output::copy_to_clipboard(png, lock)?; -- } -- Ok(()) -+ // No preview for devices and pipes (`-o /dev/null`). -+ std::fs::metadata(path) -+ .is_ok_and(|m| m.is_file()) -+ .then(|| path.clone()) -+ } -+ Target::Stdout => { -+ output::write_stdout(&pngs[0])?; -+ None -+ } -+ Target::ClipboardOnly => None, -+ }; -+ // The host may run in another directory than this capture. -+ let saved = saved.map(|p| std::fs::canonicalize(&p).unwrap_or(p)); -+ Ok((pngs, saved)) - } - - #[cfg(test)] -@@ -249,6 +278,8 @@ - assert!(!parses(&["screen", "--all", "-o", "-"])); - assert!(!parses(&["screen", "--all", "--clipboard-only"])); - assert!(!parses(&["region", "--clipboard-only", "-o", "a.png"])); -+ assert!(parses(&["region", "--no-preview"])); -+ assert!(parses(&["screen", "--all", "--no-preview"])); - } - - #[test] -``` - -(The CLI tests and the code they test are one diff because clap's derive attributes are the implementation.) - -- [ ] **Step 2: Run the tests** - -Run: `cargo nextest run` -Expected: every test passes, including `tests::flag_conflicts` (now with `--no-preview`) and `tests::preview_host_is_hidden_from_help`. - -- [ ] **Step 3: Lint without the allowance** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings` -Expected: no warnings, in particular no `dead_code`. - -- [ ] **Step 4: Preview on niri** - -Use a throwaway config so nothing touches the user's clipboard or Pictures. A thumbnail appears at the bottom right of the focused monitor for 4 s; say so before running. - -```sh -t=$(mktemp -d); mkdir -p $t/config/valw -printf '[save]\ndirectory = "%s/shots"\ncopy_to_clipboard = false\n[preview]\ntimeout_secs = 4\n' $t > $t/config/valw/config.toml -export XDG_CONFIG_HOME=$t/config XDG_STATE_HOME=$t/state -cargo build --release; V=$CARGO_TARGET_DIR/release/valw -F=$(niri msg --json focused-output | jq -r .name) -read W H < <(niri msg --json focused-output | jq -r '"\(.modes[.current_mode].width) \(.modes[.current_mode].height)"') -corner() { magick "$1" -crop 260x160+$((W-260))+$((H-160)) +repage -format '%k' info:; } -$V screen >/dev/null; sleep 0.6 -grim -o $F $t/a.png # thumbnail visible -$V screen --no-preview -o $t/b.png >/dev/null # capture while it is up -sleep 0.5; grim -o $F $t/c.png # back after the capture -echo "before: $(corner $t/a.png) valw: $(corner $t/b.png) after: $(corner $t/c.png)" -``` - -Expected: `before` and `after` show many colours (the thumbnail), and `valw` shows the corner without it. If the corner of the screen itself is a single colour, that's `1`. Then: - -```sh -sleep 5; ls $XDG_RUNTIME_DIR/valw.sock 2>/dev/null || echo "host gone" -``` - -Expected: `host gone`. - -- [ ] **Step 5: Overlapping captures** - -```sh -$V screen >/dev/null; sleep 0.5 -$V screen --delay 1 --no-preview -o $t/d.png >/dev/null & sleep 0.2 -$V screen --no-preview -o $t/e.png; echo "exit=$?"; wait -``` - -Expected: the second capture fails fast with `valw is already running` (`exit=1`, Phase 1's lock); the first finishes, and the thumbnail is back afterwards. Nothing stays hidden. - -- [ ] **Step 6: The preview can't be shown (Review Focus 5)** - -Point the runtime dir at a place where `valw.sock` is a directory, so no host can ever bind. Keep Wayland reachable with an absolute `WAYLAND_DISPLAY`: - -```sh -r=$(mktemp -d); mkdir $r/valw.sock -# Compute the display path first: in `A=x B=$A cmd`, B already sees the new A. -wd=$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY -XDG_RUNTIME_DIR=$r WAYLAND_DISPLAY=$wd $V screen -o $t/f.png; echo "exit=$?" -grep -h "no preview" $t/state/valw/logs/*.log | tail -1 -``` - -Expected: the path is printed, `exit=0` after about 1 s, and the log has `no preview: … the preview host did not come up`. - -- [ ] **Step 7: Commit** - -```sh -jj commit -m "feat: floating preview after captures" -``` - ---- - -### Task 7: Packaging and headless checks - -**Files:** -- Modify: `nix/package.ulu.nix`, `nix/checks.ulu.nix` - -**Interfaces:** -- Consumes: the whole preview flow (Tasks 1–6). -- Produces: `result/bin/valw` with Satty on its PATH; `checks.headless` extended with the preview scenarios from spec 10.2. - -Notes: -- `--suffix PATH` keeps a user's own Satty first. -- The cargo checks replace `installPhase`, so `postInstall` (the wrapper) doesn't run for them, which is fine. -- The sandbox has no swaybg, so the headless background is solid black. A visible thumbnail therefore adds colours to the bottom-right corner. grim (which knows nothing about valw) must see those colours right before valw's own capture must not. - -- [ ] **Step 1: Wrap the binary** - -```diff ---- a/nix/package.ulu.nix -+++ b/nix/package.ulu.nix -@@ -21,7 +21,10 @@ - - cargoLock.lockFile = ../Cargo.lock; - -- nativeBuildInputs = [ pkgs.pkg-config ]; -+ nativeBuildInputs = [ -+ pkgs.makeWrapper -+ pkgs.pkg-config -+ ]; - buildInputs = [ - pkgs.libxkbcommon - pkgs.wayland -@@ -29,6 +32,11 @@ - - # Tests run through `nix flake check` instead. - doCheck = false; -+ -+ # Clicking a preview opens Satty. --suffix keeps a user's own Satty first. -+ postInstall = '' -+ wrapProgram $out/bin/valw --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} -+ ''; - - meta = { - description = "macOS-style screenshots for niri"; -``` - -- [ ] **Step 2: Extend the headless check** - -```diff ---- a/nix/checks.ulu.nix -+++ b/nix/checks.ulu.nix -@@ -32,6 +32,8 @@ - { - nativeBuildInputs = [ - pkgs.file -+ pkgs.grim -+ pkgs.imagemagick - pkgs.sway-unwrapped - valw - ]; -@@ -63,6 +65,34 @@ - file shot.png | tee file.txt - grep -q 'PNG image data, 1280 x 720' file.txt - -+ # Preview. The background is solid black, so a thumbnail in the -+ # bottom-right corner shows up as more than one colour there. -+ mkdir -p $HOME/.config/valw -+ printf '[preview]\ntimeout_secs = 2\n' > $HOME/.config/valw/config.toml -+ socket=$XDG_RUNTIME_DIR/valw.sock -+ corner() { magick "$1" -crop 300x200+980+520 +repage -format '%k' info:; } -+ wait_for() { # $1: test expression, $2: tenths of a second -+ for _ in $(seq "$2"); do eval "$1" && return 0; sleep 0.1; done -+ echo "timed out waiting for: $1" -+ exit 1 -+ } -+ -+ valw screen > /dev/null -+ wait_for '[ -S $socket ]' 10 -+ sleep 0.5 -+ grim with-thumbnail.png -+ [ "$(corner with-thumbnail.png)" -gt 1 ] || { echo "thumbnail not visible"; exit 1; } -+ valw screen --no-preview -o hidden.png > /dev/null -+ [ "$(corner hidden.png)" -eq 1 ] || { echo "thumbnail ended up in a screenshot"; exit 1; } -+ wait_for '[ ! -S $socket ]' 40 -+ -+ valw screen --no-preview > /dev/null -+ sleep 1 -+ if [ -S $socket ]; then -+ echo "--no-preview started a host" -+ exit 1 -+ fi -+ - touch $out - ''; - }; -``` - -- [ ] **Step 3: Build and check Satty is reachable** - -```sh -nix build && grep -c satty result/bin/valw -``` - -Expected: a non-zero count (the wrapper script mentions Satty's store path). - -- [ ] **Step 4: Run every check** - -Run: `nix flake check -L --keep-going` -Expected: all four checks pass. The `valw-headless` log ends without `thumbnail not visible`, `thumbnail ended up in a screenshot`, `timed out waiting for`, or `--no-preview started a host`. - -- [ ] **Step 5: Format the Nix files** - -Run: `nix fmt` -Expected: no changes, or whitespace only (rerun Step 4 if anything changed). - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "build: satty on PATH; headless preview checks" -``` - ---- - -### Task 8: Wire valw into copland and start the test day - -**Files (in `~/copland`, a separate jj repo; ask the user before touching it):** -- Modify: `flake.nix` (input), `hosts/copland.ulu.nix` (module list), `modules/home/niri/desktop-niri-keybinds.ulu.nix` (binds) -- Create: `modules/home/home-valw.ulu.nix` - -**Interfaces:** -- Consumes: valw's `packages..default` (Task 7). -- Produces: `valw` on the user's PATH; `Mod+Shift+S` → `valw region`, `Print` → `valw screen`. - -This is a change outside the valw repo, and the rebuild changes the running system. Show the user the four edits below and get an explicit go-ahead before making them. The rebuild itself is the user's to run. - -- [ ] **Step 1: Flake input** - -In `~/copland/flake.nix`, inside `inputs = { … }` after `scx_soryu`: - -```nix - valw = { - url = "path:/home/elars/Projects/valw"; - inputs.nixpkgs.follows = "nixpkgs"; - inputs.flake-parts.follows = "flake-parts"; - }; -``` - -- [ ] **Step 2: A module that installs the package** - -Create `~/copland/modules/home/home-valw.ulu.nix` (discovered automatically): - -```nix -{ ... }: -{ - flake.homeModules.home-valw = - { inputs, pkgs, ... }: - { - home.packages = [ inputs.valw.packages.${pkgs.stdenv.hostPlatform.system}.default ]; - }; -} -``` - -In `~/copland/hosts/copland.ulu.nix`, add it to the host's home module list, next to `config.flake.homeModules.home-nyi`: - -```nix - config.flake.homeModules.home-valw -``` - -- [ ] **Step 3: Binds** - -In `~/copland/modules/home/niri/desktop-niri-keybinds.ulu.nix`, replace - -```nix - "Mod+Shift+S".action.spawn-sh = "noctalia msg screenshot-region"; -``` - -with - -```nix - "Mod+Shift+S".action.spawn = [ "valw" "region" ]; - "Print".action.spawn = [ "valw" "screen" ]; -``` - -`Mod+Shift+3` and `Mod+Shift+4` stay workspace moves. - -- [ ] **Step 4: Lock, check, commit, and hand over the rebuild** - -```sh -cd ~/copland -nix flake lock -nix flake check --no-build -jj commit -m "valw: install and bind for the test day" -``` - -Expected: `flake.lock` gains a `valw` node, and the check evaluates cleanly. Then ask the user to run their usual rebuild. After it, `which valw` resolves to a Nix store path and `valw doctor` is all `ok`. - -After later valw changes: `nix flake update valw` in copland, then rebuild. - -- [ ] **Step 5: Test-day checklist (with the user)** - -Ask the user to go through these once and report back: - -- Thumbnail on the correct monitor; sharp at scale 1.25. -- Typing is never interrupted while a thumbnail is visible. -- Click opens Satty; saving there overwrites the screenshot (`ls -l` shows a new mtime). -- Swipe right closes it; a short drag snaps back. -- Several captures in a row stack up to 5; none appear in later screenshots. -- During region selection the thumbnails are hidden and not clickable. -- After the timeout, `pgrep -af __preview-host` finds nothing. -- **Review Focus 1:** start `valw region` while thumbnails are visible and press Esc, or `kill -9` it from another terminal. The thumbnails come back. -- **Review Focus 2:** while a thumbnail is visible, `kill -9` the host (`pgrep -f __preview-host`). The thumbnail disappears. The next Print still works and shows a new thumbnail. -- **Review Focus 4:** run an unwrapped build (`$CARGO_TARGET_DIR/release/valw screen`, which has no Satty on PATH unless the user installed it) and click the thumbnail. It closes, and the host log has `could not start satty`. - -Then the test day starts: valw is the only screenshot tool for at least a day, and every bug or annoyance gets written down for the next round. diff --git a/docs/superpowers/plans/2026-10-02-valw-phase-3-window-mode.md b/docs/superpowers/plans/2026-10-02-valw-phase-3-window-mode.md deleted file mode 100644 index 8b3975c..0000000 --- a/docs/superpowers/plans/2026-10-02-valw-phase-3-window-mode.md +++ /dev/null @@ -1,557 +0,0 @@ -# valw Phase 3: window mode 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:** `valw window` captures the window the user clicks; Space in region mode (before dragging) switches to it. - -**Architecture:** niri picks the window (`Request::PickWindow`) and renders it into a temporary PNG (`Action::ScreenshotWindow` with `path`); valw waits for niri's `ScreenshotCaptured` event on a second socket, decodes the file, deletes it, and feeds the image into the existing save → preview → clipboard flow. The region overlay gains a `Choice::Window` outcome for Space. - -**Tech Stack:** Rust 2024, `niri-ipc =26.4.0`, SCTK 0.21 keyboard (`Keysym`), `image` 0.25. - -**Spec:** `docs/superpowers/specs/2026-10-02-valw-phase-3-window-mode-design.md` - -## Global Constraints - -- Version control is jj only: commit with `jj commit -m "..."`, never git. -- `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` (not `cargo test`), `nix flake check` (not nix2 commands). -- Code, comments and docs in plain English; match the surrounding comment density. -- niri requires an absolute `path` for `ScreenshotWindow`. -- Wait for the capture event at most 5 s. -- Don't run valw captures against the user's live session without telling them. - -## Review Focus - -1. **niri's own screenshot binding fires while valw waits:** the stream reports a capture with another path; valw must keep waiting for its own. Test: `niri::tests::only_our_capture_counts` (Task 1). -2. **The window closes between the pick and the shot, or niri fails to write:** a clear error within 5 s, never a hang. niri's error reply is surfaced by `request`; a silent failure hits the timeout (Task 1 code, real check in the checklist). -3. **The temporary file is left behind** after an error between "written" and "decoded": the guard removes it on every path. Test: `window::tests::temp_file_is_removed_on_drop` (Task 2). -4. **Esc during the pick:** `Cancelled` (exit 3), thumbnails come back (the `HideGuard` drops as before). Real check in the checklist. -5. **Space while dragging a region:** nothing happens; the drag continues. Test: `selection::tests::window_switch_only_before_a_drag` (Task 3). - ---- - -## File Structure - -| File | Change | Task | -|---|---|---| -| `src/niri.rs` | `pick_window`, `workspaces`, `screenshot_window`, `is_our_capture`, `output_of` | 1 | -| `src/window.rs` (new) | `capture(cursor)`, `TempFile` guard, `temp_path()` | 2 | -| `src/main.rs` | `mod window;`, `Command::Window`, `Mode::Window`, `window_shot` | 2, 3 | -| `src/selection.rs` | `Selection::allows_window_switch` | 3 | -| `src/region.rs` | `Choice` replaces `Picked`; `Overlay::switch_to_window` | 3 | -| `src/wayland.rs` | Space in `press_key` | 3 | -| `docs/test-checklist.md` | Phase 3 section | 4 | - ---- - -### Task 1: niri window IPC - -**Files:** -- Modify: `src/niri.rs`, `src/main.rs` (allowance) - -**Interfaces:** -- Produces (in `niri`): - - `pub fn pick_window() -> Result>` - - `pub fn workspaces() -> Result>` - - `pub fn screenshot_window(id: u64, path: &Path, cursor: bool, timeout: Duration) -> Result<()>` - - `pub fn is_our_capture(event: &Event, path: &Path) -> bool` - - `pub fn output_of(workspace_id: Option, workspaces: &[Workspace]) -> Option` - -- [ ] **Step 1: Dead-code allowance** - -At the very top of `src/main.rs`, followed by an empty line: - -```rust -// Window-mode helpers land before main uses them; Task 2 removes this. -#![allow(dead_code)] -``` - -- [ ] **Step 2: Write the failing tests** - -Add to the `tests` module of `src/niri.rs`: - -```rust - use niri_ipc::{Event, Workspace}; - use std::path::Path; - - fn ws(id: u64, output: Option<&str>) -> Workspace { - Workspace { - id, - idx: 1, - name: None, - output: output.map(str::to_owned), - is_urgent: false, - is_active: true, - is_focused: false, - active_window_id: None, - } - } - - #[test] - fn only_our_capture_counts() { - let ours = Path::new("/run/user/1000/valw-window-7.png"); - let captured = |p: Option<&str>| Event::ScreenshotCaptured { - path: p.map(str::to_owned), - }; - assert!(is_our_capture(&captured(Some("/run/user/1000/valw-window-7.png")), ours)); - assert!(!is_our_capture(&captured(None), ours), "clipboard-only shot"); - assert!( - !is_our_capture(&captured(Some("/home/u/Pictures/Screenshot.png")), ours), - "niri's own binding" - ); - assert!(!is_our_capture(&Event::WindowClosed { id: 3 }, ours)); - } - - #[test] - fn output_of_follows_the_workspace() { - let all = [ws(1, Some("DP-1")), ws(2, Some("HDMI-A-1")), ws(3, None)]; - assert_eq!(output_of(Some(2), &all), Some("HDMI-A-1".to_owned())); - assert_eq!(output_of(None, &all), None, "no workspace"); - assert_eq!(output_of(Some(9), &all), None, "unknown workspace"); - assert_eq!(output_of(Some(3), &all), None, "workspace without output"); - } -``` - -- [ ] **Step 3: Run them and watch them fail** - -Run: `cargo nextest run niri::` -Expected: compilation fails, `` cannot find function `is_our_capture` `` / `` `output_of` ``. - -- [ ] **Step 4: Implement** - -Replace the imports at the top of `src/niri.rs`: - -```rust -use std::path::Path; -use std::sync::mpsc; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow, bail}; -use niri_ipc::{Action, Event, Request, Response, Window, Workspace, socket::Socket}; -``` - -Split the connection out of `request` so the event stream can reuse it: - -```rust -fn connect() -> Result { - Socket::connect().context("could not connect to niri (is NIRI_SOCKET set?)") -} - -fn request(request: Request) -> Result { - connect()? - .send(request) - .context("niri IPC request failed")? - .map_err(|e| anyhow!("niri replied with an error: {e}")) -} -``` - -Add after `focused_output`: - -```rust -/// Lets the user click a window; niri shows its own pick cursor. -/// `None` if they pressed Esc. -pub fn pick_window() -> Result> { - match request(Request::PickWindow)? { - Response::PickedWindow(window) => Ok(window), - other => Err(anyhow!("unexpected niri reply: {other:?}")), - } -} - -pub fn workspaces() -> Result> { - match request(Request::Workspaces)? { - Response::Workspaces(workspaces) => Ok(workspaces), - other => Err(anyhow!("unexpected niri reply: {other:?}")), - } -} - -/// Has niri render window `id` into `path` (absolute) and waits until the -/// file is written. -pub fn screenshot_window(id: u64, path: &Path, cursor: bool, timeout: Duration) -> Result<()> { - // Listen before asking, so the event can't come and go unseen. - let mut events = connect()?; - events - .send(Request::EventStream) - .context("niri IPC request failed")? - .map_err(|e| anyhow!("niri replied with an error: {e}"))?; - let mut read = events.read_events(); - let (tx, rx) = mpsc::channel(); - let wanted = path.to_path_buf(); - std::thread::spawn(move || { - while let Ok(event) = read() { - if is_our_capture(&event, &wanted) { - let _ = tx.send(()); - return; - } - } - }); - - let path = path - .to_str() - .context("the temporary screenshot path is not valid UTF-8")?; - match request(Request::Action(Action::ScreenshotWindow { - id: Some(id), - write_to_disk: true, - show_pointer: cursor, - path: Some(path.to_owned()), - }))? { - Response::Handled => {} - other => bail!("unexpected niri reply: {other:?}"), - } - rx.recv_timeout(timeout).map_err(|_| { - anyhow!( - "niri did not deliver the window screenshot within {}s", - timeout.as_secs() - ) - }) -} - -/// Whether `event` reports the screenshot written to `path`. niri reports -/// every screenshot on the stream, including its own bindings'. -pub fn is_our_capture(event: &Event, path: &Path) -> bool { - matches!(event, Event::ScreenshotCaptured { path: Some(p) } if Path::new(p) == path) -} - -/// 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?; - workspaces.iter().find(|w| w.id == id)?.output.clone() -} -``` - -Delete the old `request` body (it is replaced above). If `use std::path::Path;` in the test module now duplicates the outer import, drop it from the test module. - -- [ ] **Step 5: Run the tests** - -Run: `cargo nextest run niri::` -Expected: 4 passed (`parses_versions`, `ipc_version_matches_cargo_toml`, `only_our_capture_counts`, `output_of_follows_the_workspace`). - -Run: `cargo clippy -- -D warnings` -Expected: no warnings. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: niri window pick and screenshot IPC" -``` - ---- - -### Task 2: `valw window` - -**Files:** -- Create: `src/window.rs` -- Modify: `src/main.rs` - -**Interfaces:** -- Consumes: Task 1's `niri::{pick_window, workspaces, screenshot_window, output_of}`; `thumbnail::load(&Path) -> Result`; `lock::default_path()`; `error::Cancelled`. -- Produces: `pub fn window::capture(cursor: bool) -> Result<(RgbaImage, Option)>` (image, output name if known); `fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result<(Vec<(RgbaImage, Option)>, usize, String)>` in `main.rs`, used again by Task 3. - -- [ ] **Step 1: Write the failing tests** - -Create `src/window.rs` with only the tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn temp_file_is_removed_on_drop() { - let dir = std::env::temp_dir().join(format!("valw-window-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("shot.png"); - std::fs::write(&path, b"png").unwrap(); - drop(TempFile(path.clone())); - assert!(!path.exists()); - // A file niri never wrote is fine too. - drop(TempFile(dir.join("missing.png"))); - std::fs::remove_dir(&dir).unwrap(); - } - - #[test] - fn temp_path_is_absolute_and_per_process() { - let p = temp_path(); - assert!(p.is_absolute(), "niri rejects relative paths: {p:?}"); - assert!(p.to_string_lossy().contains(&std::process::id().to_string())); - } -} -``` - -Add `mod window;` to the module list in `src/main.rs` (after `mod wayland;`). Add to the `flag_conflicts` test in `src/main.rs`: - -```rust - assert!(parses(&["window", "--cursor", "--delay", "2"])); - assert!(!parses(&["window", "--clipboard-only", "-o", "a.png"])); -``` - -- [ ] **Step 2: Run them and watch them fail** - -Run: `cargo nextest run window:: flag_conflicts` -Expected: compilation fails (`TempFile`, `temp_path` not found). - -- [ ] **Step 3: Implement `window.rs`** - -Above the test module: - -```rust -//! Window mode: niri picks the window and renders it; valw reads the result. - -use std::path::PathBuf; -use std::time::Duration; - -use anyhow::Result; -use image::RgbaImage; - -use crate::error::Cancelled; -use crate::niri; - -/// How long niri gets to render and write the window. -const TIMEOUT: Duration = Duration::from_secs(5); - -/// Lets the user click a window. Returns its pixels and the output it is -/// on, if niri knows. Esc during the pick is `Cancelled`. -pub fn capture(cursor: bool) -> Result<(RgbaImage, Option)> { - let window = niri::pick_window()?.ok_or(Cancelled)?; - tracing::info!("picked window {} ({:?})", window.id, window.app_id); - let tmp = TempFile(temp_path()); - niri::screenshot_window(window.id, &tmp.0, cursor, TIMEOUT)?; - let image = crate::thumbnail::load(&tmp.0)?; - let workspaces = niri::workspaces().unwrap_or_else(|e| { - tracing::warn!("no workspace list: {e:#}"); - Vec::new() - }); - Ok((image, niri::output_of(window.workspace_id, &workspaces))) -} - -/// Where niri writes the window: next to the capture lock, one per process. -fn temp_path() -> PathBuf { - crate::lock::default_path().with_file_name(format!("valw-window-{}.png", std::process::id())) -} - -/// Removes the temporary screenshot however `capture` ends. -struct TempFile(PathBuf); - -impl Drop for TempFile { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } -} -``` - -- [ ] **Step 4: Wire `main.rs`** - -Remove the Task 1 allowance (the two lines at the top of `src/main.rs`). - -Add the subcommand to `enum Command`, after `Region`: - -```rust - /// Click a window to capture it (Cmd+Shift+4, then Space). - Window { - #[command(flatten)] - common: Common, - }, -``` - -Add `Window` to `enum Mode`: - -```rust -enum Mode { - Screen { all: bool }, - Region, - Window, -} -``` - -In `run`, after the `Region` arm: - -```rust - Command::Window { common } => capture(Mode::Window, common), -``` - -In `capture`'s `match mode`, after the `Mode::Region` arm: - -```rust - Mode::Window => window_shot(&outputs, cursor)?, -``` - -Add below `capture`: - -```rust -/// A window shot, shaped like the other modes' results. The preview goes to -/// the window's output, or niri's focused one if that is unknown. -fn window_shot( - outputs: &[wayland::Output], - cursor: bool, -) -> Result<(Vec<(RgbaImage, Option)>, usize, String)> { - let (image, output) = window::capture(cursor)?; - let source = output.unwrap_or_else(|| outputs[focused_output(outputs)].geom.name.clone()); - Ok((vec![(image, None)], 0, source)) -} -``` - -- [ ] **Step 5: Run the tests and checks** - -Run: `cargo nextest run` -Expected: all pass (previous count + 4: two `window::` tests, the two new `flag_conflicts` asserts live in an existing test). - -Run: `cargo clippy -- -D warnings && cargo fmt --check` -Expected: clean. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: valw window" -``` - ---- - -### Task 3: Space switches region mode to window mode - -**Files:** -- Modify: `src/selection.rs`, `src/region.rs`, `src/wayland.rs`, `src/main.rs` - -**Interfaces:** -- Consumes: Task 2's `window_shot`. -- Produces: `Selection::allows_window_switch(&self) -> bool`; `pub enum region::Choice { Region(usize, PixelRect), Window }`; `region::select(...) -> Result`; `Overlay::switch_to_window(&mut self)`. - -- [ ] **Step 1: Write the failing test** - -Add to the tests of `src/selection.rs`: - -```rust - #[test] - fn window_switch_only_before_a_drag() { - let mut s = Selection::default(); - assert!(s.allows_window_switch()); - s.press(Point { x: 1.0, y: 1.0 }); - assert!(!s.allows_window_switch(), "Space during a drag is reserved"); - s.release(Point { x: 5.0, y: 5.0 }); - assert!(s.allows_window_switch()); - } -``` - -- [ ] **Step 2: Run it and watch it fail** - -Run: `cargo nextest run selection::` -Expected: compilation fails, no method `allows_window_switch`. - -- [ ] **Step 3: Implement** - -In `impl Selection` (`src/selection.rs`), after `release`: - -```rust - /// Space switches to window mode only before a drag starts. - pub fn allows_window_switch(&self) -> bool { - *self == Selection::Idle - } -``` - -In `src/region.rs`, replace the `Picked` alias: - -```rust -/// What the user chose in the overlay. -#[derive(Debug, PartialEq)] -pub enum Choice { - /// A rectangle in the frame of output `.0`. - Region(usize, PixelRect), - /// Space before dragging: capture a window instead. - Window, -} -``` - -In `struct Overlay`: `outcome: Option>,`. - -`select`'s signature becomes `-> Result` (body unchanged) and its doc comment gains: "Space before dragging returns `Choice::Window`." - -In `pointer`'s release arm: `self.outcome = Some(Some(Choice::Region(a, rect)));`. - -In `impl Overlay`, after `cancel`: - -```rust - pub fn switch_to_window(&mut self) { - if self.selection.allows_window_switch() { - tracing::info!("switching to window mode"); - self.outcome.get_or_insert(Some(Choice::Window)); - } - } -``` - -In `src/wayland.rs`, `press_key` body becomes: - -```rust - let Some(overlay) = &mut self.overlay else { - return; - }; - match event.keysym { - Keysym::Escape => overlay.cancel(), - Keysym::space => overlay.switch_to_window(), - _ => {} - } -``` - -In `src/main.rs`, the `Mode::Region` arm becomes: - -```rust - Mode::Region => { - let frames = wl.capture(&outputs, cursor)?; - match region::select(&mut wl, &outputs, &frames)? { - region::Choice::Region(i, r) => { - let source = frames[i].output.name.clone(); - (vec![(frames[i].to_rgba(r), None)], 0, source) - } - region::Choice::Window => { - drop(frames); - window_shot(&outputs, cursor)? - } - } - } -``` - -- [ ] **Step 4: Run the tests and checks** - -Run: `cargo nextest run` -Expected: all pass (+1). - -Run: `cargo clippy -- -D warnings && cargo fmt --check` -Expected: clean. - -- [ ] **Step 5: Commit** - -```sh -jj commit -m "feat: space switches region mode to window mode" -``` - ---- - -### Task 4: Checks and checklist - -**Files:** -- Modify: `docs/test-checklist.md` - -- [ ] **Step 1: Full checks** - -Run: `nix flake check -L --keep-going` -Expected: every check passes (the headless sway check is unchanged; window mode needs niri and is not covered there). - -- [ ] **Step 2: Append the checklist section** - -Append at the end of `docs/test-checklist.md` (after the drag-out "Known minors"): - -```markdown - -## Window mode (Phase 3) - -- [ ] `valw window`, click a tiled window → that window alone is saved, preview on its monitor. -- [ ] Same for a floating window, and for a window partly covered by another (the covered part is still there). -- [ ] A window on the other monitor (different scale, if any) → right size and sharpness; preview on that monitor. -- [ ] Esc during the pick → nothing saved, exit code 3, thumbnails come back. -- [ ] `--cursor` with the pointer over the window → the pointer is in the shot. -- [ ] `valw region`, press Space before dragging → the overlay closes and the window pick starts; Space while dragging does nothing. -- [ ] `--clipboard-only` and `-o -` → clipboard / stdout get the window; no stray `valw-window-*.png` left in `$XDG_RUNTIME_DIR`. -- [ ] niri's own "Screenshot captured" notification appears (expected, see the spec); the clipboard ends up with the window. -``` - -- [ ] **Step 3: Commit** - -```sh -jj commit -m "docs: window mode checklist" -``` diff --git a/docs/superpowers/plans/2026-10-02-valw-phase-4-zoom.md b/docs/superpowers/plans/2026-10-02-valw-phase-4-zoom.md deleted file mode 100644 index ee5da84..0000000 --- a/docs/superpowers/plans/2026-10-02-valw-phase-4-zoom.md +++ /dev/null @@ -1,1170 +0,0 @@ -# valw Phase 4: zoom mode 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:** `valw zoom` freezes the focused output and shows it zoomable (wheel), pannable (drag), with a flashlight (`f`), and `c` captures the visible area. - -**Architecture:** Pure view math in `zoom/view.rs`; an EGL + OpenGL ES 3 renderer in `zoom/gl.rs` (one fullscreen triangle, `texelFetch` from the frozen frame, flashlight in the fragment shader); `zoom/mod.rs` owns the layer surface, input and the frame-callback-driven draw loop. The whole program moves to `wayland-backend`'s `client_system` so EGL can use the C `wl_display*`. - -**Tech Stack:** Rust 2024, SCTK 0.21, `wayland-backend` (`client_system`), `khronos-egl` 6 (`dynamic`), `libloading` 0.8, `wayland-egl` 0.32, `glow` 0.18. - -**Spec:** `docs/superpowers/specs/2026-10-02-valw-phase-4-zoom-design.md` - -## Global Constraints - -- jj only (`jj commit -m`), never git. `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`. -- No Vulkan, no wgpu. OpenGL ES 3.0, `#version 300 es`. -- Scale range 1–32; flashlight radius 20–2000 logical px, default 180; `scroll_step` default 1.15; touchpad: one step per 15 logical px; flashlight outside = 25 % brightness; animation ≈ 90 % in 120 ms. -- Namespace `valw-zoom`; Esc/`q` exit 0 with nothing saved. -- Tell the user before running zoom against the live session (it takes the keyboard). - -## Review Focus - -1. **Zoom keeps the point under the pointer**, at every scale and near the edges where clamping kicks in. Tests: `view::tests::zoom_keeps_the_point_under_the_pointer`, `zoom_out_near_an_edge_stays_inside` (Task 1). -2. **`c` after zooming** must crop exactly the visible pixels, never outside the frame, never empty. Test: `view::tests::visible_rect_*` (Task 1). -3. **Touchpad scrolling** (no `value120`) must zoom smoothly, not in jumps or not at all. Test: `view::tests::scroll_steps_from_wheel_and_touchpad` (Task 1). -4. **The switch to libwayland-client** must not change region/screen/preview behaviour. Covered by the existing suite and the headless sway check (Task 4). -5. **No EGL / no GLES 3** (other machines, the headless check): `valw zoom` fails clearly, `valw doctor` warns, other modes unaffected. Test: `doctor::tests::egl_check_levels` (Task 2). - ---- - -## File Structure - -| File | Change | Task | -|---|---|---| -| `src/zoom/view.rs` (new) | `View`, `Animated`, `scroll_steps`, `radius` | 1 | -| `src/zoom/mod.rs` (new) | Task 1: `pub mod view;` only. Task 3: `Zoom`, `Outcome`, `Command`, `command()`, `run()` | 1, 3 | -| `src/config.rs` | `[zoom]` | 1 | -| `src/zoom/gl.rs` (new) | `probe`, `Renderer` | 2 | -| `src/doctor.rs` | `check_egl` | 2 | -| `Cargo.toml`, `nix/package.ulu.nix` | deps, `client_system`, `LD_LIBRARY_PATH` | 2 | -| `src/wayland.rs` | `State::zoom` and event routing | 3 | -| `src/main.rs` | `Command::Zoom`, `Mode::Zoom` | 3 | -| `docs/test-checklist.md` | Phase 4 section | 4 | - ---- - -### Task 1: View math and config - -**Files:** Create `src/zoom/mod.rs`, `src/zoom/view.rs`; modify `src/main.rs` (`mod zoom;` + allowance), `src/config.rs`. - -**Interfaces — Produces:** -- `zoom::view::{View, Animated, scroll_steps, radius, MIN_SCALE, MAX_SCALE, MIN_RADIUS, MAX_RADIUS}` -- `View { scale: f64, origin: (f64, f64) }`, `View::IDENTITY`, `to_frame(p)`, `zoom_at(p, factor, size) -> View`, `pan(delta, size) -> View`, `clamped(size) -> View`, `visible_rect((u32, u32)) -> PixelRect` -- `Animated { shown: View, target: View }`, `Animated::new()`, `set(View)`, `step(dt) -> bool` (true while moving) -- `scroll_steps(value120: i32, absolute: f64) -> f64` (positive = zoom in), `radius(r, steps, step) -> f64` -- `config::Zoom { scroll_step: f64, flashlight_radius: f64 }`, `Config::zoom` - -- [ ] **Step 1: Failing tests** - -`src/zoom/mod.rs`: - -```rust -//! Zoom mode: the frozen focused output, zoomed and panned on the GPU. - -pub mod view; -``` - -At the top of `src/main.rs`: `// Zoom lands in pieces; Task 3 removes this.` + `#![allow(dead_code)]` + empty line; add `mod zoom;` after `mod window;`. - -`src/zoom/view.rs` with only: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - const SIZE: (f64, f64) = (1920.0, 1080.0); - - fn close(a: (f64, f64), b: (f64, f64)) -> bool { - (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6 - } - - #[test] - fn zoom_keeps_the_point_under_the_pointer() { - for p in [(960.0, 540.0), (100.0, 900.0), (1500.0, 200.0)] { - let v = View::IDENTITY.zoom_at(p, 2.0, SIZE); - assert_eq!(v.scale, 2.0); - assert!(close(v.to_frame(p), p), "{p:?} -> {v:?}"); - let w = v.zoom_at(p, 1.15, SIZE); - assert!(close(w.to_frame(p), v.to_frame(p)), "{p:?} -> {w:?}"); - } - } - - #[test] - fn scale_is_clamped() { - let v = View::IDENTITY.zoom_at((10.0, 10.0), 0.5, SIZE); - assert_eq!(v, View::IDENTITY); - let v = View::IDENTITY.zoom_at((10.0, 10.0), 1000.0, SIZE); - assert_eq!(v.scale, MAX_SCALE); - } - - #[test] - fn zoom_out_near_an_edge_stays_inside() { - let v = View::IDENTITY.zoom_at((1900.0, 1070.0), 4.0, SIZE); - let out = v.zoom_at((0.0, 0.0), 0.5, SIZE); - assert_eq!(out.scale, 2.0); - assert!(out.origin.0 >= 0.0 && out.origin.0 <= 1920.0 - 960.0, "{out:?}"); - assert!(out.origin.1 >= 0.0 && out.origin.1 <= 1080.0 - 540.0, "{out:?}"); - } - - #[test] - fn pan_moves_with_the_pointer_and_is_clamped() { - let v = View::IDENTITY.zoom_at((960.0, 540.0), 2.0, SIZE); - let p = v.pan((100.0, 0.0), SIZE); - assert!(close(p.origin, (v.origin.0 - 50.0, v.origin.1)), "{p:?}"); - let far = v.pan((-1e6, 1e6), SIZE); - assert!(close(far.origin, (960.0, 0.0)), "{far:?}"); - assert_eq!(View::IDENTITY.pan((50.0, 50.0), SIZE), View::IDENTITY); - } - - #[test] - fn visible_rect_at_identity_is_the_frame() { - let r = View::IDENTITY.visible_rect((1920, 1080)); - assert_eq!((r.x, r.y, r.width, r.height), (0, 0, 1920, 1080)); - } - - #[test] - fn visible_rect_rounds_outwards_inside_the_frame() { - let v = View { scale: 4.0, origin: (100.5, 200.25) }; - let r = v.visible_rect((1920, 1080)); - assert_eq!((r.x, r.y, r.width, r.height), (100, 200, 481, 271)); - let edge = View { scale: 4.0, origin: (1440.0, 810.0) }; - let r = edge.visible_rect((1920, 1080)); - assert_eq!((r.x, r.y, r.width, r.height), (1440, 810, 480, 270)); - } - - #[test] - fn animation_reaches_the_target_and_stops() { - let mut a = Animated::new(); - a.target = View::IDENTITY.zoom_at((960.0, 540.0), 4.0, SIZE); - let mut frames = 0; - while a.step(1.0 / 60.0) { - frames += 1; - assert!(frames < 120, "never settles"); - } - assert_eq!(a.shown, a.target); - // About 90 % of the way after 120 ms. - let mut b = Animated::new(); - b.target = View { scale: 11.0, origin: (0.0, 0.0) }; - b.step(0.12); - assert!((b.shown.scale - 10.0).abs() < 0.2, "{:?}", b.shown); - } - - #[test] - fn set_jumps() { - let mut a = Animated::new(); - let v = View { scale: 2.0, origin: (5.0, 5.0) }; - a.set(v); - assert_eq!((a.shown, a.target), (v, v)); - assert!(!a.step(0.016)); - } - - #[test] - fn scroll_steps_from_wheel_and_touchpad() { - assert_eq!(scroll_steps(-120, -15.0), 1.0, "one notch up zooms in"); - assert_eq!(scroll_steps(240, 30.0), -2.0, "two notches down"); - assert_eq!(scroll_steps(0, -7.5), 0.5, "touchpad: 15 px per step"); - assert_eq!(scroll_steps(0, 0.0), 0.0); - } - - #[test] - fn radius_steps_and_bounds() { - assert!((radius(180.0, 1.0, 1.15) - 207.0).abs() < 1e-9); - assert_eq!(radius(25.0, -10.0, 1.15), MIN_RADIUS); - assert_eq!(radius(1900.0, 10.0, 1.15), MAX_RADIUS); - } -} -``` - -In `src/config.rs` tests, change `unknown_keys_are_reported_not_rejected` to use an `[editor]` table (zoom becomes known): - -```rust - let text = "[editor]\nbackend = \"satty\"\n\n[save]\nfolder = \"x\"\n"; - let (config, unknown) = parse(text).unwrap(); - assert_eq!(config, Config::default()); - assert_eq!(unknown, vec!["editor".to_string(), "save.folder".to_string()]); -``` - -and add: - -```rust - #[test] - fn zoom_defaults_overrides_and_bounds() { - assert_eq!( - Config::default().zoom, - Zoom { - scroll_step: 1.15, - flashlight_radius: 180.0 - } - ); - let (config, unknown) = parse("[zoom]\nscroll_step = 1.3\n").unwrap(); - assert!(unknown.is_empty()); - assert_eq!(config.zoom.scroll_step, 1.3); - assert_eq!(config.zoom.flashlight_radius, 180.0); - let err = parse("[zoom]\nscroll_step = 1.0\n").unwrap_err(); - assert_eq!(err.to_string(), "zoom.scroll_step must be greater than 1"); - let err = parse("[zoom]\nflashlight_radius = 5.0\n").unwrap_err(); - assert_eq!( - err.to_string(), - "zoom.flashlight_radius must be between 20 and 2000" - ); - } -``` - -- [ ] **Step 2: Run, watch them fail** - -Run: `cargo nextest run zoom:: config::` -Expected: compilation fails (`View`, `Animated`, `scroll_steps`, `radius`, `Zoom` missing). - -- [ ] **Step 3: Implement `view.rs`** (above the tests) - -```rust -//! The zoom view: which part of the frozen frame fills the screen. -//! -//! Everything is in the frame's physical pixels. The surface's buffer has -//! the frame's size, so at 1× one screen pixel is one frame pixel. - -use crate::frame::PixelRect; - -pub const MIN_SCALE: f64 = 1.0; -pub const MAX_SCALE: f64 = 32.0; -pub const MIN_RADIUS: f64 = 20.0; -pub const MAX_RADIUS: f64 = 2000.0; -/// Logical px of touchpad scrolling per zoom step. -const TOUCHPAD_PX_PER_STEP: f64 = 15.0; -/// Per second: 1 - e^(-RATE * 0.12) ≈ 0.9, 90 % of the way in 120 ms. -const RATE: f64 = 19.2; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct View { - pub scale: f64, - /// The frame pixel at the screen's top-left corner. - pub origin: (f64, f64), -} - -impl View { - pub const IDENTITY: View = View { - scale: 1.0, - origin: (0.0, 0.0), - }; - - /// The frame point under screen point `p`. - pub fn to_frame(&self, p: (f64, f64)) -> (f64, f64) { - ( - self.origin.0 + p.0 / self.scale, - self.origin.1 + p.1 / self.scale, - ) - } - - /// Zooms by `factor`, keeping the frame point under `p` in place. - pub fn zoom_at(&self, p: (f64, f64), factor: f64, size: (f64, f64)) -> View { - let f = self.to_frame(p); - let scale = (self.scale * factor).clamp(MIN_SCALE, MAX_SCALE); - View { - scale, - origin: (f.0 - p.0 / scale, f.1 - p.1 / scale), - } - .clamped(size) - } - - /// Moves the image along with a pointer that moved `delta` screen px. - pub fn pan(&self, delta: (f64, f64), size: (f64, f64)) -> View { - View { - scale: self.scale, - origin: ( - self.origin.0 - delta.0 / self.scale, - self.origin.1 - delta.1 / self.scale, - ), - } - .clamped(size) - } - - /// Keeps the view inside a frame of `size` px. - pub fn clamped(self, size: (f64, f64)) -> View { - let max = |len: f64| len - len / self.scale; - View { - scale: self.scale, - origin: ( - self.origin.0.clamp(0.0, max(size.0)), - self.origin.1.clamp(0.0, max(size.1)), - ), - } - } - - /// The frame pixels on screen, rounded outwards and inside the frame. - pub fn visible_rect(&self, size: (u32, u32)) -> PixelRect { - let span = |origin: f64, len: u32| { - let len = len as f64; - let start = origin.floor().clamp(0.0, len - 1.0); - let end = (origin + len / self.scale).ceil().clamp(start + 1.0, len); - (start as u32, (end - start) as u32) - }; - let (x, width) = span(self.origin.0, size.0); - let (y, height) = span(self.origin.1, size.1); - PixelRect { - x, - y, - width, - height, - } - } -} - -/// The view on screen, easing towards the one asked for. -#[derive(Debug, Clone, Copy)] -pub struct Animated { - pub shown: View, - pub target: View, -} - -impl Animated { - pub fn new() -> Self { - Animated { - shown: View::IDENTITY, - target: View::IDENTITY, - } - } - - /// Jumps to `view` without easing (panning). - pub fn set(&mut self, view: View) { - self.shown = view; - self.target = view; - } - - /// Advances by `dt` seconds. True while still moving. - pub fn step(&mut self, dt: f64) -> bool { - let k = 1.0 - (-RATE * dt).exp(); - let (s, t) = (&mut self.shown, self.target); - let lerp = |a: f64, b: f64| a + (b - a) * k; - s.scale = lerp(s.scale, t.scale); - s.origin = (lerp(s.origin.0, t.origin.0), lerp(s.origin.1, t.origin.1)); - let settled = (s.scale - t.scale).abs() < 1e-3 - && (s.origin.0 - t.origin.0).abs() < 0.05 - && (s.origin.1 - t.origin.1).abs() < 0.05; - if settled { - self.shown = t; - } - !settled - } -} - -/// Zoom steps in one axis event: wheel notches (`value120`), or touchpad -/// pixels when there are none. Positive zooms in (scrolling up). -pub fn scroll_steps(value120: i32, absolute: f64) -> f64 { - if value120 != 0 { - -(value120 as f64) / 120.0 - } else { - -absolute / TOUCHPAD_PX_PER_STEP - } -} - -/// The flashlight radius after `steps` scroll steps of `step` each. -pub fn radius(r: f64, steps: f64, step: f64) -> f64 { - (r * step.powf(steps)).clamp(MIN_RADIUS, MAX_RADIUS) -} -``` - -(Check `visible_rect_rounds_outwards_inside_the_frame`: origin 100.5, width 1920/4 = 480 → start 100, end ceil(580.5) = 581 → 481; y: 200.25 + 270 = 470.25 → end 471, start 200 → 271. Edge: 1440 + 480 = 1920 → 480; 810 + 270 = 1080 → 270.) - -- [ ] **Step 4: Implement `config::Zoom`** - -In `src/config.rs`: add `pub zoom: Zoom,` to `Config`; add - -```rust -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(default)] -pub struct Zoom { - pub scroll_step: f64, - pub flashlight_radius: f64, -} - -impl Default for Zoom { - fn default() -> Self { - Self { - scroll_step: 1.15, - flashlight_radius: 180.0, - } - } -} -``` - -and in `parse`, after the timeout check: - -```rust - if config.zoom.scroll_step <= 1.0 { - bail!("zoom.scroll_step must be greater than 1"); - } - if !(20.0..=2000.0).contains(&config.zoom.flashlight_radius) { - bail!("zoom.flashlight_radius must be between 20 and 2000"); - } -``` - -- [ ] **Step 5: Run** - -Run: `cargo nextest run` → all pass (previous 121 + 10 view + 1 config). `cargo clippy -- -D warnings`, `cargo fmt --check` clean. (`Animated::new` without `Default` may trip `clippy::new_without_default`; if so add `impl Default for Animated` delegating to `new` and ledger it.) - -- [ ] **Step 6: Commit** — `jj commit -m "feat: zoom view math and config"` - ---- - -### Task 2: EGL/GLES renderer, backend switch, doctor, packaging - -**Files:** Modify `Cargo.toml`, `nix/package.ulu.nix`, `src/doctor.rs`, `src/zoom/mod.rs`; create `src/zoom/gl.rs`. - -**Interfaces — Produces:** -- `zoom::gl::probe(conn: &Connection) -> Result` (EGL vendor when a GLES 3 window config exists) -- `zoom::gl::Renderer::new(conn: &Connection, surface: &WlSurface, size: (u32, u32), frame: &Bgrx) -> Result`; `Renderer::draw(&self, view: &View, flashlight: Option<(f64, f64, f64)>) -> Result<()>` (centre and radius in buffer px); `Drop` releases EGL. -- `doctor::check_egl(result: Result) -> Check` - -- [ ] **Step 1: Failing test** — in `src/doctor.rs` tests: - -```rust - #[test] - fn egl_check_levels() { - let ok = check_egl(Ok("Mesa Project".into())); - assert_eq!(ok.level, Level::Ok); - assert!(ok.text.contains("Mesa Project"), "{}", ok.text); - let missing = check_egl(Err(anyhow::anyhow!("could not load libEGL.so.1"))); - assert_eq!(missing.level, Level::Warn, "only zoom needs it"); - assert!(missing.text.contains("zoom"), "{}", missing.text); - } -``` - -(Check `Check`'s field names in `src/doctor.rs` and use them; the test above assumes `level` and `text`.) - -Run: `cargo nextest run doctor::` → fails, `check_egl` missing. - -- [ ] **Step 2: Dependencies** — in `Cargo.toml` `[dependencies]` (alphabetical): - -```toml -glow = "0.18.0" -khronos-egl = { version = "6.0.0", features = ["dynamic"] } -libloading = "0.8" -wayland-backend = { version = "0.3.17", features = ["client_system"] } -wayland-egl = "0.32.11" -``` - -- [ ] **Step 3: `check_egl` and the doctor line** - -```rust -/// EGL with an OpenGL ES 3 config; only zoom needs it. -pub fn check_egl(result: Result) -> Check { - match result { - Ok(vendor) => check(Level::Ok, format!("EGL ({vendor}) with OpenGL ES 3 (zoom)")), - Err(e) => check( - Level::Warn, - format!("no OpenGL ES 3 through EGL: {} (zoom won't work)", one_line(&e)), - ), - } -} -``` - -In `run`, inside `Ok(wl) =>` after the outputs loop: `checks.push(check_egl(crate::zoom::gl::probe(&wl.conn)));` - -- [ ] **Step 4: `src/zoom/gl.rs`** — add `pub mod gl;` to `src/zoom/mod.rs`. - -```rust -//! OpenGL ES 3 through EGL on a Wayland surface: just enough to show the -//! frozen frame zoomed in. - -use anyhow::{Context, Result, anyhow}; -use glow::HasContext; -use khronos_egl as egl; -use wayland_client::{Connection, Proxy, protocol::wl_surface::WlSurface}; - -use super::view::View; -use crate::frame::Bgrx; - -type Egl = egl::DynamicInstance; - -/// `EGL_PLATFORM_WAYLAND_KHR`. -const PLATFORM_WAYLAND: egl::Enum = 0x31D8; - -const VERTEX: &str = "#version 300 es -// One triangle that covers the screen. -void main() { - vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} -"; - -const FRAGMENT: &str = "#version 300 es -precision highp float; -uniform sampler2D frame; -uniform float scale; -// The frame pixel at the screen's top-left. -uniform vec2 origin; -// Centre and radius in screen px; radius 0 means off. -uniform vec3 flashlight; -out vec4 color; -void main() { - ivec2 size = textureSize(frame, 0); - // gl_FragCoord starts at the bottom-left; the frame at the top-left. - vec2 screen = vec2(gl_FragCoord.x, float(size.y) - gl_FragCoord.y); - ivec2 texel = clamp(ivec2(floor(origin + screen / scale)), ivec2(0), size - 1); - // The texture holds the frame's B, G, R, X bytes. - vec3 rgb = texelFetch(frame, texel, 0).bgr; - if (flashlight.z > 0.0) { - float d = distance(screen, flashlight.xy); - rgb *= mix(1.0, 0.25, smoothstep(flashlight.z - 1.5, flashlight.z + 1.5, d)); - } - color = vec4(rgb, 1.0); -} -"; - -/// Loads libEGL and opens the compositor's EGL display. -fn open(conn: &Connection) -> Result<(Egl, egl::Display)> { - let lib = unsafe { libloading::Library::new("libEGL.so.1") } - .context("could not load libEGL.so.1")?; - let egl = unsafe { Egl::load_required_from(lib) } - .map_err(|e| anyhow!("libEGL.so.1 has no EGL 1.5: {e}"))?; - let display = unsafe { - egl.get_platform_display( - PLATFORM_WAYLAND, - conn.backend().display_ptr().cast(), - &[egl::ATTRIB_NONE], - ) - } - .context("no EGL display for the Wayland connection")?; - egl.initialize(display).context("could not initialise EGL")?; - Ok((egl, display)) -} - -fn config(egl: &Egl, display: egl::Display) -> Result { - #[rustfmt::skip] - let attributes = [ - egl::SURFACE_TYPE, egl::WINDOW_BIT, - egl::RENDERABLE_TYPE, egl::OPENGL_ES3_BIT, - egl::RED_SIZE, 8, egl::GREEN_SIZE, 8, egl::BLUE_SIZE, 8, - egl::NONE, - ]; - egl.choose_first_config(display, &attributes) - .context("eglChooseConfig failed")? - .context("no EGL config for OpenGL ES 3 windows") -} - -/// The EGL vendor, if a window config for OpenGL ES 3 exists. -pub fn probe(conn: &Connection) -> Result { - let (egl, display) = open(conn)?; - let result = config(&egl, display).map(|_| { - egl.query_string(Some(display), egl::VENDOR) - .map(|v| v.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "unknown vendor".into()) - }); - let _ = egl.terminate(display); - result -} - -/// Draws the frozen frame on one surface. -pub struct Renderer { - gl: glow::Context, - scale: glow::UniformLocation, - origin: glow::UniformLocation, - flashlight: glow::UniformLocation, - egl: Egl, - display: egl::Display, - context: egl::Context, - surface: egl::Surface, - // Destroyed after the EGL surface that draws into it. - _window: wayland_egl::WlEglSurface, -} - -impl Renderer { - /// A GL surface of `size` buffer px on `surface`, showing `frame`. - pub fn new( - conn: &Connection, - surface: &WlSurface, - size: (u32, u32), - frame: &Bgrx, - ) -> Result { - let (egl, display) = open(conn)?; - egl.bind_api(egl::OPENGL_ES_API) - .context("no OpenGL ES in EGL")?; - let config = config(&egl, display)?; - let context = egl - .create_context(display, config, None, &[egl::CONTEXT_MAJOR_VERSION, 3, egl::NONE]) - .context("could not create an OpenGL ES 3 context")?; - let window = wayland_egl::WlEglSurface::new(surface.id(), size.0 as i32, size.1 as i32) - .context("could not create a wl_egl_window")?; - let egl_surface = unsafe { - egl.create_window_surface(display, config, window.ptr() as egl::NativeWindowType, None) - } - .context("could not create the EGL window surface")?; - egl.make_current(display, Some(egl_surface), Some(egl_surface), Some(context)) - .context("eglMakeCurrent failed")?; - // valw paces drawing with frame callbacks; swaps must not block. - egl.swap_interval(display, 0) - .context("eglSwapInterval failed")?; - - let gl = unsafe { - glow::Context::from_loader_function(|name| { - egl.get_proc_address(name) - .map_or(std::ptr::null(), |f| f as *const _) - }) - }; - let (scale, origin, flashlight) = unsafe { setup(&gl, size, frame)? }; - Ok(Renderer { - gl, - scale, - origin, - flashlight, - egl, - display, - context, - surface: egl_surface, - _window: window, - }) - } - - /// Draws `view` and swaps (which commits the surface). - pub fn draw(&self, view: &View, flashlight: Option<(f64, f64, f64)>) -> Result<()> { - let (x, y, r) = flashlight.unwrap_or_default(); - unsafe { - self.gl.uniform_1_f32(Some(&self.scale), view.scale as f32); - self.gl - .uniform_2_f32(Some(&self.origin), view.origin.0 as f32, view.origin.1 as f32); - self.gl - .uniform_3_f32(Some(&self.flashlight), x as f32, y as f32, r as f32); - self.gl.draw_arrays(glow::TRIANGLES, 0, 3); - } - self.egl - .swap_buffers(self.display, self.surface) - .context("eglSwapBuffers failed") - } -} - -impl Drop for Renderer { - fn drop(&mut self) { - let _ = self.egl.make_current(self.display, None, None, None); - let _ = self.egl.destroy_surface(self.display, self.surface); - let _ = self.egl.destroy_context(self.display, self.context); - let _ = self.egl.terminate(self.display); - } -} - -/// Compiles the program, uploads the frame and returns the uniforms. -unsafe fn setup( - gl: &glow::Context, - size: (u32, u32), - frame: &Bgrx, -) -> Result<(glow::UniformLocation, glow::UniformLocation, glow::UniformLocation)> { - unsafe { - tracing::info!("zoom renderer: {}", gl.get_parameter_string(glow::RENDERER)); - let program = gl.create_program().map_err(|e| anyhow!(e))?; - for (kind, source) in [(glow::VERTEX_SHADER, VERTEX), (glow::FRAGMENT_SHADER, FRAGMENT)] { - let shader = gl.create_shader(kind).map_err(|e| anyhow!(e))?; - gl.shader_source(shader, source); - gl.compile_shader(shader); - if !gl.get_shader_compile_status(shader) { - anyhow::bail!("shader: {}", gl.get_shader_info_log(shader)); - } - gl.attach_shader(program, shader); - } - gl.link_program(program); - if !gl.get_program_link_status(program) { - anyhow::bail!("program: {}", gl.get_program_info_log(program)); - } - gl.use_program(Some(program)); - // GLES 3 needs a bound vertex array even without attributes. - let vao = gl.create_vertex_array().map_err(|e| anyhow!(e))?; - gl.bind_vertex_array(Some(vao)); - - let texture = gl.create_texture().map_err(|e| anyhow!(e))?; - gl.bind_texture(glow::TEXTURE_2D, Some(texture)); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); - gl.tex_image_2d( - glow::TEXTURE_2D, - 0, - glow::RGBA8 as i32, - frame.width() as i32, - frame.height() as i32, - 0, - glow::RGBA, - glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice(Some(frame.as_raw())), - ); - gl.viewport(0, 0, size.0 as i32, size.1 as i32); - - let uniform = |name: &str| { - gl.get_uniform_location(program, name) - .with_context(|| format!("no uniform {name}")) - }; - gl.uniform_1_i32(uniform("frame").ok().as_ref(), 0); - Ok((uniform("scale")?, uniform("origin")?, uniform("flashlight")?)) - } -} -``` - -(Exact `glow` 0.18 / `khronos-egl` 6 signatures may differ slightly, e.g. `tex_image_2d`'s pixel argument or `swap_interval`'s; adapt to what the compiler says and keep the behaviour.) - -- [ ] **Step 5: Packaging** — in `nix/package.ulu.nix`, `postInstall` becomes: - -```nix - # Clicking a preview opens Satty. --suffix keeps a user's own Satty first. - # Zoom loads libEGL.so.1 (libglvnd); Mesa's drivers come from the system. - postInstall = '' - wrapProgram $out/bin/valw \ - --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} \ - --suffix LD_LIBRARY_PATH : ${lib.makeLibraryPath [ pkgs.libglvnd ]} - ''; -``` - -- [ ] **Step 6: Run** — `cargo nextest run` (all pass, +1), clippy and fmt clean. Then `cargo run -q -- doctor 2>&1 | grep -i egl` with `LD_LIBRARY_PATH` set to libglvnd's lib dir (`nix build --no-link --print-out-paths nixpkgs#libglvnd`): expect `ok EGL (Mesa Project) with OpenGL ES 3 (zoom)`. doctor only reads; it's safe on the live session. - -- [ ] **Step 7: Commit** — `jj commit -m "feat: EGL/GLES 3 renderer for zoom"` - ---- - -### Task 3: The zoom mode - -**Files:** Modify `src/zoom/mod.rs`, `src/wayland.rs`, `src/main.rs`. - -**Interfaces:** -- Consumes: Task 1 `view::*`, `config::Zoom`; Task 2 `gl::Renderer`. -- Produces: `zoom::run(wl: &mut Wayland, output: &Output, frame: &Frame, config: &config::Zoom) -> Result`; `enum Outcome { Capture(PixelRect), Leave }`; `enum Command { Leave, Capture, Flashlight, Reset }`, `fn command(Keysym) -> Option`; `Zoom::{configure, frame_done, closed, pointer, key, modifiers}`; `State::zoom: Option`. - -- [ ] **Step 1: Failing tests** — append to `src/zoom/mod.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn keys() { - assert_eq!(command(Keysym::Escape), Some(Command::Leave)); - assert_eq!(command(Keysym::q), Some(Command::Leave)); - assert_eq!(command(Keysym::c), Some(Command::Capture)); - assert_eq!(command(Keysym::f), Some(Command::Flashlight)); - assert_eq!(command(Keysym::_0), Some(Command::Reset)); - assert_eq!(command(Keysym::a), None); - } -} -``` - -and to `flag_conflicts` in `src/main.rs`: - -```rust - assert!(parses(&["zoom", "--cursor", "--no-preview"])); - assert!(!parses(&["zoom", "--clipboard-only", "-o", "a.png"])); -``` - -Run: `cargo nextest run zoom::tests flag_conflicts` → fails to compile (`command` missing). - -- [ ] **Step 2: Implement `src/zoom/mod.rs`** - -```rust -//! Zoom mode: the frozen focused output, zoomed and panned on the GPU. - -pub mod gl; -pub mod view; - -use std::sync::Arc; -use std::time::Instant; - -use anyhow::{Context, Result}; -use smithay_client_toolkit::{ - compositor::FrameCallbackData, - seat::{ - keyboard::{Keysym, Modifiers}, - pointer::{BTN_LEFT, PointerEvent, PointerEventKind}, - }, - shell::{ - WaylandSurface, - wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface}, - }, -}; -use wayland_client::{QueueHandle, protocol::wl_surface::WlSurface}; -use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{ - Shape, WpCursorShapeDeviceV1, -}; -use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; - -use self::view::{Animated, View}; -use crate::config; -use crate::error::HintExt; -use crate::frame::{Frame, PixelRect}; -use crate::wayland::{Output, State, Wayland}; - -pub const NAMESPACE: &str = "valw-zoom"; - -/// How a zoom session ended. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Outcome { - /// `c`: this part of the frame. - Capture(PixelRect), - /// Esc or `q`: nothing to save. - Leave, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Command { - Leave, - Capture, - Flashlight, - Reset, -} - -pub fn command(key: Keysym) -> Option { - match key { - Keysym::Escape | Keysym::q => Some(Command::Leave), - Keysym::c => Some(Command::Capture), - Keysym::f => Some(Command::Flashlight), - Keysym::_0 => Some(Command::Reset), - _ => None, - } -} - -pub struct Zoom { - // Dropped first: the EGL surface goes before the wl_surface under it. - renderer: gl::Renderer, - layer: LayerSurface, - viewport: WpViewport, - /// Frame size in physical px. - size: (u32, u32), - /// Buffer px per surface-local (logical) px. - ratio: f64, - view: Animated, - step: f64, - /// Pointer position in buffer px. - pointer: (f64, f64), - /// Last pointer position while the left button pans. - panning: Option<(f64, f64)>, - flashlight: bool, - /// Flashlight radius in logical px. - radius: f64, - ctrl: bool, - configured: bool, - frame_pending: bool, - dirty: bool, - last_draw: Option, - outcome: Option, - error: Option, -} - -/// Shows `frame` (the frozen `output`) until the user leaves or captures. -pub fn run( - wl: &mut Wayland, - output: &Output, - frame: &Frame, - config: &config::Zoom, -) -> Result { - let qh = wl.queue.handle(); - let s = &wl.state; - let layer_shell = s - .layer_shell - .as_ref() - .context("compositor does not support wlr-layer-shell") - .hint("run `valw doctor` to see what the compositor supports")?; - let viewporter = s - .viewporter - .as_ref() - .context("compositor does not support wp-viewporter") - .hint("run `valw doctor` to see what the compositor supports")?; - let surface = s.compositor.create_surface(&qh); - let layer = layer_shell.create_layer_surface( - &qh, - surface, - Layer::Overlay, - Some(NAMESPACE), - Some(&output.wl), - ); - layer.set_anchor(Anchor::all()); - layer.set_exclusive_zone(-1); - layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive); - layer.commit(); - let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ()); - let pixels: &Arc<_> = &frame.pixels; - let size = pixels.dimensions(); - let renderer = gl::Renderer::new(&wl.conn, layer.wl_surface(), size, pixels) - .context("could not start OpenGL ES for zoom") - .hint("run `valw doctor` to check EGL")?; - - wl.state.zoom = Some(Zoom { - renderer, - layer, - viewport, - size, - ratio: 1.0, - view: Animated::new(), - step: config.scroll_step, - pointer: (0.0, 0.0), - panning: None, - flashlight: false, - radius: config.flashlight_radius, - ctrl: false, - configured: false, - frame_pending: false, - dirty: true, - last_draw: None, - outcome: None, - error: None, - }); - let result = wl.dispatch_blocking(|s| { - s.zoom - .as_ref() - .is_some_and(|z| z.outcome.is_some() || z.error.is_some()) - }); - let ended = wl.state.zoom.take().map(|z| (z.outcome, z.error)); - // Dropping the zoom destroys its surface; make that visible right away. - let _ = wl.conn.flush(); - result?; - match ended { - Some((_, Some(e))) => Err(e), - Some((Some(outcome), None)) => Ok(outcome), - _ => Ok(Outcome::Leave), - } -} - -impl Zoom { - fn is(&self, surface: &WlSurface) -> bool { - self.layer.wl_surface() == surface - } - - fn sizef(&self) -> (f64, f64) { - (self.size.0 as f64, self.size.1 as f64) - } - - pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle) { - if !self.is(layer.wl_surface()) { - return; - } - if w > 0 { - self.ratio = self.size.0 as f64 / w as f64; - } - // Show the physical-size buffer 1:1 on the logical-size surface. - self.viewport.set_destination(w as i32, h as i32); - self.configured = true; - self.request_draw(qh); - } - - pub fn closed(&mut self, layer: &LayerSurface) { - if self.is(layer.wl_surface()) { - self.outcome.get_or_insert(Outcome::Leave); - } - } - - pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle) { - if !self.is(surface) { - return; - } - self.frame_pending = false; - if self.dirty { - self.draw(qh); - } - } - - fn request_draw(&mut self, qh: &QueueHandle) { - self.dirty = true; - if self.configured && !self.frame_pending { - self.draw(qh); - } - } - - fn draw(&mut self, qh: &QueueHandle) { - let now = Instant::now(); - let dt = self - .last_draw - .map_or(0.0, |t| now.duration_since(t).as_secs_f64().min(0.1)); - let moving = self.view.step(dt); - self.last_draw = moving.then_some(now); - let surface = self.layer.wl_surface(); - surface.frame(qh, FrameCallbackData(surface.clone())); - let flashlight = self - .flashlight - .then(|| (self.pointer.0, self.pointer.1, self.radius * self.ratio)); - if let Err(e) = self.renderer.draw(&self.view.shown, flashlight) { - self.error = Some(e); - return; - } - self.frame_pending = true; - self.dirty = moving; - } - - pub fn pointer( - &mut self, - events: &[PointerEvent], - cursor: Option<&WpCursorShapeDeviceV1>, - qh: &QueueHandle, - ) { - for event in events { - if !self.is(&event.surface) { - continue; - } - let p = (event.position.0 * self.ratio, event.position.1 * self.ratio); - match event.kind { - PointerEventKind::Enter { serial } => { - if let Some(cursor) = cursor { - cursor.set_shape(serial, Shape::Default); - } - self.pointer = p; - } - PointerEventKind::Press { - button: BTN_LEFT, - serial, - .. - } => { - self.panning = Some(p); - if let Some(cursor) = cursor { - cursor.set_shape(serial, Shape::Grabbing); - } - } - PointerEventKind::Release { - button: BTN_LEFT, - serial, - .. - } => { - self.panning = None; - if let Some(cursor) = cursor { - cursor.set_shape(serial, Shape::Default); - } - } - PointerEventKind::Motion { .. } => { - self.pointer = p; - if let Some(last) = self.panning { - let size = self.sizef(); - let moved = self.view.target.pan((p.0 - last.0, p.1 - last.1), size); - self.view.set(moved); - self.panning = Some(p); - self.request_draw(qh); - } else if self.flashlight { - self.request_draw(qh); - } - } - PointerEventKind::Axis { vertical, .. } => { - let steps = view::scroll_steps(vertical.value120, vertical.absolute); - if steps == 0.0 { - continue; - } - if self.ctrl { - self.radius = view::radius(self.radius, steps, self.step); - } else { - let size = self.sizef(); - self.view.target = - self.view.target.zoom_at(p, self.step.powf(steps), size); - } - self.request_draw(qh); - } - _ => {} - } - } - } - - pub fn key(&mut self, key: Keysym, qh: &QueueHandle) { - match command(key) { - Some(Command::Leave) => { - self.outcome.get_or_insert(Outcome::Leave); - } - Some(Command::Capture) => { - let rect = self.view.target.visible_rect(self.size); - tracing::info!("zoom capture {rect:?} at {:.2}x", self.view.target.scale); - self.outcome.get_or_insert(Outcome::Capture(rect)); - } - Some(Command::Flashlight) => { - self.flashlight = !self.flashlight; - self.request_draw(qh); - } - Some(Command::Reset) => { - self.view.target = View::IDENTITY; - self.request_draw(qh); - } - None => {} - } - } - - pub fn modifiers(&mut self, modifiers: Modifiers) { - self.ctrl = modifiers.ctrl; - } -} -``` - -- [ ] **Step 3: Route events in `src/wayland.rs`** - -- `use crate::zoom::Zoom;`; field `/// Zoom mode, while it is open.` `pub zoom: Option,` after `overlay`; `zoom: None,` in `connect`. -- `frame`: add `if let Some(zoom) = &mut self.zoom { zoom.frame_done(surface, qh); }`. -- `closed`: add `if let Some(zoom) = &mut self.zoom { zoom.closed(layer); }`. -- `configure`: add `if let Some(zoom) = &mut self.zoom { zoom.configure(layer, configure.new_size, qh); }`. -- `press_key` (take `qh` instead of `_`): - -```rust - if let Some(overlay) = &mut self.overlay { - match event.keysym { - Keysym::Escape => overlay.cancel(), - Keysym::space => overlay.switch_to_window(), - _ => {} - } - } - if let Some(zoom) = &mut self.zoom { - zoom.key(event.keysym, qh); - } -``` - -- `update_modifiers` (name the `Modifiers` argument `modifiers`): `if let Some(zoom) = &mut self.zoom { zoom.modifiers(modifiers); }`. -- `pointer_frame`: add `if let Some(zoom) = &mut self.zoom { zoom.pointer(events, self.cursor_device.as_ref(), qh); }`. - -- [ ] **Step 4: `src/main.rs`** - -Remove the Task 1 allowance. `enum Command` after `Window`: - -```rust - /// Freeze the screen and zoom in: wheel, drag, f, c, 0, Esc. - Zoom { - #[command(flatten)] - common: Common, - }, -``` - -`Mode::Zoom`; in `run`: `Command::Zoom { common } => capture(Mode::Zoom, common),`; in `capture`'s match, after `Mode::Window`: - -```rust - Mode::Zoom => { - let focused = focused_output(&outputs); - let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); - match zoom::run(&mut wl, &outputs[focused], &frame, &config.zoom)? { - zoom::Outcome::Capture(r) => { - let source = frame.output.name.clone(); - (vec![(frame.to_rgba(r), None)], 0, source) - } - zoom::Outcome::Leave => { - tracing::info!("left zoom without capturing"); - return Ok(()); - } - } - } -``` - -- [ ] **Step 5: Run** — `cargo nextest run` (all pass, +1), clippy and fmt clean. - -- [ ] **Step 6: Commit** — `jj commit -m "feat: valw zoom"` - ---- - -### Task 4: Smoke run, checks and checklist - -- [ ] **Step 1: Smoke run** (tell the user first: it covers the focused output and takes the keyboard for 3 s) - -Run: `LD_LIBRARY_PATH=/lib timeout -s INT 3 cargo run -q -- zoom; echo exit=$?` then read the newest log in `~/.local/state/valw/` (or wherever `log::default_dir()` points). -Expected: `zoom renderer: Mesa Intel(R) HD Graphics 4000 …`, no errors; exit from the signal. - -- [ ] **Step 2: Full checks** — `nix flake check -L --keep-going`: all pass (headless sway now on libwayland-client). - -- [ ] **Step 3: Checklist** — append to `docs/test-checklist.md`: - -```markdown - -## Zoom (Phase 4) - -- [ ] `valw zoom` → the focused output freezes at 1×; Esc and `q` leave, nothing saved, exit 0, thumbnails come back. -- [ ] Wheel zooms towards the pointer (the point under it stays put), smoothly, up to 32×; pixels stay crisp. -- [ ] Touchpad scrolling zooms smoothly too. -- [ ] Left drag pans 1:1 and never shows anything outside the screenshot; the cursor turns into a grabbing hand. -- [ ] `f` toggles the flashlight (outside dimmed to 25 %), it follows the pointer; Ctrl+wheel resizes it. -- [ ] `0` animates back to 1×. -- [ ] `c` at some zoom → the saved image is exactly the visible area at native resolution; preview and clipboard as usual. -- [ ] On the 1366×768 laptop panel and the 1920×1080 monitor; stays smooth (no stutter) on the HD 4000. -- [ ] `[zoom] scroll_step` / `flashlight_radius` in the config take effect; `valw doctor` shows the EGL line. -``` - -- [ ] **Step 4: Commit** — `jj commit -m "docs: zoom checklist"` diff --git a/docs/superpowers/plans/2026-10-02-valw-phase-5a-editor.md b/docs/superpowers/plans/2026-10-02-valw-phase-5a-editor.md deleted file mode 100644 index 78d72c6..0000000 --- a/docs/superpowers/plans/2026-10-02-valw-phase-5a-editor.md +++ /dev/null @@ -1,1284 +0,0 @@ -# valw Phase 5a: built-in editor basics 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:** `valw edit `: a Markup-like window with arrow/rect/ellipse/line/pen/highlighter, colours, widths, undo/redo, save/copy; the preview opens it instead of Satty. - -**Architecture:** Pure modules first (`editor/shape.rs` tools, constraints and shared geometry; `editor/doc.rs` history; `editor/view.rs` fitting; `editor/export.rs` tiny-skia rendering), then the eframe (glow) app in `editor/mod.rs`. Copy goes through a hidden `valw __clipboard` child (no fork inside a multithreaded GUI process). Runtime libraries come from the binary's RUNPATH instead of `LD_LIBRARY_PATH`. - -**Tech Stack:** Rust 2024, `eframe`/`egui` 0.36.2 (`glow`, `wayland`, `default_fonts`; no wgpu, no x11), `tiny-skia` 0.12. - -**Spec:** `docs/superpowers/specs/2026-10-02-valw-phase-5a-editor-design.md` - -## Global Constraints - -- jj only; `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`. -- No Vulkan, no wgpu. App id `valw-editor`; title ` — valw`. -- Colours `#ff3b30` (default) `#ff9500` `#ffcc00` `#34c759` `#007aff` black white; widths 2/4/8 image px (default 4); highlighter = colour at 40 % opacity (alpha 102), width × 4; arrow head length `max(12, 4 × width)`, 30° half-angle; clicks under 2 image px are discarded except Pen (dot). -- Status messages last 2 s. Ctrl+S overwrites; Ctrl+Shift+S writes ` edited.png` (unique). -- Tell the user before opening editor windows on the live session. - -## Review Focus - -1. **Export ≠ screen:** shapes must land at the same image pixels in the saved PNG as on the canvas, at any window size. Tests: `editor::view::tests::round_trip`, `editor::export::tests::line_pixels` (Tasks 2–3). -2. **Images with alpha** (window shots from Phase 3): export must keep transparency, not turn it black or premultiply twice. Test: `editor::export::tests::keeps_transparency` (Task 2). -3. **Unsaved changes on close** (Esc or the compositor's close): never lose edits silently. Test: `editor::doc::tests::*dirty*` (Task 1); real check in the checklist. -4. **Undo/redo after save:** dirty must track content, not a counter. Test: `editor::doc::tests::undo_past_a_save_is_dirty` (Task 1). -5. **The editor closes while the clipboard must live on:** Ctrl+C then Esc must keep the copied image available. Covered by the `__clipboard` child (Task 4), checked in the checklist. - ---- - -## File Structure - -| File | Change | Task | -|---|---|---| -| `src/editor/shape.rs` | `Tool`, `Style`, `Shape`, `Drag`, `Prim`, `constrain`, `geometry`, palette/widths | 1 | -| `src/editor/doc.rs` | `Doc` | 1 | -| `src/editor/view.rs` | `Fit` | 2 | -| `src/editor/export.rs` | `render`, `edited_path` | 2 | -| `src/config.rs` | `[editor] backend` | 3 | -| `src/host.rs` | `editor_command`, `open_editor` | 3 | -| `src/output.rs`, `src/main.rs` | `serve_clipboard`, `__clipboard` | 4 | -| `src/editor/mod.rs` | eframe app | 4 | -| `Cargo.toml`, `nix/package.ulu.nix`, `nix/devshell.ulu.nix` | deps, RUNPATH, dev `LD_LIBRARY_PATH` | 4 | -| `docs/test-checklist.md` | Phase 5a section | 5 | - ---- - -### Task 1: Shapes, geometry and history - -**Files:** create `src/editor/mod.rs` (`pub mod doc; pub mod shape;` only), `src/editor/shape.rs`, `src/editor/doc.rs`; `src/main.rs`: `mod editor;` + `// The editor lands in pieces; Task 4 removes this.` `#![allow(dead_code)]`. - -**Produces:** `shape::{Tool, Style, Shape, Drag, Prim, PALETTE, WIDTHS, constrain, geometry}`, `Shape::is_click`, `doc::Doc::{new, add, undo, redo, can_undo, can_redo, is_dirty, mark_saved, shapes}`. - -- [ ] **Step 1: Failing tests** — `src/editor/shape.rs` (tests only): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - const RED: Style = Style { color: [255, 59, 48], width: 4.0 }; - - fn shape(tool: Tool, points: &[(f32, f32)]) -> Shape { - Shape { tool, style: RED, points: points.to_vec() } - } - - fn close(a: (f32, f32), b: (f32, f32)) -> bool { - (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 - } - - #[test] - fn shift_snaps_lines_to_45_degrees() { - let end = constrain(Tool::Line, (0.0, 0.0), (100.0, 10.0), true); - assert!(close(end, ((100f32.hypot(10.0)), 0.0)), "{end:?}"); - let end = constrain(Tool::Arrow, (0.0, 0.0), (50.0, 45.0), true); - let d = 50f32.hypot(45.0) / 2f32.sqrt(); - assert!(close(end, (d, d)), "{end:?}"); - assert_eq!(constrain(Tool::Line, (0.0, 0.0), (100.0, 10.0), false), (100.0, 10.0)); - } - - #[test] - fn shift_makes_squares_and_circles() { - assert_eq!(constrain(Tool::Rectangle, (10.0, 10.0), (40.0, 20.0), true), (40.0, 40.0)); - assert_eq!(constrain(Tool::Ellipse, (10.0, 10.0), (0.0, 60.0), true), (-40.0, 60.0)); - } - - #[test] - fn clicks_are_discarded_but_a_pen_dot_is_kept() { - assert!(shape(Tool::Arrow, &[(5.0, 5.0), (6.0, 6.0)]).is_click()); - assert!(!shape(Tool::Arrow, &[(5.0, 5.0), (8.0, 5.0)]).is_click()); - assert!(shape(Tool::Highlighter, &[(5.0, 5.0), (5.5, 5.0)]).is_click()); - assert!(!shape(Tool::Pen, &[(5.0, 5.0)]).is_click()); - } - - #[test] - fn arrow_has_a_line_and_a_head_at_the_end() { - let g = geometry(&shape(Tool::Arrow, &[(0.0, 0.0), (100.0, 0.0)])); - assert_eq!(g.len(), 2); - let Prim::Stroke { points, width, .. } = &g[0] else { panic!("{g:?}") }; - assert_eq!(*width, 4.0); - assert!(close(points[0], (0.0, 0.0)) && close(points[1], (84.0, 0.0)), "{points:?}"); - let Prim::Fill { points, .. } = &g[1] else { panic!("{g:?}") }; - // Head length max(12, 4 × 4) = 16, half-width 16 × tan 30°. - let half = 16.0 * (30f32).to_radians().tan(); - assert!(close(points[0], (100.0, 0.0)), "{points:?}"); - assert!(close(points[1], (84.0, half)) && close(points[2], (84.0, -half)), "{points:?}"); - } - - #[test] - fn highlighter_is_wide_and_translucent() { - let g = geometry(&shape(Tool::Highlighter, &[(0.0, 0.0), (50.0, 0.0)])); - let Prim::Stroke { width, color, round, .. } = &g[0] else { panic!("{g:?}") }; - assert_eq!(*width, 16.0); - assert_eq!(*color, [255, 59, 48, 102]); - assert!(!round, "butt caps: overlapping caps would darken"); - } - - #[test] - fn rectangle_and_ellipse_are_closed_outlines() { - let g = geometry(&shape(Tool::Rectangle, &[(10.0, 10.0), (0.0, 20.0)])); - let Prim::Stroke { points, closed, .. } = &g[0] else { panic!("{g:?}") }; - assert!(*closed); - assert_eq!(points, &[(10.0, 10.0), (0.0, 10.0), (0.0, 20.0), (10.0, 20.0)]); - let g = geometry(&shape(Tool::Ellipse, &[(0.0, 0.0), (20.0, 10.0)])); - let Prim::Stroke { points, closed, .. } = &g[0] else { panic!("{g:?}") }; - assert!(*closed && points.len() == 72); - assert!(points.iter().all(|p| ((p.0 - 10.0) / 10.0).powi(2) + ((p.1 - 5.0) / 5.0).powi(2) - 1.0 < 1e-3)); - } - - #[test] - fn a_pen_dot_is_a_filled_circle() { - let g = geometry(&shape(Tool::Pen, &[(5.0, 5.0)])); - let Prim::Fill { points, color } = &g[0] else { panic!("{g:?}") }; - assert_eq!(*color, [255, 59, 48, 255]); - assert!(points.iter().all(|p| ((p.0 - 5.0).hypot(p.1 - 5.0) - 2.0).abs() < 1e-3)); - } - - #[test] - fn drag_collects_points_per_tool() { - let mut d = Drag::new(Tool::Pen, RED, (0.0, 0.0)); - d.move_to((1.0, 1.0)); - d.move_to((2.0, 3.0)); - assert_eq!(d.shape(false).points, vec![(0.0, 0.0), (1.0, 1.0), (2.0, 3.0)]); - let mut d = Drag::new(Tool::Rectangle, RED, (0.0, 0.0)); - d.move_to((5.0, 9.0)); - d.move_to((30.0, 10.0)); - assert_eq!(d.shape(false).points, vec![(0.0, 0.0), (30.0, 10.0)]); - assert_eq!(d.shape(true).points, vec![(0.0, 0.0), (30.0, 30.0)]); - } -} -``` - -`src/editor/doc.rs` (tests only): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::editor::shape::{Style, Tool}; - - fn line(x: f32) -> Shape { - Shape { tool: Tool::Line, style: Style { color: [0, 0, 0], width: 2.0 }, points: vec![(0.0, 0.0), (x, 0.0)] } - } - - #[test] - fn undo_and_redo_move_shapes() { - let mut d = Doc::new(); - assert!(!d.can_undo() && !d.can_redo()); - d.add(line(1.0)); - d.add(line(2.0)); - assert!(d.undo()); - assert_eq!(d.shapes(), &[line(1.0)]); - assert!(d.redo()); - assert_eq!(d.shapes(), &[line(1.0), line(2.0)]); - assert!(!d.redo()); - } - - #[test] - fn a_new_shape_clears_redo() { - let mut d = Doc::new(); - d.add(line(1.0)); - d.undo(); - d.add(line(3.0)); - assert!(!d.can_redo()); - assert_eq!(d.shapes(), &[line(3.0)]); - } - - #[test] - fn dirty_until_saved() { - let mut d = Doc::new(); - assert!(!d.is_dirty()); - d.add(line(1.0)); - assert!(d.is_dirty()); - d.mark_saved(); - assert!(!d.is_dirty()); - } - - #[test] - fn undo_past_a_save_is_dirty() { - let mut d = Doc::new(); - d.add(line(1.0)); - d.mark_saved(); - d.undo(); - assert!(d.is_dirty()); - d.redo(); - assert!(!d.is_dirty(), "back to the saved content"); - } -} -``` - -Run: `cargo nextest run editor::` → fails to compile (types missing). - -- [ ] **Step 2: Implement `shape.rs`** (above its tests) - -```rust -//! Markup tools and the geometry both painters share: the canvas (egui) -//! and the export (tiny-skia) draw the same primitives, in image pixels. - -pub type P = (f32, f32); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Tool { - Arrow, - Rectangle, - Ellipse, - Line, - Pen, - Highlighter, -} - -impl Tool { - pub const ALL: [Tool; 6] = [ - Tool::Arrow, - Tool::Rectangle, - Tool::Ellipse, - Tool::Line, - Tool::Pen, - Tool::Highlighter, - ]; - - pub fn label(self) -> &'static str { - match self { - Tool::Arrow => "Arrow", - Tool::Rectangle => "Rectangle", - Tool::Ellipse => "Ellipse", - Tool::Line => "Line", - Tool::Pen => "Pen", - Tool::Highlighter => "Highlighter", - } - } - - fn freehand(self) -> bool { - matches!(self, Tool::Pen | Tool::Highlighter) - } -} - -/// The toolbar's colours; the first is the default. -pub const PALETTE: [[u8; 3]; 7] = [ - [0xff, 0x3b, 0x30], - [0xff, 0x95, 0x00], - [0xff, 0xcc, 0x00], - [0x34, 0xc7, 0x59], - [0x00, 0x7a, 0xff], - [0x00, 0x00, 0x00], - [0xff, 0xff, 0xff], -]; - -/// S, M, L in image pixels; M is the default. -pub const WIDTHS: [(&str, f32); 3] = [("S", 2.0), ("M", 4.0), ("L", 8.0)]; - -const HIGHLIGHT_ALPHA: u8 = 102; -const CLICK: f32 = 2.0; -const ELLIPSE_POINTS: usize = 72; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Style { - pub color: [u8; 3], - pub width: f32, -} - -/// One finished mark. Two-point tools hold [start, end]; Pen and -/// Highlighter hold every point. -#[derive(Debug, Clone, PartialEq)] -pub struct Shape { - pub tool: Tool, - pub style: Style, - pub points: Vec

, -} - -impl Shape { - /// A press without a real drag. A Pen click still leaves a dot. - pub fn is_click(&self) -> bool { - if self.tool == Tool::Pen { - return false; - } - let first = self.points[0]; - self.points - .iter() - .all(|p| (p.0 - first.0).hypot(p.1 - first.1) < CLICK) - } -} - -/// A shape being drawn. -#[derive(Debug, Clone)] -pub struct Drag { - tool: Tool, - style: Style, - points: Vec

, -} - -impl Drag { - pub fn new(tool: Tool, style: Style, start: P) -> Drag { - Drag { tool, style, points: vec![start] } - } - - pub fn move_to(&mut self, p: P) { - if self.tool.freehand() || self.points.len() == 1 { - self.points.push(p); - } else { - self.points[1] = p; - } - } - - /// The shape so far; `shift` applies the tool's constraint. - pub fn shape(&self, shift: bool) -> Shape { - let mut points = self.points.clone(); - if !self.tool.freehand() && points.len() == 2 { - points[1] = constrain(self.tool, points[0], points[1], shift); - } - Shape { tool: self.tool, style: self.style, points } - } -} - -/// Shift: lines and arrows snap to 45° steps, rectangles and ellipses -/// become squares and circles. -pub fn constrain(tool: Tool, start: P, end: P, shift: bool) -> P { - if !shift { - return end; - } - let (dx, dy) = (end.0 - start.0, end.1 - start.1); - match tool { - Tool::Line | Tool::Arrow => { - let step = std::f32::consts::FRAC_PI_4; - let angle = (dy.atan2(dx) / step).round() * step; - let len = dx.hypot(dy); - (start.0 + len * angle.cos(), start.1 + len * angle.sin()) - } - Tool::Rectangle | Tool::Ellipse => { - let side = dx.abs().max(dy.abs()); - (start.0 + side.copysign(dx), start.1 + side.copysign(dy)) - } - Tool::Pen | Tool::Highlighter => end, - } -} - -/// A primitive in image pixels. Colours are straight (not premultiplied) RGBA. -#[derive(Debug, Clone, PartialEq)] -pub enum Prim { - Stroke { - points: Vec

, - width: f32, - color: [u8; 4], - closed: bool, - /// Round caps and joins; false means butt caps. - round: bool, - }, - /// A convex polygon. - Fill { points: Vec

, color: [u8; 4] }, -} - -pub fn geometry(shape: &Shape) -> Vec { - let [r, g, b] = shape.style.color; - let opaque = [r, g, b, 255]; - let w = shape.style.width; - let pts = &shape.points; - let stroke = |points: Vec

, closed: bool| Prim::Stroke { - points, - width: w, - color: opaque, - closed, - round: true, - }; - match shape.tool { - Tool::Line => vec![stroke(vec![pts[0], pts[1]], false)], - Tool::Arrow => arrow(pts[0], pts[1], w, opaque), - Tool::Rectangle => { - let ((x0, y0), (x1, y1)) = (pts[0], pts[1]); - vec![stroke(vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)], true)] - } - Tool::Ellipse => { - let ((x0, y0), (x1, y1)) = (pts[0], pts[1]); - let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0).abs() / 2.0, (y1 - y0).abs() / 2.0); - vec![stroke(polygon(cx, cy, rx, ry, ELLIPSE_POINTS), true)] - } - Tool::Pen if pts.len() == 1 => { - let (x, y) = pts[0]; - vec![Prim::Fill { points: polygon(x, y, w / 2.0, w / 2.0, 24), color: opaque }] - } - Tool::Pen => vec![stroke(pts.clone(), false)], - Tool::Highlighter => vec![Prim::Stroke { - points: pts.clone(), - width: w * 4.0, - color: [r, g, b, HIGHLIGHT_ALPHA], - closed: false, - round: false, - }], - } -} - -/// A line to the base of a filled head whose tip is at `end`. -fn arrow(start: P, end: P, width: f32, color: [u8; 4]) -> Vec { - let (dx, dy) = (end.0 - start.0, end.1 - start.1); - let len = dx.hypot(dy).max(f32::EPSILON); - let (ux, uy) = (dx / len, dy / len); - let head = (4.0 * width).max(12.0).min(len); - let half = head * 30f32.to_radians().tan(); - let base = (end.0 - ux * head, end.1 - uy * head); - vec![ - Prim::Stroke { points: vec![start, base], width, color, closed: false, round: true }, - Prim::Fill { - points: vec![end, (base.0 - uy * half, base.1 + ux * half), (base.0 + uy * half, base.1 - ux * half)], - color, - }, - ] -} - -/// `n` points around an ellipse, starting at angle 0. -fn polygon(cx: f32, cy: f32, rx: f32, ry: f32, n: usize) -> Vec

{ - (0..n) - .map(|i| { - let a = i as f32 / n as f32 * std::f32::consts::TAU; - (cx + rx * a.cos(), cy + ry * a.sin()) - }) - .collect() -} -``` - -(Check `arrow_has_a_line_and_a_head_at_the_end`: direction (1,0) → base (84,0); head points (84, +half) and (84, −half) — the second point is `base − u⊥·half` with u⊥ = (−uy, ux) = (0, 1)… compute: `(base.0 - uy*half, base.1 + ux*half)` = (84, +half) ✓, third = (84, −half) ✓.) - -- [ ] **Step 3: Implement `doc.rs`** (above its tests) - -```rust -//! The edit history: the shapes drawn so far, what undo took away, and -//! what was last saved. - -use crate::editor::shape::Shape; - -#[derive(Debug, Default)] -pub struct Doc { - shapes: Vec, - redo: Vec, - saved: Vec, -} - -impl Doc { - pub fn new() -> Doc { - Doc::default() - } - - pub fn shapes(&self) -> &[Shape] { - &self.shapes - } - - pub fn add(&mut self, shape: Shape) { - self.shapes.push(shape); - self.redo.clear(); - } - - pub fn undo(&mut self) -> bool { - let Some(shape) = self.shapes.pop() else { return false }; - self.redo.push(shape); - true - } - - pub fn redo(&mut self) -> bool { - let Some(shape) = self.redo.pop() else { return false }; - self.shapes.push(shape); - true - } - - pub fn can_undo(&self) -> bool { - !self.shapes.is_empty() - } - - pub fn can_redo(&self) -> bool { - !self.redo.is_empty() - } - - /// Whether the shapes differ from what was last saved. - pub fn is_dirty(&self) -> bool { - self.shapes != self.saved - } - - pub fn mark_saved(&mut self) { - self.saved = self.shapes.clone(); - } -} -``` - -- [ ] **Step 4: Run** — `cargo nextest run` (+12), clippy, fmt. **Step 5: Commit** `feat: editor shapes, geometry and history`. - ---- - -### Task 2: Fitting and export - -**Files:** `src/editor/view.rs`, `src/editor/export.rs`; `Cargo.toml`: `tiny-skia = "0.12.0"`; `src/editor/mod.rs`: `pub mod export; pub mod view;`. - -**Produces:** `view::Fit { scale: f32, offset: P }`, `Fit::new(image: (u32, u32), area: (f32, f32, f32, f32) /* x, y, w, h */, pixels_per_point: f32)`, `to_screen(P) -> P`, `to_image(P) -> P`, `clamp(P, image) -> P`; `export::render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage`, `export::edited_path(&Path) -> PathBuf`. - -- [ ] **Step 1: Failing tests** - -`src/editor/view.rs` tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn small_images_are_not_upscaled() { - let f = Fit::new((400, 300), (0.0, 50.0, 1000.0, 800.0), 1.0); - assert_eq!(f.scale, 1.0); - assert_eq!(f.offset, (300.0, 300.0), "centred"); - } - - #[test] - fn large_images_fit_the_area() { - let f = Fit::new((1920, 1080), (0.0, 40.0, 960.0, 1000.0), 1.0); - assert_eq!(f.scale, 0.5); - assert_eq!(f.offset, (0.0, 40.0 + (1000.0 - 540.0) / 2.0)); - } - - #[test] - fn hundred_percent_means_one_image_pixel_per_physical_pixel() { - let f = Fit::new((400, 300), (0.0, 0.0, 1000.0, 800.0), 2.0); - assert_eq!(f.scale, 0.5, "0.5 points per image px at 2× = 1 physical px"); - } - - #[test] - fn round_trip() { - let f = Fit::new((1920, 1080), (10.0, 40.0, 1000.0, 700.0), 1.25); - for p in [(0.0, 0.0), (1919.0, 1079.0), (333.5, 777.25)] { - let q = f.to_image(f.to_screen(p)); - assert!((q.0 - p.0).abs() < 1e-3 && (q.1 - p.1).abs() < 1e-3, "{p:?} {q:?}"); - } - } - - #[test] - fn clamp_keeps_points_on_the_image() { - assert_eq!(clamp((-5.0, 50.0), (100, 80)), (0.0, 50.0)); - assert_eq!(clamp((150.0, 90.0), (100, 80)), (100.0, 80.0)); - } -} -``` - -`src/editor/export.rs` tests: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::editor::shape::{Style, Tool}; - use image::Rgba; - - fn line(tool: Tool, color: [u8; 3]) -> Shape { - Shape { tool, style: Style { color, width: 4.0 }, points: vec![(10.0, 20.0), (90.0, 20.0)] } - } - - #[test] - fn line_pixels() { - let base = RgbaImage::from_pixel(100, 40, Rgba([255, 255, 255, 255])); - let out = render(&base, &[line(Tool::Line, [255, 0, 0])]); - assert_eq!(out.dimensions(), (100, 40)); - assert_eq!(out.get_pixel(50, 20).0, [255, 0, 0, 255], "on the line"); - assert_eq!(out.get_pixel(50, 5).0, [255, 255, 255, 255], "away from it"); - assert_eq!(out.get_pixel(5, 20).0, [255, 255, 255, 255], "before its start cap"); - } - - #[test] - fn highlighter_blends_at_40_percent() { - let base = RgbaImage::from_pixel(100, 40, Rgba([0, 0, 255, 255])); - let out = render(&base, &[line(Tool::Highlighter, [255, 255, 0])]); - let [r, g, b, a] = out.get_pixel(50, 20).0; - assert_eq!(a, 255); - for (got, want) in [(r, 102), (g, 102), (b, 153)] { - assert!((got as i32 - want).abs() <= 2, "{:?}", out.get_pixel(50, 20)); - } - } - - #[test] - fn keeps_transparency() { - let base = RgbaImage::from_pixel(100, 40, Rgba([10, 20, 30, 0])); - let mut half = base.clone(); - half.put_pixel(50, 5, Rgba([200, 100, 50, 128])); - let out = render(&half, &[line(Tool::Line, [255, 0, 0])]); - assert_eq!(out.get_pixel(0, 0).0[3], 0, "transparent stays transparent"); - let [r, g, b, a] = out.get_pixel(50, 5).0; - assert_eq!(a, 128); - assert!((r as i32 - 200).abs() <= 2 && (g as i32 - 100).abs() <= 2 && (b as i32 - 50).abs() <= 2); - assert_eq!(out.get_pixel(50, 20).0, [255, 0, 0, 255]); - } - - #[test] - fn edited_path_sits_next_to_the_file() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("Screenshot at 10.00.png"); - assert_eq!(edited_path(&file), dir.path().join("Screenshot at 10.00 edited.png")); - std::fs::write(dir.path().join("Screenshot at 10.00 edited.png"), b"x").unwrap(); - assert_ne!(edited_path(&file), dir.path().join("Screenshot at 10.00 edited.png")); - } -} -``` - -Run: `cargo nextest run editor::` → compile errors (missing items). - -- [ ] **Step 2: Implement `view.rs`** - -```rust -//! Fitting the image into the canvas: image pixels ↔ egui points. - -use crate::editor::shape::P; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Fit { - /// Points per image pixel. - pub scale: f32, - /// Where the image's top-left corner is, in points. - pub offset: P, -} - -impl Fit { - /// Fits `image` into `area` (x, y, w, h in points), centred, never - /// above 100 % (one image pixel per physical pixel). - pub fn new(image: (u32, u32), area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit { - let (iw, ih) = (image.0 as f32, image.1 as f32); - let (x, y, w, h) = area; - let scale = (w / iw).min(h / ih).min(1.0 / pixels_per_point); - Fit { scale, offset: (x + (w - iw * scale) / 2.0, y + (h - ih * scale) / 2.0) } - } - - pub fn to_screen(&self, p: P) -> P { - (self.offset.0 + p.0 * self.scale, self.offset.1 + p.1 * self.scale) - } - - pub fn to_image(&self, p: P) -> P { - ((p.0 - self.offset.0) / self.scale, (p.1 - self.offset.1) / self.scale) - } -} - -/// `p` moved onto the image's area. -pub fn clamp(p: P, image: (u32, u32)) -> P { - (p.0.clamp(0.0, image.0 as f32), p.1.clamp(0.0, image.1 as f32)) -} -``` - -(`large_images_fit_the_area`: scale = min(0.5, 0.926, 1) = 0.5; offset x = 0 + (960 − 960)/2 = 0 ✓.) - -- [ ] **Step 3: Implement `export.rs`** - -```rust -//! The edited image at full resolution: the base plus every shape, -//! drawn with tiny-skia from the same geometry as the canvas. - -use std::path::{Path, PathBuf}; - -use image::RgbaImage; -use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; - -use crate::editor::shape::{Prim, Shape, geometry}; - -pub fn render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage { - let (w, h) = base.dimensions(); - let mut pixmap = Pixmap::new(w, h).expect("the image has a size"); - for (dst, src) in pixmap.pixels_mut().iter_mut().zip(base.pixels()) { - let [r, g, b, a] = src.0; - *dst = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply(); - } - for prim in shapes.iter().flat_map(geometry) { - draw(&mut pixmap, &prim); - } - let mut out = RgbaImage::new(w, h); - for (dst, src) in out.pixels_mut().zip(pixmap.pixels()) { - let c = src.demultiply(); - dst.0 = [c.red(), c.green(), c.blue(), c.alpha()]; - } - out -} - -fn draw(pixmap: &mut Pixmap, prim: &Prim) { - let (points, color) = match prim { - Prim::Stroke { points, color, .. } | Prim::Fill { points, color } => (points, color), - }; - let mut pb = PathBuilder::new(); - pb.move_to(points[0].0, points[0].1); - for p in &points[1..] { - pb.line_to(p.0, p.1); - } - let closed = matches!(prim, Prim::Fill { .. } | Prim::Stroke { closed: true, .. }); - if closed { - pb.close(); - } - let Some(path) = pb.finish() else { return }; - let mut paint = Paint::default(); - paint.set_color_rgba8(color[0], color[1], color[2], color[3]); - paint.anti_alias = true; - match prim { - Prim::Fill { .. } => pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None), - Prim::Stroke { width, round, .. } => { - let stroke = Stroke { - width: *width, - line_cap: if *round { LineCap::Round } else { LineCap::Butt }, - line_join: if *round { LineJoin::Round } else { LineJoin::Miter }, - ..Stroke::default() - }; - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); - } - } -} - -/// ` edited.png` next to `path`, numbered if taken. -pub fn edited_path(path: &Path) -> PathBuf { - let dir = path.parent().unwrap_or(Path::new(".")); - let stem = path.file_stem().map(|s| s.to_string_lossy()).unwrap_or_default(); - crate::output::unique_path(dir, &format!("{stem} edited.png")) -} -``` - -(If `pixels_mut`, `premultiply`, `demultiply` or `PathBuilder::finish` differ in tiny-skia 0.12, adapt to its API.) - -- [ ] **Step 4: Run** (+9), clippy, fmt. **Step 5: Commit** `feat: editor fitting and export`. - ---- - -### Task 3: Editor backend config and the preview's command - -**Files:** `src/config.rs`, `src/host.rs`. - -**Produces:** `config::{Editor, Backend}` (`Backend::{Builtin, Satty}`, serde lowercase, default `Builtin`), `Config::editor`; `host::editor_command(backend: Backend, path: &Path, exe: &Path) -> std::process::Command`. - -- [ ] **Step 1: Failing tests** - -`src/config.rs` tests (the unknown-keys test uses `[editor]`: change its table to `[toolbar]\nposition = "top"` so it stays unknown → `"toolbar"`): - -```rust - #[test] - fn editor_backend_default_and_values() { - assert_eq!(Config::default().editor.backend, Backend::Builtin); - let (config, _) = parse("[editor]\nbackend = \"satty\"\n").unwrap(); - assert_eq!(config.editor.backend, Backend::Satty); - assert!(parse("[editor]\nbackend = \"gimp\"\n").is_err()); - } -``` - -`src/host.rs` tests: - -```rust - #[test] - fn editor_command_per_backend() { - let path = Path::new("/p/Shot 1.png"); - let exe = Path::new("/nix/store/x/bin/valw"); - let builtin = editor_command(Backend::Builtin, path, exe); - assert_eq!(builtin.get_program(), exe); - assert_eq!(builtin.get_args().collect::>(), ["edit", "/p/Shot 1.png"]); - let satty = editor_command(Backend::Satty, path, exe); - assert_eq!(satty.get_program(), "satty"); - assert_eq!( - satty.get_args().collect::>(), - ["--filename", "/p/Shot 1.png", "--output-filename", "/p/Shot 1.png"] - ); - } -``` - -Run → compile errors. - -- [ ] **Step 2: Implement** - -`config.rs`: `pub editor: Editor,` in `Config`, and - -```rust -#[derive(Debug, Clone, PartialEq, Deserialize, Default)] -#[serde(default)] -pub struct Editor { - pub backend: Backend, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum Backend { - /// `valw edit`. - #[default] - Builtin, - Satty, -} -``` - -`host.rs`: replace `open_editor` with - -```rust -/// The command that opens `path` in the configured editor; both save over -/// the file. -fn editor_command(backend: Backend, path: &Path, exe: &Path) -> std::process::Command { - let mut command = match backend { - Backend::Builtin => { - let mut c = std::process::Command::new(exe); - c.arg("edit").arg(path); - c - } - Backend::Satty => { - let mut c = std::process::Command::new("satty"); - c.arg("--filename").arg(path).arg("--output-filename").arg(path); - c - } - }; - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - command -} - -/// Opens the screenshot in the configured editor. -fn open_editor(path: &Path) { - use std::os::unix::process::CommandExt; - - let backend = match config::load(&config::default_path()) { - Ok(c) => c.editor.backend, - Err(e) => { - tracing::warn!("using the built-in editor: {e:#}"); - Backend::Builtin - } - }; - let exe = std::env::current_exe().unwrap_or_else(|_| "valw".into()); - let mut command = editor_command(backend, path, &exe); - // SAFETY: setsid is async-signal-safe. - unsafe { - command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); - } - if let Err(e) = command.spawn() { - tracing::warn!("could not start the editor ({backend:?}): {e}"); - } -} -``` - -with `use crate::config::{self, Backend};` (merge with existing imports). `get_args` yields `&OsStr`; compare via `.collect::>()` against `&str` arrays works through `PartialEq for OsStr`; if not, map with `to_str().unwrap()`. - -- [ ] **Step 3: Run** (+2), clippy, fmt. **Step 4: Commit** `feat: preview opens the configured editor`. - ---- - -### Task 4: The editor window, `__clipboard`, packaging - -**Files:** `src/editor/mod.rs`, `src/output.rs`, `src/main.rs`, `Cargo.toml`, `nix/package.ulu.nix`, `nix/devshell.ulu.nix`. - -**Consumes:** Tasks 1–2. **Produces:** `editor::run(path: &Path) -> Result<()>`; `output::serve_clipboard(png: Vec) -> Result<()>` (serves in the foreground until replaced); `Command::Edit { file: PathBuf }`; hidden `Command::Clipboard` (`__clipboard`, PNG on stdin). - -- [ ] **Step 1: Failing tests** — `src/main.rs` `flag_conflicts` gains: - -```rust - assert!(parses(&["edit", "a.png"])); - assert!(!parses(&["edit"])); - assert!(parses(&["__clipboard"])); -``` - -and `preview_host_is_hidden_from_help` gains `assert!(!help.contains("__clipboard"), "{help}");`. Run → fails. - -- [ ] **Step 2: Dependencies and packaging** - -`Cargo.toml`: `eframe = { version = "0.36.2", default-features = false, features = ["default_fonts", "glow", "wayland"] }`. - -winit (through eframe) loads libwayland-client, libxkbcommon and libEGL with `dlopen`, and Cargo's feature unification switches valw's own libwayland to `dlopen` too. Give the binary a RUNPATH instead of an inherited `LD_LIBRARY_PATH` (this also fixes the Phase 4 minor about the variable leaking into Satty and the clipboard child). `nix/package.ulu.nix`: - -```nix - # Clicking a preview opens Satty if configured. --suffix keeps a user's own Satty first. - postInstall = '' - wrapProgram $out/bin/valw --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} - ''; - - # dlopen()ed at run time: libwayland (winit, valw), libxkbcommon (winit), - # libEGL (zoom, editor; Mesa's drivers come from the system). - postFixup = '' - patchelf --add-rpath ${lib.makeLibraryPath [ pkgs.wayland pkgs.libxkbcommon pkgs.libglvnd ]} $out/bin/.valw-wrapped - ''; -``` - -(If the wrapped file is named differently, `ls $out/bin` in the build log and adjust.) `nix/devshell.ulu.nix`: inside `mkShell`, `LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.wayland pkgs.libxkbcommon pkgs.libglvnd ];` (take `lib` from the module arguments). - -- [ ] **Step 3: `output::serve_clipboard` and `__clipboard`** - -In `src/output.rs`, split `copy_to_clipboard`: - -```rust -fn prepare_clipboard(png: Vec) -> Result { - let mut options = Options::new(); - options.foreground(true); - options - .prepare_copy(Source::Bytes(png.into()), MimeType::Specific("image/png".into())) - .context("could not copy to the clipboard") - .hint("the compositor needs ext-data-control or wlr-data-control; see `valw doctor`") -} - -pub fn copy_to_clipboard(png: Vec, lock: Lock) -> Result<()> { - let prepared = prepare_clipboard(png)?; - crate::detach::spawn(lock, move || { - let _ = prepared.serve(); - }) -} - -/// Serves `png` as the clipboard from this process until something else -/// takes the clipboard. -pub fn serve_clipboard(png: Vec) -> Result<()> { - prepare_clipboard(png)?.serve().context("the clipboard server failed") -} -``` - -(`PreparedCopy` is `wl_clipboard_rs::copy::PreparedCopy`; import it.) - -In `src/main.rs`: `Command::Edit { /// The image to edit. file: PathBuf }` documented "Mark up an image (opened by clicking a preview).", and - -```rust - /// Internal: serve a PNG from stdin as the clipboard (the editor's copy). - #[command(name = "__clipboard", hide = true)] - Clipboard, -``` - -`run` arms: `Command::Edit { file } => editor::run(&file),` and - -```rust - Command::Clipboard => { - let mut png = Vec::new(); - std::io::Read::read_to_end(&mut std::io::stdin(), &mut png).context("could not read the image")?; - output::serve_clipboard(png) - } -``` - -- [ ] **Step 4: `src/editor/mod.rs`** (replace the Task 1/2 module list; keep the four `pub mod` lines) - -```rust -//! `valw edit`: a Markup-like editor window (eframe, OpenGL ES). - -pub mod doc; -pub mod export; -pub mod shape; -pub mod view; - -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result, anyhow}; -use eframe::egui::{self, Color32, Key, Modifiers, Pos2, Rect, Sense, Stroke, StrokeKind, ViewportCommand}; -use image::RgbaImage; - -use self::doc::Doc; -use self::shape::{Drag, PALETTE, Prim, Style, Tool, WIDTHS, geometry}; -use self::view::Fit; - -const APP_ID: &str = "valw-editor"; -const STATUS_FOR: Duration = Duration::from_secs(2); - -pub fn run(path: &Path) -> Result<()> { - let image = crate::thumbnail::load(path)?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let (w, h) = image.dimensions(); - let viewport = egui::ViewportBuilder::default() - .with_title(format!("{name} — valw")) - .with_app_id(APP_ID) - .with_inner_size([(w as f32).clamp(480.0, 1600.0), (h as f32 + 48.0).clamp(320.0, 1000.0)]); - let options = eframe::NativeOptions { viewport, ..Default::default() }; - let path = path.to_path_buf(); - eframe::run_native( - APP_ID, - options, - Box::new(move |cc| Ok(Box::new(Editor::new(&cc.egui_ctx, path, image)))), - ) - .map_err(|e| anyhow!("the editor window failed: {e}")) -} - -struct Editor { - path: PathBuf, - base: RgbaImage, - texture: egui::TextureHandle, - doc: Doc, - tool: Tool, - color: usize, - width: usize, - drag: Option, - status: Option<(String, Instant)>, - /// The unsaved-changes bar is showing. - confirm_close: bool, - /// The user chose to close; let the close through. - closing: bool, -} - -impl Editor { - fn new(ctx: &egui::Context, path: PathBuf, base: RgbaImage) -> Editor { - let size = [base.width() as usize, base.height() as usize]; - let pixels = egui::ColorImage::from_rgba_unmultiplied(size, base.as_raw()); - let texture = ctx.load_texture("image", pixels, egui::TextureOptions::LINEAR); - Editor { - path, - base, - texture, - doc: Doc::new(), - tool: Tool::Arrow, - color: 0, - width: 1, - drag: None, - status: None, - confirm_close: false, - closing: false, - } - } - - fn style(&self) -> Style { - Style { color: PALETTE[self.color], width: WIDTHS[self.width].1 } - } - - fn say(&mut self, text: impl Into) { - self.status = Some((text.into(), Instant::now())); - } - - fn png(&self) -> Result> { - crate::output::encode_png(&export::render(&self.base, self.doc.shapes())) - } - - fn save(&mut self, copy: bool) -> bool { - let target = if copy { export::edited_path(&self.path) } else { self.path.clone() }; - match self.png().and_then(|png| crate::output::write_atomic(&target, &png)) { - Ok(()) => { - if !copy { - self.doc.mark_saved(); - } - tracing::info!("saved {}", target.display()); - self.say(if copy { "Saved a copy" } else { "Saved" }); - true - } - Err(e) => { - tracing::error!("save failed: {e:#}"); - self.say(format!("Could not save: {e}")); - false - } - } - } - - fn copy(&mut self) { - let result = self.png().and_then(|png| { - use std::os::unix::process::CommandExt; - let exe = std::env::current_exe().context("no path to valw")?; - let mut command = std::process::Command::new(exe); - command - .arg("__clipboard") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - // SAFETY: setsid is async-signal-safe. - unsafe { - command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); - } - let mut child = command.spawn().context("could not start the clipboard server")?; - child.stdin.take().context("no stdin")?.write_all(&png).context("could not hand over the image")?; - Ok(()) - }); - match result { - Ok(()) => self.say("Copied"), - Err(e) => { - tracing::error!("copy failed: {e:#}"); - self.say(format!("Could not copy: {e}")); - } - } - } - - fn close(&mut self, ctx: &egui::Context) { - self.closing = true; - ctx.send_viewport_cmd(ViewportCommand::Close); - } - - fn keys(&mut self, ctx: &egui::Context) { - let shift_cmd = Modifiers::COMMAND | Modifiers::SHIFT; - let pressed = |m: Modifiers, k: Key| ctx.input_mut(|i| i.consume_key(m, k)); - // Most specific first: consume_key ignores extra Shift. - if pressed(shift_cmd, Key::S) { - self.save(true); - } else if pressed(Modifiers::COMMAND, Key::S) { - if self.save(false) && self.confirm_close { - self.close(ctx); - } - } - if pressed(shift_cmd, Key::Z) || pressed(Modifiers::COMMAND, Key::Y) { - self.doc.redo(); - } else if pressed(Modifiers::COMMAND, Key::Z) { - self.doc.undo(); - } - if pressed(Modifiers::COMMAND, Key::C) { - self.copy(); - } - if pressed(Modifiers::NONE, Key::Escape) { - if self.doc.is_dirty() && !self.confirm_close { - self.confirm_close = true; - } else { - self.close(ctx); - } - } - for (key, tool) in [ - (Key::A, Tool::Arrow), - (Key::R, Tool::Rectangle), - (Key::O, Tool::Ellipse), - (Key::L, Tool::Line), - (Key::P, Tool::Pen), - (Key::H, Tool::Highlighter), - ] { - if pressed(Modifiers::NONE, key) { - self.tool = tool; - } - } - } - - /// The compositor asked to close: with unsaved changes, show the bar instead. - fn guard_close(&mut self, ctx: &egui::Context) { - if ctx.input(|i| i.viewport().close_requested()) && self.doc.is_dirty() && !self.closing { - ctx.send_viewport_cmd(ViewportCommand::CancelClose); - self.confirm_close = true; - } - } - - fn toolbar(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - for tool in Tool::ALL { - if ui.selectable_label(self.tool == tool, tool.label()).clicked() { - self.tool = tool; - } - } - ui.separator(); - for (i, [r, g, b]) in PALETTE.into_iter().enumerate() { - let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), Sense::click()); - ui.painter().rect_filled(rect, 4.0, Color32::from_rgb(r, g, b)); - let ring = if i == self.color { ui.visuals().strong_text_color() } else { ui.visuals().weak_text_color() }; - ui.painter().rect_stroke(rect, 4.0, Stroke::new(if i == self.color { 2.0 } else { 1.0 }, ring), StrokeKind::Outside); - if response.clicked() { - self.color = i; - } - } - ui.separator(); - for (i, (label, _)) in WIDTHS.into_iter().enumerate() { - if ui.selectable_label(self.width == i, label).clicked() { - self.width = i; - } - } - ui.separator(); - if ui.add_enabled(self.doc.can_undo(), egui::Button::new("Undo")).clicked() { - self.doc.undo(); - } - if ui.add_enabled(self.doc.can_redo(), egui::Button::new("Redo")).clicked() { - self.doc.redo(); - } - ui.separator(); - if ui.button("Copy").clicked() { - self.copy(); - } - if ui.button("Save").clicked() { - self.save(false); - } - if let Some((text, at)) = &self.status { - if at.elapsed() < STATUS_FOR { - ui.label(text.as_str()); - ui.ctx().request_repaint_after(STATUS_FOR); - } - } - }); - } - - fn canvas(&mut self, ui: &mut egui::Ui) { - let area = ui.max_rect(); - let (response, painter) = ui.allocate_painter(area.size(), Sense::drag()); - let size = self.base.dimensions(); - let fit = Fit::new(size, (area.min.x, area.min.y, area.width(), area.height()), ui.ctx().pixels_per_point()); - let to_pos = |p: shape::P| { - let (x, y) = fit.to_screen(p); - Pos2::new(x, y) - }; - let image_rect = Rect::from_min_max(to_pos((0.0, 0.0)), to_pos((size.0 as f32, size.1 as f32))); - painter.image(self.texture.id(), image_rect, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), Color32::WHITE); - - let shift = ui.input(|i| i.modifiers.shift); - let pointer = response.interact_pointer_pos().map(|p| fit.to_image((p.x, p.y))); - if response.drag_started() - && let Some(p) = pointer - && image_rect.contains(response.interact_pointer_pos().unwrap_or_default()) - { - self.drag = Some(Drag::new(self.tool, self.style(), p)); - } - if let (Some(drag), Some(p)) = (&mut self.drag, pointer) { - drag.move_to(view::clamp(p, size)); - } - if response.drag_stopped() - && let Some(drag) = self.drag.take() - { - let shape = drag.shape(shift); - if !shape.is_click() { - self.doc.add(shape); - } - } - - let live = self.drag.as_ref().map(|d| d.shape(shift)); - for shape in self.doc.shapes().iter().chain(live.as_ref()) { - for prim in geometry(shape) { - paint(&painter, &prim, fit.scale, to_pos); - } - } - } -} - -/// Draws one primitive on the canvas. -fn paint(painter: &egui::Painter, prim: &Prim, scale: f32, to_pos: impl Fn(shape::P) -> Pos2) { - let color = |c: &[u8; 4]| Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]); - match prim { - Prim::Fill { points, color: c } => { - let points = points.iter().map(|&p| to_pos(p)).collect(); - painter.add(egui::Shape::convex_polygon(points, color(c), Stroke::NONE)); - } - Prim::Stroke { points, width, color: c, closed, round } => { - let points: Vec = points.iter().map(|&p| to_pos(p)).collect(); - let stroke = Stroke::new(width * scale, color(c)); - if *closed { - painter.add(egui::Shape::closed_line(points, stroke)); - } else { - // egui lines have butt caps; add the round caps the export draws. - if *round { - for end in [points[0], points[points.len() - 1]] { - painter.circle_filled(end, width * scale / 2.0, color(c)); - } - } - painter.add(egui::Shape::line(points, stroke)); - } - } - } -} - -impl eframe::App for Editor { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - let ctx = ui.ctx().clone(); - self.keys(&ctx); - self.guard_close(&ctx); - egui::Panel::top("toolbar").show(ui, |ui| self.toolbar(ui)); - if self.confirm_close && self.doc.is_dirty() { - egui::Panel::bottom("unsaved").show(ui, |ui| { - ui.label("Unsaved changes — Esc to discard, Ctrl+S to save"); - }); - } - egui::CentralPanel::no_frame().show(ui, |ui| self.canvas(ui)); - } -} -``` - -Remove the Task 1 dead-code allowance from `src/main.rs`. API drift (egui 0.36: `selectable_label`, `rect_stroke`'s `StrokeKind`, `Panel::top/bottom(..).show(ui, ..)`, `CentralPanel::no_frame().show(ui, ..)`): follow the compiler, keep the behaviour, and ledger any change. - -- [ ] **Step 5: Run** — `cargo nextest run` (all pass), clippy, fmt. - -- [ ] **Step 6: Smoke run** (tell the user first: a window opens for a few seconds) — `cargo run -q -- edit ` in the dev shell under `timeout -s INT 4`; read the newest log: no errors. Optionally a niri screenshot shows the window. - -- [ ] **Step 7: Commit** — `jj commit -m "feat: valw edit"` - ---- - -### Task 5: Checks and checklist - -- [ ] `nix flake check -L --keep-going`: all pass (the headless sway check exercises the dlopen'ed libwayland through RUNPATH). -- [ ] Append to `docs/test-checklist.md`: - -```markdown - -## Editor (Phase 5a) - -- [ ] Clicking a preview opens `valw edit` (title ` — valw`); `[editor] backend = "satty"` still opens Satty. -- [ ] Each tool draws (arrow head at the release point, outline rectangle/ellipse, line, pen dot on a click, translucent highlighter); Shift snaps lines to 45° and makes squares/circles. -- [ ] Colours and S/M/L change new shapes only; A/R/O/L/P/H switch tools. -- [ ] Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y and the toolbar buttons undo and redo. -- [ ] Ctrl+S overwrites the file (the preview's file shows the edits); Ctrl+Shift+S writes `… edited.png`; "Saved" appears for 2 s. -- [ ] The saved PNG matches the canvas (same positions and widths), also for a window shot with transparent corners. -- [ ] Ctrl+C then Esc: the edited image is still pasteable afterwards. -- [ ] Esc with unsaved changes shows the bar; Esc again discards; Ctrl+S in the bar saves and closes. Closing the window with Mod+Q behaves the same. -- [ ] A large (1920×1080) shot fits the window; a small one isn't blown up; the window can be resized. -``` - -- [ ] Commit `docs: editor checklist`. diff --git a/docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md b/docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md deleted file mode 100644 index 959283d..0000000 --- a/docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md +++ /dev/null @@ -1,1181 +0,0 @@ -# valw: drag the preview out — 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:** Grab a preview thumbnail and drop the screenshot into any app. A mostly-rightward drag still dismisses it, and a click still opens Satty. - -**Architecture:** A press on a thumbnail is classified once it has moved 4 px. Rightward within 45° is a swipe (unchanged); any other direction starts a Wayland drag-and-drop. The preview host creates a `wl_data_source` offering `text/uri-list` and `image/png`, starts the drag with the press serial, and shows the thumbnail image as the drag icon while the original hides. Data is written on a short-lived thread per request. `dnd_finished` closes the thumbnail; `cancelled` brings it back, or slides it out if its timer fired during the drag. - -**Tech Stack:** Rust 2024; SCTK 0.21 `data_device_manager` (DataDeviceManagerState, DragSource, WritePipe); existing preview host (calloop); jj. - -**Spec:** `docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md`. The Phase 2 spec, `docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md`, still applies to everything not changed here. - -## Global Constraints - -- Code, comments, error/log messages: English, plain and short, saying *why*. -- Version control is **jj**. Commit with `jj commit -m ""`; never use git commands. If a background `jj util snapshot` leaves the working copy stale or divergent: back up the changed files, run `jj workspace update-stale`, restore the newer files, and abandon an identical divergent twin. -- `cargo clippy` (never `cargo check`) and `cargo nextest run` (never `cargo test`), inside the devShell. When applying a diff with `patch`, use `patch -p1 --no-backup-if-mismatch`, so no `.orig` file ends up in a commit. -- Until Task 3, `src/main.rs` starts with `#![allow(dead_code)]`, because the new pure code lands before the host uses it. Task 3 removes it, and clippy must then be clean without it. -- Gesture: classify once at `CLICK_SLOP` (4 logical px). Swipe when `dx > 0 && |dy| <= dx`; everything else is drag-out. -- Offered MIME types, in order: `text/uri-list`, `image/png`. The action is `Copy`. -- `file_uri`: `file://` plus the path with every byte except `A-Z a-z 0-9 - . _ ~ /` percent-encoded; `text/uri-list` data is that URI plus `\r\n`. -- "Hidden for a capture" (`hidden`) and "hidden for a drag" (`dragging`) are separate flags; a thumbnail is visible only when neither is set. -- No new config option; no Nix changes. - -## Review Focus - -These are the inputs and failure modes most likely to bite in real use that no unit test fully covers. Each is pinned in the task that owns the code. - -1. **A capture starts mid-drag** (Print pressed while dragging): the dragged thumbnail must not reappear when the capture ends, while the others do. Covered by the separate `dragging` flag (Task 3). Real check: Task 4, step 4. -2. **The timer fires during a drag:** after a cancel the thumbnail slides out; after a drop it closes. Test: `dnd::tests::after_drag_decisions` (Task 2). Real check: Task 4, step 4. -3. **A large PNG and a slow receiver:** the host stays responsive and doesn't exit before the data is fully written. Test: `dnd::tests::png_is_the_file_bytes` (200 KB, more than a pipe buffer, Task 2); the host waits for its `writers` counter (Task 3). Real check: Task 4, step 4. -4. **File names with spaces or Turkish characters:** receivers must get the right file. Tests: the `dnd::tests::*encoded*` tests (Task 2). Real check: Task 4, step 4 (the default file name has spaces). -5. **Swipe and click regressions:** a rightward swipe still dismisses, and a click still opens Satty. Tests: `stack::tests::rightward_within_45_degrees_is_a_swipe`, `small_moves_are_undecided` (Task 1), plus the existing `release_decisions`. Real check: Task 3, step 5. - ---- - -## File Structure - -| File | Change | Task | -|---|---|---| -| `src/stack.rs` | `enum Gesture { Swipe, DragOut }`, `classify(dx, dy)` | 1 | -| `src/dnd.rs` (new) | `MIME_TYPES`, `file_uri`, `write_offer`, `Outcome`, `AfterDrag`, `after_drag` | 2 | -| `src/thumbnail.rs` | press/gesture state, `DragStart`, `begin_drag`/`end_drag`, `draw_icon`, separate `dragging` flag | 3 | -| `src/host.rs` | `Drag`, free `pointer()` and `start_drag()`, `send`, `drag_ended`, `writers` | 3 | -| `src/wayland.rs` | `DataDeviceManagerState`/`DataDevice` in `State`; `DataSourceHandler`/`DataDeviceHandler`/`DataOfferHandler`; pointer routed to `host::pointer` | 3 | -| `src/main.rs` | allowance (Task 1), `mod dnd;` (Task 2), allowance removed (Task 3) | 1–3 | - ---- - -### Task 1: Gesture classification - -**Files:** -- Modify: `src/main.rs` (allowance), `src/stack.rs` - -**Interfaces:** -- Consumes: `stack::CLICK_SLOP` (Phase 2). -- Produces: `pub enum Gesture { Swipe, DragOut }` and `pub fn classify(dx: f64, dy: f64) -> Option` in `stack`. - -- [ ] **Step 1: Add the dead-code allowance** - -At the very top of `src/main.rs`, followed by an empty line: - -```rust -// Drag-out modules land before the host uses them; Task 3 removes this. -#![allow(dead_code)] -``` - -- [ ] **Step 2: Write the failing tests** - -Apply to the test module of `src/stack.rs`: - -```diff ---- a/src/stack.rs -+++ b/src/stack.rs -@@ -46,6 +46,27 @@ - } - - #[test] -+ fn small_moves_are_undecided() { -+ assert_eq!(classify(0.0, 0.0), None); -+ assert_eq!(classify(3.0, 2.0), None); -+ } -+ -+ #[test] -+ fn rightward_within_45_degrees_is_a_swipe() { -+ assert_eq!(classify(10.0, 0.0), Some(Gesture::Swipe)); -+ assert_eq!(classify(10.0, -5.0), Some(Gesture::Swipe), "about 27° up"); -+ assert_eq!(classify(10.0, 10.0), Some(Gesture::Swipe), "exactly 45°"); -+ } -+ -+ #[test] -+ fn every_other_direction_drags_out() { -+ assert_eq!(classify(-10.0, 0.0), Some(Gesture::DragOut), "left"); -+ assert_eq!(classify(0.0, -10.0), Some(Gesture::DragOut), "up"); -+ assert_eq!(classify(0.0, 10.0), Some(Gesture::DragOut), "down"); -+ assert_eq!(classify(5.0, -10.0), Some(Gesture::DragOut), "steep right"); -+ } -+ -+ #[test] - fn drag_follows_right_only() { - assert_eq!(drag_offset(30.0), 30.0); - assert_eq!(drag_offset(-30.0), 0.0); -``` - -- [ ] **Step 3: Run the tests and watch them fail** - -Run: `cargo nextest run stack::` -Expected: compilation fails, e.g. `` cannot find function `classify` in this scope ``. - -- [ ] **Step 4: Implement** - -Apply above the test module: - -```diff ---- a/src/stack.rs -+++ b/src/stack.rs -@@ -66,6 +66,29 @@ - Release::Dismiss - } else { - Release::SnapBack -+ } -+} -+ -+/// What a press turns into once the pointer has moved. -+#[derive(Debug, Clone, Copy, PartialEq, Eq)] -+pub enum Gesture { -+ /// Mostly rightward: the thumbnail follows and may be dismissed. -+ Swipe, -+ /// Any other direction: carry the screenshot out by drag-and-drop. -+ DragOut, -+} -+ -+/// Classifies a press that has moved (`dx`, `dy`), once it has moved at -+/// least `CLICK_SLOP`. Rightward and at most 45° off horizontal is a swipe; -+/// the thumbnails sit in the bottom-right corner, so every drop target is -+/// left or up anyway. -+pub fn classify(dx: f64, dy: f64) -> Option { -+ if dx.hypot(dy) < CLICK_SLOP { -+ None -+ } else if dx > 0.0 && dy.abs() <= dx { -+ Some(Gesture::Swipe) -+ } else { -+ Some(Gesture::DragOut) - } - } - -``` - -- [ ] **Step 5: Run the tests and watch them pass** - -Run: `cargo nextest run stack::` -Expected: all 13 tests in `stack::tests` pass. - -- [ ] **Step 6: Lint and run the whole suite** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 7: Commit** - -```sh -jj commit -m "feat: classify thumbnail drags into swipe and drag-out" -``` - ---- - -### Task 2: Drag data and outcomes (pure) - -**Files:** -- Create: `src/dnd.rs` -- Modify: `src/main.rs` (`mod dnd;`) - -**Interfaces:** -- Consumes: nothing. -- Produces: `pub const MIME_TYPES: [&str; 2]`; `pub fn file_uri(path: &Path) -> String`; `pub fn write_offer(mime: &str, path: &Path, out: &mut impl Write) -> Result<()>`; `pub enum Outcome { Dropped, Cancelled }`; `pub enum AfterDrag { Close, ComeBack, SlideOut }`; `pub fn after_drag(outcome: Outcome, expired: bool) -> AfterDrag`. - -- [ ] **Step 1: Write the failing tests** - -Create `src/dnd.rs` with only the test module: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use std::io::Read; - - #[test] - fn plain_path() { - assert_eq!(file_uri(Path::new("/tmp/a.png")), "file:///tmp/a.png"); - } - - #[test] - fn spaces_are_encoded() { - assert_eq!( - file_uri(Path::new("/home/u/Screenshot 2026-10-01 at 14.03.22.png")), - "file:///home/u/Screenshot%202026-10-01%20at%2014.03.22.png" - ); - } - - #[test] - fn non_ascii_is_encoded_as_utf8() { - assert_eq!( - file_uri(Path::new("/tmp/ışık.png")), - "file:///tmp/%C4%B1%C5%9F%C4%B1k.png" - ); - } - - #[test] - fn percent_and_hash_are_encoded() { - assert_eq!( - file_uri(Path::new("/tmp/50%#1.png")), - "file:///tmp/50%25%231.png" - ); - } - - /// Writes through a real pipe, as a receiving app would read it. - fn through_pipe(mime: &str, path: &Path) -> Result> { - let (reader, writer) = rustix::pipe::pipe()?; - let mut writer = std::fs::File::from(writer); - let path = path.to_path_buf(); - let mime = mime.to_string(); - let handle = std::thread::spawn(move || write_offer(&mime, &path, &mut writer)); - let mut out = Vec::new(); - std::fs::File::from(reader).read_to_end(&mut out)?; - handle.join().unwrap()?; - Ok(out) - } - - #[test] - fn uri_list_is_one_crlf_line() { - let out = through_pipe("text/uri-list", Path::new("/tmp/a b.png")).unwrap(); - assert_eq!(out, b"file:///tmp/a%20b.png\r\n"); - } - - #[test] - fn png_is_the_file_bytes() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shot.png"); - // Larger than a pipe buffer, so the writer really has to wait. - let bytes: Vec = (0..200_000u32).map(|i| i as u8).collect(); - std::fs::write(&path, &bytes).unwrap(); - assert_eq!(through_pipe("image/png", &path).unwrap(), bytes); - } - - #[test] - fn missing_file_is_an_error_not_a_panic() { - let err = through_pipe("image/png", Path::new("/nonexistent/x.png")).unwrap_err(); - assert!( - format!("{err:#}").contains("could not read /nonexistent/x.png"), - "{err:#}" - ); - } - - #[test] - fn unknown_mime_is_an_error() { - assert!(through_pipe("text/html", Path::new("/tmp/a.png")).is_err()); - } - - #[test] - fn after_drag_decisions() { - assert_eq!(after_drag(Outcome::Dropped, false), AfterDrag::Close); - assert_eq!(after_drag(Outcome::Dropped, true), AfterDrag::Close); - assert_eq!(after_drag(Outcome::Cancelled, false), AfterDrag::ComeBack); - assert_eq!(after_drag(Outcome::Cancelled, true), AfterDrag::SlideOut); - } -} -``` - -Add `mod dnd;` to the `mod` list at the top of `src/main.rs` (keep it sorted). - -- [ ] **Step 2: Run the tests and watch them fail** - -Run: `cargo nextest run dnd::` -Expected: compilation fails, e.g. `` cannot find function `file_uri` in this scope ``. - -- [ ] **Step 3: Implement** - -Put this above the test module in `src/dnd.rs`: - -```rust -//! Drag-and-drop out of a preview thumbnail: what is offered, how it is -//! written, and what the thumbnail does afterwards. No Wayland in here. - -use std::io::Write; -use std::path::Path; - -use anyhow::{Context, Result, bail}; - -/// What a drag offers; the receiving app picks one. File managers and chat -/// apps take the file, image editors take the pixels. -pub const MIME_TYPES: [&str; 2] = ["text/uri-list", "image/png"]; - -/// `file://` URI for an absolute path, with everything but RFC 3986 -/// unreserved characters and `/` percent-encoded byte by byte. The default -/// file names have spaces; Turkish names have non-ASCII. -pub fn file_uri(path: &Path) -> String { - let mut uri = String::from("file://"); - for &b in path.as_os_str().as_encoded_bytes() { - if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) { - uri.push(b as char); - } else { - uri.push_str(&format!("%{b:02X}")); - } - } - uri -} - -/// Writes the screenshot at `path` in the requested `mime` type. -pub fn write_offer(mime: &str, path: &Path, out: &mut impl Write) -> Result<()> { - match mime { - "text/uri-list" => out.write_all(format!("{}\r\n", file_uri(path)).as_bytes())?, - "image/png" => { - let bytes = std::fs::read(path) - .with_context(|| format!("could not read {}", path.display()))?; - out.write_all(&bytes)?; - } - other => bail!("nobody offered {other}"), - } - out.flush()?; - Ok(()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Outcome { - /// Dropped on an app that took it. - Dropped, - /// Released on nothing, rejected, or Esc. - Cancelled, -} - -/// What the thumbnail does once its drag is over. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AfterDrag { - Close, - ComeBack, - SlideOut, -} - -/// `expired`: the thumbnail's timer fired while it was being dragged. -pub fn after_drag(outcome: Outcome, expired: bool) -> AfterDrag { - match (outcome, expired) { - (Outcome::Dropped, _) => AfterDrag::Close, - (Outcome::Cancelled, false) => AfterDrag::ComeBack, - (Outcome::Cancelled, true) => AfterDrag::SlideOut, - } -} -``` - -- [ ] **Step 4: Run the tests and watch them pass** - -Run: `cargo nextest run dnd::` -Expected: all 9 tests in `dnd::tests` pass. - -- [ ] **Step 5: Lint and run the whole suite** - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings; every test passes. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: drag-out data (uri-list, png) and outcomes" -``` - ---- - -### Task 3: Start the drag from a thumbnail - -**Files:** -- Modify: `src/thumbnail.rs`, `src/host.rs`, `src/wayland.rs`, `src/main.rs` (remove the allowance) - -**Interfaces:** -- Consumes: `stack::{classify, Gesture}` (Task 1); `dnd::*` (Task 2); Phase 2's `Thumbnail`, `Host`, `State`. -- Produces: - - `thumbnail`: `DragStart { serial, grab }`; `Thumbnail::press(x, y, serial)`; `motion(x, y, qh) -> Option`; `begin_drag(qh)`, `end_drag(outcome, qh)`, `draw_icon(icon, viewport, grab)`, `surface()`. - - `host`: free functions `pointer(state, events, qh)` and `start_drag(...)`; `Host::send(source, mime, fd)` and `Host::drag_ended(source, outcome, qh)`. - - `State`: `data_devices: Option`, `data_device: Option`, plus the three SCTK data handler impls. - -How it fits together: -- `State::pointer_frame` hands thumbnail events to `host::pointer`. It's a free function because starting a drag needs the data device, compositor and viewporter as well as the host. -- When `Thumbnail::motion` returns a `DragStart`, `start_drag` creates the source and an icon surface, calls `start_drag` with the press serial, draws the icon, and calls `begin_drag`. That hides this thumbnail through the separate `dragging` flag. -- `DataSourceHandler::send_request` → `Host::send` (a writer thread per request, counted in `writers` so the host doesn't exit early). -- `dnd_finished` and `cancelled` → `Host::drag_ended` → `Thumbnail::end_drag`, which uses `dnd::after_drag`. -- Without a `wl_data_device_manager`, a drag-out does nothing, and a warning is logged once. - -There are no new unit tests here: the decisions live in Tasks 1 and 2, and this task is protocol plumbing, checked on niri in Step 5. - -- [ ] **Step 1: Thumbnail** - -```diff ---- a/src/thumbnail.rs -+++ b/src/thumbnail.rs -@@ -16,12 +16,16 @@ - slot::{Buffer, SlotPool}, - }, - }; --use wayland_client::{QueueHandle, protocol::wl_shm}; -+use wayland_client::{ -+ Proxy, QueueHandle, -+ protocol::{wl_shm, wl_surface::WlSurface}, -+}; - use wayland_protocols::wp::viewporter::client::{ - wp_viewport::WpViewport, wp_viewporter::WpViewporter, - }; - --use crate::stack::{self, Anim, EDGE_MARGIN, Release}; -+use crate::dnd::{self, AfterDrag, Outcome}; -+use crate::stack::{self, Anim, EDGE_MARGIN, Gesture, Release}; - use crate::wayland::{Output, State}; - - pub const NAMESPACE: &str = "valw-preview"; -@@ -93,6 +97,24 @@ - OpenEditor, - } - -+/// A press turned into a drag-out: the host starts the drag with these. -+#[derive(Debug, Clone, Copy, PartialEq)] -+pub struct DragStart { -+ /// Serial of the press; the compositor only accepts a drag for it. -+ pub serial: u32, -+ /// Where on the image the press was, so it stays under the pointer. -+ pub grab: (f64, f64), -+} -+ -+/// A press in progress and what it turned into once the pointer moved. -+#[derive(Debug, Clone, Copy)] -+struct Press { -+ x: f64, -+ y: f64, -+ serial: u32, -+ gesture: Option, -+} -+ - pub struct Thumbnail { - pub id: u64, - pub output: String, -@@ -108,13 +130,17 @@ - /// How far right of its resting place the image is drawn, logical px. - offset: f64, - anim: Option<(Anim, Instant)>, -- drag: Option<(f64, f64)>, -+ press: Option, - margin: u32, - /// Input region while visible (the image) and while hidden (nothing). - input: Region, - no_input: Region, - /// Hidden for a capture: drawn fully transparent, clicks pass through. - hidden: bool, -+ /// Being dragged out: the drag icon shows it, so this one hides too. -+ /// Separate from `hidden` so a capture ending mid-drag can't bring it back. -+ dragging: bool, -+ icon_buffer: Option, - configured: bool, - frame_pending: bool, - started: bool, -@@ -178,11 +204,13 @@ - scale: output.scale, - offset: travel(size.0), - anim: None, -- drag: None, -+ press: None, - margin: EDGE_MARGIN, - input, - no_input, - hidden: false, -+ dragging: false, -+ icon_buffer: None, - configured: false, - frame_pending: false, - started: false, -@@ -191,8 +219,12 @@ - }) - } - -- pub fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { -+ pub fn is(&self, surface: &WlSurface) -> bool { - self.layer.wl_surface() == surface -+ } -+ -+ pub fn surface(&self) -> &WlSurface { -+ self.layer.wl_surface() - } - - pub fn is_layer(&self, layer: &LayerSurface) -> bool { -@@ -205,7 +237,23 @@ - - /// The slide-out has finished (or is invisible anyway). - pub fn is_finished(&self) -> bool { -- self.closing && (self.hidden || anim_over(self.anim, Instant::now())) -+ self.closing && (!self.visible() || anim_over(self.anim, Instant::now())) -+ } -+ -+ fn visible(&self) -> bool { -+ !self.hidden && !self.dragging -+ } -+ -+ /// Clicks reach the image only while it is visible. -+ fn apply_input(&self) { -+ let region = if self.visible() { -+ &self.input -+ } else { -+ &self.no_input -+ }; -+ self.layer -+ .wl_surface() -+ .set_input_region(Some(region.wl_region())); - } - - /// Makes the thumbnail invisible and click-through. The surface stays -@@ -215,9 +263,7 @@ - return; - } - self.hidden = true; -- self.layer -- .wl_surface() -- .set_input_region(Some(self.no_input.wl_region())); -+ self.apply_input(); - self.draw(qh, true); - } - -@@ -226,9 +272,7 @@ - return; - } - self.hidden = false; -- self.layer -- .wl_surface() -- .set_input_region(Some(self.input.wl_region())); -+ self.apply_input(); - self.draw(qh, true); - } - -@@ -252,15 +296,16 @@ - - pub fn frame_done(&mut self, qh: &QueueHandle) { - self.frame_pending = false; -- if self.anim.is_some() || self.drag.is_some() { -+ if self.anim.is_some() || self.press.is_some() { - self.draw(qh, false); - } - } - -- /// The timeout fired: slide out, unless the user is dragging it. -+ /// The timeout fired: slide out, unless the user is holding or -+ /// dragging it; then it is decided when they let go. - pub fn expire(&mut self, qh: &QueueHandle) { - self.expired = true; -- if self.drag.is_none() { -+ if self.press.is_none() && !self.dragging { - self.slide_out(qh); - } - } -@@ -277,23 +322,46 @@ - self.draw(qh, false); - } - -- pub fn press(&mut self, x: f64, y: f64) { -- if !self.closing { -+ pub fn press(&mut self, x: f64, y: f64, serial: u32) { -+ if !self.closing && !self.dragging { - self.anim = None; -- self.drag = Some((x, y)); -- } -- } -- -- pub fn motion(&mut self, x: f64, qh: &QueueHandle) { -- if let Some((x0, _)) = self.drag { -- self.offset = stack::drag_offset(x - x0); -+ self.press = Some(Press { -+ x, -+ y, -+ serial, -+ gesture: None, -+ }); -+ } -+ } -+ -+ /// Follows a swipe, or returns a `DragStart` the moment the press turns -+ /// into a drag-out. -+ pub fn motion(&mut self, x: f64, y: f64, qh: &QueueHandle) -> Option { -+ let press = self.press.as_mut()?; -+ let (dx, dy) = (x - press.x, y - press.y); -+ if press.gesture.is_none() { -+ press.gesture = stack::classify(dx, dy); -+ if press.gesture == Some(Gesture::DragOut) { -+ return Some(DragStart { -+ serial: press.serial, -+ grab: (press.x, press.y), -+ }); -+ } -+ } -+ if press.gesture == Some(Gesture::Swipe) { -+ self.offset = stack::drag_offset(dx); - self.draw(qh, false); - } -+ None - } - - pub fn release(&mut self, x: f64, y: f64, qh: &QueueHandle) -> Option { -- let (x0, y0) = self.drag.take()?; -- match stack::release(x - x0, y - y0, self.size.0 as f64) { -+ let press = self.press.take()?; -+ if press.gesture == Some(Gesture::DragOut) { -+ // The drag never started (no data device); nothing moved. -+ return None; -+ } -+ match stack::release(x - press.x, y - press.y, self.size.0 as f64) { - Release::Click => { - self.slide_out(qh); - Some(Action::OpenEditor) -@@ -312,6 +380,63 @@ - None - } - } -+ } -+ -+ /// The drag started: the icon carries the image, this one hides. -+ pub fn begin_drag(&mut self, qh: &QueueHandle) { -+ self.press = None; -+ self.dragging = true; -+ self.anim = None; -+ self.offset = 0.0; -+ self.apply_input(); -+ self.draw(qh, true); -+ } -+ -+ pub fn end_drag(&mut self, outcome: Outcome, qh: &QueueHandle) { -+ self.dragging = false; -+ self.icon_buffer = None; -+ self.apply_input(); -+ match dnd::after_drag(outcome, self.expired) { -+ AfterDrag::Close => { -+ // Already invisible; nothing to animate. -+ self.closing = true; -+ self.anim = None; -+ } -+ AfterDrag::ComeBack => { -+ self.anim = Some((Anim::slide_in(travel(self.size.0)), Instant::now())); -+ self.draw(qh, true); -+ } -+ AfterDrag::SlideOut => self.slide_out(qh), -+ } -+ } -+ -+ /// Draws the drag icon: the image alone, sharp at the output's scale, -+ /// placed so the grabbed point stays under the pointer. -+ pub fn draw_icon(&mut self, icon: &WlSurface, viewport: &WpViewport, grab: (f64, f64)) { -+ let (width, height) = self.small.dimensions(); -+ let Ok((buffer, canvas)) = self.pool.create_buffer( -+ width as i32, -+ height as i32, -+ width as i32 * 4, -+ wl_shm::Format::Argb8888, -+ ) else { -+ tracing::warn!("could not allocate a drag icon buffer"); -+ return; -+ }; -+ paint(canvas, width, &self.small, 0); -+ viewport.set_destination(self.size.0 as i32, self.size.1 as i32); -+ // wl_surface.offset needs version 5; older compositors put the -+ // icon's corner at the pointer instead. -+ if icon.version() >= 5 { -+ icon.offset(-(grab.0.round() as i32), -(grab.1.round() as i32)); -+ } -+ icon.damage_buffer(0, 0, width as i32, height as i32); -+ if buffer.attach_to(icon).is_err() { -+ tracing::warn!("could not attach the drag icon"); -+ return; -+ } -+ icon.commit(); -+ self.icon_buffer = Some(buffer); - } - - fn draw(&mut self, qh: &QueueHandle, force: bool) { -@@ -328,6 +453,7 @@ - } - let width = canvas_width(self.size.0, self.scale); - let height = self.small.height(); -+ let visible = self.visible(); - let Ok((buffer, canvas)) = self.pool.create_buffer( - width as i32, - height as i32, -@@ -337,7 +463,7 @@ - tracing::warn!("could not allocate a thumbnail buffer"); - return; - }; -- if self.hidden { -+ if !visible { - canvas.fill(0); - } else { - paint( -@@ -351,7 +477,7 @@ - surface.damage_buffer(0, 0, width as i32, height as i32); - // Keep drawing while something moves; motion that arrives while a - // frame is pending is picked up by the next frame callback. -- if self.anim.is_some() || self.drag.is_some() { -+ if self.anim.is_some() || self.press.is_some() { - surface.frame(qh, FrameCallbackData(surface.clone())); - self.frame_pending = true; - } -``` - -- [ ] **Step 2: Wayland state and handlers** - -```diff ---- a/src/wayland.rs -+++ b/src/wayland.rs -@@ -4,6 +4,12 @@ - use rustix::event::{PollFd, PollFlags, Timespec, poll}; - use smithay_client_toolkit::{ - compositor::{CompositorHandler, CompositorState}, -+ data_device_manager::{ -+ DataDeviceManagerState, WritePipe, -+ data_device::{DataDevice, DataDeviceHandler}, -+ data_offer::{DataOfferHandler, DragOffer}, -+ data_source::DataSourceHandler, -+ }, - delegate_registry, - output::{OutputHandler, OutputState}, - registry::{ProvidesRegistryState, RegistryState}, -@@ -19,7 +25,10 @@ - use wayland_client::{ - Connection, EventQueue, QueueHandle, - globals::{GlobalList, registry_queue_init}, -- protocol::{wl_buffer::WlBuffer, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, -+ protocol::{ -+ wl_buffer::WlBuffer, wl_data_device::WlDataDevice, wl_data_device_manager::DndAction, -+ wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface, -+ }, - }; - use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; - use wayland_protocols::wp::viewporter::client::{ -@@ -28,6 +37,7 @@ - use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; - - use crate::capture::Pending; -+use crate::dnd::Outcome; - use crate::error::HintExt; - use crate::frame::OutputGeom; - use crate::host::Host; -@@ -61,6 +71,9 @@ - pub viewporter: Option, - pub cursor_shape: Option, - pub cursor_device: Option, -+ /// For dragging a preview out; the host never receives drops. -+ pub data_devices: Option, -+ pub data_device: Option, - pub keyboard: Option, - pub pointer: Option, - /// Screencopy requests in flight. -@@ -92,6 +105,8 @@ - viewporter: globals.bind(&qh, 1..=1, ()).ok(), - cursor_shape: CursorShapeManager::bind(&globals, &qh).ok(), - cursor_device: None, -+ data_devices: DataDeviceManagerState::bind(&globals, &qh).ok(), -+ data_device: None, - keyboard: None, - pointer: None, - captures: Vec::new(), -@@ -370,6 +385,10 @@ - .cursor_shape - .as_ref() - .map(|m| m.get_shape_device(&pointer, qh)); -+ self.data_device = self -+ .data_devices -+ .as_ref() -+ .map(|m| m.get_data_device(qh, &seat)); - self.pointer = Some(pointer); - } - } -@@ -481,9 +500,91 @@ - if let Some(overlay) = &mut self.overlay { - overlay.pointer(events, self.cursor_device.as_ref(), qh); - } -+ if self.preview.is_some() { -+ crate::host::pointer(self, events, qh); -+ } -+ } -+} -+ -+impl DataSourceHandler for State { -+ fn accept_mime( -+ &mut self, -+ _: &Connection, -+ _: &QueueHandle, -+ _: &WlDataSource, -+ _: Option, -+ ) { -+ } -+ -+ fn send_request( -+ &mut self, -+ _: &Connection, -+ _: &QueueHandle, -+ source: &WlDataSource, -+ mime: String, -+ fd: WritePipe, -+ ) { -+ if let Some(preview) = &self.preview { -+ preview.send(source, mime, fd); -+ } -+ } -+ -+ fn cancelled(&mut self, _: &Connection, qh: &QueueHandle, source: &WlDataSource) { - if let Some(preview) = &mut self.preview { -- preview.pointer(events, self.cursor_device.as_ref(), qh); -- } -+ preview.drag_ended(source, Outcome::Cancelled, qh); -+ } -+ } -+ -+ fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource) {} -+ -+ fn dnd_finished(&mut self, _: &Connection, qh: &QueueHandle, source: &WlDataSource) { -+ if let Some(preview) = &mut self.preview { -+ preview.drag_ended(source, Outcome::Dropped, qh); -+ } -+ } -+ -+ fn action(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource, _: DndAction) {} -+} -+ -+// valw only ever starts drags; incoming offers are ignored. -+impl DataDeviceHandler for State { -+ fn enter( -+ &mut self, -+ _: &Connection, -+ _: &QueueHandle, -+ _: &WlDataDevice, -+ _: f64, -+ _: f64, -+ _: &wl_surface::WlSurface, -+ ) { -+ } -+ -+ fn leave(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} -+ -+ fn motion(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice, _: f64, _: f64) {} -+ -+ fn selection(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} -+ -+ fn drop_performed(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} -+} -+ -+impl DataOfferHandler for State { -+ fn source_actions( -+ &mut self, -+ _: &Connection, -+ _: &QueueHandle, -+ _: &mut DragOffer, -+ _: DndAction, -+ ) { -+ } -+ -+ fn selected_action( -+ &mut self, -+ _: &Connection, -+ _: &QueueHandle, -+ _: &mut DragOffer, -+ _: DndAction, -+ ) { - } - } - -``` - -- [ ] **Step 3: Host** - -```diff ---- a/src/host.rs -+++ b/src/host.rs -@@ -5,9 +5,12 @@ - use std::io::{ErrorKind, Read, Write}; - use std::os::unix::net::{UnixListener, UnixStream}; - use std::path::Path; -+use std::sync::Arc; -+use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, Instant}; - - use anyhow::{Context, Result}; -+use smithay_client_toolkit::data_device_manager::{WritePipe, data_source::DragSource}; - use smithay_client_toolkit::reexports::calloop::{ - EventLoop, Interest, LoopHandle, Mode, PostAction, - generic::Generic, -@@ -16,15 +19,16 @@ - use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; - use smithay_client_toolkit::seat::pointer::{BTN_LEFT, PointerEvent, PointerEventKind}; - use smithay_client_toolkit::shell::wlr_layer::LayerSurface; -+use wayland_client::protocol::{wl_data_device_manager::DndAction, wl_data_source::WlDataSource}; - use wayland_client::{Connection, QueueHandle, protocol::wl_surface::WlSurface}; --use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{ -- Shape, WpCursorShapeDeviceV1, --}; -- -+use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; -+use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; -+ -+use crate::dnd::{self, Outcome}; - use crate::ipc::{self, Reply, Request}; - use crate::lock::Lock; - use crate::stack; --use crate::thumbnail::{Action, Parts, Thumbnail}; -+use crate::thumbnail::{Action, DragStart, Parts, Thumbnail}; - use crate::wayland::{State, Wayland}; - - /// A host nobody connects to within this time gives up. -@@ -89,6 +93,19 @@ - conn: Connection, - handle: LoopHandle<'static, State>, - qh: QueueHandle, -+ drag: Option, -+ /// Threads still writing dropped data; the host waits for them, or the -+ /// receiving app would get a cut-off file. -+ writers: Arc, -+ warned_no_dnd: bool, -+} -+ -+/// The drag-out in progress. Dropping the source cancels the drag. -+struct Drag { -+ thumb: u64, -+ source: DragSource, -+ icon: WlSurface, -+ icon_viewport: WpViewport, - } - - /// Runs the host until its last thumbnail closes. A second host exits -@@ -128,6 +145,9 @@ - conn, - handle: handle.clone(), - qh, -+ drag: None, -+ writers: Arc::new(AtomicUsize::new(0)), -+ warned_no_dnd: false, - }); - - let mut next_client = 0u64; -@@ -152,7 +172,8 @@ - .run(Duration::from_millis(500), &mut state, |state| { - let host = state.preview.as_mut().expect("host state"); - host.tidy(); -- if host.core.should_exit(host.thumbs.len(), Instant::now()) { -+ let busy = host.thumbs.len() + host.writers.load(Ordering::SeqCst); -+ if host.core.should_exit(busy, Instant::now()) { - signal.stop(); - } - }) -@@ -253,38 +274,40 @@ - self.thumbs.retain(|t| !t.is_layer(layer)); - } - -- pub fn pointer( -- &mut self, -- events: &[PointerEvent], -- cursor: Option<&WpCursorShapeDeviceV1>, -- qh: &QueueHandle, -- ) { -- for event in events { -- let Some(t) = self.thumb(&event.surface) else { -- continue; -- }; -- let (x, y) = event.position; -- match event.kind { -- PointerEventKind::Enter { serial } => { -- if let Some(cursor) = cursor { -- cursor.set_shape(serial, Shape::Pointer); -- } -- } -- PointerEventKind::Press { -- button: BTN_LEFT, .. -- } => t.press(x, y), -- PointerEventKind::Motion { .. } => t.motion(x, qh), -- PointerEventKind::Release { -- button: BTN_LEFT, .. -- } => { -- let action = t.release(x, y, qh); -- if action == Some(Action::OpenEditor) { -- open_editor(&t.path); -- } -- } -- _ => {} -- } -- } -+ /// Writes the dragged screenshot for the receiving app, on its own -+ /// thread: a large PNG fills the pipe, and blocking here would freeze -+ /// every thumbnail. -+ pub fn send(&self, source: &WlDataSource, mime: String, fd: WritePipe) { -+ let Some(drag) = self.drag.as_ref().filter(|d| d.source.inner() == source) else { -+ return; -+ }; -+ let Some(thumb) = self.thumbs.iter().find(|t| t.id == drag.thumb) else { -+ return; -+ }; -+ let path = thumb.path.clone(); -+ let writers = Arc::clone(&self.writers); -+ writers.fetch_add(1, Ordering::SeqCst); -+ std::thread::spawn(move || { -+ let mut fd = fd; -+ if let Err(e) = dnd::write_offer(&mime, &path, &mut fd) { -+ tracing::warn!("could not send the screenshot as {mime}: {e:#}"); -+ } -+ drop(fd); -+ writers.fetch_sub(1, Ordering::SeqCst); -+ }); -+ } -+ -+ pub fn drag_ended(&mut self, source: &WlDataSource, outcome: Outcome, qh: &QueueHandle) { -+ let Some(drag) = self.drag.take_if(|d| d.source.inner() == source) else { -+ return; -+ }; -+ tracing::info!("drag-out {outcome:?}"); -+ drag.icon_viewport.destroy(); -+ drag.icon.destroy(); -+ if let Some(t) = self.thumbs.iter_mut().find(|t| t.id == drag.thumb) { -+ t.end_drag(outcome, qh); -+ } -+ let _ = self.conn.flush(); - } - - fn expire(&mut self, id: u64) { -@@ -304,6 +327,78 @@ - } - } - } -+} -+ -+/// Pointer events on thumbnails. A free function: starting a drag needs -+/// more of `State` than the host. -+pub fn pointer(state: &mut State, events: &[PointerEvent], qh: &QueueHandle) { -+ for event in events { -+ let Some(host) = state.preview.as_mut() else { -+ return; -+ }; -+ let Some(i) = host.thumbs.iter().position(|t| t.is(&event.surface)) else { -+ continue; -+ }; -+ let (x, y) = event.position; -+ match event.kind { -+ PointerEventKind::Enter { serial } => { -+ if let Some(cursor) = &state.cursor_device { -+ cursor.set_shape(serial, Shape::Pointer); -+ } -+ } -+ PointerEventKind::Press { -+ button: BTN_LEFT, -+ serial, -+ .. -+ } => host.thumbs[i].press(x, y, serial), -+ PointerEventKind::Motion { .. } => { -+ let start = host.thumbs[i].motion(x, y, qh); -+ if let Some(start) = start { -+ start_drag(state, i, start, qh); -+ } -+ } -+ PointerEventKind::Release { -+ button: BTN_LEFT, .. -+ } => { -+ let t = &mut host.thumbs[i]; -+ if t.release(x, y, qh) == Some(Action::OpenEditor) { -+ open_editor(&t.path); -+ } -+ } -+ _ => {} -+ } -+ } -+} -+ -+fn start_drag(state: &mut State, i: usize, start: DragStart, qh: &QueueHandle) { -+ let host = state.preview.as_mut().expect("host state"); -+ let (Some(manager), Some(device), Some(viewporter)) = -+ (&state.data_devices, &state.data_device, &state.viewporter) -+ else { -+ if !host.warned_no_dnd { -+ host.warned_no_dnd = true; -+ tracing::warn!("the compositor has no wl_data_device_manager; drag-out is off"); -+ } -+ return; -+ }; -+ if host.drag.is_some() { -+ return; -+ } -+ let source = manager.create_drag_and_drop_source(qh, dnd::MIME_TYPES, DndAction::Copy); -+ let icon = state.compositor.create_surface(qh); -+ let icon_viewport = viewporter.get_viewport(&icon, qh, ()); -+ let thumb = &mut host.thumbs[i]; -+ source.start_drag(device, thumb.surface(), Some(&icon), start.serial); -+ thumb.draw_icon(&icon, &icon_viewport, start.grab); -+ thumb.begin_drag(qh); -+ tracing::info!("drag-out started for {}", thumb.path.display()); -+ host.drag = Some(Drag { -+ thumb: thumb.id, -+ source, -+ icon, -+ icon_viewport, -+ }); -+ let _ = host.conn.flush(); - } - - fn disconnect(state: &mut State, id: u64) { -``` - -- [ ] **Step 4: Remove the allowance, then lint and test** - -Apply to `src/main.rs`: - -```diff ---- a/src/main.rs -+++ b/src/main.rs -@@ -1,6 +1,3 @@ --// Drag-out modules land before the host uses them; Task 3 removes this. --#![allow(dead_code)] -- - mod capture; - mod config; - mod detach; -``` - -Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` -Expected: no warnings, in particular no `dead_code`; every test passes. - -- [ ] **Step 5: Try it on niri** - -Build `cargo build --release`. Use a throwaway config with `copy_to_clipboard = false` and `timeout_secs = 20`. Stop any running preview host first (an installed valw's host would answer instead): - -```sh -for pid in $(pgrep -f __preview-host); do - case "$(ps -o comm= -p $pid)" in valw|.valw-wrapped) kill $pid;; esac -done -``` - -Then run `$CARGO_TARGET_DIR/release/valw screen` and ask the user to: -1. Drag the thumbnail left or up into Dolphin, a browser upload field, or Discord. -2. Run it again, drag left or up, and release on the empty desktop. -3. Run it once more and swipe right; then once more and click. - -Expected: -- (1) The file arrives, and the thumbnail image travels under the pointer. The log has `drag-out started for …` and `drag-out Dropped`. -- (2) The thumbnail comes back, and the log has `drag-out Cancelled`. -- (3) The swipe dismisses, and the click opens Satty, both as before. - -- [ ] **Step 6: Commit** - -```sh -jj commit -m "feat: drag the preview out" -``` - ---- - -### Task 4: Checks, rollout and test with the user - -**Files:** none in valw. In `~/copland`, only `flake.lock` changes, through `nix flake update valw`; tell the user before running it. - -**Interfaces:** -- Consumes: Tasks 1–3. -- Produces: the feature on the user's system. - -- [ ] **Step 1: Full checks** - -Run: `nix flake check -L --keep-going` -Expected: every check passes. The headless preview check is unchanged and still guards hide/show and click/swipe regressions. - -- [ ] **Step 2: Update copland's lock** - -```sh -cd ~/copland && nix flake update valw && jj commit -m "valw: drag the preview out" -``` - -Expected: `flake.lock`'s `valw` node moves to the new tree. - -- [ ] **Step 3: Hand over the rebuild** - -Ask the user to rebuild as usual. Then `valw doctor` should be all `ok`. - -- [ ] **Step 4: Checklist with the user** - -- Drop into Dolphin → the file is copied (the default name has spaces, so this also checks `%20`). -- Drop into a browser upload field → it uploads. Drop into a Discord message → it attaches. -- Drop into an image editor that takes `image/png` (if they have one) → the pixels arrive. -- Release on nothing, or Esc mid-drag → the thumbnail comes back. -- **Review Focus 2:** with `timeout_secs = 3`, start a drag and hold it past 3 s, then release on nothing → it slides out. -- **Review Focus 1:** while dragging, press Print → after the capture the other thumbnails reappear but the dragged one doesn't; finishing the drag behaves normally. -- Drag onto the other monitor and drop there → it works. -- Swipe right still dismisses; click still opens Satty. diff --git a/docs/superpowers/plans/2026-10-03-valw-home-manager.md b/docs/superpowers/plans/2026-10-03-valw-home-manager.md deleted file mode 100644 index d18a4b2..0000000 --- a/docs/superpowers/plans/2026-10-03-valw-home-manager.md +++ /dev/null @@ -1,250 +0,0 @@ -# valw home-manager module 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:** `flake.homeModules.default` with `programs.valw.{enable, package, settings}`, the config checked at build time by `valw __check-config`, plus a README. - -**Architecture:** A strict `config::check` behind a hidden CLI command; a dendritic `nix/home-module.ulu.nix` that writes `settings` with `pkgs.formats.toml` through a checking `runCommand`; two flake checks that evaluate the module with stub home-manager options (no new input). - -**Tech Stack:** Rust 2024; Nix (flake-parts, dendritic `*.ulu.nix`), `pkgs.formats.toml`, `pkgs.testers.testBuildFailure`. - -**Spec:** `docs/superpowers/specs/2026-10-03-valw-home-manager-design.md` - -## Global Constraints - -- jj only; `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`. -- No manual `imports = [ ... ]` in the dendritic flake; no new flake inputs. -- Error text for unknown keys: `unknown config key: a` / `unknown config keys: a, b`. - -## Review Focus - -1. **The checked config is byte-identical to what valw reads** (no re-serialisation in between). Task 2's check greps the installed file. -2. **`__check-config` in the sandbox** (no HOME, no Wayland, no niri) must work. Task 2's checks run it there. -3. **Empty `settings` writes no file** (valw defaults apply). Task 2 asserts it. -4. **A typo'd key fails the build with the key's name.** Task 2's rejects check. -5. **Toolbar state stays writable.** Not managed (spec), README says so. - ---- - -### Task 1: `valw __check-config` - -**Files:** `src/config.rs`, `src/main.rs`. - -- [ ] **Step 1: Failing tests** - -`config.rs` tests: - -```rust - #[test] - fn check_accepts_valid_files() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "[preview]\ntimeout_secs = 3\n[sound]\nvolume = 0.4\n").unwrap(); - check(&path).unwrap(); - std::fs::write(&path, "").unwrap(); - check(&path).unwrap(); - } - - #[test] - fn check_rejects_invalid_values_with_valws_message() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "[sound]\nvolume = 2.0\n").unwrap(); - let err = format!("{:#}", check(&path).unwrap_err()); - assert!(err.contains("sound.volume must be between 0 and 1"), "{err}"); - } - - #[test] - fn check_rejects_unknown_keys_by_name() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "[preview]\ntimout_secs = 3\n").unwrap(); - assert_eq!(check(&path).unwrap_err().to_string(), "unknown config key: preview.timout_secs"); - std::fs::write(&path, "[preview]\ntimout_secs = 3\n[sund]\nx = 1\n").unwrap(); - assert_eq!( - check(&path).unwrap_err().to_string(), - "unknown config keys: preview.timout_secs, sund" - ); - } -``` - -`main.rs`: `flag_conflicts` gains `assert!(parses(&["__check-config", "c.toml"]));` and `assert!(!parses(&["__check-config"]));`; the help test gains `assert!(!help.contains("check-config"), "{help}");`. - -- [ ] **Step 2: Implement** - -`config.rs`: - -```rust -/// Checks a config file strictly, for home-manager's build: invalid values -/// and unknown keys are errors (at run time unknown keys only warn). -pub fn check(path: &Path) -> Result<()> { - let text = std::fs::read_to_string(path).with_context(|| format!("could not read {}", path.display()))?; - let (_, unknown) = parse(&text).with_context(|| format!("invalid config file {}", path.display()))?; - match unknown.len() { - 0 => Ok(()), - 1 => bail!("unknown config key: {}", unknown[0]), - _ => bail!("unknown config keys: {}", unknown.join(", ")), - } -} -``` - -`main.rs`: `/// Internal: check a config file (home-manager runs it at build time).` `#[command(name = "__check-config", hide = true)] CheckConfig { file: PathBuf }` → `Command::CheckConfig { file } => config::check(&file),`. - -- [ ] **Step 3: Run** (+3 tests), clippy, fmt. **Step 4: Commit** `feat: valw __check-config`. - ---- - -### Task 2: The module and its checks - -**Files:** create `nix/home-module.ulu.nix`; modify `nix/checks.ulu.nix`. - -- [ ] **Step 1: Failing checks** — `nix/checks.ulu.nix`: take `self` and `lib` in the module arguments, and add - -```nix - # The home-manager module, evaluated with stand-ins for the two - # home-manager options it sets (no home-manager input needed). - homeModule = - settings: - (lib.evalModules { - modules = [ - self.homeModules.default - ( - { lib, ... }: - { - options.home.packages = lib.mkOption { - type = lib.types.listOf lib.types.package; - default = [ ]; - }; - options.xdg.configFile = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { options.source = lib.mkOption { type = lib.types.path; }; } - ); - default = { }; - }; - config._module.args.pkgs = pkgs; - config.programs.valw = { - enable = true; - inherit settings; - }; - } - ) - ]; - }).config; -``` - -(in the `let`), and in `checks`: - -```nix - home-module = - let - plain = homeModule { }; - set = homeModule { - preview.timeout_secs = 3; - sound.volume = 0.4; - }; - in - assert lib.assertMsg (plain.home.packages == [ valw ]) "the module installs valw"; - assert lib.assertMsg (plain.xdg.configFile == { }) "no settings, no config file"; - pkgs.runCommand "valw-home-module" { } '' - grep -qx 'timeout_secs = 3' ${set.xdg.configFile."valw/config.toml".source} - grep -qx 'volume = 0.4' ${set.xdg.configFile."valw/config.toml".source} - touch $out - ''; - - home-module-rejects = - let - fails = settings: pkgs.testers.testBuildFailure (homeModule settings).xdg.configFile."valw/config.toml".source; - in - pkgs.runCommand "valw-home-module-rejects" { } '' - grep -q 'sound.volume must be between 0 and 1' ${fails { sound.volume = 2; }}/testBuildFailure.log - grep -q 'unknown config key: preview.timout_secs' ${fails { preview.timout_secs = 3; }}/testBuildFailure.log - touch $out - ''; -``` - -Run `nix flake check path:. --no-build` (evaluation only) → fails: `self.homeModules` missing. - -- [ ] **Step 2: The module** — `nix/home-module.ulu.nix`: - -```nix -{ withSystem, ... }: -{ - flake.homeModules.default = - { - config, - lib, - pkgs, - ... - }: - let - cfg = config.programs.valw; - toml = pkgs.formats.toml { }; - # valw checks its own config: a typo or a bad value fails the build. - checked = - settings: - let - file = toml.generate "valw-config.toml" settings; - in - pkgs.runCommand "valw-config.toml" { } '' - ${lib.getExe cfg.package} __check-config ${file} - cp ${file} $out - ''; - in - { - options.programs.valw = { - enable = lib.mkEnableOption "valw, macOS-style screenshots for niri"; - package = lib.mkOption { - type = lib.types.package; - default = withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.default); - defaultText = lib.literalExpression "valw.packages.\${system}.default"; - description = "The valw package to install and to check the settings with."; - }; - settings = lib.mkOption { - inherit (toml) type; - default = { }; - example = lib.literalExpression '' - { - preview.timeout_secs = 3; - sound.volume = 0.4; - capture.window_shadow = true; - } - ''; - description = '' - valw's config (`$XDG_CONFIG_HOME/valw/config.toml`), checked by - valw at build time. Empty means valw's defaults. - ''; - }; - }; - - config = lib.mkIf cfg.enable { - home.packages = [ cfg.package ]; - xdg.configFile."valw/config.toml" = lib.mkIf (cfg.settings != { }) { - source = checked cfg.settings; - }; - }; - }; -} -``` - -- [ ] **Step 3: Run** — `nix flake check path:. -L --keep-going`: all pass, including `home-module` and `home-module-rejects`. - -- [ ] **Step 4: Commit** `feat: home-manager module`. - ---- - -### Task 3: README and checklist - -- [ ] `README.md`: what valw is (one paragraph); install (`nix run`, flake package, the module); the module example (from the spec) and a note that the toolbar's memory stays in `~/.local/state/valw`; niri bind examples (`Print` → `valw screen`, `Mod+Shift+S` → `valw region`, plus `window`, `zoom`, `toolbar`); a table of the commands; `valw doctor`; where logs are. -- [ ] Checklist section: - -```markdown - -## home-manager module - -- [ ] copland: `home-valw.ulu.nix` uses `inputs.valw.homeModules.default` with `programs.valw.enable = true` and a few `settings`; rebuild; `~/.config/valw/config.toml` is the store file with those settings. -- [ ] A typo in `settings` (e.g. `preview.timout_secs`) fails the rebuild naming the key. -- [ ] The toolbar still remembers its options (state file stays writable). -- [ ] New binds for `valw window`, `valw zoom`, `valw toolbar` in copland's niri keybinds. -``` - -- [ ] Commit `docs: README and home-manager checklist`. diff --git a/docs/superpowers/plans/2026-10-03-valw-phase-5b-editor.md b/docs/superpowers/plans/2026-10-03-valw-phase-5b-editor.md deleted file mode 100644 index 8a8c3ce..0000000 --- a/docs/superpowers/plans/2026-10-03-valw-phase-5b-editor.md +++ /dev/null @@ -1,844 +0,0 @@ -# valw Phase 5b: editor text, pixelate, numbers, crop 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:** Text (T), Pixelate (B), Number (N) and Crop (C) in `valw edit`, exported exactly as shown. - -**Architecture:** Shapes gain a `text` payload; `Doc` history entries become `Item::{Shape, Crop}`. A new `editor/text.rs` lays out the vendored DejaVu Sans Bold with `ab_glyph` and turns glyph outlines into tiny-skia paths. `export::render` draws text, numbers and pixelation itself (they have no egui-paintable geometry) and `export::crop` cuts the last crop out. The canvas keeps showing the export renderer's output (also for the text being typed); egui only paints in-progress drags, the caret and the crop overlay. `Fit` learns to fit a crop rectangle. - -**Tech Stack:** Rust 2024, eframe/egui 0.36.2, tiny-skia 0.12, `ab_glyph` 0.2.32. - -**Spec:** `docs/superpowers/specs/2026-10-03-valw-phase-5b-editor-design.md` - -## Global Constraints - -- jj only; `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`. -- Sizes per S/M/L (index of `WIDTHS`): text 16/24/36 px, pixel block 8/12/20 px, number diameter 24/32/44 px (number glyphs 0.55 × diameter); line height 1.2 × size; text outline `#1c1c1e` alpha 204, width size/8; crop overlay 50 % black, 1 px white border, 8 screen px edge grab. -- Keys: T text, B pixelate, N number, C crop (Ctrl+C stays Copy); while typing, letters are text and Ctrl shortcuts are ignored; Esc cancels typing, then a pending crop, then starts the close flow; Enter finishes typing or applies a pending crop. -- Tell the user before opening editor windows on the live session. - -## Review Focus - -1. **Turkish text and kerning** survive export (no notdef boxes). Test: `editor::text::tests::turkish_letters_have_glyphs` (Task 1). -2. **Typing doesn't trigger shortcuts** (T, A, Ctrl+Z, Esc closing the window). Real check in the checklist; key routing in Task 4. -3. **Pixelate covers earlier shapes and stays inside the image** at edges. Tests: `editor::export::tests::pixelate_*` (Task 2). -4. **Crop + undo/redo + dirty** and the export size/offset. Tests: `editor::doc::tests::crops_*`, `editor::export::tests::crop_cuts_out_the_rectangle` (Tasks 2–3). -5. **Drawing after a crop** lands at the right image pixels. Test: `editor::view::tests::round_trip_with_a_crop` (Task 3). - ---- - -## File Structure - -| File | Change | Task | -|---|---|---| -| `assets/fonts/DejaVuSans-Bold.ttf`, `assets/fonts/LICENSE-DejaVu` | vendored font | 1 | -| `src/editor/text.rs` | `layout`, `path`, `FONT` | 1 | -| `src/editor/shape.rs` | new tools, `text` field, size tables | 2 | -| `src/editor/export.rs` | text, numbers, pixelate, `crop` | 2 | -| `src/editor/doc.rs` | `Item`, crops, `next_number` | 3 | -| `src/editor/view.rs` | `Fit::with_crop` | 3 | -| `src/editor/mod.rs` | typing, numbers, crop UI, keys, toolbar | 4 | -| `docs/test-checklist.md` | Phase 5b section | 5 | - ---- - -### Task 1: Font and text layout - -**Files:** create `assets/fonts/DejaVuSans-Bold.ttf` (from `nixpkgs#dejavu_fonts`, `share/fonts/truetype/DejaVuSans-Bold.ttf`) and `assets/fonts/LICENSE-DejaVu` (upstream `LICENSE`); `Cargo.toml`: `ab_glyph = "0.2.32"`; `src/editor/mod.rs`: `pub mod text;`; create `src/editor/text.rs`. - -**Produces:** `text::layout(text: &str, size: f32) -> Layout { glyphs: Vec<(GlyphId, P)>, width: f32, height: f32, lines: Vec /* width of each line */ }`; `text::path(text: &str, size: f32, origin: P) -> Option` (origin = top-left of the first line box); `text::line_height(size) -> f32`. - -- [ ] **Step 1: Failing tests** — `src/editor/text.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_text_has_no_size() { - let l = layout("", 24.0); - assert_eq!((l.width, l.height), (0.0, 0.0)); - assert!(path("", 24.0, (0.0, 0.0)).is_none()); - } - - #[test] - fn lines_and_widths() { - let one = layout("Hello", 24.0); - let longer = layout("Hello!", 24.0); - assert!(longer.width > one.width && one.width > 0.0); - assert_eq!(one.height, line_height(24.0)); - let two = layout("Hi\nthere", 24.0); - assert_eq!(two.height, 2.0 * line_height(24.0)); - assert_eq!(two.lines.len(), 2); - assert!(two.lines[1] > two.lines[0]); - assert_eq!(two.width, two.lines[1]); - } - - #[test] - fn kerning_is_applied() { - let font = FONT.as_scaled(PxScale::from(48.0)); - let (a, v) = (FONT.glyph_id('A'), FONT.glyph_id('V')); - let plain = font.h_advance(a) + font.h_advance(v); - assert!(font.kern(a, v) < 0.0, "DejaVu kerns AV"); - assert!(layout("AV", 48.0).width < plain); - } - - #[test] - fn turkish_letters_have_glyphs() { - for c in "ğüşıöçĞÜŞİÖÇ".chars() { - assert_ne!(FONT.glyph_id(c).0, 0, "{c} has no glyph"); - } - } - - #[test] - fn the_path_sits_in_the_line_box() { - let p = path("Hg", 24.0, (100.0, 50.0)).unwrap(); - let b = p.bounds(); - assert!(b.left() >= 100.0 && b.top() >= 50.0, "{b:?}"); - assert!(b.bottom() <= 50.0 + line_height(24.0), "{b:?}"); - } -} -``` - -Run `cargo nextest run editor::text` → compile errors. - -- [ ] **Step 2: Implement** (above the tests) - -```rust -//! Text for the editor: the bundled DejaVu Sans Bold, laid out with -//! ab_glyph and turned into tiny-skia paths, so canvas and export draw the -//! same glyphs. - -use std::sync::LazyLock; - -use ab_glyph::{Font, FontRef, GlyphId, OutlineCurve, PxScale, ScaleFont}; -use tiny_skia::{Path, PathBuilder}; - -use crate::editor::shape::P; - -static FONT_DATA: &[u8] = include_bytes!("../../assets/fonts/DejaVuSans-Bold.ttf"); - -pub static FONT: LazyLock> = - LazyLock::new(|| FontRef::try_from_slice(FONT_DATA).expect("the bundled font is valid")); - -pub fn line_height(size: f32) -> f32 { - size * 1.2 -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Layout { - /// Each glyph and its pen position (baseline) relative to the box's top-left. - pub glyphs: Vec<(GlyphId, P)>, - pub width: f32, - pub height: f32, - /// The width of every line. - pub lines: Vec, -} - -pub fn layout(text: &str, size: f32) -> Layout { - if text.is_empty() { - return Layout { glyphs: Vec::new(), width: 0.0, height: 0.0, lines: Vec::new() }; - } - let font = FONT.as_scaled(PxScale::from(size)); - let mut glyphs = Vec::new(); - let mut lines = Vec::new(); - for (i, line) in text.split('\n').enumerate() { - let baseline = i as f32 * line_height(size) + font.ascent(); - let mut x = 0.0; - let mut previous = None; - for c in line.chars() { - let id = font.glyph_id(c); - if let Some(p) = previous { - x += font.kern(p, id); - } - glyphs.push((id, (x, baseline))); - x += font.h_advance(id); - previous = Some(id); - } - lines.push(x); - } - Layout { - glyphs, - width: lines.iter().copied().fold(0.0, f32::max), - height: lines.len() as f32 * line_height(size), - lines, - } -} - -/// The outlines of `text` with its line box's top-left at `origin`. -pub fn path(text: &str, size: f32, origin: P) -> Option { - let layout = layout(text, size); - let scale = FONT.as_scaled(PxScale::from(size)).scale_factor(); - let mut pb = PathBuilder::new(); - for (id, (gx, gy)) in layout.glyphs { - let Some(outline) = FONT.outline(id) else { continue }; - // Font units, y up; the pen sits on the baseline. - let at = |p: ab_glyph::Point| { - (origin.0 + gx + p.x * scale.horizontal, origin.1 + gy - p.y * scale.vertical) - }; - let mut end: Option = None; - for curve in &outline.curves { - let start = match curve { - OutlineCurve::Line(a, _) | OutlineCurve::Quad(a, _, _) | OutlineCurve::Cubic(a, _, _, _) => *a, - }; - if end != Some(start) { - if end.is_some() { - pb.close(); - } - let (x, y) = at(start); - pb.move_to(x, y); - } - match curve { - OutlineCurve::Line(_, b) => { - let (x, y) = at(*b); - pb.line_to(x, y); - end = Some(*b); - } - OutlineCurve::Quad(_, c, b) => { - let ((cx, cy), (x, y)) = (at(*c), at(*b)); - pb.quad_to(cx, cy, x, y); - end = Some(*b); - } - OutlineCurve::Cubic(_, c1, c2, b) => { - let ((ax, ay), (bx, by), (x, y)) = (at(*c1), at(*c2), at(*b)); - pb.cubic_to(ax, ay, bx, by, x, y); - end = Some(*b); - } - } - } - if end.is_some() { - pb.close(); - } - } - pb.finish() -} -``` - -(If ab_glyph's outline y axis turns out to point down, `the_path_sits_in_the_line_box` fails: flip the sign and ledger it.) - -- [ ] **Step 3: Run** (+5), clippy, fmt. **Step 4: Commit** `feat: bundled font and text layout for the editor`. - ---- - -### Task 2: New tools in shapes and export - -**Files:** `src/editor/shape.rs`, `src/editor/export.rs`. - -**Produces:** `Tool::{Text, Pixelate, Number, Crop}` (in `Tool::ALL` after Highlighter; labels "Text", "Pixelate", "Number", "Crop"); `Shape { tool, style, points, text: String }`; `shape::{size_index, text_size, block_size, number_diameter}` (all from `Style::width`); `export::render<'a>(base, shapes: impl IntoIterator) -> RgbaImage`; `export::crop(image: RgbaImage, rect: Option) -> RgbaImage`. - -Shape semantics: Text: `points = [top-left]`, `text` = the text. Number: `points = [centre]`, `text` = its number. Pixelate: `points = [start, end]` (Shift: square, like Rectangle). Crop never becomes a `Shape` (it is a `Doc` item, Task 3); `Drag` is never created for Text, Number or Crop. `geometry()` returns nothing for Text and Number, and for Pixelate a 2 px closed white outline (alpha 200, butt) used only while dragging. `is_click()` is false for Text and Number. - -- [ ] **Step 1: Failing tests** - -Add `text: String::new()` to every `Shape { .. }` literal in existing tests (shape, doc, export tests). Then in `shape.rs` tests: - -```rust - #[test] - fn sizes_follow_s_m_l() { - let at = |w: f32| Style { color: [0, 0, 0], width: w }; - assert_eq!([2.0, 4.0, 8.0].map(|w| text_size(at(w).width)), [16.0, 24.0, 36.0]); - assert_eq!([2.0, 4.0, 8.0].map(|w| block_size(at(w).width)), [8, 12, 20]); - assert_eq!([2.0, 4.0, 8.0].map(|w| number_diameter(at(w).width)), [24.0, 32.0, 44.0]); - } - - #[test] - fn pixelate_drags_like_a_rectangle() { - let mut d = Drag::new(Tool::Pixelate, RED, (0.0, 0.0)); - d.move_to((30.0, 10.0)); - assert_eq!(d.shape(true).points, vec![(0.0, 0.0), (30.0, 30.0)]); - let g = geometry(&d.shape(false)); - let Prim::Stroke { color, closed, .. } = &g[0] else { panic!("{g:?}") }; - assert!(*closed && *color == [255, 255, 255, 200]); - } - - #[test] - fn text_and_numbers_are_never_clicks_and_have_no_egui_geometry() { - for tool in [Tool::Text, Tool::Number] { - let s = Shape { tool, style: RED, points: vec![(5.0, 5.0)], text: "1".into() }; - assert!(!s.is_click()); - assert!(geometry(&s).is_empty()); - } - } -``` - -`export.rs` tests: - -```rust - fn white(w: u32, h: u32) -> RgbaImage { - RgbaImage::from_pixel(w, h, Rgba([255, 255, 255, 255])) - } - - #[test] - fn text_is_filled_with_the_colour_and_outlined() { - let t = Shape { - tool: Tool::Text, - style: Style { color: [0, 122, 255], width: 8.0 }, - points: vec![(10.0, 10.0)], - text: "I".into(), - }; - let out = render(&white(80, 60), &[t]); - // The middle of the I's stem: x ≈ 10 + stem centre, y in the cap height. - let blue = out.pixels().filter(|p| p.0 == [0, 122, 255, 255]).count(); - assert!(blue > 40, "{blue} fully blue pixels"); - let dark = out.pixels().filter(|p| p.0[0] < 80 && p.0[2] < 80).count(); - assert!(dark > 10, "the outline: {dark}"); - assert_eq!(out.get_pixel(70, 55).0, [255, 255, 255, 255]); - } - - #[test] - fn a_number_is_a_filled_circle_with_a_white_digit() { - let n = Shape { - tool: Tool::Number, - style: Style { color: [255, 0, 0], width: 8.0 }, - points: vec![(30.0, 30.0)], - text: "1".into(), - }; - let out = render(&white(60, 60), &[n]); - assert_eq!(out.get_pixel(30, 10).0, [255, 0, 0, 255], "inside the 44 px circle, above the digit"); - assert_eq!(out.get_pixel(3, 3).0, [255, 255, 255, 255], "outside it"); - let whites_inside = (20..40).flat_map(|x| (20..40).map(move |y| (x, y))) - .filter(|&(x, y)| out.get_pixel(x, y).0 == [255, 255, 255, 255]) - .count(); - assert!(whites_inside > 10, "the digit: {whites_inside}"); - } - - #[test] - fn pixelate_averages_blocks_including_earlier_shapes() { - let base = RgbaImage::from_fn(24, 12, |x, _| if x % 2 == 0 { Rgba([0, 0, 0, 255]) } else { Rgba([200, 100, 50, 255]) }); - let px = Shape { - tool: Tool::Pixelate, - style: Style { color: [0, 0, 0], width: 4.0 }, - points: vec![(0.0, 0.0), (24.0, 12.0)], - text: String::new(), - }; - let out = render(&base, &[px.clone()]); - let first = out.get_pixel(0, 0).0; - for x in 0..12 { - for y in 0..12 { - assert_eq!(out.get_pixel(x, y).0, first); - } - } - for (got, want) in first.iter().zip([100, 50, 25, 255]) { - assert!((*got as i32 - want).abs() <= 1, "{first:?}"); - } - let red = Shape { - tool: Tool::Line, - style: Style { color: [255, 0, 0], width: 8.0 }, - points: vec![(0.0, 6.0), (24.0, 6.0)], - text: String::new(), - }; - let both = render(&base, &[red, px]); - assert!(both.get_pixel(0, 0).0[0] > first[0], "the line is pixelated in"); - } - - #[test] - fn pixelate_stays_inside_the_image() { - let px = Shape { - tool: Tool::Pixelate, - style: Style { color: [0, 0, 0], width: 4.0 }, - points: vec![(-10.0, -10.0), (500.0, 500.0)], - text: String::new(), - }; - let out = render(&white(30, 20), &[px]); - assert_eq!(out.dimensions(), (30, 20)); - assert_eq!(out.get_pixel(29, 19).0, [255, 255, 255, 255]); - } - - #[test] - fn crop_cuts_out_the_rectangle() { - let img = RgbaImage::from_fn(10, 8, |x, y| Rgba([x as u8, y as u8, 0, 255])); - let rect = PixelRect { x: 2, y: 3, width: 4, height: 2 }; - let out = crop(img.clone(), Some(rect)); - assert_eq!(out.dimensions(), (4, 2)); - assert_eq!(out.get_pixel(0, 0).0, [2, 3, 0, 255]); - assert_eq!(crop(img.clone(), None), img); - } -``` - -(Add `use crate::frame::PixelRect;` in the test module if not covered by `super::*`.) - -Run → compile errors. - -- [ ] **Step 2: Implement `shape.rs` changes** - -- `Tool` gains `Text, Pixelate, Number, Crop`; `ALL` becomes 10 entries; labels as above. -- `Shape` gains `pub text: String`; `Drag::shape` sets `text: String::new()`. -- `constrain`: `Tool::Rectangle | Tool::Ellipse | Tool::Pixelate` share the square branch; `Tool::Pen | Tool::Highlighter | Tool::Text | Tool::Number | Tool::Crop => end`. -- `is_click`: `if matches!(self.tool, Tool::Pen | Tool::Text | Tool::Number) { return false; }`. -- Size tables: - -```rust -/// 0, 1, 2 for S, M, L. -pub fn size_index(width: f32) -> usize { - WIDTHS.iter().position(|w| w.1 == width).unwrap_or(1) -} - -pub fn text_size(width: f32) -> f32 { - [16.0, 24.0, 36.0][size_index(width)] -} - -pub fn block_size(width: f32) -> u32 { - [8, 12, 20][size_index(width)] -} - -pub fn number_diameter(width: f32) -> f32 { - [24.0, 32.0, 44.0][size_index(width)] -} -``` - -- `geometry`: `Tool::Text | Tool::Number | Tool::Crop => Vec::new()`, and - -```rust - Tool::Pixelate => { - let ((x0, y0), (x1, y1)) = (pts[0], pts[1]); - vec![Prim::Stroke { - points: vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)], - width: 2.0, - color: [255, 255, 255, 200], - closed: true, - round: false, - }] - } -``` - -- [ ] **Step 3: Implement `export.rs` changes** - -`render` takes `shapes: impl IntoIterator` and draws each shape with: - -```rust -fn draw_shape(pixmap: &mut Pixmap, shape: &Shape) { - let [r, g, b] = shape.style.color; - match shape.tool { - Tool::Text => draw_text(pixmap, &shape.text, text_size(shape.style.width), shape.points[0], [r, g, b, 255], true), - Tool::Number => { - let d = number_diameter(shape.style.width); - let (cx, cy) = shape.points[0]; - if let Some(circle) = PathBuilder::from_circle(cx, cy, d / 2.0) { - pixmap.fill_path(&circle, &paint([r, g, b, 255]), FillRule::Winding, Transform::identity(), None); - } - let size = d * 0.55; - if let Some(glyphs) = text::path(&shape.text, size, (0.0, 0.0)) { - // Centre the digits' ink, not their line box. - let b = glyphs.bounds(); - let shift = Transform::from_translate(cx - (b.left() + b.right()) / 2.0, cy - (b.top() + b.bottom()) / 2.0); - if let Some(glyphs) = glyphs.transform(shift) { - pixmap.fill_path(&glyphs, &paint([255, 255, 255, 255]), FillRule::Winding, Transform::identity(), None); - } - } - } - Tool::Pixelate => pixelate(pixmap, shape.points[0], shape.points[1], block_size(shape.style.width)), - _ => { - for prim in geometry(shape) { - draw(pixmap, &prim); - } - } - } -} - -fn paint(c: [u8; 4]) -> Paint<'static> { - let mut paint = Paint::default(); - paint.set_color_rgba8(c[0], c[1], c[2], c[3]); - paint.anti_alias = true; - paint -} - -const OUTLINE: [u8; 4] = [0x1c, 0x1c, 0x1e, 204]; - -/// `text` with its line box at `origin`; `outlined` adds the dark outline -/// that keeps it readable on any background. -fn draw_text(pixmap: &mut Pixmap, text: &str, size: f32, origin: P, color: [u8; 4], outlined: bool) { - let Some(path) = text::path(text, size, origin) else { return }; - if outlined { - let stroke = Stroke { width: size / 8.0, line_join: LineJoin::Round, ..Stroke::default() }; - pixmap.stroke_path(&path, &paint(OUTLINE), &stroke, Transform::identity(), None); - } - pixmap.fill_path(&path, &paint(color), FillRule::Winding, Transform::identity(), None); -} - -/// Replaces each `block`-sized square of the rectangle (aligned to its -/// top-left, clipped to the image) with its average colour. -fn pixelate(pixmap: &mut Pixmap, a: P, b: P, block: u32) { - let (w, h) = (pixmap.width(), pixmap.height()); - let clamp = |v: f32, max: u32| v.round().clamp(0.0, max as f32) as u32; - let (x0, x1) = (clamp(a.0.min(b.0), w), clamp(a.0.max(b.0), w)); - let (y0, y1) = (clamp(a.1.min(b.1), h), clamp(a.1.max(b.1), h)); - let pixels = pixmap.pixels_mut(); - for by in (y0..y1).step_by(block as usize) { - for bx in (x0..x1).step_by(block as usize) { - let (ex, ey) = ((bx + block).min(x1), (by + block).min(y1)); - let mut sum = [0u32; 4]; - for y in by..ey { - for x in bx..ex { - let p = pixels[(y * w + x) as usize]; - for (s, v) in sum.iter_mut().zip([p.red(), p.green(), p.blue(), p.alpha()]) { - *s += v as u32; - } - } - } - let n = (ex - bx) * (ey - by); - let [r, g, bl, al] = sum.map(|s| ((s + n / 2) / n) as u8); - // Premultiplied channels average to valid premultiplied values. - let avg = tiny_skia::PremultipliedColorU8::from_rgba(r.min(al), g.min(al), bl.min(al), al) - .expect("channels never exceed alpha"); - for y in by..ey { - for x in bx..ex { - pixels[(y * w + x) as usize] = avg; - } - } - } - } -} - -/// The part of `image` inside the last crop, or all of it. -pub fn crop(image: RgbaImage, rect: Option) -> RgbaImage { - match rect { - Some(r) => image::imageops::crop_imm(&image, r.x, r.y, r.width, r.height).to_image(), - None => image, - } -} -``` - -`draw` (the existing primitive painter) keeps using its own `Paint`; switch it to `paint(*color)` to share code. Imports: `crate::editor::shape::{P, Tool, block_size, number_diameter, text_size}`, `crate::editor::text`, `crate::frame::PixelRect`. - -- [ ] **Step 4: Run** (all pass, +8), clippy, fmt. **Step 5: Commit** `feat: text, numbers and pixelate in the editor export`. - ---- - -### Task 3: History items, crops and fitting - -**Files:** `src/editor/doc.rs`, `src/editor/view.rs`, `src/editor/mod.rs` (call sites only). - -**Produces:** `doc::Item::{Shape(Shape), Crop(PixelRect)}`; `Doc::{add_crop(PixelRect), crop() -> Option, shapes() -> impl Iterator, next_number() -> u32}` (the rest unchanged); `view::Fit::with_crop(crop: PixelRect, area, pixels_per_point) -> Fit` (and `Fit::new` = `with_crop` over the whole image); `Fit` gains `origin: P` (the crop's top-left in image px), used by `to_screen`/`to_image`. - -- [ ] **Step 1: Failing tests** - -`doc.rs` (the existing tests use `d.shapes()` as a slice: change those asserts to `d.shapes().cloned().collect::>()`): - -```rust - fn number(n: &str) -> Shape { - Shape { tool: Tool::Number, style: Style { color: [0, 0, 0], width: 4.0 }, points: vec![(1.0, 1.0)], text: n.into() } - } - - const R: PixelRect = PixelRect { x: 1, y: 2, width: 30, height: 20 }; - - #[test] - fn numbers_count_only_numbers_and_follow_undo() { - let mut d = Doc::new(); - assert_eq!(d.next_number(), 1); - d.add(number("1")); - d.add(line(5.0)); - d.add(number("2")); - assert_eq!(d.next_number(), 3); - d.undo(); - assert_eq!(d.next_number(), 2); - } - - #[test] - fn crops_are_undoable_and_nest() { - let mut d = Doc::new(); - assert_eq!(d.crop(), None); - d.add_crop(R); - let inner = PixelRect { x: 5, y: 5, width: 10, height: 10 }; - d.add_crop(inner); - assert_eq!(d.crop(), Some(inner)); - d.undo(); - assert_eq!(d.crop(), Some(R)); - d.undo(); - assert_eq!(d.crop(), None); - d.redo(); - assert_eq!(d.crop(), Some(R)); - } - - #[test] - fn crops_make_the_doc_dirty_and_skip_shapes() { - let mut d = Doc::new(); - d.add(line(1.0)); - d.mark_saved(); - d.add_crop(R); - assert!(d.is_dirty()); - assert_eq!(d.shapes().count(), 1); - } -``` - -`view.rs`: - -```rust - #[test] - fn a_crop_fills_the_area() { - let crop = PixelRect { x: 100, y: 50, width: 200, height: 100 }; - let f = Fit::with_crop(crop, (0.0, 0.0, 400.0, 400.0), 1.0); - assert_eq!(f.scale, 1.0); - assert_eq!(f.to_screen((100.0, 50.0)), (100.0, 150.0), "the crop's corner is centred"); - } - - #[test] - fn round_trip_with_a_crop() { - let crop = PixelRect { x: 640, y: 360, width: 640, height: 360 }; - let f = Fit::with_crop(crop, (0.0, 30.0, 500.0, 400.0), 1.0); - for p in [(640.0, 360.0), (1279.0, 719.0), (800.5, 400.25)] { - let q = f.to_image(f.to_screen(p)); - assert!((q.0 - p.0).abs() < 1e-3 && (q.1 - p.1).abs() < 1e-3, "{p:?} {q:?}"); - } - } -``` - -Run → compile errors. - -- [ ] **Step 2: Implement `doc.rs`** - -Replace the three `Vec` fields with `Vec`: - -```rust -use crate::editor::shape::{Shape, Tool}; -use crate::frame::PixelRect; - -/// One step of the history. -#[derive(Debug, Clone, PartialEq)] -pub enum Item { - Shape(Shape), - /// Show and export only this part of the image (image pixels). - Crop(PixelRect), -} - -#[derive(Debug, Default)] -pub struct Doc { - items: Vec, - redo: Vec, - saved: Vec, - revision: u64, -} -``` - -`add(shape)` pushes `Item::Shape`; `add_crop(rect)` pushes `Item::Crop` (both through a private `push(item)` that clears redo and bumps the revision); `undo`/`redo` move `Item`s; `can_undo` = items non-empty; `is_dirty` = `items != saved`; `mark_saved` clones items; and - -```rust - pub fn shapes(&self) -> impl Iterator { - self.items.iter().filter_map(|i| match i { - Item::Shape(s) => Some(s), - Item::Crop(_) => None, - }) - } - - /// The last crop, if any. - pub fn crop(&self) -> Option { - self.items.iter().rev().find_map(|i| match i { - Item::Crop(r) => Some(*r), - Item::Shape(_) => None, - }) - } - - pub fn next_number(&self) -> u32 { - 1 + self.shapes().filter(|s| s.tool == Tool::Number).count() as u32 - } -``` - -- [ ] **Step 3: Implement `view.rs`** - -```rust -use crate::frame::PixelRect; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Fit { - /// Points per image pixel. - pub scale: f32, - /// Where `origin` is drawn, in points. - pub offset: P, - /// The image pixel at the top-left of what is shown (the crop's corner). - pub origin: P, -} - -impl Fit { - pub fn new(image: (u32, u32), area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit { - Fit::with_crop(PixelRect { x: 0, y: 0, width: image.0, height: image.1 }, area, pixels_per_point) - } - - /// Fits `crop` into `area` (x, y, w, h in points), centred, never above - /// 100 % (one image pixel per physical pixel). - pub fn with_crop(crop: PixelRect, area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit { - // ... the existing body with iw/ih = crop.width/height ... - Fit { scale, offset: (snap(..), snap(..)), origin: (crop.x as f32, crop.y as f32) } - } - - pub fn to_screen(self, p: P) -> P { - (self.offset.0 + (p.0 - self.origin.0) * self.scale, self.offset.1 + (p.1 - self.origin.1) * self.scale) - } - - pub fn to_image(self, p: P) -> P { - ((p.0 - self.offset.0) / self.scale + self.origin.0, (p.1 - self.offset.1) / self.scale + self.origin.1) - } -} - -/// `p` moved into `rect`. -pub fn clamp(p: P, rect: PixelRect) -> P { - ( - p.0.clamp(rect.x as f32, (rect.x + rect.width) as f32), - p.1.clamp(rect.y as f32, (rect.y + rect.height) as f32), - ) -} -``` - -Update `clamp_keeps_points_on_the_image` to pass a `PixelRect { x: 0, y: 0, width: 100, height: 80 }`. - -- [ ] **Step 4: Call sites in `mod.rs`** (minimal, so it compiles; the UI comes in Task 4): `self.doc.shapes()` is now an iterator (`export::render` takes iterators); `png()` = `export::crop(export::render(&self.base, self.doc.shapes()), self.doc.crop())`; the canvas uses `let shown = self.doc.crop().unwrap_or(full)`, `Fit::with_crop(shown, …)`, the image rect from `to_pos` of the crop's corners with UVs `crop / image size`, and clamps the drag to `shown`. - -- [ ] **Step 5: Run** (all pass, +5), clippy, fmt. **Step 6: Commit** `feat: crop history and fitting in the editor`. - ---- - -### Task 4: The editor UI for the new tools - -**Files:** `src/editor/mod.rs`. - -**Produces:** private `Typing { at: P, text: String }`, `CropEdit { rect: (f32, f32, f32, f32), grab: Option }`, `Edges { left, right, top, bottom: bool }`, `fn crop_edges(rect_screen: (f32, f32, f32, f32), p: P, tolerance: f32) -> Option`. - -- [ ] **Step 1: Failing test** — in `mod.rs` tests: - -```rust - #[test] - fn crop_edges_near_edges_and_corners() { - let r = (100.0, 100.0, 300.0, 200.0); - let e = |l, r_, t, b| Some(Edges { left: l, right: r_, top: t, bottom: b }); - assert_eq!(crop_edges(r, (102.0, 150.0), 8.0), e(true, false, false, false)); - assert_eq!(crop_edges(r, (295.0, 150.0), 8.0), e(false, true, false, false)); - assert_eq!(crop_edges(r, (200.0, 205.0), 8.0), e(false, false, false, true)); - assert_eq!(crop_edges(r, (99.0, 101.0), 8.0), e(true, false, true, false)); - assert_eq!(crop_edges(r, (200.0, 150.0), 8.0), None, "inside"); - assert_eq!(crop_edges(r, (50.0, 150.0), 8.0), None, "outside"); - } -``` - -Run → compile error. - -- [ ] **Step 2: Implement** - -```rust -/// Which edges of the crop rectangle a press grabs. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Edges { - left: bool, - right: bool, - top: bool, - bottom: bool, -} - -/// The edges of `r` (x0, y0, x1, y1 on screen) within `tolerance` of `p`; -/// `None` inside, outside, or far from every edge. -fn crop_edges(r: (f32, f32, f32, f32), p: P, tolerance: f32) -> Option { - let (x0, y0, x1, y1) = r; - let within_x = p.0 > x0 - tolerance && p.0 < x1 + tolerance; - let within_y = p.1 > y0 - tolerance && p.1 < y1 + tolerance; - let e = Edges { - left: within_y && (p.0 - x0).abs() <= tolerance, - right: within_y && (p.0 - x1).abs() <= tolerance, - top: within_x && (p.1 - y0).abs() <= tolerance, - bottom: within_x && (p.1 - y1).abs() <= tolerance, - }; - (e.left || e.right || e.top || e.bottom).then_some(e) -} -``` - -Editor fields: `typing: Option`, `cropping: Option` (rect in image px as x0, y0, x1, y1, unnormalised while dragging), plus the composite key becomes `shown: (u64, Option)` — the revision and the typed text — so the texture re-renders while typing. - -Behaviour, in `keys()` before everything else: - -```rust - if self.typing.is_some() { - self.type_keys(ctx); - return; - } - if pressed(Modifiers::NONE, Key::Enter) && self.cropping.is_some() { - self.apply_crop(); - } - // Esc: a pending crop first, then the close flow. - if self.cropping.is_some() && pressed(Modifiers::NONE, Key::Escape) { - self.cropping = None; - } -``` - -and the tool-key table gains `(Key::T, Tool::Text), (Key::B, Tool::Pixelate), (Key::N, Tool::Number), (Key::C, Tool::Crop)` (after the Ctrl+C check, which consumes Ctrl+C first). Changing tool (keys or toolbar) goes through `fn set_tool(&mut self, tool)` which finishes typing and drops a pending crop. - -```rust - /// While typing every key edits the text; nothing else reacts. - fn type_keys(&mut self, ctx: &egui::Context) { - let events = ctx.input_mut(|i| std::mem::take(&mut i.events)); - for event in events { - match event { - egui::Event::Text(s) => { - if let Some(t) = &mut self.typing { - t.text.extend(s.chars().filter(|c| !c.is_control())); - } - } - egui::Event::Key { key, pressed: true, modifiers, .. } => match key { - Key::Backspace => { - if let Some(t) = &mut self.typing { - t.text.pop(); - } - } - Key::Enter if modifiers.shift => { - if let Some(t) = &mut self.typing { - t.text.push('\n'); - } - } - Key::Enter => self.finish_typing(), - Key::Escape => self.typing = None, - _ => {} - }, - _ => {} - } - } - } - - fn finish_typing(&mut self) { - if let Some(t) = self.typing.take() - && !t.text.trim().is_empty() - { - self.doc.add(Shape { tool: Tool::Text, style: self.style(), points: vec![t.at], text: t.text }); - } - } - - fn apply_crop(&mut self) { - if let Some(c) = self.cropping.take() { - let (x0, y0, x1, y1) = c.rect; - let (x0, x1, y0, y1) = (x0.min(x1).floor(), x0.max(x1).ceil(), y0.min(y1).floor(), y0.max(y1).ceil()); - if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 { - self.doc.add_crop(PixelRect { x: x0 as u32, y: y0 as u32, width: (x1 - x0) as u32, height: (y1 - y0) as u32 }); - } - } - } -``` - -In `canvas()`, after the pointer is mapped (`p` = image point clamped to the shown rect): - -- On `drag_started`: if `typing` is some → `finish_typing()` and stop (the press only finishes the text). Otherwise by tool: Text → `typing = Some(Typing { at: p, text: String::new() })`; Number → `doc.add(Shape { tool: Number, style, points: vec![p], text: doc.next_number().to_string() })`; Crop → if `cropping` is some and `crop_edges(screen rect, pointer, 8.0)` hits → set its `grab`; else `cropping = Some(CropEdit { rect: (p.0, p.1, p.0, p.1), grab: None })` (a new rectangle, its far corner follows the pointer); everything else → `Drag::new` as in 5a. -- While dragging with Crop: with `grab` move the grabbed edges to `p`; without, move the far corner (x1, y1). On `drag_stopped` with Crop: clear `grab`; a rectangle under 2 px is dropped. -- The Esc-during-drag minor from 5a stays deferred. -- Texture key: `(revision, typing text)`; when it changes, render `doc.shapes()` plus the typed text as a temporary `Tool::Text` shape into the texture. -- Overlays painted with egui on top of the texture: the caret while typing (a 1.5 px line in the text colour at `at + (last line width, (lines − 1) × line height)`, `line height` tall; visible when `(time × 2) as i64 % 2 == 0`, `request_repaint_after(500 ms)`), and with a pending crop the darkened outside (four `rect_filled` with `Color32::from_black_alpha(128)`) plus a 1 px white `rect_stroke`. Hovering a crop edge sets the cursor icon (`ResizeHorizontal`, `ResizeVertical`, `ResizeNwSe`, `ResizeNeSw`). - -- [ ] **Step 3: Run** (all pass, +1), clippy, fmt. - -- [ ] **Step 4: Smoke run** (tell the user first): open `valw edit` on a generated image for a few seconds; the toolbar shows the 10 tools; no errors in the log. - -- [ ] **Step 5: Commit** `feat: text, numbers, pixelate and crop in the editor`. - ---- - -### Task 5: Checks and checklist - -- [ ] `nix flake check -L --keep-going` passes. -- [ ] Append to `docs/test-checklist.md`: - -```markdown - -## Editor tools (Phase 5b) - -- [ ] T: click, type (Turkish: ğüşıöçİ), Shift+Enter for a new line, Enter finishes; a click elsewhere also finishes; Esc drops it; while typing, letters don't switch tools and Ctrl+Z/Esc don't undo or close. -- [ ] Text is bold in the chosen colour with a dark outline, readable on light and dark backgrounds; S/M/L sizes; the saved PNG matches the canvas. -- [ ] B: dragging pixelates the area (also earlier arrows inside it); S/M/L block sizes; edges of the image are fine. -- [ ] N: clicks place 1, 2, 3 …; undo then a new click reuses the freed number. -- [ ] C: drag a crop, adjust its edges and corners (the cursor changes), Enter applies, Esc cancels; the canvas then shows only the crop; drawing after a crop lands where you click; undo restores the full image; the saved PNG has the crop's size. -- [ ] The toolbar shows all 10 tools and still wraps in a narrow window. -``` - -- [ ] Commit `docs: editor tools checklist`. diff --git a/docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md b/docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md deleted file mode 100644 index c4a725e..0000000 --- a/docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md +++ /dev/null @@ -1,1051 +0,0 @@ -# valw Phase 6: the toolbar 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:** `valw toolbar`: a floating bar (Screen, Window, Region, Zoom, Options ▾) that starts the chosen mode at once, with a remembered timer/cursor/preview. - -**Architecture:** Pure pieces first — `toolbar/state.rs` (remembered choices), `toolbar/layout.rs` (rectangles and hit-testing from measured label widths), `toolbar/draw.rs` (tiny-skia painting of the bar, menu and countdown pill into premultiplied pixels). Then `toolbar/mod.rs` puts the bar on a transparent full-output layer surface and the countdown on a small one, and `main.rs` turns the choice into the existing `capture(mode, common)`. - -**Tech Stack:** Rust 2024, SCTK 0.21 layer-shell + shm + viewporter, tiny-skia 0.12, the editor's `text` module (DejaVu Sans Bold). - -**Spec:** `docs/superpowers/specs/2026-10-03-valw-phase-6-toolbar-design.md` - -## Global Constraints - -- jj only; `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`. -- Namespaces `valw-toolbar` and `valw-countdown`. Bar: 24 logical px above the bottom, centred; label 13 px white; background `#1c1c1e` alpha 230 (90 %); corner radius 12; buttons 32 px tall, 12 px side padding; bar padding 6 px. Menu rows 28 px. Selected mode fill white alpha 46; hover fill white alpha 26. -- State file `$XDG_STATE_HOME/valw/toolbar.toml` (fallback `~/.local/state`); defaults mode `region`, timer 0, cursor = `capture.show_cursor`, preview = `preview.enabled`. -- Esc / a click outside the bar and menu / the compositor closing the surface → `Cancelled` (exit 3). -- Tell the user before showing the toolbar on the live session. - -## Review Focus - -1. **The countdown or the bar ends up in the shot.** Both surfaces are destroyed and the connection flushed and round-tripped before `capture` starts (Task 3); real check in the checklist. -2. **A broken or hand-edited `toolbar.toml`** must not stop the toolbar. Test: `toolbar::state::tests::a_broken_file_falls_back` (Task 1). -3. **Hit-testing at the bar's edges and between buttons** (separators, padding) must not start a mode by accident. Test: `toolbar::layout::tests::gaps_hit_nothing` (Task 1). -4. **Fractional scale** (1.25): the bar must be crisp and hits must match what is drawn. Layout works in logical px, drawing scales by the output's scale (Task 2 test `draw::tests::scales`). -5. **The timer is meant for setting up the screen**: during the countdown the rest of the desktop must stay usable. Countdown surface takes no keyboard (Ruling below; Task 3). - -**Ruling (plan):** the spec gives the countdown pill exclusive keyboard focus so Esc can cancel. That would block typing during the countdown, which is what a timer is for. The pill takes no keyboard focus; a click on the pill cancels instead (its label shows the seconds; a hover shows "Cancel"). Cost if wrong: Esc doesn't cancel a timer. - ---- - -## File Structure - -| File | Change | Task | -|---|---|---| -| `src/toolbar/state.rs` | `Mode`, `ToolbarState`, `defaults`, `load`, `save`, `default_path` | 1 | -| `src/toolbar/layout.rs` | `Target`, `MenuItem`, `Rect`, `Layout`, `layout`, `Layout::hit` | 1 | -| `src/toolbar/draw.rs` | `bar`, `pill`, `to_argb` | 2 | -| `src/toolbar/mod.rs` | `run`, `Toolbar`, `Pill` surfaces | 1 (module list), 3 | -| `src/wayland.rs` | `State::{toolbar, pill}` routing | 3 | -| `src/main.rs` | `Command::Toolbar` | 3 | -| `docs/test-checklist.md` | Phase 6 section | 4 | - ---- - -### Task 1: State and layout - -**Files:** create `src/toolbar/mod.rs` (`pub mod layout; pub mod state;`), `src/toolbar/state.rs`, `src/toolbar/layout.rs`; `src/main.rs`: `mod toolbar;` plus `// The toolbar lands in pieces; Task 3 removes this.` `#![allow(dead_code)]` at the top. - -**Produces:** -- `state::Mode::{Screen, Window, Region, Zoom}` (serde lowercase), `state::ToolbarState { mode, timer: u32, cursor: bool, preview: bool }`, `state::defaults(&Config) -> ToolbarState`, `state::load(&Path, &Config) -> ToolbarState`, `state::save(&Path, &ToolbarState) -> Result<()>`, `state::default_path() -> PathBuf`. -- `layout::{Rect { x, y, w, h }, Target::{Mode(Mode), Options, Menu(MenuItem)}, MenuItem::{Timer(u32), Cursor, Preview}, Layout { bar, buttons: Vec<(Target, Rect, &'static str)>, separators: Vec, menu, rows: Vec<(MenuItem, Rect, &'static str)> }, layout(output: (f32, f32), measure: impl Fn(&str) -> f32) -> Layout, Layout::hit(&self, p: (f32, f32), menu_open: bool) -> Option, Layout::inside(&self, p, menu_open) -> bool}` — all in logical px. - -- [ ] **Step 1: Failing tests** - -`state.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_come_from_the_config() { - let mut config = Config::default(); - config.capture.show_cursor = true; - config.preview.enabled = false; - assert_eq!( - defaults(&config), - ToolbarState { mode: Mode::Region, timer: 0, cursor: true, preview: false } - ); - } - - #[test] - fn save_and_load_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("deep/toolbar.toml"); - let state = ToolbarState { mode: Mode::Zoom, timer: 10, cursor: true, preview: true }; - save(&path, &state).unwrap(); - assert_eq!(load(&path, &Config::default()), state); - } - - #[test] - fn a_missing_file_gives_the_defaults() { - let dir = tempfile::tempdir().unwrap(); - let config = Config::default(); - assert_eq!(load(&dir.path().join("none.toml"), &config), defaults(&config)); - } - - #[test] - fn a_broken_file_falls_back() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("toolbar.toml"); - let config = Config::default(); - for text in ["mode = \"video\"\n", "timer = 7\n", "not toml at all [", "cursor = \"yes\"\n"] { - std::fs::write(&path, text).unwrap(); - assert_eq!(load(&path, &config), defaults(&config), "{text:?}"); - } - } - - #[test] - fn missing_and_unknown_keys_are_fine() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("toolbar.toml"); - std::fs::write(&path, "mode = \"window\"\ncolour = \"red\"\n").unwrap(); - let config = Config::default(); - let state = load(&path, &config); - assert_eq!(state.mode, Mode::Window); - assert_eq!(state.timer, 0); - assert_eq!(state.preview, config.preview.enabled); - } -} -``` - -`layout.rs` (labels measured as 7 px per character so numbers are easy): - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn measure(s: &str) -> f32 { - 7.0 * s.chars().count() as f32 - } - - fn l() -> Layout { - layout((1366.0, 768.0), measure) - } - - fn centre(r: Rect) -> (f32, f32) { - (r.x + r.w / 2.0, r.y + r.h / 2.0) - } - - #[test] - fn the_bar_is_centred_above_the_bottom() { - let l = l(); - assert!(((l.bar.x + l.bar.w / 2.0) - 683.0).abs() < 0.01); - assert_eq!(l.bar.y + l.bar.h, 768.0 - 24.0); - assert_eq!(l.bar.h, 32.0 + 2.0 * 6.0); - } - - #[test] - fn buttons_are_in_order_and_do_not_overlap() { - let l = l(); - let labels: Vec<_> = l.buttons.iter().map(|b| b.2).collect(); - assert_eq!(labels, ["Screen", "Window", "Region", "Zoom", "Options ▾"]); - for pair in l.buttons.windows(2) { - assert!(pair[0].1.x + pair[0].1.w <= pair[1].1.x); - } - // "Screen": 6 chars × 7 + 2 × 12 padding. - assert_eq!(l.buttons[0].1.w, 42.0 + 24.0); - assert_eq!(l.separators.len(), 2); - } - - #[test] - fn each_button_hits_its_target() { - let l = l(); - for (target, rect, _) in &l.buttons { - assert_eq!(l.hit(centre(*rect), false), Some(*target)); - } - } - - #[test] - fn gaps_hit_nothing() { - let l = l(); - for sep in &l.separators { - assert_eq!(l.hit(centre(*sep), false), None); - } - assert_eq!(l.hit((l.bar.x + 2.0, l.bar.y + 2.0), false), None, "padding"); - assert_eq!(l.hit((10.0, 10.0), false), None, "outside"); - assert!(l.inside((l.bar.x + 2.0, l.bar.y + 2.0), false)); - assert!(!l.inside((10.0, 10.0), false)); - } - - #[test] - fn the_menu_sits_above_options_and_only_counts_when_open() { - let l = l(); - let options = l.buttons[4].1; - assert_eq!(l.menu.y + l.menu.h, l.bar.y - 8.0); - assert!(l.menu.x <= options.x + 0.01 || l.menu.x + l.menu.w <= 1366.0); - let rows: Vec<_> = l.rows.iter().map(|r| r.0).collect(); - assert_eq!(rows, [MenuItem::Timer(0), MenuItem::Timer(5), MenuItem::Timer(10), MenuItem::Cursor, MenuItem::Preview]); - let cursor = centre(l.rows[3].1); - assert_eq!(l.hit(cursor, true), Some(Target::Menu(MenuItem::Cursor))); - assert_eq!(l.hit(cursor, false), None); - assert!(l.inside(cursor, true) && !l.inside(cursor, false)); - } - - #[test] - fn the_menu_stays_on_a_narrow_output() { - let l = layout((400.0, 300.0), measure); - assert!(l.menu.x >= 0.0 && l.menu.x + l.menu.w <= 400.0); - } -} -``` - -Run `cargo nextest run toolbar::` → compile errors. - -- [ ] **Step 2: Implement `state.rs`** - -```rust -//! What the toolbar remembers between runs, kept out of the config so a -//! read-only (home-manager) config still works. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; -use serde::{Deserialize, Serialize}; - -use crate::config::Config; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Mode { - Screen, - Window, - Region, - Zoom, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct ToolbarState { - pub mode: Mode, - /// Seconds before the capture: 0, 5 or 10. - pub timer: u32, - pub cursor: bool, - pub preview: bool, -} - -/// The file, every key optional. -#[derive(Deserialize)] -struct File { - mode: Option, - timer: Option, - cursor: Option, - preview: Option, -} - -pub const TIMERS: [u32; 3] = [0, 5, 10]; - -/// `$XDG_STATE_HOME/valw/toolbar.toml`, falling back to `~/.local/state`. -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/toolbar.toml") -} - -pub fn defaults(config: &Config) -> ToolbarState { - ToolbarState { - mode: Mode::Region, - timer: 0, - cursor: config.capture.show_cursor, - preview: config.preview.enabled, - } -} - -/// The remembered state, or the defaults if there is none or it is broken. -pub fn load(path: &Path, config: &Config) -> ToolbarState { - let fallback = defaults(config); - let text = match std::fs::read_to_string(path) { - Ok(text) => text, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return fallback, - Err(e) => { - tracing::warn!("could not read {}: {e}", path.display()); - return fallback; - } - }; - match parse(&text, fallback) { - Ok(state) => state, - Err(e) => { - tracing::warn!("ignoring {}: {e:#}", path.display()); - fallback - } - } -} - -fn parse(text: &str, fallback: ToolbarState) -> Result { - let file: File = toml::from_str(text)?; - let timer = file.timer.unwrap_or(fallback.timer); - if !TIMERS.contains(&timer) { - bail!("timer must be 0, 5 or 10, not {timer}"); - } - Ok(ToolbarState { - mode: file.mode.unwrap_or(fallback.mode), - timer, - cursor: file.cursor.unwrap_or(fallback.cursor), - preview: file.preview.unwrap_or(fallback.preview), - }) -} - -pub fn save(path: &Path, state: &ToolbarState) -> Result<()> { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?; - } - let text = toml::to_string(state).context("could not encode the toolbar state")?; - crate::output::write_atomic(path, text.as_bytes()) -} -``` - -(Unknown keys are ignored because `File` doesn't deny them.) - -- [ ] **Step 3: Implement `layout.rs`** - -```rust -//! Where the toolbar's buttons and menu are, in logical px of the output, -//! and what a point hits. - -use crate::toolbar::state::Mode; - -pub const MARGIN_BOTTOM: f32 = 24.0; -pub const PADDING: f32 = 6.0; -pub const BUTTON_H: f32 = 32.0; -pub const BUTTON_PAD: f32 = 12.0; -pub const SEPARATOR_GAP: f32 = 8.0; -pub const MENU_GAP: f32 = 8.0; -pub const ROW_H: f32 = 28.0; -/// Room for the check or radio mark left of a menu label. -pub const MARK_W: f32 = 24.0; - -#[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 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MenuItem { - Timer(u32), - Cursor, - Preview, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Target { - Mode(Mode), - Options, - Menu(MenuItem), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Layout { - pub bar: Rect, - pub buttons: Vec<(Target, Rect, &'static str)>, - pub separators: Vec, - pub menu: Rect, - pub rows: Vec<(MenuItem, Rect, &'static str)>, -} - -/// `None` stands for a separator. -const BAR: [Option<(Target, &str)>; 7] = [ - Some((Target::Mode(Mode::Screen), "Screen")), - Some((Target::Mode(Mode::Window), "Window")), - Some((Target::Mode(Mode::Region), "Region")), - None, - Some((Target::Mode(Mode::Zoom), "Zoom")), - None, - Some((Target::Options, "Options ▾")), -]; - -const MENU: [(MenuItem, &str); 5] = [ - (MenuItem::Timer(0), "No timer"), - (MenuItem::Timer(5), "5 second timer"), - (MenuItem::Timer(10), "10 second timer"), - (MenuItem::Cursor, "Show cursor"), - (MenuItem::Preview, "Show preview"), -]; - -/// Lays the toolbar out on an output of `output` logical px; `measure` -/// gives a label's width in logical px. -pub fn layout(output: (f32, f32), measure: impl Fn(&str) -> f32) -> Layout { - let widths: Vec = BAR - .iter() - .map(|item| match item { - Some((_, label)) => measure(label) + 2.0 * BUTTON_PAD, - None => 2.0 * SEPARATOR_GAP + 1.0, - }) - .collect(); - let w = widths.iter().sum::() + 2.0 * PADDING; - let h = BUTTON_H + 2.0 * PADDING; - let bar = Rect { x: (output.0 - w) / 2.0, y: output.1 - MARGIN_BOTTOM - h, w, h }; - - let mut buttons = Vec::new(); - let mut separators = Vec::new(); - let mut x = bar.x + PADDING; - for (item, width) in BAR.iter().zip(widths) { - match item { - Some((target, label)) => { - buttons.push((*target, Rect { x, y: bar.y + PADDING, w: width, h: BUTTON_H }, *label)); - } - None => separators.push(Rect { x: x + SEPARATOR_GAP, y: bar.y + PADDING + 6.0, w: 1.0, h: BUTTON_H - 12.0 }), - } - x += width; - } - - let menu_w = MENU.iter().map(|(_, l)| measure(l)).fold(0.0, f32::max) + MARK_W + 2.0 * BUTTON_PAD; - let menu_h = MENU.len() as f32 * ROW_H + 2.0 * PADDING; - let options = buttons.last().expect("the bar has buttons").1; - let menu_x = options.x.min(output.0 - menu_w).max(0.0); - let menu = Rect { x: menu_x, y: bar.y - MENU_GAP - menu_h, w: menu_w, h: menu_h }; - let rows = MENU - .iter() - .enumerate() - .map(|(i, (item, label))| { - (*item, Rect { x: menu.x, y: menu.y + PADDING + i as f32 * ROW_H, w: menu.w, h: ROW_H }, *label) - }) - .collect(); - Layout { bar, buttons, separators, menu, rows } -} - -impl Layout { - pub fn hit(&self, p: (f32, f32), menu_open: bool) -> Option { - if menu_open - && let Some(row) = self.rows.iter().find(|r| r.1.contains(p)) - { - return Some(Target::Menu(row.0)); - } - self.buttons.iter().find(|b| b.1.contains(p)).map(|b| b.0) - } - - /// Whether `p` is on the bar or the open menu (a click elsewhere closes). - pub fn inside(&self, p: (f32, f32), menu_open: bool) -> bool { - self.bar.contains(p) || (menu_open && self.menu.contains(p)) - } -} -``` - -- [ ] **Step 4: Run** (+11), clippy, fmt. **Step 5: Commit** `feat: toolbar state and layout`. - ---- - -### Task 2: Drawing - -**Files:** `src/toolbar/draw.rs`; `src/toolbar/mod.rs`: `pub mod draw;`. - -**Produces:** `draw::LABEL: f32 = 13.0`; `draw::measure(label) -> f32` (logical px, via `editor::text::layout`); `draw::bar(layout, size_px: (u32, u32), scale: f32, state: &ToolbarState, hover: Option, menu_open: bool) -> Pixmap` (whole output, transparent outside); `draw::pill(size_px, scale, text: &str) -> Pixmap`; `draw::to_argb(&Pixmap, &mut [u8])` (premultiplied RGBA → wl_shm ARGB8888 bytes B, G, R, A). - -- [ ] **Step 1: Failing tests** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::toolbar::layout::layout; - use crate::toolbar::state::Mode; - - const STATE: ToolbarState = ToolbarState { mode: Mode::Region, timer: 0, cursor: false, preview: true }; - - fn alpha(p: &Pixmap, x: f32, y: f32, scale: f32) -> u8 { - p.pixel((x * scale) as u32, (y * scale) as u32).unwrap().alpha() - } - - #[test] - fn the_bar_is_opaque_and_the_rest_transparent() { - let l = layout((800.0, 600.0), measure); - let p = bar(&l, (800, 600), 1.0, &STATE, None, false); - assert_eq!(alpha(&p, 5.0, 5.0, 1.0), 0); - assert!(alpha(&p, l.bar.x + l.bar.w / 2.0, l.bar.y + 3.0, 1.0) >= 230); - assert_eq!(alpha(&p, l.menu.x + 5.0, l.menu.y + 5.0, 1.0), 0, "menu closed"); - let open = bar(&l, (800, 600), 1.0, &STATE, None, true); - assert!(alpha(&open, l.menu.x + l.menu.w / 2.0, l.menu.y + 3.0, 1.0) >= 230); - } - - #[test] - fn the_selected_mode_is_lighter() { - let l = layout((800.0, 600.0), measure); - let p = bar(&l, (800, 600), 1.0, &STATE, None, false); - let lum = |r: crate::toolbar::layout::Rect| p.pixel((r.x + 3.0) as u32, (r.y + 3.0) as u32).unwrap().demultiply().red(); - let region = l.buttons[2].1; - let screen = l.buttons[0].1; - assert!(lum(region) > lum(screen)); - } - - #[test] - fn scales() { - let l = layout((800.0, 600.0), measure); - let p = bar(&l, (1000, 750), 1.25, &STATE, None, false); - assert_eq!((p.width(), p.height()), (1000, 750)); - assert!(alpha(&p, l.bar.x + l.bar.w / 2.0, l.bar.y + 3.0, 1.25) >= 230); - assert_eq!(alpha(&p, l.bar.x - 3.0, l.bar.y + 3.0, 1.25), 0); - } - - #[test] - fn argb_swaps_red_and_blue() { - let mut p = Pixmap::new(1, 1).unwrap(); - p.fill(tiny_skia::Color::from_rgba8(10, 20, 30, 255)); - let mut out = [0u8; 4]; - to_argb(&p, &mut out); - assert_eq!(out, [30, 20, 10, 255]); - } - - #[test] - fn the_pill_shows_its_text() { - let p = pill((72, 40), 1.0, "5"); - let lit = p.pixels().iter().filter(|c| c.demultiply().red() > 200 && c.alpha() > 200).count(); - assert!(lit > 10, "white digit pixels: {lit}"); - } -} -``` - -Run → compile errors. - -- [ ] **Step 2: Implement** - -```rust -//! Painting the toolbar, its menu and the countdown pill with tiny-skia. - -use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect as SkRect, Transform}; - -use crate::editor::text; -use crate::toolbar::layout::{Layout, MenuItem, Rect, Target}; -use crate::toolbar::state::ToolbarState; - -pub const LABEL: f32 = 13.0; -const RADIUS: f32 = 12.0; -const BACKGROUND: [u8; 4] = [0x1c, 0x1c, 0x1e, 230]; -const SELECTED: [u8; 4] = [255, 255, 255, 46]; -const HOVER: [u8; 4] = [255, 255, 255, 26]; -const SEPARATOR: [u8; 4] = [255, 255, 255, 60]; -const WHITE: [u8; 4] = [255, 255, 255, 255]; - -/// A label's width in logical px. -pub fn measure(label: &str) -> f32 { - text::layout(label, LABEL).width -} - -fn paint(c: [u8; 4]) -> Paint<'static> { - let mut p = Paint::default(); - p.set_color_rgba8(c[0], c[1], c[2], c[3]); - p.anti_alias = true; - p -} - -fn rounded(pixmap: &mut Pixmap, r: Rect, radius: f32, color: [u8; 4], scale: f32) { - let (x, y, w, h, k) = (r.x * scale, r.y * scale, r.w * scale, r.h * scale, radius * scale); - let mut pb = PathBuilder::new(); - pb.move_to(x + k, y); - pb.line_to(x + w - k, y); - pb.quad_to(x + w, y, x + w, y + k); - pb.line_to(x + w, y + h - k); - pb.quad_to(x + w, y + h, x + w - k, y + h); - pb.line_to(x + k, y + h); - pb.quad_to(x, y + h, x, y + h - k); - pb.line_to(x, y + k); - pb.quad_to(x, y, x + k, y); - pb.close(); - if let Some(path) = pb.finish() { - pixmap.fill_path(&path, &paint(color), FillRule::Winding, Transform::identity(), None); - } -} - -/// `label` centred vertically in `r`, starting `left` logical px in. -fn label(pixmap: &mut Pixmap, r: Rect, left: f32, s: &str, scale: f32) { - let size = LABEL * scale; - let height = text::line_height(size); - let origin = ((r.x + left) * scale, (r.y + r.h / 2.0) * scale - height / 2.0); - if let Some(path) = text::path(s, size, origin) { - pixmap.fill_path(&path, &paint(WHITE), FillRule::Winding, Transform::identity(), None); - } -} - -/// The whole output: transparent but for the bar and, if open, the menu. -pub fn bar( - layout: &Layout, - size: (u32, u32), - scale: f32, - state: &ToolbarState, - hover: Option, - menu_open: bool, -) -> Pixmap { - let mut p = Pixmap::new(size.0.max(1), size.1.max(1)).expect("the output has a size"); - rounded(&mut p, layout.bar, RADIUS, BACKGROUND, scale); - for (target, r, text) in &layout.buttons { - let fill = if *target == Target::Mode(state.mode) || (*target == Target::Options && menu_open) { - Some(SELECTED) - } else if hover == Some(*target) { - Some(HOVER) - } else { - None - }; - if let Some(fill) = fill { - rounded(&mut p, *r, 8.0, fill, scale); - } - label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD, text, scale); - } - for s in &layout.separators { - rounded(&mut p, *s, 0.0, SEPARATOR, scale); - } - if menu_open { - rounded(&mut p, layout.menu, RADIUS, BACKGROUND, scale); - for (item, r, text) in &layout.rows { - if hover == Some(Target::Menu(*item)) { - rounded(&mut p, *r, 6.0, HOVER, scale); - } - let on = match item { - MenuItem::Timer(t) => state.timer == *t, - MenuItem::Cursor => state.cursor, - MenuItem::Preview => state.preview, - }; - if on { - label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD, "✓", scale); - } - label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD + crate::toolbar::layout::MARK_W, text, scale); - } - } - p -} - -/// The countdown pill: a rounded background with `text` centred. -pub fn pill(size: (u32, u32), scale: f32, s: &str) -> Pixmap { - let mut p = Pixmap::new(size.0.max(1), size.1.max(1)).expect("the pill has a size"); - let r = Rect { x: 0.0, y: 0.0, w: size.0 as f32 / scale, h: size.1 as f32 / scale }; - rounded(&mut p, r, RADIUS, BACKGROUND, scale); - let left = (r.w - measure(s)) / 2.0; - label(&mut p, r, left, s, scale); - p -} - -/// Copies premultiplied RGBA into wl_shm ARGB8888 (bytes B, G, R, A). -pub fn to_argb(pixmap: &Pixmap, out: &mut [u8]) { - for (dst, src) in out.chunks_exact_mut(4).zip(pixmap.pixels()) { - dst.copy_from_slice(&[src.blue(), src.green(), src.red(), src.alpha()]); - } -} -``` - -(`SkRect` and `Color` imports only if used; drop the unused ones. If DejaVu Sans Bold has no "✓" glyph — check with `text::FONT.glyph_id('✓')` — draw the check as a short stroked polyline instead and ledger it.) - -- [ ] **Step 3: Run** (+5), clippy, fmt. **Step 4: Commit** `feat: toolbar drawing`. - ---- - -### Task 3: Surfaces, events and `valw toolbar` - -**Files:** `src/toolbar/mod.rs`, `src/wayland.rs`, `src/main.rs`. - -**Consumes:** Tasks 1–2. **Produces:** `toolbar::run(config: &Config) -> Result` where `Picked { mode: state::Mode, cursor: bool, preview: bool }` (the countdown has already run); `State::{toolbar: Option, pill: Option}`; `Command::Toolbar`. - -- [ ] **Step 1: Failing tests** — `src/main.rs` `flag_conflicts`: `assert!(parses(&["toolbar"]));` and a new test - -```rust - #[test] - fn a_toolbar_pick_becomes_a_capture() { - use toolbar::state::Mode as T; - let pick = |mode, cursor, preview| toolbar::Picked { mode, cursor, preview }; - let (mode, common) = from_toolbar(pick(T::Screen, true, false)); - assert!(matches!(mode, Mode::Screen { all: false })); - assert!(common.cursor && common.no_preview); - assert!(matches!(from_toolbar(pick(T::Window, false, true)).0, Mode::Window)); - assert!(matches!(from_toolbar(pick(T::Region, false, true)).0, Mode::Region)); - let (mode, common) = from_toolbar(pick(T::Zoom, false, true)); - assert!(matches!(mode, Mode::Zoom)); - assert!(!common.cursor && !common.no_preview && common.delay.is_none() && common.output.is_none()); - } -``` - -Run → compile errors. - -- [ ] **Step 2: `main.rs`** - -`Command::Toolbar` documented "Pick a mode from a floating bar (Cmd+Shift+5).", then in `run`: - -```rust - Command::Toolbar => { - let config = config::load(&config::default_path())?; - let (mode, common) = from_toolbar(toolbar::run(&config)?); - capture(mode, common) - } -``` - -and - -```rust -/// What the toolbar picked, as the command line would have said it. -fn from_toolbar(pick: toolbar::Picked) -> (Mode, Common) { - let mode = match pick.mode { - toolbar::state::Mode::Screen => Mode::Screen { all: false }, - toolbar::state::Mode::Window => Mode::Window, - toolbar::state::Mode::Region => Mode::Region, - toolbar::state::Mode::Zoom => Mode::Zoom, - }; - let common = Common { - clipboard_only: false, - output: None, - delay: None, - cursor: pick.cursor, - no_preview: !pick.preview, - }; - (mode, common) -} -``` - -(`Common` needs no derive changes; it is constructed by field.) - -- [ ] **Step 3: `toolbar/mod.rs`** - -```rust -//! `valw toolbar`: a floating bar to pick a mode, then the optional -//! countdown. The choice runs through the normal capture. - -pub mod draw; -pub mod layout; -pub mod state; - -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result}; -use smithay_client_toolkit::{ - seat::{ - keyboard::Keysym, - pointer::{BTN_LEFT, PointerEvent, PointerEventKind}, - }, - shell::{ - WaylandSurface, - wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface}, - }, - shm::slot::{Buffer, SlotPool}, -}; -use wayland_client::{QueueHandle, protocol::wl_shm}; -use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{Shape, WpCursorShapeDeviceV1}; -use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; - -use self::layout::{Layout, MenuItem, Target}; -use self::state::{Mode, ToolbarState}; -use crate::config::Config; -use crate::error::{Cancelled, HintExt}; -use crate::wayland::{Output, State, Wayland}; - -pub const NAMESPACE: &str = "valw-toolbar"; -pub const COUNTDOWN_NAMESPACE: &str = "valw-countdown"; -const PILL: (f32, f32) = (72.0, 40.0); - -/// The mode and options the user picked. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Picked { - pub mode: Mode, - pub cursor: bool, - pub preview: bool, -} - -/// Shows the bar, waits for a pick, saves it, runs the countdown. -pub fn run(config: &Config) -> Result { - let path = state::default_path(); - let mut wl = Wayland::connect()?; - let outputs = wl.outputs(); - anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); - let output = outputs[crate::focused_output(&outputs)].clone(); - let initial = state::load(&path, config); - - let picked = pick(&mut wl, &output, initial)?; - if let Err(e) = state::save(&path, &picked.0) { - tracing::warn!("could not remember the toolbar choice: {e:#}"); - } - countdown(&mut wl, &output, picked.0.timer)?; - let s = picked.0; - Ok(Picked { mode: picked.1, cursor: s.cursor, preview: s.preview }) -} -``` - -(`crate::focused_output` is the existing private fn in `main.rs`; make it `pub(crate)`.) - -The bar surface: - -```rust -pub struct Toolbar { - layer: LayerSurface, - viewport: WpViewport, - pool: SlotPool, - buffer: Option, - scale: f64, - /// Logical size once configured. - size: Option<(u32, u32)>, - layout: Option, - state: ToolbarState, - hover: Option, - menu_open: bool, - outcome: Option>, -} - -fn pick(wl: &mut Wayland, output: &Output, initial: ToolbarState) -> Result<(ToolbarState, Mode)> { - let qh = wl.queue.handle(); - let s = &wl.state; - let layer_shell = s.layer_shell.as_ref().context("compositor does not support wlr-layer-shell").hint("run `valw doctor` to see what the compositor supports")?; - let viewporter = s.viewporter.as_ref().context("compositor does not support wp-viewporter").hint("run `valw doctor` to see what the compositor supports")?; - let surface = s.compositor.create_surface(&qh); - let layer = layer_shell.create_layer_surface(&qh, surface, Layer::Overlay, Some(NAMESPACE), Some(&output.wl)); - layer.set_anchor(Anchor::all()); - layer.set_exclusive_zone(-1); - layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive); - layer.commit(); - let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ()); - let pool = SlotPool::new(4096, &s.shm).context("could not create shm pool")?; - wl.state.toolbar = Some(Toolbar { - layer, viewport, pool, buffer: None, scale: output.scale, size: None, layout: None, - state: initial, hover: None, menu_open: false, outcome: None, - }); - let result = wl.dispatch_blocking(|s| s.toolbar.as_ref().is_some_and(|t| t.outcome.is_some())); - let toolbar = wl.state.toolbar.take(); - // The bar must be gone before anything is captured. - let _ = wl.conn.flush(); - let _ = wl.queue.roundtrip(&mut wl.state); - result?; - let toolbar = toolbar.context("the toolbar vanished")?; - match toolbar.outcome.flatten() { - Some(mode) => Ok((ToolbarState { mode, ..toolbar.state }, mode)), - None => Err(Cancelled.into()), - } -} - -impl Toolbar { - fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { - self.layer.wl_surface() == surface - } - - pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32)) { - if !self.is(layer.wl_surface()) || w == 0 || h == 0 { - return; - } - self.size = Some((w, h)); - self.layout = Some(layout::layout((w as f32, h as f32), draw::measure)); - self.viewport.set_destination(w as i32, h as i32); - self.draw(); - } - - pub fn closed(&mut self, layer: &LayerSurface) { - if self.is(layer.wl_surface()) { - self.outcome.get_or_insert(None); - } - } - - fn draw(&mut self) { - let (Some((w, h)), Some(layout)) = (self.size, &self.layout) else { return }; - let (pw, ph) = ((w as f64 * self.scale).round() as u32, (h as f64 * self.scale).round() as u32); - let pixmap = draw::bar(layout, (pw, ph), self.scale as f32, &self.state, self.hover, self.menu_open); - let Ok((buffer, canvas)) = self.pool.create_buffer(pw as i32, ph as i32, pw as i32 * 4, wl_shm::Format::Argb8888) else { - tracing::warn!("could not allocate a toolbar buffer"); - return; - }; - draw::to_argb(&pixmap, canvas); - let surface = self.layer.wl_surface(); - surface.damage_buffer(0, 0, pw as i32, ph as i32); - if buffer.attach_to(surface).is_err() { - tracing::warn!("could not attach the toolbar buffer"); - return; - } - self.layer.commit(); - self.buffer = Some(buffer); - } - - pub fn pointer(&mut self, events: &[PointerEvent], cursor: Option<&WpCursorShapeDeviceV1>) { - let Some(layout) = self.layout.clone() else { return }; - for event in events { - if !self.is(&event.surface) { - continue; - } - let p = (event.position.0 as f32, event.position.1 as f32); - match event.kind { - PointerEventKind::Enter { serial } => { - if let Some(cursor) = cursor { - cursor.set_shape(serial, Shape::Default); - } - } - PointerEventKind::Motion { .. } => { - let hover = layout.hit(p, self.menu_open); - if hover != self.hover { - self.hover = hover; - self.draw(); - } - } - PointerEventKind::Press { button: BTN_LEFT, .. } => match layout.hit(p, self.menu_open) { - Some(Target::Mode(mode)) => self.outcome = Some(Some(mode)), - Some(Target::Options) => { - self.menu_open = !self.menu_open; - self.draw(); - } - Some(Target::Menu(item)) => { - match item { - MenuItem::Timer(t) => self.state.timer = t, - MenuItem::Cursor => self.state.cursor = !self.state.cursor, - MenuItem::Preview => self.state.preview = !self.state.preview, - } - self.draw(); - } - None if !layout.inside(p, self.menu_open) => self.outcome = Some(None), - None => {} - }, - _ => {} - } - } - } - - pub fn key(&mut self, key: Keysym) { - if key == Keysym::Escape { - self.outcome.get_or_insert(None); - } - } -} -``` - -The countdown: - -```rust -pub struct Pill { - layer: LayerSurface, - viewport: WpViewport, - pool: SlotPool, - buffer: Option, - scale: f64, - configured: bool, - text: String, - cancelled: bool, -} - -/// Shows `timer`, `timer - 1`, … 1, one second each; a click on the pill -/// cancels. The pill takes no keyboard focus, so the desktop stays usable. -fn countdown(wl: &mut Wayland, output: &Output, timer: u32) -> Result<()> { - if timer == 0 { - return Ok(()); - } - let qh = wl.queue.handle(); - let s = &wl.state; - let layer_shell = s.layer_shell.as_ref().context("compositor does not support wlr-layer-shell")?; - let viewporter = s.viewporter.as_ref().context("compositor does not support wp-viewporter")?; - let surface = s.compositor.create_surface(&qh); - let layer = layer_shell.create_layer_surface(&qh, surface, Layer::Overlay, Some(COUNTDOWN_NAMESPACE), Some(&output.wl)); - layer.set_anchor(Anchor::BOTTOM); - layer.set_margin(0, 0, layout::MARGIN_BOTTOM as i32, 0); - layer.set_size(PILL.0 as u32, PILL.1 as u32); - layer.set_keyboard_interactivity(KeyboardInteractivity::None); - layer.commit(); - let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ()); - let pool = SlotPool::new(4096, &s.shm).context("could not create shm pool")?; - wl.state.pill = Some(Pill { layer, viewport, pool, buffer: None, scale: output.scale, configured: false, text: timer.to_string(), cancelled: false }); - - let start = Instant::now(); - let mut result = Ok(()); - for left in (1..=timer).rev() { - if let Some(pill) = &mut wl.state.pill { - pill.set_text(&left.to_string()); - } - let until = start + Duration::from_secs((timer - left + 1) as u64); - // dispatch_until fails when the deadline passes: that is the tick. - let _ = wl.dispatch_until(until, |s| s.pill.as_ref().is_some_and(|p| p.cancelled)); - if wl.state.pill.as_ref().is_some_and(|p| p.cancelled) { - result = Err(Cancelled.into()); - break; - } - } - wl.state.pill = None; - // The pill must be gone before anything is captured. - let _ = wl.conn.flush(); - let _ = wl.queue.roundtrip(&mut wl.state); - result -} - -impl Pill { - fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { - self.layer.wl_surface() == surface - } - - pub fn configure(&mut self, layer: &LayerSurface) { - if self.is(layer.wl_surface()) { - self.configured = true; - self.viewport.set_destination(PILL.0 as i32, PILL.1 as i32); - self.draw(); - } - } - - fn set_text(&mut self, text: &str) { - self.text = text.to_string(); - self.draw(); - } - - fn draw(&mut self) { - if !self.configured { - return; - } - let (pw, ph) = ((PILL.0 as f64 * self.scale).round() as u32, (PILL.1 as f64 * self.scale).round() as u32); - let pixmap = draw::pill((pw, ph), self.scale as f32, &self.text); - let Ok((buffer, canvas)) = self.pool.create_buffer(pw as i32, ph as i32, pw as i32 * 4, wl_shm::Format::Argb8888) else { - return; - }; - draw::to_argb(&pixmap, canvas); - let surface = self.layer.wl_surface(); - surface.damage_buffer(0, 0, pw as i32, ph as i32); - if buffer.attach_to(surface).is_ok() { - self.layer.commit(); - self.buffer = Some(buffer); - } - } - - pub fn pointer(&mut self, events: &[PointerEvent]) { - for event in events { - if self.is(&event.surface) && matches!(event.kind, PointerEventKind::Press { button: BTN_LEFT, .. }) { - self.cancelled = true; - } - } - } - - pub fn closed(&mut self, layer: &LayerSurface) { - if self.is(layer.wl_surface()) { - self.cancelled = true; - } - } -} -``` - -(The "Cancel on hover" label from the plan's ruling is dropped for simplicity: the pill shows the seconds; a click cancels. Ledger it.) - -- [ ] **Step 4: `wayland.rs` routing** - -`use crate::toolbar::{Pill, Toolbar};`; fields `pub toolbar: Option`, `pub pill: Option` (initialised `None`); in `closed`: `toolbar.closed(layer)` and `pill.closed(layer)`; in `configure`: `toolbar.configure(layer, configure.new_size)` and `pill.configure(layer)`; in `press_key`: `if let Some(t) = &mut self.toolbar { t.key(event.keysym); }`; in `pointer_frame`: `toolbar.pointer(events, self.cursor_device.as_ref())` and `pill.pointer(events)`. - -Remove the Task 1 allowance in `main.rs`. - -- [ ] **Step 5: Run** — all pass (+1 test, +1 assert), clippy, fmt. - -- [ ] **Step 6: Smoke run** (tell the user first) — `timeout -s INT 4 valw toolbar` in the dev shell: the bar appears bottom-centre on the focused output; a niri screenshot shows it; exit by the signal; no errors in the log. Then `valw toolbar` with a temporary state file `timer = 5` is not run (it would capture); the countdown is checked by hand later. - -- [ ] **Step 7: Commit** `feat: valw toolbar`. - ---- - -### Task 4: Checks and checklist - -- [ ] `nix flake check -L --keep-going` passes. -- [ ] Append to `docs/test-checklist.md`: - -```markdown - -## Toolbar (Phase 6) - -- [ ] `valw toolbar` shows the bar bottom-centre on the focused output (both monitors), crisp; the last mode is highlighted; hovering highlights buttons. -- [ ] Screen, Window, Region and Zoom each start their mode at once; the bar is not in the shot. -- [ ] Options ▾ opens the menu; timer None/5/10, Show cursor and Show preview toggle (✓) and are remembered next time (`~/.local/state/valw/toolbar.toml`). -- [ ] With a 5 s timer the pill counts 5 → 1 bottom-centre, the desktop stays usable meanwhile, and the pill is not in the shot; clicking the pill cancels (exit 3). -- [ ] Esc or a click beside the bar closes it without capturing (exit 3). -- [ ] A broken `toolbar.toml` doesn't stop the bar (warning in the log, defaults used). -- [ ] Bind it in niri (Mod+Shift+5 is taken in copland; pick a key). -``` - -- [ ] Commit `docs: toolbar checklist`. diff --git a/docs/superpowers/plans/2026-10-03-valw-polish.md b/docs/superpowers/plans/2026-10-03-valw-polish.md deleted file mode 100644 index 914cbdf..0000000 --- a/docs/superpowers/plans/2026-10-03-valw-polish.md +++ /dev/null @@ -1,969 +0,0 @@ -# valw polish phase 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:** Region size label and Shift/Alt/Space modifiers (P1), an optional window shadow (P2), and the synthesised combo shutter sound (P3). - -**Architecture:** Each feature is a pure core with tests (`selection::Selection` modifier math, `render::label_origin`/`blend`, `shadow::add`, `sound::synth`, `sound::combo`) plus thin wiring (`region.rs` overlay, `main.rs` capture flow, `toolbar`, Nix wrapper). - -**Tech Stack:** Rust 2024, SCTK 0.21, tiny-skia 0.12, `image` 0.25; `pw-play` (PipeWire) at run time. - -**Spec:** `docs/superpowers/specs/2026-10-03-valw-polish-design.md` - -## Global Constraints - -- jj only; `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`. -- Label: 13 px white on `#1c1c1e` 90 %, 6 px padding, 16 logical px from the pointer, flips at edges. Shadow: 40 px padding, alpha 50 %, blur radius 20 (3 box passes), 12 px down. Sound: 44 100 Hz mono 16-bit; speeds 1.00/1.12/1.25/1.40/1.60, 6–7 at 1.80 + Shepard glide 0→½ and ½→1 octave over 400 ms; combo resets after `combo_reset_secs` (default 5, 1–60); `volume` default 0.6 (0–1). -- No Caps Lock bindings. -- Tell the user before playing sounds or showing the overlay on the live session. - -## Review Focus - -1. **Modifier changes mid-drag** (Shift/Space pressed and released in any order, Alt toggling) never make the rectangle jump or flip unexpectedly. Tests in `selection::tests` (Task 1). -2. **The label never leaves the output** and never covers the pointer. Test: `render::tests::label_flips_at_edges` (Task 2). -3. **Shadow on images with and without alpha** (a fully opaque shot still gets a rectangular shadow). Tests in `shadow::tests` (Task 3). -4. **No clicks/pops or clipping in the sounds** and a seamless 6↔7 glide. Tests in `sound::synth::tests` (Task 4); listening in the checklist. -5. **A missing `pw-play` or audio server never fails a capture.** Task 5 (warning only). - ---- - -## File Structure - -| File | Change | Task | -|---|---|---| -| `src/selection.rs` | `Mods`, modifier-aware `Selection` | 1 | -| `src/region.rs`, `src/wayland.rs` | modifiers and Space to the overlay | 1 | -| `src/render.rs`, `src/region.rs` | size label | 2 | -| `src/shadow.rs`, `src/config.rs`, `src/main.rs` | window shadow | 3 | -| `src/sound/{mod,synth,combo}.rs` | sounds and combo | 4, 5 | -| `src/config.rs`, `src/main.rs`, `src/toolbar/*`, `nix/package.ulu.nix` | playing, settings, toolbar option, `pw-play` | 5 | -| `docs/test-checklist.md` | polish section | 6 | - ---- - -### Task 1: Modifier-aware selection - -**Files:** `src/selection.rs`, `src/region.rs`, `src/wayland.rs`. - -**Produces:** `selection::Mods { shift, alt, space }`; `Selection::{press(p), motion(p), set_mods(Mods), corners() -> Option<(Point, Point)>, release(p) -> Option<(Point, Point)>, allows_window_switch()}`; `Overlay::{modifiers(Modifiers), space(bool)}`. - -- [ ] **Step 1: Failing tests** — add to `selection.rs` tests: - -```rust - fn pt(x: f64, y: f64) -> Point { - Point { x, y } - } - - const SHIFT: Mods = Mods { shift: true, alt: false, space: false }; - const ALT: Mods = Mods { shift: false, alt: true, space: false }; - const SPACE: Mods = Mods { shift: false, alt: false, space: true }; - - #[test] - fn plain_drag_is_press_to_pointer() { - let mut s = Selection::default(); - s.press(pt(10.0, 10.0)); - s.motion(pt(50.0, 30.0)); - assert_eq!(s.corners(), Some((pt(10.0, 10.0), pt(50.0, 30.0)))); - } - - #[test] - fn shift_locks_the_dimension_that_moves_first() { - let mut s = Selection::default(); - s.press(pt(0.0, 0.0)); - s.motion(pt(40.0, 30.0)); - s.set_mods(SHIFT); - s.motion(pt(60.0, 32.0)); // mostly horizontal: width follows - assert_eq!(s.corners(), Some((pt(0.0, 0.0), pt(60.0, 30.0)))); - s.motion(pt(70.0, 90.0)); // the axis stays decided - assert_eq!(s.corners(), Some((pt(0.0, 0.0), pt(70.0, 30.0)))); - s.set_mods(Mods::default()); - s.motion(pt(70.0, 90.0)); - assert_eq!(s.corners(), Some((pt(0.0, 0.0), pt(70.0, 90.0))), "plain again"); - - let mut v = Selection::default(); - v.press(pt(0.0, 0.0)); - v.motion(pt(40.0, 30.0)); - v.set_mods(SHIFT); - v.motion(pt(41.0, 80.0)); // mostly vertical: height follows - assert_eq!(v.corners(), Some((pt(0.0, 0.0), pt(40.0, 80.0)))); - } - - #[test] - fn alt_grows_from_the_centre() { - let mut s = Selection::default(); - s.press(pt(100.0, 100.0)); - s.set_mods(ALT); - s.motion(pt(130.0, 120.0)); - assert_eq!(s.corners(), Some((pt(70.0, 80.0), pt(130.0, 120.0)))); - } - - #[test] - fn shift_and_alt_combine() { - let mut s = Selection::default(); - s.press(pt(100.0, 100.0)); - s.motion(pt(120.0, 110.0)); - s.set_mods(Mods { shift: true, alt: true, space: false }); - s.motion(pt(150.0, 112.0)); - assert_eq!(s.corners(), Some((pt(50.0, 90.0), pt(150.0, 110.0)))); - } - - #[test] - fn space_moves_then_resizing_continues_without_a_jump() { - let mut s = Selection::default(); - s.press(pt(0.0, 0.0)); - s.motion(pt(40.0, 30.0)); - s.set_mods(SPACE); - s.motion(pt(50.0, 40.0)); - assert_eq!(s.corners(), Some((pt(10.0, 10.0), pt(50.0, 40.0)))); - s.motion(pt(60.0, 40.0)); - assert_eq!(s.corners(), Some((pt(20.0, 10.0), pt(60.0, 40.0)))); - s.set_mods(Mods::default()); - s.motion(pt(60.0, 40.0)); - assert_eq!(s.corners(), Some((pt(20.0, 10.0), pt(60.0, 40.0))), "no jump on release"); - s.motion(pt(70.0, 45.0)); - assert_eq!(s.corners(), Some((pt(20.0, 10.0), pt(70.0, 45.0)))); - } - - #[test] - fn space_with_a_pointer_away_from_the_corner_keeps_the_offset() { - let mut s = Selection::default(); - s.press(pt(0.0, 0.0)); - s.motion(pt(40.0, 30.0)); - s.set_mods(SPACE); - s.motion(pt(45.0, 30.0)); - s.set_mods(Mods::default()); - s.motion(pt(55.0, 40.0)); - assert_eq!(s.corners(), Some((pt(5.0, 0.0), pt(55.0, 40.0)))); - } - - #[test] - fn release_applies_the_held_modifiers() { - let mut s = Selection::default(); - s.press(pt(100.0, 100.0)); - s.set_mods(ALT); - assert_eq!(s.release(pt(110.0, 105.0)), Some((pt(90.0, 95.0), pt(110.0, 105.0)))); - assert_eq!(s, Selection::Idle); - } -``` - -Run `cargo nextest run selection::` → compile errors. - -- [ ] **Step 2: Implement** — replace `Selection` (keep `Point`, `clip`, `resolve`, `MIN_PIXELS`): - -```rust -/// Modifier keys that shape a selection while dragging. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Mods { - pub shift: bool, - pub alt: bool, - pub space: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -enum Axis { - X, - Y, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Drag { - /// The press point (the centre with Alt); Space moves it. - start: Point, - /// The far corner before Alt is applied. - end: Point, - /// The last pointer position. - last: Point, - /// Added to the pointer to get `end` (non-zero after a Space move). - offset: (f64, f64), - mods: Mods, - /// Shift: `end` when Shift went down, and the axis that follows. - lock: Option<(Point, Option)>, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub enum Selection { - #[default] - Idle, - Dragging(Drag), -} - -impl Selection { - pub fn press(&mut self, p: Point) { - let mods = match self { - Selection::Dragging(d) => d.mods, - Selection::Idle => Mods::default(), - }; - *self = Selection::Dragging(Drag { start: p, end: p, last: p, offset: (0.0, 0.0), mods, lock: None }); - } - - pub fn motion(&mut self, p: Point) { - let Selection::Dragging(d) = self else { return }; - if d.mods.space { - let (dx, dy) = (p.x - d.last.x, p.y - d.last.y); - d.start = Point { x: d.start.x + dx, y: d.start.y + dy }; - d.end = Point { x: d.end.x + dx, y: d.end.y + dy }; - } else { - let target = Point { x: p.x + d.offset.0, y: p.y + d.offset.1 }; - d.end = match &mut d.lock { - Some((frozen, axis)) => { - let axis = *axis.get_or_insert_with(|| { - if (target.x - frozen.x).abs() >= (target.y - frozen.y).abs() { Axis::X } else { Axis::Y } - }); - match axis { - Axis::X => Point { x: target.x, y: frozen.y }, - Axis::Y => Point { x: frozen.x, y: target.y }, - } - } - None => target, - }; - } - d.last = p; - } - - /// The keyboard's modifiers changed (also while idle, for the next drag). - pub fn set_mods(&mut self, mods: Mods) { - let Selection::Dragging(d) = self else { return }; - if mods.shift && !d.mods.shift { - d.lock = Some((d.end, None)); - } else if !mods.shift { - d.lock = None; - } - if d.mods.space && !mods.space { - // Resize again from where the corner is, not where the pointer is. - d.offset = (d.end.x - d.last.x, d.end.y - d.last.y); - } - d.mods = mods; - } - - /// The selection's corners in global logical coordinates. - pub fn corners(&self) -> Option<(Point, Point)> { - let Selection::Dragging(d) = self else { return None }; - if d.mods.alt { - let opposite = Point { x: 2.0 * d.start.x - d.end.x, y: 2.0 * d.start.y - d.end.y }; - Some((opposite, d.end)) - } else { - Some((d.start, d.end)) - } - } - - /// Ends the drag at `p` and returns its corners. Back to `Idle` either way. - pub fn release(&mut self, p: Point) -> Option<(Point, Point)> { - self.motion(p); - let corners = self.corners(); - *self = Selection::Idle; - corners - } - - /// Space switches to window mode only before a drag starts. - pub fn allows_window_switch(&self) -> bool { - *self == Selection::Idle - } -} -``` - -Note: with Shift held while idle, the first drag starts without a lock; `set_mods` before the press stores nothing (the overlay passes the current modifiers to `set_mods` right after `press`, see below). Update the older selection tests that used `Selection::Dragging { .. }` patterns to use `corners()`. - -- [ ] **Step 3: Overlay wiring** — `region.rs`: add `mods: Mods` to `Overlay` (default); `pub fn modifiers(&mut self, m: Modifiers)` → `self.mods.shift = m.shift; self.mods.alt = m.alt;` then `self.selection.set_mods(self.mods)` and redraw the anchor; `pub fn space(&mut self, down: bool)` → if `down && self.selection.allows_window_switch()` → `switch_to_window()`, else `self.mods.space = down; self.selection.set_mods(self.mods)`; after `self.selection.press(p)` call `self.selection.set_mods(self.mods)` so modifiers already held apply; `selection_on` uses `self.selection.corners()`. - -`wayland.rs`: `press_key` Space → `overlay.space(true)` (replacing `switch_to_window`); `release_key` (name its `KeyEvent` argument) → `if event.keysym == Keysym::space && let Some(o) = &mut self.overlay { o.space(false) }`; `update_modifiers` → also `if let Some(o) = &mut self.overlay { o.modifiers(modifiers) }`. - -- [ ] **Step 4: Run** (+7), clippy, fmt. **Step 5: Commit** `feat: Shift, Alt and Space while selecting a region`. - ---- - -### Task 2: The size label - -**Files:** `src/render.rs`, `src/region.rs`. - -**Produces:** `render::label_origin(pointer: (f64, f64), label: (u32, u32), surface: (u32, u32), gap: f64) -> (u32, u32)` (physical px); `render::blend(dst: &mut [u8], dst_width: u32, src: &tiny_skia::Pixmap, at: (u32, u32))` (premultiplied over XRGB, clipped); `render::size_text(rect: PixelRect) -> String` (`"640 × 480"`). - -- [ ] **Step 1: Failing tests** — `render.rs` tests: - -```rust - #[test] - fn label_sits_below_right_of_the_pointer() { - assert_eq!(label_origin((100.0, 100.0), (80, 24), (1920, 1080), 16.0), (116, 116)); - } - - #[test] - fn label_flips_at_edges() { - assert_eq!(label_origin((1900.0, 100.0), (80, 24), (1920, 1080), 16.0), (1900 - 16 - 80, 116)); - assert_eq!(label_origin((100.0, 1070.0), (80, 24), (1920, 1080), 16.0), (116, 1070 - 16 - 24)); - assert_eq!(label_origin((1900.0, 1070.0), (80, 24), (1920, 1080), 16.0), (1804, 1030)); - // Tiny outputs: never negative. - assert_eq!(label_origin((5.0, 5.0), (80, 24), (60, 20), 16.0), (0, 0)); - } - - #[test] - fn size_text_uses_the_multiplication_sign() { - let r = PixelRect { x: 3, y: 4, width: 640, height: 480 }; - assert_eq!(size_text(r), "640 × 480"); - } - - #[test] - fn blend_draws_over_and_clips() { - let (w, h) = (4u32, 2u32); - let mut dst = vec![100u8; (w * h * 4) as usize]; - let mut src = tiny_skia::Pixmap::new(2, 2).unwrap(); - src.fill(tiny_skia::Color::from_rgba8(255, 0, 0, 255)); - blend(&mut dst, w, &src, (3, 0)); - assert_eq!(px(&dst, w, 3, 0)[..3], [0, 0, 255], "BGR red, opaque"); - assert_eq!(px(&dst, w, 2, 0)[..3], [100, 100, 100], "left of it untouched"); - let mut half = tiny_skia::Pixmap::new(1, 1).unwrap(); - half.fill(tiny_skia::Color::from_rgba8(0, 0, 0, 128)); - blend(&mut dst, w, &half, (0, 1)); - assert!((px(&dst, w, 0, 1)[0] as i32 - 50).abs() <= 1, "half black over 100"); - } -``` - -(`PixelRect` is available in tests through the existing `#[cfg(test)] use`.) Run → compile errors. - -- [ ] **Step 2: Implement** (in `render.rs`, non-test): - -```rust -/// Where a `label`-sized box goes next to the pointer: `gap` px right of and -/// below it, or on the other side where it would leave the surface. -pub fn label_origin(pointer: (f64, f64), label: (u32, u32), surface: (u32, u32), gap: f64) -> (u32, u32) { - let place = |p: f64, len: u32, max: u32| { - let after = p + gap; - let v = if after + len as f64 <= max as f64 { after } else { p - gap - len as f64 }; - v.clamp(0.0, max.saturating_sub(len) as f64) as u32 - }; - (place(pointer.0, label.0, surface.0), place(pointer.1, label.1, surface.1)) -} - -/// `W × H` of a selection in physical pixels. -pub fn size_text(r: crate::frame::PixelRect) -> String { - format!("{} × {}", r.width, r.height) -} - -/// Draws premultiplied `src` over the XRGB8888 `dst` at `at`, clipped. -pub fn blend(dst: &mut [u8], dst_width: u32, src: &tiny_skia::Pixmap, at: (u32, u32)) { - let dst_height = (dst.len() / 4) as u32 / dst_width.max(1); - for sy in 0..src.height() { - let y = at.1 + sy; - if y >= dst_height { - break; - } - for sx in 0..src.width() { - let x = at.0 + sx; - if x >= dst_width { - break; - } - let s = src.pixel(sx, sy).expect("in bounds"); - let i = ((y * dst_width + x) * 4) as usize; - let inv = 255 - s.alpha() as u16; - let over = |d: u8, c: u8| (c as u16 + (d as u16 * inv + 127) / 255) as u8; - dst[i] = over(dst[i], s.blue()); - dst[i + 1] = over(dst[i + 1], s.green()); - dst[i + 2] = over(dst[i + 2], s.red()); - } - } -} -``` - -`region.rs`: `Overlay` gains `pointer: Option<(usize, (f64, f64))>` (surface index and surface-local logical position) updated on Enter/Motion. In `Surface::draw` (the caller passes the label): after `render::compose`, if there is a selection on this surface and the pointer is on it, build `let scale = s.width as f64 / s.geom.width as f64;` `let text = render::size_text(rect);` `let size = ((crate::toolbar::draw::measure(&text) + 12.0) * scale).ceil() as u32` wide and `((crate::toolbar::draw::LABEL * 1.2 + 12.0) * scale).ceil() as u32` tall; `let pill = crate::toolbar::draw::pill(size, scale as f32, &text);` then `render::blend(canvas, width, &pill, render::label_origin((px * scale, py * scale), (pill.width(), pill.height()), (width, height), 16.0 * scale))`. Pass the label into `draw` as an `Option<(String, (f64, f64))>` computed by `redraw`. - -- [ ] **Step 3: Run** (+4), clippy, fmt. **Step 4: Smoke** (tell the user): not possible without dragging; covered by the checklist. **Step 5: Commit** `feat: size label while selecting a region`. - ---- - -### Task 3: Window shadow - -**Files:** create `src/shadow.rs`; `src/config.rs` (`Capture.window_shadow: bool`, default false); `src/main.rs` (`mod shadow;`, apply in `window_shot`). - -**Produces:** `shadow::add(image: &RgbaImage) -> RgbaImage`; `PAD = 40`, `OFFSET_Y = 12`, `BLUR = 20`, `OPACITY = 0.5`. - -- [ ] **Step 1: Failing tests** (`shadow.rs`): - -```rust -#[cfg(test)] -mod tests { - use super::*; - use image::Rgba; - - fn window() -> RgbaImage { - RgbaImage::from_pixel(100, 60, Rgba([200, 100, 50, 255])) - } - - #[test] - fn the_image_grows_by_the_padding() { - let out = add(&window()); - assert_eq!(out.dimensions(), (100 + 2 * PAD, 60 + 2 * PAD)); - } - - #[test] - fn the_window_is_unchanged_in_the_middle() { - let out = add(&window()); - assert_eq!(out.get_pixel(PAD + 50, PAD + 30).0, [200, 100, 50, 255]); - } - - #[test] - fn the_shadow_is_below_and_fades_out() { - let out = add(&window()); - let below = out.get_pixel(PAD + 50, PAD + 60 + 8).0; - let above = out.get_pixel(PAD + 50, PAD - 8).0; - assert_eq!(&below[..3], &[0, 0, 0]); - assert!(below[3] > 40, "{below:?}"); - assert!(below[3] > above[3], "offset down: {below:?} vs {above:?}"); - assert_eq!(out.get_pixel(0, 0).0[3], 0, "far corner clear"); - } - - #[test] - fn rounded_corners_are_followed() { - let mut w = window(); - for (x, y) in [(0, 0), (1, 0), (0, 1)] { - w.put_pixel(x, y, Rgba([0, 0, 0, 0])); - } - let out = add(&w); - assert_eq!(out.get_pixel(PAD, PAD).0[3] < 255, true, "the transparent corner stays see-through"); - } -} -``` - -Run → compile errors. - -- [ ] **Step 2: Implement** - -```rust -//! A macOS-style soft shadow around window shots. - -use image::{Rgba, RgbaImage}; - -pub const PAD: u32 = 40; -pub const OFFSET_Y: u32 = 12; -pub const BLUR: u32 = 20; -pub const OPACITY: f32 = 0.5; - -/// `image` on a transparent canvas `PAD` px larger on every side, over a -/// blurred, offset copy of its own alpha in black. -pub fn add(image: &RgbaImage) -> RgbaImage { - let (w, h) = image.dimensions(); - let (cw, ch) = (w + 2 * PAD, h + 2 * PAD); - let mut alpha = vec![0f32; (cw * ch) as usize]; - for (x, y, p) in image.enumerate_pixels() { - let (sx, sy) = (x + PAD, y + PAD + OFFSET_Y); - if sy < ch { - alpha[(sy * cw + sx) as usize] = p.0[3] as f32 / 255.0; - } - } - // Three box blurs approximate a Gaussian of about BLUR / 2 sigma. - let r = (BLUR / 3).max(1) as usize; - for _ in 0..3 { - alpha = box_blur(&alpha, cw as usize, ch as usize, r); - } - let mut out = RgbaImage::from_fn(cw, ch, |x, y| { - let a = (alpha[(y * cw + x) as usize] * OPACITY * 255.0).round() as u8; - Rgba([0, 0, 0, a]) - }); - for (x, y, p) in image.enumerate_pixels() { - let dst = out.get_pixel_mut(x + PAD, y + PAD); - *dst = over(*p, *dst); - } - out -} - -/// Straight-alpha `top` over `bottom`. -fn over(top: Rgba, bottom: Rgba) -> Rgba { - let ta = top.0[3] as f32 / 255.0; - let ba = bottom.0[3] as f32 / 255.0; - let a = ta + ba * (1.0 - ta); - if a <= 0.0 { - return Rgba([0, 0, 0, 0]); - } - let mix = |t: u8, b: u8| ((t as f32 * ta + b as f32 * ba * (1.0 - ta)) / a).round() as u8; - Rgba([mix(top.0[0], bottom.0[0]), mix(top.0[1], bottom.0[1]), mix(top.0[2], bottom.0[2]), (a * 255.0).round() as u8]) -} - -/// A separable box blur of radius `r` (horizontal then vertical). -fn box_blur(src: &[f32], w: usize, h: usize, r: usize) -> Vec { - let n = (2 * r + 1) as f32; - let mut tmp = vec![0f32; src.len()]; - for y in 0..h { - let row = &src[y * w..(y + 1) * w]; - let mut sum: f32 = (0..=r).map(|i| row.get(i).copied().unwrap_or(0.0)).sum(); - for x in 0..w { - tmp[y * w + x] = sum / n; - if let Some(v) = row.get(x + r + 1) { sum += v; } - if x >= r { sum -= row[x - r]; } - } - } - let mut out = vec![0f32; src.len()]; - for x in 0..w { - let at = |y: usize| tmp[y * w + x]; - let mut sum: f32 = (0..=r.min(h - 1)).map(at).sum(); - for y in 0..h { - out[y * w + x] = sum / n; - if y + r + 1 < h { sum += at(y + r + 1); } - if y >= r { sum -= at(y - r); } - } - } - out -} -``` - -`config.rs`: `Capture { show_cursor, window_shadow }` (default false); `main.rs`: `window_shot(outputs, cursor, shadow: bool)` applies `shadow::add(&image)` when `shadow`; both call sites pass `config.capture.window_shadow`. - -- [ ] **Step 3: Run** (+4 and a config default check added to `partial_file_keeps_other_defaults`: `assert!(!config.capture.window_shadow)`), clippy, fmt. **Step 4: Commit** `feat: optional shadow around window shots`. - ---- - -### Task 4: Synthesised sounds and the combo - -**Files:** create `src/sound/mod.rs` (`pub mod combo; pub mod synth;`), `src/sound/synth.rs`, `src/sound/combo.rs`; `src/main.rs`: `mod sound;` (+ a temporary dead-code allowance until Task 5). - -**Produces:** `synth::{RATE, shutter() -> Vec, speed(&[f32], f32) -> Vec, partials(position: f32) -> Vec<(f32, f32)>, glide(from: f32, to: f32, secs: f32) -> Vec, sound(n: u8) -> Vec, wav(&[f32]) -> Vec}`; `combo::{next(prev: Option<(u8, u64)>, now_ms: u64, reset_ms: u64) -> u8, load(&Path) -> Option<(u8, u64)>, save(&Path, u8, u64) -> Result<()>}`. - -- [ ] **Step 1: Failing tests** - -`synth.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn peak(s: &[f32]) -> f32 { - s.iter().fold(0.0, |m, v| m.max(v.abs())) - } - - #[test] - fn the_shutter_is_short_and_never_clips() { - let s = shutter(); - let ms = s.len() as f32 * 1000.0 / RATE as f32; - assert!((140.0..=160.0).contains(&ms), "{ms} ms"); - assert!(peak(&s) <= 0.95 && peak(&s) > 0.3); - assert!(s.last().unwrap().abs() < 0.01, "ends quietly (no pop)"); - } - - #[test] - fn faster_is_shorter() { - let s = shutter(); - assert_eq!(speed(&s, 1.6).len(), (s.len() as f32 / 1.6).ceil() as usize); - } - - #[test] - fn sounds_escalate_then_loop() { - let lens: Vec = (1..=5).map(|n| sound(n).len()).collect(); - assert!(lens.windows(2).all(|w| w[1] < w[0]), "{lens:?}"); - assert!(sound(6).len() > sound(5).len(), "6 adds the glide"); - assert_eq!(sound(6).len(), sound(7).len()); - for n in 1..=7 { - assert!(peak(&sound(n)) <= 0.95, "sound {n} clips"); - } - } - - #[test] - fn the_glide_returns_to_its_start_an_octave_up() { - let start = partials(0.0); - let end = partials(1.0); - for (f, a) in start.iter().filter(|p| p.1 > 0.01) { - let twin = end.iter().find(|p| (p.0 - f).abs() < 0.01).expect("same frequency an octave later"); - assert!((twin.1 - a).abs() < 1e-3, "{f} Hz: {a} vs {}", twin.1); - } - } - - #[test] - fn wav_header() { - let w = wav(&[0.0, 0.5, -0.5]); - assert_eq!(&w[0..4], b"RIFF"); - assert_eq!(&w[8..16], b"WAVEfmt "); - assert_eq!(u32::from_le_bytes(w[24..28].try_into().unwrap()), RATE); - assert_eq!(w.len(), 44 + 3 * 2); - assert_eq!(i16::from_le_bytes(w[46..48].try_into().unwrap()), 16383); - } -} -``` - -`combo.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn counts_up_then_alternates_six_and_seven() { - let mut prev = None; - let mut seen = Vec::new(); - for i in 0..11u64 { - let n = next(prev, i * 1000, 5000); - seen.push(n); - prev = Some((n, i * 1000)); - } - assert_eq!(seen, [1, 2, 3, 4, 5, 6, 7, 6, 7, 6, 7]); - } - - #[test] - fn a_pause_resets() { - assert_eq!(next(Some((4, 1000)), 7000, 5000), 1); - assert_eq!(next(Some((4, 1000)), 5999, 5000), 5); - assert_eq!(next(None, 0, 5000), 1); - assert_eq!(next(Some((3, 9000)), 1000, 5000), 1, "a clock going backwards"); - } - - #[test] - fn state_file_round_trip_and_broken_files() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("valw-combo"); - assert_eq!(load(&path), None); - save(&path, 6, 123456).unwrap(); - assert_eq!(load(&path), Some((6, 123456))); - std::fs::write(&path, "garbage").unwrap(); - assert_eq!(load(&path), None); - std::fs::write(&path, "9 5").unwrap(); - assert_eq!(load(&path), None, "out of range"); - } -} -``` - -Run → compile errors. - -- [ ] **Step 2: Implement `synth.rs`** - -```rust -//! The shutter sounds, synthesised: no samples, no licences. - -use std::f32::consts::TAU; - -pub const RATE: u32 = 44_100; -const SPEEDS: [f32; 5] = [1.00, 1.12, 1.25, 1.40, 1.60]; -const LOOP_SPEED: f32 = 1.80; -const GLIDE_SECS: f32 = 0.4; -/// The Shepard tone: octave-spaced partials from BASE Hz under a bell -/// centred on CENTRE Hz (log scale). -const BASE: f32 = 55.0; -const PARTIALS: i32 = 9; -const CENTRE: f32 = 660.0; -const WIDTH_OCTAVES: f32 = 1.4; - -/// A deterministic noise source (xorshift), so every build sounds the same. -struct Noise(u32); - -impl Noise { - fn next(&mut self) -> f32 { - self.0 ^= self.0 << 13; - self.0 ^= self.0 >> 17; - self.0 ^= self.0 << 5; - self.0 as f32 / u32::MAX as f32 * 2.0 - 1.0 - } -} - -fn secs(n: usize) -> f32 { - n as f32 / RATE as f32 -} - -/// A two-part mechanical "tak-shk", about 150 ms. -pub fn shutter() -> Vec { - let len = (0.150 * RATE as f32) as usize; - let mut noise = Noise(0x1234_5678); - let (mut low, mut high_prev, mut high) = (0.0f32, 0.0f32, 0.0f32); - let mut out = Vec::with_capacity(len); - for i in 0..len { - let t = secs(i); - let n = noise.next(); - // Click: bright, 0.5 ms attack, ~6 ms decay. - let click = n * (t / 0.0005).min(1.0) * (-t / 0.006).exp(); - // "shk": band-passed noise from 60 ms, ~30 ms decay, softer. - low += 0.25 * (n - low); - high = 0.9 * (high + low - high_prev); - high_prev = low; - let u = t - 0.060; - let shk = if u >= 0.0 { high * 0.9 * (u / 0.002).min(1.0) * (-u / 0.03).exp() } else { 0.0 }; - out.push(click * 0.8 + shk); - } - fade_out(&mut out, 0.010); - normalise(&mut out, 0.9); - out -} - -/// `samples` played `factor` times faster (shorter and higher). -pub fn speed(samples: &[f32], factor: f32) -> Vec { - let len = (samples.len() as f32 / factor).ceil() as usize; - (0..len) - .map(|i| { - let pos = i as f32 * factor; - let j = pos as usize; - let frac = pos - j as f32; - let a = samples.get(j).copied().unwrap_or(0.0); - let b = samples.get(j + 1).copied().unwrap_or(0.0); - a + (b - a) * frac - }) - .collect() -} - -/// The Shepard tone's partials `(frequency, amplitude)` at `position` -/// octaves (0..1) along the glide. Amplitude depends only on frequency, so -/// position 1 sounds like position 0. -pub fn partials(position: f32) -> Vec<(f32, f32)> { - (-1..PARTIALS) - .map(|k| { - let f = BASE * 2f32.powf(k as f32 + position); - let d = (f / CENTRE).log2() / WIDTH_OCTAVES; - (f, (-0.5 * d * d).exp()) - }) - .collect() -} - -/// A Shepard glide from `from` to `to` octaves over `secs` seconds. -pub fn glide(from: f32, to: f32, secs_long: f32) -> Vec { - let len = (secs_long * RATE as f32) as usize; - let count = partials(0.0).len(); - let mut phases = vec![0f32; count]; - let mut out = Vec::with_capacity(len); - for i in 0..len { - let position = from + (to - from) * i as f32 / len as f32; - let mut v = 0.0; - for (p, (f, a)) in phases.iter_mut().zip(partials(position)) { - *p = (*p + TAU * f / RATE as f32) % TAU; - v += a * p.sin(); - } - out.push(v); - } - fade_in(&mut out, 0.010); - fade_out(&mut out, 0.040); - normalise(&mut out, 0.5); - out -} - -/// The sound for shot `n` of a combo (1–7). -pub fn sound(n: u8) -> Vec { - match n { - 1..=5 => speed(&shutter(), SPEEDS[n as usize - 1]), - 6 | 7 => { - let mut s = speed(&shutter(), LOOP_SPEED); - let half = if n == 6 { (0.0, 0.5) } else { (0.5, 1.0) }; - s.extend(glide(half.0, half.1, GLIDE_SECS)); - s - } - _ => sound(1), - } -} - -/// 16-bit mono PCM WAV. -pub fn wav(samples: &[f32]) -> Vec { - let data = (samples.len() * 2) as u32; - let mut w = Vec::with_capacity(44 + data as usize); - w.extend_from_slice(b"RIFF"); - w.extend_from_slice(&(36 + data).to_le_bytes()); - w.extend_from_slice(b"WAVEfmt "); - w.extend_from_slice(&16u32.to_le_bytes()); - w.extend_from_slice(&1u16.to_le_bytes()); // PCM - w.extend_from_slice(&1u16.to_le_bytes()); // mono - w.extend_from_slice(&RATE.to_le_bytes()); - w.extend_from_slice(&(RATE * 2).to_le_bytes()); - w.extend_from_slice(&2u16.to_le_bytes()); - w.extend_from_slice(&16u16.to_le_bytes()); - w.extend_from_slice(b"data"); - w.extend_from_slice(&data.to_le_bytes()); - for s in samples { - let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16; - w.extend_from_slice(&v.to_le_bytes()); - } - w -} - -fn fade_in(s: &mut [f32], secs_long: f32) { - let n = ((secs_long * RATE as f32) as usize).min(s.len()); - for (i, v) in s[..n].iter_mut().enumerate() { - *v *= i as f32 / n as f32; - } -} - -fn fade_out(s: &mut [f32], secs_long: f32) { - let n = ((secs_long * RATE as f32) as usize).min(s.len()); - let len = s.len(); - for (i, v) in s[len - n..].iter_mut().enumerate() { - *v *= 1.0 - (i + 1) as f32 / n as f32; - } -} - -fn normalise(s: &mut [f32], peak: f32) { - let max = s.iter().fold(0.0f32, |m, v| m.max(v.abs())); - if max > 0.0 { - for v in s.iter_mut() { - *v *= peak / max; - } - } -} -``` - -(The wav test expects `0.5 → 16383`: `(0.5 * 32767).round() = 16384`; adjust the test to 16384 if that is what it yields — the rounding rule is the implementation's — and ledger it.) - -- [ ] **Step 3: Implement `combo.rs`** - -```rust -//! Which shot of a combo this is, shared between `valw` runs. - -use std::path::Path; - -use anyhow::Result; - -/// The number for a shot at `now_ms`, given the previous shot. -pub fn next(prev: Option<(u8, u64)>, now_ms: u64, reset_ms: u64) -> u8 { - match prev { - Some((n, at)) if now_ms >= at && now_ms - at < reset_ms => match n { - 7 => 6, - n => n + 1, - }, - _ => 1, - } -} - -/// `(number, unix ms)` of the last shot, if the file makes sense. -pub fn load(path: &Path) -> Option<(u8, u64)> { - let text = std::fs::read_to_string(path).ok()?; - let mut parts = text.split_whitespace(); - let n: u8 = parts.next()?.parse().ok()?; - let at: u64 = parts.next()?.parse().ok()?; - (1..=7).contains(&n).then_some((n, at)) -} - -pub fn save(path: &Path, n: u8, at_ms: u64) -> Result<()> { - crate::output::write_atomic(path, format!("{n} {at_ms}\n").as_bytes()) -} -``` - -- [ ] **Step 4: Run** (+8), clippy, fmt. **Step 5: Commit** `feat: synthesised shutter sounds and the combo count`. - ---- - -### Task 5: Playing, settings, toolbar option, packaging - -**Files:** `src/sound/mod.rs`, `src/config.rs`, `src/main.rs`, `src/toolbar/{state,layout,draw,mod}.rs`, `nix/package.ulu.nix`. - -**Produces:** `config::Sound { enabled: bool, volume: f32, combo_reset_secs: u64 }` (defaults true, 0.6, 5; validation `volume` in 0–1 and finite, `combo_reset_secs` in 1–60); `sound::play(config: &config::Sound, enabled: bool)`; `Common.toolbar_sound: Option` + `wants_sound(&Common, &Config) -> bool`; toolbar `ToolbarState.sound`, `MenuItem::Sound` ("Play sound"), `Picked.sound`; hidden `valw __combo-demo` (plays 1…7, 6, 7 with 0.5 s gaps, for tuning). - -- [ ] **Step 1: Failing tests** - -- `config.rs`: `sound_defaults_and_bounds` — defaults (true, 0.6, 5); `volume = 1.5` → error "sound.volume must be between 0 and 1"; `volume = nan` → same error; `combo_reset_secs = 0` → "sound.combo_reset_secs must be between 1 and 60". -- `main.rs`: in `toolbar_options_win_over_the_config_both_ways` add `sound` to the pick and assert `wants_sound` both ways; `flag_conflicts`: `parses(&["__combo-demo"])`; help hides `__combo-demo`. -- `toolbar::state` tests: `ToolbarState` gains `sound` (defaults from `sound.enabled`; round trip includes it); `toolbar::layout` menu rows now end with `MenuItem::Sound`. -- `sound/mod.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_cache_holds_seven_wavs() { - let dir = tempfile::tempdir().unwrap(); - for n in 1..=7 { - let path = cached(dir.path(), n).unwrap(); - assert!(path.ends_with(format!("{n}.wav"))); - assert_eq!(&std::fs::read(&path).unwrap()[0..4], b"RIFF"); - } - } -} -``` - -Run → compile errors. - -- [ ] **Step 2: Implement** - -`sound/mod.rs`: - -```rust -//! The combo shutter sound, played with PipeWire's `pw-play`. - -pub mod combo; -pub mod synth; - -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::{Context, Result}; - -use crate::config; - -fn runtime_dir() -> PathBuf { - crate::lock::default_path().parent().map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir) -} - -/// The WAV for shot `n`, written into `dir` the first time. -fn cached(dir: &Path, n: u8) -> Result { - let path = dir.join(format!("{n}.wav")); - if !path.exists() { - std::fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?; - crate::output::write_atomic(&path, &synth::wav(&synth::sound(n)))?; - } - Ok(path) -} - -/// Plays the next sound of the combo, unless sounds are off. Never fails the -/// capture: problems are logged. -pub fn play(config: &config::Sound, enabled: bool) { - if !enabled { - return; - } - let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); - let state = runtime_dir().join("valw-combo"); - let n = combo::next(combo::load(&state), now, config.combo_reset_secs * 1000); - if let Err(e) = combo::save(&state, n, now) { - tracing::warn!("could not remember the combo: {e:#}"); - } - if let Err(e) = spawn(n, config.volume) { - tracing::warn!("no shutter sound: {e:#}"); - } -} - -fn spawn(n: u8, volume: f32) -> Result<()> { - use std::os::unix::process::CommandExt; - let path = cached(&runtime_dir().join("valw-sound"), n)?; - let mut command = std::process::Command::new("pw-play"); - command - .arg(format!("--volume={volume}")) - .arg(&path) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - // SAFETY: setsid is async-signal-safe. - unsafe { - command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); - } - command.spawn().context("could not start pw-play")?; - Ok(()) -} - -/// `valw __combo-demo`: the whole combo, for tuning by ear. -pub fn demo(config: &config::Sound) -> Result<()> { - for n in [1, 2, 3, 4, 5, 6, 7, 6, 7] { - spawn(n, config.volume)?; - std::thread::sleep(std::time::Duration::from_millis(500)); - } - Ok(()) -} -``` - -`main.rs`: `Common.toolbar_sound: Option` (`#[arg(skip)]`); `fn wants_sound(common, config) -> bool { common.toolbar_sound.unwrap_or(config.sound.enabled) }`; in `capture`, right after the `let (shots, clip, source) = match mode { … };` block: `sound::play(&config.sound, wants_sound(&common, &config));` (compute `let sound_on = wants_sound(&common, &config);` early next to `cursor`/`preview`, since `common.output` moves later); `from_toolbar` sets `toolbar_sound: Some(pick.sound)`; hidden `#[command(name = "__combo-demo", hide = true)] ComboDemo` → `sound::demo(&config::load(&config::default_path())?.sound)`. - -Toolbar: `ToolbarState.sound: bool` (`File.sound: Option`, default `config.sound.enabled`); `MenuItem::Sound` row "Play sound" appended to `MENU`; `draw::bar` treats `MenuItem::Sound` like Cursor (`state.sound`); `Toolbar::pointer` toggles it; `Picked.sound`; `run` returns it. - -`nix/package.ulu.nix`: `--suffix PATH : ${lib.makeBinPath [ pkgs.satty pkgs.pipewire ]}`. - -- [ ] **Step 3: Run** (all pass), clippy, fmt. Remove the Task 4 allowance. - -- [ ] **Step 4: Listen** (tell the user first: it plays sounds): `valw __combo-demo` once in the dev shell (`pw-play` is on the system), and read the log for warnings. Record what was heard in the ledger; tuning by ear is the user's in the checklist. - -- [ ] **Step 5: Commit** `feat: combo shutter sound`. - ---- - -### Task 6: Checks and checklist - -- [ ] `nix flake check -L --keep-going` passes. -- [ ] Append to `docs/test-checklist.md`: - -```markdown - -## Polish - -- [ ] Region: while dragging, a `W × H` label follows the pointer (physical pixels, same as the saved PNG), flipping near the edges. -- [ ] Shift locks the dimension you move first; Alt grows from the centre; Space held moves the selection, and after letting go resizing continues without a jump; Space before a drag still switches to window mode. -- [ ] `[capture] window_shadow = true`: window shots get a soft shadow following the rounded corners; preview, clipboard and file all have it. -- [ ] Shots in quick succession play sounds 1 → 5 getting faster and brighter, then 6, 7, 6, 7 rising endlessly; after a 5 s pause it starts at 1 again. `valw __combo-demo` plays the whole sequence for tuning. -- [ ] `[sound] enabled = false`, `volume`, `combo_reset_secs` take effect; the toolbar's Play sound option overrides the config; no `pw-play` → no sound, capture still works (warning in the log). -``` - -- [ ] Commit `docs: polish checklist`. diff --git a/docs/superpowers/plans/2026-10-03-valw-theme.md b/docs/superpowers/plans/2026-10-03-valw-theme.md deleted file mode 100644 index 02af239..0000000 --- a/docs/superpowers/plans/2026-10-03-valw-theme.md +++ /dev/null @@ -1,468 +0,0 @@ -# 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`; `theme::from_image(img: &RgbaImage, icon: bool) -> Palette`; `theme::current() -> Palette` (Noctalia or default); `theme::wallpaper(connector: &str) -> Option`; private `noctalia(args) -> Option` (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 { - 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::() > 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 `'s output, or None (not installed, failing, or -/// slower than a second). -fn noctalia(args: &[&str]) -> Option { - 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 { - let scheme = noctalia(&["color-scheme-get"])?; // " " - 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 { - 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` (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 { - 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::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, 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`: 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>`), 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` (focused first, then by `focus_timestamp` descending, windows without a timestamp last by id). -- `layout::cards(n: usize, output: (f32, f32), selected: usize) -> Vec` (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`; `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`; `Dirs { data: Vec, 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` (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 (``) renders to the requested size with red pixels; a PNG loads and resizes. -- [ ] **Step 2: Implement.** Desktop lookup: for each data dir `applications/`, try `.desktop`, then case-insensitive file name match, then scan for `StartupWMClass=` (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/.(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` (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. diff --git a/docs/superpowers/plans/2026-10-04-valw-noctalia-plugin.md b/docs/superpowers/plans/2026-10-04-valw-noctalia-plugin.md deleted file mode 100644 index 8f19be3..0000000 --- a/docs/superpowers/plans/2026-10-04-valw-noctalia-plugin.md +++ /dev/null @@ -1,1271 +0,0 @@ -# valw Noctalia plugin 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 Noctalia plugin (bar widget, toolbar panel, control-center tile) that drives valw through three new `valw toolbar` flags, installed by the home-manager module. - -**Architecture:** valw gains `valw toolbar --state | --set KEY=VALUE | --run [MODE]` over the existing `toolbar.toml`, so the plugin holds no capture logic. The plugin is plain Luau in `noctalia-plugin/`, drawn entirely with Noctalia's components and colour roles; its logic is tested with the `luau` CLI against stubbed Noctalia globals. The nix package ships the folder; `programs.valw.noctalia.enable` links it into `$XDG_DATA_HOME/noctalia/plugins/valw`. - -**Tech Stack:** Rust 2024, clap 4 (derive, `ValueEnum`), serde_json, toml; Luau (Noctalia 5.2, `plugin_api = 3`), `luau`/`luau-compile` 0.738 from nixpkgs; flake-parts (dendritic `*.ulu.nix`), home-manager module. - -**Spec:** `docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md` - -## Global Constraints - -- Version control: `jj` only, never `git`. Commit with explicit paths: `jj commit -m "" `. -- Rust checks: `cargo clippy --all-targets -- -D warnings` (never `cargo check`), `cargo nextest run` (never `cargo test`), `cargo fmt --check`. -- Nix: nix3 commands only (`nix flake check`, `nix build`, `nix shell nixpkgs#luau -c …`); no `find` over `/nix/store`; never write a manual `imports = [ … ]` list in a `*.ulu.nix` file. -- Code, comments, docs and plugin strings in plain English (plus a Turkish translation file); match the surrounding code's comment density and idiom. -- Plugin id `elars/valw`, `plugin_api = 3`, `license = "MIT"`, `dependencies = ["valw"]`, icon `camera`; entries: widget `bar`, panel `toolbar`, shortcut `tile`. -- The plugin never uses hex colours, only Noctalia role names (`primary`, `on_surface`, `outline`, `error`, …). -- No user text is ever interpolated into a `noctalia.runAsync` command string; commands are fixed strings plus known mode/key/value tokens. -- `--set` errors exit 1 like every valw error; nothing is written unless every pair is valid. -- Mode clicks and the tile wait `sleep 0.3` before capturing, so Noctalia's panel or control center is not in the shot. -- Anything touching the live session (loading the plugin into the running Noctalia, opening its panel) is announced to the user first. Edits to `~/copland` need the user's approval; the user runs rebuilds. Never include `~/copland/modules/nixos/common/common-packages.ulu.nix` in a commit. - -## Review Focus - -1. valw not on Noctalia's `PATH` (or `--state` failing): the panel shows "valw is not available" and an error notification, never a blank or crashed panel. — Luau test in Task 3. -2. A capture that fails (exit 1) raises a notification with stderr's last line; a cancel (exit 3) stays silent. — Luau tests in Task 3 (panel, bar, tile). -3. The panel or control center ending up in the shot: a mode click must close the panel *before* starting `sleep 0.3 && valw toolbar --run `. — Luau test in Task 3 checks the order and the command. -4. A missing or broken `toolbar.toml`: `--state` prints the defaults, `--set` starts from the defaults and leaves a valid file. — Rust tests in Task 1. -5. Anything on stdout besides the JSON line breaks the plugin's `json.decode`: `valw toolbar --state` prints exactly one JSON line (logs go to stderr and the log file). — Task 2 runs the real binary and parses its stdout. - ---- - -### Task 1: Toolbar state bridge (`to_json`, `apply_sets`, `set`) - -**Files:** -- Modify: `src/toolbar/state.rs` (the `Mode` derive at the top; new functions after `save`; tests at the bottom) - -**Interfaces:** -- Consumes: existing `ToolbarState`, `Mode`, `TIMERS`, `defaults`, `load`, `save` in `src/toolbar/state.rs`. -- Produces: - - `Mode` derives `clap::ValueEnum` (values `screen`, `window`, `region`, `zoom`). - - `pub fn to_json(state: &ToolbarState) -> String` — one JSON line: `mode`, `timer`, `cursor`, `preview`, `sound`, `timers`. - - `pub fn apply_sets(state: ToolbarState, sets: &[String]) -> anyhow::Result` - - `pub fn set(path: &Path, config: &Config, sets: &[String]) -> anyhow::Result<()>` - -- [ ] **Step 1: Write the failing tests** - -Append inside `mod tests` at the bottom of `src/toolbar/state.rs`: - -```rust - #[test] - fn json_for_the_plugin() { - let state = ToolbarState { - mode: Mode::Zoom, - timer: 5, - cursor: true, - preview: false, - sound: true, - }; - let text = to_json(&state); - assert!(!text.contains('\n'), "one line"); - let value: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!( - value, - serde_json::json!({ - "mode": "zoom", - "timer": 5, - "cursor": true, - "preview": false, - "sound": true, - "timers": [0, 5, 10], - }) - ); - } - - #[test] - fn sets_apply_every_key_in_order() { - let start = defaults(&Config::default()); - let sets: Vec = ["mode=window", "timer=10", "cursor=true", "preview=false", "sound=false"] - .map(String::from) - .into(); - assert_eq!( - apply_sets(start, &sets).unwrap(), - ToolbarState { - mode: Mode::Window, - timer: 10, - cursor: true, - preview: false, - sound: false, - } - ); - let twice = ["timer=5".to_string(), "timer=0".to_string()]; - assert_eq!(apply_sets(start, &twice).unwrap().timer, 0, "the last one wins"); - } - - #[test] - fn bad_sets_are_rejected_by_name() { - let start = defaults(&Config::default()); - for (pair, message) in [ - ("colour=red", "unknown toolbar key \"colour\""), - ("mode=video", "mode must be screen, window, region or zoom"), - ("timer=7", "timer must be 0, 5 or 10"), - ("timer=soon", "timer must be 0, 5 or 10"), - ("cursor=maybe", "cursor must be true or false"), - ("cursor", "expected KEY=VALUE"), - ] { - let err = format!("{:#}", apply_sets(start, &[pair.to_string()]).unwrap_err()); - assert!(err.contains(message), "{pair}: {err}"); - } - } - - #[test] - fn set_writes_all_or_nothing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("valw/toolbar.toml"); - let config = Config::default(); - let bad = ["timer=5".to_string(), "mode=video".to_string()]; - assert!(set(&path, &config, &bad).is_err()); - assert!(!path.exists(), "nothing written"); - - let good = ["timer=5".to_string(), "sound=false".to_string()]; - set(&path, &config, &good).unwrap(); - let state = load(&path, &config); - assert_eq!((state.timer, state.sound, state.mode), (5, false, Mode::Region)); - - std::fs::write(&path, "not toml [").unwrap(); - set(&path, &config, &["cursor=true".to_string()]).unwrap(); - assert_eq!( - load(&path, &config), - ToolbarState { - cursor: true, - ..defaults(&config) - }, - "a broken file is replaced, starting from the defaults" - ); - } -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cargo nextest run -E 'test(toolbar::state)'` -Expected: compile error, `cannot find function \`to_json\`` (and `apply_sets`, `set`). - -- [ ] **Step 3: Implement** - -In `src/toolbar/state.rs`, change the `Mode` derive line to: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] -``` - -Add after `pub fn save(…)`: - -```rust -/// One line of JSON for the Noctalia plugin: the state and the timer choices. -pub fn to_json(state: &ToolbarState) -> String { - serde_json::json!({ - "mode": state.mode, - "timer": state.timer, - "cursor": state.cursor, - "preview": state.preview, - "sound": state.sound, - "timers": TIMERS, - }) - .to_string() -} - -/// `state` with every `KEY=VALUE` applied in order; the first bad pair is -/// an error naming it. -pub fn apply_sets(mut state: ToolbarState, sets: &[String]) -> Result { - for pair in sets { - let Some((key, value)) = pair.split_once('=') else { - bail!("expected KEY=VALUE, not {pair:?}"); - }; - let flag = || match value { - "true" => Ok(true), - "false" => Ok(false), - _ => Err(anyhow::anyhow!("{key} must be true or false, not {value:?}")), - }; - match key { - "mode" => { - state.mode = ::from_str(value, false).map_err(|_| { - anyhow::anyhow!("mode must be screen, window, region or zoom, not {value:?}") - })?; - } - "timer" => { - state.timer = value - .parse() - .ok() - .filter(|t| TIMERS.contains(t)) - .with_context(|| format!("timer must be 0, 5 or 10, not {value:?}"))?; - } - "cursor" => state.cursor = flag()?, - "preview" => state.preview = flag()?, - "sound" => state.sound = flag()?, - _ => bail!("unknown toolbar key {key:?} (mode, timer, cursor, preview or sound)"), - } - } - Ok(state) -} - -/// `valw toolbar --set`: `sets` applied to the remembered state and saved; -/// nothing changes if any pair is bad. -pub fn set(path: &Path, config: &Config, sets: &[String]) -> Result<()> { - let state = apply_sets(load(path, config), sets)?; - save(path, &state) -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cargo fmt && cargo nextest run -E 'test(toolbar::state)'` -Expected: PASS, the 5 existing state tests and the 4 new ones. - -- [ ] **Step 5: Commit** - -```bash -cargo clippy --all-targets -- -D warnings -jj commit -m "toolbar: state as JSON and KEY=VALUE sets, for the Noctalia plugin" src/toolbar/state.rs -``` - ---- - -### Task 2: `valw toolbar --state | --set | --run` - -**Files:** -- Modify: `src/toolbar/mod.rs` (`Picked`, `run`; new `remember`, `run_remembered`; a new `tests` module at the bottom) -- Modify: `src/main.rs` (`Command::Toolbar`, its arm in `run`, `flag_conflicts` test) - -**Interfaces:** -- Consumes: Task 1's `state::{to_json, set, load, save, default_path}`, `Mode: clap::ValueEnum`. -- Produces: - - `impl From for Picked` - - `pub fn remember(path: &Path, config: &Config, mode: Option) -> ToolbarState` - - `pub fn run_remembered(config: &Config, mode: Option) -> Result` - - CLI: `valw toolbar --state`, `valw toolbar --set KEY=VALUE` (repeatable), `valw toolbar --run [MODE]`, mutually exclusive. - -- [ ] **Step 1: Write the failing tests** - -Append to the end of `src/toolbar/mod.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_pick_carries_the_state_options() { - let state = ToolbarState { - mode: Mode::Window, - timer: 5, - cursor: true, - preview: false, - sound: true, - }; - assert_eq!( - Picked::from(state), - Picked { - mode: Mode::Window, - cursor: true, - preview: false, - sound: true, - } - ); - } - - #[test] - fn run_remembers_a_given_mode() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("toolbar.toml"); - let config = Config::default(); - state::save(&path, &ToolbarState { timer: 5, ..state::defaults(&config) }).unwrap(); - - let state = remember(&path, &config, Some(Mode::Zoom)); - assert_eq!((state.mode, state.timer), (Mode::Zoom, 5), "the options stay"); - assert_eq!(state::load(&path, &config).mode, Mode::Zoom, "saved"); - - assert_eq!(remember(&path, &config, None).mode, Mode::Zoom, "no mode: the remembered one"); - } - - #[test] - fn run_without_a_mode_writes_nothing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("toolbar.toml"); - let config = Config::default(); - assert_eq!(remember(&path, &config, None), state::defaults(&config)); - assert!(!path.exists()); - } -} -``` - -In `src/main.rs`, inside `fn flag_conflicts()`, after `assert!(parses(&["toolbar"]));` add: - -```rust - assert!(parses(&["toolbar", "--state"])); - assert!(parses(&["toolbar", "--set", "timer=5", "--set", "sound=false"])); - assert!(parses(&["toolbar", "--run"])); - assert!(parses(&["toolbar", "--run", "zoom"])); - assert!(!parses(&["toolbar", "--run", "video"])); - assert!(!parses(&["toolbar", "--state", "--run"])); - assert!(!parses(&["toolbar", "--state", "--set", "timer=5"])); - assert!(!parses(&["toolbar", "--set", "timer=5", "--run", "zoom"])); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cargo nextest run -E 'test(toolbar::tests) | test(flag_conflicts)'` -Expected: compile errors: `Picked: From` not implemented, `remember` not found. - -- [ ] **Step 3: Implement** - -In `src/toolbar/mod.rs`: add `use std::path::Path;` next to the other `std` import, and after the `Picked` struct: - -```rust -impl From for Picked { - fn from(s: ToolbarState) -> Self { - Picked { - mode: s.mode, - cursor: s.cursor, - preview: s.preview, - sound: s.sound, - } - } -} -``` - -Replace the end of `pub fn run` (from `countdown(…)?;` to the closing `}`) with: - -```rust - countdown(&mut wl, &output, picked.0.timer)?; - Ok(Picked::from(picked.0)) -} - -/// The remembered state, with `mode` (if given) remembered as the new mode. -pub fn remember(path: &Path, config: &Config, mode: Option) -> ToolbarState { - let mut s = state::load(path, config); - if let Some(mode) = mode { - s.mode = mode; - if let Err(e) = state::save(path, &s) { - tracing::warn!("could not remember the toolbar choice: {e:#}"); - } - } - s -} - -/// `valw toolbar --run`: the remembered options without the bar (for the -/// Noctalia plugin), then the countdown. -pub fn run_remembered(config: &Config, mode: Option) -> Result { - let s = remember(&state::default_path(), config, mode); - if s.timer > 0 { - let mut wl = Wayland::connect()?; - let outputs = wl.outputs(); - anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); - let output = outputs[crate::focused_output(&outputs)].clone(); - countdown(&mut wl, &output, s.timer)?; - } - Ok(Picked::from(s)) -} -``` - -(`pick` returns `(ToolbarState { mode, ..state }, mode)`, so `picked.0.mode == picked.1` and `Picked::from(picked.0)` is what `run` built before.) - -In `src/main.rs`, replace the `Toolbar` variant: - -```rust - /// Pick a mode from a floating bar (Cmd+Shift+5). - Toolbar { - /// Print the remembered choices as one line of JSON (for the Noctalia plugin). - #[arg(long, conflicts_with_all = ["set", "run"])] - state: bool, - /// Change a remembered choice: mode, timer, cursor, preview or sound. - #[arg(long, value_name = "KEY=VALUE", conflicts_with = "run")] - set: Vec, - /// Capture at once with the remembered choices, without the bar. - #[arg(long, value_name = "MODE", num_args = 0..=1)] - run: Option>, - }, -``` - -and its arm in `fn run`: - -```rust - Command::Toolbar { state, set, run } => { - let config = config::load(&config::default_path())?; - let path = toolbar::state::default_path(); - if state { - println!("{}", toolbar::state::to_json(&toolbar::state::load(&path, &config))); - return Ok(()); - } - if !set.is_empty() { - return toolbar::state::set(&path, &config, &set); - } - let picked = match run { - Some(mode) => toolbar::run_remembered(&config, mode)?, - None => toolbar::run(&config)?, - }; - let (mode, common) = from_toolbar(picked); - capture(mode, common) - } -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cargo fmt && cargo nextest run -E 'test(toolbar) | test(flag_conflicts) | test(cli_definition)'` -Expected: PASS (including `cli_definition_is_valid`). - -- [ ] **Step 5: Check the real binary's stdout (Review Focus 5)** - -Run (no surfaces, safe on the live session; uses a throwaway state dir): - -```bash -export XDG_STATE_HOME=$(mktemp -d) -cargo run -q -- toolbar --state | python3 -c 'import json,sys; lines=sys.stdin.read().splitlines(); assert len(lines)==1, lines; print(json.loads(lines[0]))' -cargo run -q -- toolbar --set timer=10 --set mode=zoom && cargo run -q -- toolbar --state -cargo run -q -- toolbar --set timer=7; echo "exit=$?" -cat $XDG_STATE_HOME/valw/toolbar.toml -unset XDG_STATE_HOME -``` - -Expected: the defaults dict printed; then a line with `"mode":"zoom"` and `"timer":10`; then an error mentioning `timer must be 0, 5 or 10` and `exit=1`; the file still says `timer = 10`. - -- [ ] **Step 6: Commit** - -```bash -cargo clippy --all-targets -- -D warnings -jj commit -m "toolbar: --state, --set and --run for the Noctalia plugin" src/toolbar/mod.rs src/main.rs -``` - ---- - -### Task 3: The plugin, its Luau tests, packaging and the flake check - -**Files:** -- Create: `noctalia-plugin/plugin.toml`, `noctalia-plugin/bar.luau`, `noctalia-plugin/panel.luau`, `noctalia-plugin/shortcut.luau`, `noctalia-plugin/translations/en.json`, `noctalia-plugin/translations/tr.json`, `noctalia-plugin/README.md` -- Create: `noctalia-plugin/tests/stubs.luau`, `noctalia-plugin/tests/panel_test.luau`, `noctalia-plugin/tests/bar_test.luau`, `noctalia-plugin/tests/shortcut_test.luau`, `noctalia-plugin/tests/run.sh` -- Modify: `nix/package.ulu.nix` (`postInstall`) -- Modify: `nix/checks.ulu.nix` (new `noctalia-plugin` check) - -**Interfaces:** -- Consumes: Task 2's CLI: `valw toolbar --state` (JSON `{mode,timer,cursor,preview,sound,timers}`), `valw toolbar --set KEY=VALUE`, `valw toolbar --run MODE`; exit 3 = cancelled. -- Produces: `$out/share/valw/noctalia-plugin/` (without `tests/`), panel id `elars/valw:toolbar`, widget type `elars/valw:bar`. - -- [ ] **Step 1: Write the test harness and the failing tests** - -`noctalia-plugin/tests/stubs.luau` (stands in for Noctalia's globals and records what the plugin does): - -```lua ---!nonstrict --- Stand-ins for Noctalia's globals. Each test is run as one chunk: --- these stubs, then the entry script, then the test (see run.sh). - -calls = { run = {}, notify = {}, toggled = {}, closed = 0, order = {} } -fake = { - exists = true, - -- runAsync answers by command prefix: { exitCode, stdout, stderr } - answers = {}, - decoded = nil, -} -rendered = nil - -local function node(kind) - return function(props, children) - return { kind = kind, props = props or {}, children = children or {} } - end -end - -ui = { - row = node("row"), column = node("column"), label = node("label"), - button = node("button"), glyph = node("glyph"), toggle = node("toggle"), - select = node("select"), separator = node("separator"), spacer = node("spacer"), -} - -noctalia = { - tr = function(key) return key end, - commandExists = function(_name) return fake.exists end, - runAsync = function(command, callback) - table.insert(calls.run, command) - table.insert(calls.order, "run") - if callback then - local answer = { exitCode = 0, stdout = "", stderr = "" } - for prefix, a in pairs(fake.answers) do - if string.sub(command, 1, #prefix) == prefix then answer = a end - end - callback(answer) - end - return true - end, - notifyError = function(title, body) table.insert(calls.notify, { title, body }) end, - togglePanel = function(id) table.insert(calls.toggled, id) end, - json = { decode = function(_text) return fake.decoded end }, -} - -panel = { - render = function(tree) rendered = tree end, - close = function() - calls.closed += 1 - table.insert(calls.order, "close") - end, -} - -barWidget = { - render = function(tree) rendered = tree end, - setTooltip = function(_text) end, -} - -shortcut = { - setLabel = function(_text) end, - setIcon = function(_a, _b) end, -} - --- Every node of `tree` that `match` accepts, depth first. -function findAll(tree, match, out) - out = out or {} - if type(tree) ~= "table" then return out end - if tree.kind and match(tree) then table.insert(out, tree) end - for _, child in ipairs(tree.children or {}) do findAll(child, match, out) end - return out -end - -function button(text) - local found = findAll(rendered, function(n) return n.kind == "button" and n.props.text == text end) - assert(#found == 1, "one button " .. text .. ", found " .. #found) - return found[1] -end - -function reset() - calls = { run = {}, notify = {}, toggled = {}, closed = 0, order = {} } -end - -function ok(name) - print("ok - " .. name) -end -``` - -`noctalia-plugin/tests/panel_test.luau`: - -```lua ---!nonstrict --- The toolbar panel. Runs after stubs.luau and panel.luau. - -local STATE = { mode = "zoom", timer = 5, cursor = false, preview = true, sound = true, timers = { 0, 5, 10 } } - --- valw missing from Noctalia's PATH: a label and a notification, no panel. -fake.exists = false -onOpen(nil) -local labels = findAll(rendered, function(n) return n.kind == "label" end) -assert(#labels == 1 and labels[1].props.text == "panel.unavailable", "unavailable label") -assert(#calls.notify == 1, "notified once") -assert(#calls.run == 0, "nothing run") -ok("valw missing") - --- --state failing: the same. -reset() -fake.exists = true -fake.answers["valw toolbar --state"] = { exitCode = 1, stdout = "", stderr = "warn\nerror: broken\n" } -onOpen(nil) -assert(findAll(rendered, function(n) return n.kind == "label" and n.props.text == "panel.unavailable" end)[1], "label") -assert(calls.notify[1][2] == "error: broken", "the stderr's last line: " .. tostring(calls.notify[1][2])) -ok("--state failing") - --- A working valw: the remembered mode is primary, the timer selected. -reset() -fake.answers["valw toolbar --state"] = { exitCode = 0, stdout = "{}", stderr = "" } -fake.decoded = STATE -onOpen(nil) -assert(calls.run[1] == "valw toolbar --state", calls.run[1]) -assert(button("mode.zoom").props.variant == "primary", "zoom is primary") -for _, m in ipairs({ "mode.screen", "mode.region", "mode.window" }) do - assert(button(m).props.variant == "outline", m .. " is outline") -end -local select = findAll(rendered, function(n) return n.kind == "select" end)[1] -assert(select.props.selectedIndex == 1, "5 s is index 1 (0-based)") -assert(select.props.options[1] == "timer.off" and select.props.options[2] == "5 s", "timer labels") -local toggles = findAll(rendered, function(n) return n.kind == "toggle" end) -assert(#toggles == 3, "three toggles") -assert(toggles[1].props.checked == false and toggles[2].props.checked == true, "cursor off, preview on") -local hex = findAll(rendered, function(n) - for _, v in pairs(n.props) do - if type(v) == "string" and string.find(v, "^#") then return true end - end - return false -end) -assert(#hex == 0, "no hex colours") -ok("renders the state") - --- A mode click closes the panel first, then captures after the wait. -reset() -button("mode.region").props.onClick() -assert(calls.order[1] == "close" and calls.order[2] == "run", "close, then run") -assert(calls.run[1] == "sleep 0.3 && valw toolbar --run region", calls.run[1]) -assert(#calls.notify == 0, "success is silent") -ok("mode click") - --- A failed capture notifies; a cancelled one doesn't. -reset() -fake.answers["sleep 0.3 && valw toolbar --run"] = { exitCode = 1, stdout = "", stderr = "error: no outputs\n" } -button("mode.screen").props.onClick() -assert(calls.notify[1][2] == "error: no outputs", "failure notified") -reset() -fake.answers["sleep 0.3 && valw toolbar --run"] = { exitCode = 3, stdout = "", stderr = "" } -button("mode.window").props.onClick() -assert(#calls.notify == 0, "cancel is silent") -ok("capture results") - --- The timer and the toggles write through --set and re-render at once. -reset() -fake.answers["sleep 0.3 && valw toolbar --run"] = nil -select = findAll(rendered, function(n) return n.kind == "select" end)[1] -select.props.onChange("2") -assert(calls.run[1] == "valw toolbar --set timer=10", calls.run[1]) -select = findAll(rendered, function(n) return n.kind == "select" end)[1] -assert(select.props.selectedIndex == 2, "re-rendered with 10 s") -reset() -toggles = findAll(rendered, function(n) return n.kind == "toggle" end) -toggles[3].props.onChange("false") -assert(calls.run[1] == "valw toolbar --set sound=false", calls.run[1]) -toggles = findAll(rendered, function(n) return n.kind == "toggle" end) -assert(toggles[3].props.checked == false, "re-rendered with sound off") -reset() -fake.answers["valw toolbar --set"] = { exitCode = 1, stdout = "", stderr = "error: read-only\n" } -toggles[1].props.onChange("true") -assert(calls.run[1] == "valw toolbar --set cursor=true", calls.run[1]) -assert(calls.notify[1][2] == "error: read-only", "a failed --set notifies") -ok("timer and toggles") -``` - -`noctalia-plugin/tests/bar_test.luau`: - -```lua ---!nonstrict --- The bar widget. Runs after stubs.luau and bar.luau. - -update() -assert(findAll(rendered, function(n) return n.kind == "glyph" and n.props.name == "camera" end)[1], "camera glyph") -ok("renders") - -onClick() -assert(calls.toggled[1] == "elars/valw:toolbar", "left click toggles the panel") -assert(#calls.run == 0) -ok("left click") - -reset() -onRightClick() -assert(calls.run[1] == "valw region", calls.run[1]) -reset() -fake.answers["valw region"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" } -onRightClick() -assert(calls.notify[1][2] == "error: busy", "failure notified") -reset() -fake.answers["valw region"] = { exitCode = 3, stdout = "", stderr = "" } -onRightClick() -assert(#calls.notify == 0, "cancel is silent") -ok("right click") -``` - -`noctalia-plugin/tests/shortcut_test.luau`: - -```lua ---!nonstrict --- The control-center tile. Runs after stubs.luau and shortcut.luau. - -onClick() -assert(calls.run[1] == "noctalia msg panel-close; sleep 0.3 && valw region", calls.run[1]) -reset() -fake.answers["noctalia msg panel-close"] = { exitCode = 1, stdout = "", stderr = "error: busy\n" } -onClick() -assert(calls.notify[1][2] == "error: busy", "failure notified") -reset() -fake.answers["noctalia msg panel-close"] = { exitCode = 3, stdout = "", stderr = "" } -onClick() -assert(#calls.notify == 0, "cancel is silent") -ok("click") - -reset() -onRightClick() -assert(calls.toggled[1] == "elars/valw:toolbar", "right click toggles the panel") -ok("right click") -``` - -`noctalia-plugin/tests/run.sh`: - -```sh -#!/bin/sh -# Runs each plugin test as one chunk: the stubs, the entry script, the test. -# Needs `luau` (nix shell nixpkgs#luau -c sh noctalia-plugin/tests/run.sh). -set -eu -dir=$(dirname "$0") -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT -for pair in panel:panel_test bar:bar_test shortcut:shortcut_test; do - entry=${pair%%:*} - test=${pair##*:} - cat "$dir/stubs.luau" "$dir/../$entry.luau" "$dir/$test.luau" > "$tmp/$test.luau" - luau "$tmp/$test.luau" -done -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `nix shell nixpkgs#luau -c sh noctalia-plugin/tests/run.sh; echo "exit=$?"` -Expected: `cat` fails on the missing `panel.luau` (`No such file or directory`), `exit=1`. - -- [ ] **Step 3: Write the plugin** - -`noctalia-plugin/plugin.toml`: - -```toml -# valw for Noctalia: a bar button, the toolbar as a Noctalia panel and a -# control-center tile. Thin clients of the valw CLI: the choices live in -# valw's own toolbar.toml (`valw toolbar --state` / `--set`), captures run -# through `valw toolbar --run` and `valw region`. - -id = "elars/valw" -name = "valw" -version = "0.1.0" -plugin_api = 3 -author = "elars" -license = "MIT" -dependencies = ["valw"] -tags = ["screenshot", "capture"] -icon = "camera" -description = "macOS-style screenshots with valw: a bar button, a toolbar panel and a control-center tile." - -# Left click: the toolbar panel. Right click: a region shot. -[[widget]] -id = "bar" -entry = "bar.luau" - -[[panel]] -id = "toolbar" -entry = "panel.luau" -width = 360 -height = 220 -placement = "attached" -position = "auto" -open_near_click = true - -# Click: a region shot. Right click: the toolbar panel. -[[shortcut]] -id = "tile" -entry = "shortcut.luau" -``` - -`noctalia-plugin/bar.luau`: - -```lua ---!nonstrict --- valw bar widget: a camera glyph. Left click opens the toolbar panel, --- right click takes a region shot at once (plain `valw region`, like the --- keybind: the toolbar's timer and options don't apply). - -local PANEL = "elars/valw:toolbar" - -local function lastLine(text) - local last = "" - for line in string.gmatch(text or "", "[^\n]+") do - last = line - end - return last -end - --- Exit 3 is a cancel (Esc): not an error. -local function report(result) - if result.exitCode ~= 0 and result.exitCode ~= 3 then - noctalia.notifyError("valw", lastLine(result.stderr)) - end -end - -function update() - barWidget.render(ui.row({ align = "center" }, { - ui.glyph({ name = "camera" }), - })) - barWidget.setTooltip(noctalia.tr("bar.tooltip")) -end - -function onClick() - noctalia.togglePanel(PANEL) -end - -function onRightClick() - noctalia.runAsync("valw region", report) -end -``` - -`noctalia-plugin/panel.luau`: - -```lua ---!nonstrict --- valw toolbar panel: the four modes, the timer and the options, drawn --- with Noctalia's own components. The choices live in valw's toolbar.toml: --- read with `valw toolbar --state`, changed with `valw toolbar --set`. --- A mode click closes the panel, waits for it to go, then captures. - -local MODES = { - { id = "screen", glyph = "device-desktop" }, - { id = "region", glyph = "crop" }, - { id = "window", glyph = "app-window" }, - { id = "zoom", glyph = "zoom-in" }, -} -local OPTIONS = { "cursor", "preview", "sound" } - --- The decoded `--state` JSON; nil until it arrives. -local state = nil -local unavailable = false - -local function lastLine(text) - local last = "" - for line in string.gmatch(text or "", "[^\n]+") do - last = line - end - return last -end - --- Exit 3 is a cancel (Esc): not an error. -local function report(result) - if result.exitCode ~= 0 and result.exitCode ~= 3 then - noctalia.notifyError("valw", lastLine(result.stderr)) - end -end - -local render - -local function capture(mode) - panel.close() - noctalia.runAsync("sleep 0.3 && valw toolbar --run " .. mode, report) -end - --- `key` and `value` are always one of our own tokens, never user text. -local function remember(key, value) - state[key] = value - render() - noctalia.runAsync("valw toolbar --set " .. key .. "=" .. tostring(value), report) -end - -local function modeButton(m) - return ui.button({ - text = noctalia.tr("mode." .. m.id), - glyph = m.glyph, - variant = if state.mode == m.id then "primary" else "outline", - onClick = function() - capture(m.id) - end, - }) -end - -local function optionRow(key) - return ui.row({ gap = 8, align = "center", justify = "space_between" }, { - ui.label({ text = noctalia.tr("option." .. key), color = "on_surface" }), - ui.toggle({ - checked = state[key], - onChange = function(value) - remember(key, value == "true") - end, - }), - }) -end - -render = function() - if unavailable then - panel.render(ui.column({ padding = 16 }, { - ui.label({ text = noctalia.tr("panel.unavailable"), color = "error" }), - })) - return - end - if state == nil then - return - end - local modes = {} - for _, m in ipairs(MODES) do - table.insert(modes, modeButton(m)) - end - local timers, selected = {}, 0 - for i, t in ipairs(state.timers) do - table.insert(timers, if t == 0 then noctalia.tr("timer.off") else tostring(t) .. " s") - if t == state.timer then - selected = i - 1 - end - end - local rows = { - ui.row({ gap = 8, justify = "space_between" }, modes), - ui.separator({}), - ui.row({ gap = 8, align = "center", justify = "space_between" }, { - ui.label({ text = noctalia.tr("timer.label"), color = "on_surface" }), - ui.select({ - options = timers, - selectedIndex = selected, - onChange = function(index) - remember("timer", state.timers[tonumber(index) + 1]) - end, - }), - }), - } - for _, key in ipairs(OPTIONS) do - table.insert(rows, optionRow(key)) - end - panel.render(ui.column({ gap = 12, padding = 16 }, rows)) -end - -local function fail(message) - unavailable = true - noctalia.notifyError("valw", message) - render() -end - -function onOpen(_context) - unavailable = false - if not noctalia.commandExists("valw") then - fail(noctalia.tr("panel.unavailable")) - return - end - noctalia.runAsync("valw toolbar --state", function(result) - if result.exitCode ~= 0 then - fail(lastLine(result.stderr)) - return - end - local decoded = noctalia.json.decode(result.stdout) - if type(decoded) ~= "table" then - fail(noctalia.tr("panel.unavailable")) - return - end - state = decoded - render() - end) -end -``` - -`noctalia-plugin/shortcut.luau`: - -```lua ---!nonstrict --- valw control-center tile. Click: the control center closes, then a --- region shot (so it isn't in it). Right click: the toolbar panel. - -local PANEL = "elars/valw:toolbar" - -local function lastLine(text) - local last = "" - for line in string.gmatch(text or "", "[^\n]+") do - last = line - end - return last -end - --- Exit 3 is a cancel (Esc): not an error. -local function report(result) - if result.exitCode ~= 0 and result.exitCode ~= 3 then - noctalia.notifyError("valw", lastLine(result.stderr)) - end -end - -shortcut.setLabel(noctalia.tr("tile.label")) -shortcut.setIcon("camera", "camera") - -function onClick() - noctalia.runAsync("noctalia msg panel-close; sleep 0.3 && valw region", report) -end - -function onRightClick() - noctalia.togglePanel(PANEL) -end -``` - -`noctalia-plugin/translations/en.json`: - -```json -{ - "bar": { "tooltip": "Screenshot" }, - "tile": { "label": "Screenshot" }, - "panel": { "unavailable": "valw is not available" }, - "mode": { "screen": "Screen", "region": "Region", "window": "Window", "zoom": "Zoom" }, - "timer": { "label": "Timer", "off": "Off" }, - "option": { "cursor": "Show cursor", "preview": "Show preview", "sound": "Play sound" } -} -``` - -`noctalia-plugin/translations/tr.json`: - -```json -{ - "bar": { "tooltip": "Ekran görüntüsü" }, - "tile": { "label": "Ekran görüntüsü" }, - "panel": { "unavailable": "valw bulunamadı" }, - "mode": { "screen": "Ekran", "region": "Bölge", "window": "Pencere", "zoom": "Yakınlaştır" }, - "timer": { "label": "Zamanlayıcı", "off": "Kapalı" }, - "option": { "cursor": "İmleci göster", "preview": "Önizlemeyi göster", "sound": "Ses çal" } -} -``` - -`noctalia-plugin/README.md`: - -```markdown -# valw for Noctalia - -A bar button, the valw toolbar as a Noctalia panel, and a control-center -tile. Noctalia draws everything, so it follows your Noctalia theme. - -- Bar button: left click opens the toolbar, right click takes a region shot. -- Toolbar: click a mode to capture; the timer and options are remembered - (shared with `valw toolbar`). -- Tile: click for a region shot, right click for the toolbar. - -Install it with the home-manager module (`programs.valw.noctalia.enable`), -then enable `elars/valw` in Noctalia and place the widget (type -`elars/valw:bar`). Toggle the toolbar from a keybind with -`noctalia msg panel-toggle elars/valw:toolbar`. - -Tests: `nix shell nixpkgs#luau -c sh noctalia-plugin/tests/run.sh`. -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `nix shell nixpkgs#luau -c sh -c 'sh noctalia-plugin/tests/run.sh && luau-compile --null noctalia-plugin/*.luau && echo ALL-OK'` -Expected: `ok - …` lines for every test (6 panel, 3 bar, 2 tile), then the compile summary and `ALL-OK`. A failing `assert` would print the message and stop with a non-zero exit (check that once: temporarily change `"valw region"` in `bar_test.luau` to `"valw regio"`, see it fail, revert). - -- [ ] **Step 5: Package it and add the flake check** - -In `nix/package.ulu.nix`, append to `postInstall` (after the `install -Dm644 …LICENSE-DejaVu…` line): - -```nix - # The Noctalia plugin (programs.valw.noctalia), without its tests. - mkdir -p $out/share/valw - cp -r noctalia-plugin $out/share/valw/noctalia-plugin - rm -r $out/share/valw/noctalia-plugin/tests -``` - -In `nix/checks.ulu.nix`, add to `checks` (after `home-module-rejects`): - -```nix - # The plugin as packaged: manifest, every entry present, Luau that - # compiles, translations that parse; and its logic against stubbed - # Noctalia globals. - noctalia-plugin = - pkgs.runCommand "valw-noctalia-plugin" - { - nativeBuildInputs = [ - pkgs.jq - pkgs.luau - ]; - } - '' - dir=${valw}/share/valw/noctalia-plugin - grep -qx 'id = "elars/valw"' $dir/plugin.toml - test "$(grep -c '^entry = ' $dir/plugin.toml)" -eq 3 - for entry in $(sed -n 's/^entry = "\(.*\)"$/\1/p' $dir/plugin.toml); do - test -f $dir/$entry - done - test ! -e $dir/tests - luau-compile --null $dir/*.luau - for f in $dir/translations/*.json; do jq empty $f; done - cp -r ${../noctalia-plugin} plugin - chmod -R u+w plugin - sh plugin/tests/run.sh - touch $out - ''; -``` - -- [ ] **Step 6: Run the check** - -Run: `nix build .#checks.x86_64-linux.noctalia-plugin -L 2>&1 | tail -20` -Expected: the `ok - …` lines in the log and a successful build. - -- [ ] **Step 7: Commit** - -```bash -jj commit -m "noctalia-plugin: bar widget, toolbar panel and tile, with Luau tests and a flake check" noctalia-plugin nix/package.ulu.nix nix/checks.ulu.nix -``` - ---- - -### Task 4: home-manager option, README and checklist - -**Files:** -- Modify: `nix/home-module.ulu.nix` (option `noctalia.enable`, `xdg.dataFile`) -- Modify: `nix/checks.ulu.nix` (`homeModule` helper takes the whole `programs.valw` config; `xdg.dataFile` stand-in; assertions) -- Modify: `README.md` (a "Noctalia" section; `valw toolbar` flags in the commands table) -- Modify: `docs/test-checklist.md` (a "Noctalia plugin" section) - -**Interfaces:** -- Consumes: Task 3's `$out/share/valw/noctalia-plugin`. -- Produces: `programs.valw.noctalia.enable` → `xdg.dataFile."noctalia/plugins/valw".source = "${cfg.package}/share/valw/noctalia-plugin"`. - -- [ ] **Step 1: Write the failing check** - -In `nix/checks.ulu.nix`, change the helper's signature and body so callers pass the whole `programs.valw` config: - -```nix - homeModule = - valwConfig: - (lib.evalModules { - modules = [ - self.homeModules.default - ( - { lib, ... }: - let - files = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { options.source = lib.mkOption { type = lib.types.path; }; } - ); - default = { }; - }; - in - { - options.home.packages = lib.mkOption { - type = lib.types.listOf lib.types.package; - default = [ ]; - }; - options.xdg.configFile = files; - options.xdg.dataFile = files; - config._module.args.pkgs = pkgs; - config.programs.valw = { - enable = true; - } - // valwConfig; - } - ) - ]; - }).config; -``` - -Update the callers: in `home-module`, `plain = homeModule { };`, `set = homeModule { settings = { preview.timeout_secs = 3; sound.volume = 0.4; }; };`, and add `noctalia = homeModule { noctalia.enable = true; };` to its `let`; in `home-module-rejects`, `fails = settings: pkgs.testers.testBuildFailure (homeModule { inherit settings; }).xdg.configFile."valw/config.toml".source;`. - -Add these asserts to `home-module`, next to the existing ones: - -```nix - assert lib.assertMsg (plain.xdg.dataFile == { }) "no Noctalia plugin unless asked"; - assert lib.assertMsg ( - noctalia.xdg.dataFile."noctalia/plugins/valw".source == "${valw}/share/valw/noctalia-plugin" - ) "the plugin is linked where Noctalia looks"; -``` - -- [ ] **Step 2: Run it to verify it fails** - -Run: `nix build .#checks.x86_64-linux.home-module 2>&1 | tail -5` -Expected: evaluation error: `The option 'programs.valw.noctalia' does not exist.` - -- [ ] **Step 3: Implement the option** - -In `nix/home-module.ulu.nix`, add to `options.programs.valw` (after `settings`): - -```nix - noctalia.enable = lib.mkEnableOption '' - valw's Noctalia plugin (a bar button, the toolbar as a Noctalia - panel and a control-center tile), linked into - `$XDG_DATA_HOME/noctalia/plugins/valw`. Enable `elars/valw` and - place its widget in Noctalia's own settings - ''; -``` - -and to `config = lib.mkIf cfg.enable { … }`: - -```nix - xdg.dataFile."noctalia/plugins/valw" = lib.mkIf cfg.noctalia.enable { - source = "${cfg.package}/share/valw/noctalia-plugin"; - }; -``` - -- [ ] **Step 4: Run the checks to verify they pass** - -Run: `nix build .#checks.x86_64-linux.home-module .#checks.x86_64-linux.home-module-rejects 2>&1 | tail -5` -Expected: both build. - -- [ ] **Step 5: README and checklist** - -In `README.md`, in the commands table change the toolbar row to: - -```markdown -| `valw toolbar` | Pick a mode from a floating bar, with a timer and options; `--state`, `--set KEY=VALUE` and `--run [MODE]` drive it without the bar (the Noctalia plugin uses them) | -``` - -and add after the "The overview backdrop" section: - -````markdown -## Noctalia - -With [Noctalia](https://noctalia.dev), valw comes as a plugin: a camera -button on the bar (left click: the toolbar, right click: a region shot), -the toolbar as a Noctalia panel and a control-center tile. Noctalia draws -them, so they follow your Noctalia theme. - -```nix -programs.valw.noctalia.enable = true; -``` - -Then enable `elars/valw` in Noctalia, add a widget of type `elars/valw:bar` -to a bar, and bind the toolbar: - -```kdl -Mod+Shift+T { spawn "noctalia" "msg" "panel-toggle" "elars/valw:toolbar"; } -``` -```` - -In `docs/test-checklist.md`, add a section at the end: - -```markdown -## Noctalia plugin - -- [ ] `programs.valw.noctalia.enable` + `elars/valw` enabled: the camera button appears where the widget is placed; its tooltip says Screenshot. -- [ ] Left click opens the toolbar panel next to the bar; it uses Noctalia's colours, font, corners and border; switching the palette or light/dark restyles it. -- [ ] The remembered mode is highlighted; Screen / Region / Window / Zoom each capture, and the panel is never in the shot. -- [ ] Timer and the three toggles persist, and match what `valw toolbar` (the self-drawn bar) shows, both ways; the timer shows valw's countdown. -- [ ] Right click on the button: a region shot (no timer). -- [ ] Control-center tile: click closes the control center, then a region shot; right click opens the toolbar panel. -- [ ] `noctalia msg panel-toggle elars/valw:toolbar` (Mod+Shift+T) opens and closes the panel. -- [ ] With valw off Noctalia's PATH: "valw is not available" and a notification, no crash. -- [ ] Turkish locale: the Turkish strings appear. -``` - -- [ ] **Step 6: Commit** - -```bash -jj commit -m "home-manager: programs.valw.noctalia.enable; README and checklist" nix/home-module.ulu.nix nix/checks.ulu.nix README.md docs/test-checklist.md -``` - ---- - -### Task 5: Load it in the live Noctalia (verify the folder name and the symlink) - -**Files:** -- Possibly modify: `nix/home-module.ulu.nix`, `nix/checks.ulu.nix`, `README.md`, `noctalia-plugin/README.md` (only if the folder name or the symlink needs a change) - -**Interfaces:** -- Consumes: Task 3's plugin folder; Task 4's install path `noctalia/plugins/valw`. -- Produces: a verified install path (ledgered as a ruling if it differs). - -- [ ] **Step 1: Tell the user** that the plugin is about to be loaded into their running Noctalia (a symlink in `~/.local/share/noctalia/plugins/`, `noctalia msg plugins enable elars/valw`, the panel opened once), and wait for their go-ahead. - -- [ ] **Step 2: Link the built plugin the way home-manager will (a symlink into the store)** - -```bash -out=$(nix build --no-link --print-out-paths .#default) -mkdir -p ~/.local/share/noctalia/plugins -test ! -e ~/.local/share/noctalia/plugins/valw -ln -s $out/share/valw/noctalia-plugin ~/.local/share/noctalia/plugins/valw -noctalia msg config-reload -noctalia msg plugins list | grep -i valw -``` - -Expected: a line `elars/valw [...] 0.1.0 disabled` (or similar). If it is missing: check `~/.cache/noctalia/noctalia.log` for the plugin loader's messages, try the folder names `elars-valw` and `elars/valw` (nested), and if a store symlink is refused, a plain copy; ledger the result as `Task 5: Ruling: …` and apply it to `nix/home-module.ulu.nix` (`recursive = true` for a copy, or the new folder name), the `home-module` check's expected key, and both READMEs. - -- [ ] **Step 3: Enable it and open the panel once** - -```bash -noctalia msg plugins enable elars/valw -noctalia msg panel-toggle elars/valw:toolbar -``` - -Expected: the panel appears near the bar with the four modes, the timer and three toggles, in Noctalia's style. Ask the user to confirm what they see, then close it (`noctalia msg panel-close`). With the user's go-ahead, also take two real shots from the panel: one with the timer at 5 s (the countdown runs, the shot lands, no error notification) and one Region shot held open for more than 6 s before selecting (it must not be killed). Check that nothing is clipped at 360×220 (the "Play sound" row, the four mode buttons in Turkish); adjust `width`/`height` in `plugin.toml` if needed. Check `grep -i valw ~/.cache/noctalia/noctalia.log | tail -20` for Luau errors (unknown glyph names, bad props); fix any in the plugin, rerun Task 3's tests, and commit (`jj commit -m "noctalia-plugin: fixes from the live Noctalia" noctalia-plugin`). - -- [ ] **Step 4: Undo the dev install** - -```bash -noctalia msg plugins disable elars/valw -rm ~/.local/share/noctalia/plugins/valw -``` - -(home-manager will own that path after the user's rebuild; a leftover link would collide.) - ---- - -### Task 6: copland wiring (after the final review; needs the user's approval) - -**Files (in `~/copland`, only after the user approves the diff):** -- Modify: `modules/home/home-valw.ulu.nix` (`programs.valw.noctalia.enable = true;`; Mod+Shift+T → `noctalia msg panel-toggle elars/valw:toolbar`) -- Modify: `modules/home/home-noctalia.ulu.nix` (`"elars/valw"` in `plugins.enabled`; `widget.valw = { type = "elars/valw:bar"; };`; `"valw"` in the bar's `end` list or a capsule group, where the user wants it) -- Modify: `flake.lock` (`nix flake update valw`) - -- [ ] **Step 1:** Push valw `main` first (after `nix flake check` passes), then show the user the copland diff and ask where the button goes on the bar. -- [ ] **Step 2:** On approval: edit, `nix flake update valw`, `jj commit -m "valw: Noctalia plugin" ` (never `modules/nixos/common/common-packages.ulu.nix`); the user rebuilds. diff --git a/docs/superpowers/plans/2026-10-04-valw-spanning-region.md b/docs/superpowers/plans/2026-10-04-valw-spanning-region.md deleted file mode 100644 index b53b807..0000000 --- a/docs/superpowers/plans/2026-10-04-valw-spanning-region.md +++ /dev/null @@ -1,556 +0,0 @@ -# valw spanning region 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 region drag can cross outputs; each output shows its part, and the shot is the whole rectangle stitched from every output it covers, transparent where no output is. - -**Architecture:** `selection::span` turns the drag's corners (global logical coordinates, as today) into per-output parts with their place in the whole image; `stitch::stitch` assembles the RGBA image from the frozen frames; `region` draws every output's part, puts the whole-size label on the pointer's output, and returns `Choice::Region` (one output, unchanged) or the new `Choice::Span`. - -**Tech Stack:** Rust 2024, `image` (`RgbaImage`, `imageops::{replace, resize}`), SCTK layer-shell (unchanged). - -**Spec:** `docs/superpowers/specs/2026-10-04-valw-spanning-region-design.md` - -## Global Constraints - -- `jj` only (`jj commit -m "" `), never `git`; `cargo clippy --all-targets -- -D warnings`, `cargo nextest run`, `cargo fmt --check`; nix3 commands only. -- English code and comments, matching the surrounding density and idiom. -- A selection inside one output must produce exactly today's result (`Choice::Region(output, rect)`, opaque, same pixels). -- Areas of the rectangle that no output covers are alpha 0; covered pixels alpha 255. -- The whole image is at the largest scale among the outputs the rectangle covers. -- No live-session runs without telling the user first; copland edits need approval. - -## Review Focus - -1. A box that touches one output but also covers a gap (a corner beyond the output): it becomes a `Span` with a transparent strip, never a silently clipped `Region`. — `choose` test in Task 3. -2. Seams: two parts meet without a gap or overlap row/column for integer layouts (the user's). — `span` test in Task 1 checks offsets and sizes add up. -3. The label: the whole shot's size, on the output the pointer is over, also during a drag whose events still arrive on the starting surface. — `locate` test in Task 1, `label` logic in Task 3 uses the global pointer. -4. Mixed scales: the lower-scale part is resized to its place, not pasted at its native size. — `span` and `stitch` tests (Tasks 1, 2). -5. A click (under `MIN_PIXELS` in the whole rectangle) still clears and keeps waiting. — `choose` test in Task 3. - ---- - -### Task 1: `selection::span` and `selection::locate` - -**Files:** -- Modify: `src/selection.rs` (new types and functions after `resolve`; tests at the bottom) - -**Interfaces:** -- Consumes: `Point`, `clip`, `MIN_PIXELS`, `OutputGeom`, `PixelRect`. -- Produces: - ```rust - pub struct Part { pub output: usize, pub rect: PixelRect, pub at: (u32, u32), pub size: (u32, u32) } - pub struct Span { pub parts: Vec, pub size: (u32, u32) } - impl Span { pub fn single(&self) -> Option<(usize, PixelRect)> } - pub fn span(a: Point, b: Point, outputs: &[(&OutputGeom, (u32, u32))]) -> Option - pub fn locate(p: Point, outputs: &[(&OutputGeom, (u32, u32))]) -> usize - ``` - (`outputs[i]` = output `i`'s geometry and frame size in physical pixels; both derive `Debug, Clone, PartialEq`.) - -- [ ] **Step 1: Write the failing tests** (append inside `mod tests`, which already has `out`, `p` and `rect` helpers) - -```rust - /// The user's layout: the laptop at the origin, the monitor above it, - /// wider and 277 px to the left. - fn layout() -> (OutputGeom, OutputGeom) { - (out("LVDS-1", 0, 0, 1366, 768), out("HDMI-A-1", -277, -1080, 1920, 1080)) - } - - #[test] - fn a_span_inside_one_output_is_todays_clip() { - let (laptop, monitor) = layout(); - let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))]; - let s = span(p(100.0, 100.0), p(300.0, 250.0), &outputs).unwrap(); - assert_eq!(s.size, (200, 150)); - assert_eq!(s.single(), Some((0, rect(100, 100, 200, 150)))); - } - - #[test] - fn a_span_from_the_laptop_up_onto_the_monitor() { - let (laptop, monitor) = layout(); - let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))]; - let s = span(p(100.0, 500.0), p(600.0, -300.0), &outputs).unwrap(); - assert_eq!(s.size, (500, 800)); - assert_eq!( - s.parts, - vec![ - Part { output: 0, rect: rect(100, 0, 500, 500), at: (0, 300), size: (500, 500) }, - Part { output: 1, rect: rect(377, 780, 500, 300), at: (0, 0), size: (500, 300) }, - ] - ); - assert_eq!(s.single(), None); - // The parts meet without a gap or an overlap. - assert_eq!(s.parts[1].at.1 + s.parts[1].size.1, s.parts[0].at.1); - } - - #[test] - fn a_span_over_a_gap_keeps_the_whole_box() { - let (laptop, monitor) = layout(); - let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))]; - let s = span(p(-200.0, -200.0), p(300.0, 300.0), &outputs).unwrap(); - assert_eq!(s.size, (500, 500)); - assert_eq!( - s.parts, - vec![ - Part { output: 0, rect: rect(0, 0, 300, 300), at: (200, 200), size: (300, 300) }, - Part { output: 1, rect: rect(77, 880, 500, 200), at: (0, 0), size: (500, 200) }, - ] - ); - // One output touched, but the box reaches past it: not a plain crop. - let corner = span(p(-50.0, 100.0), p(200.0, 300.0), &outputs).unwrap(); - assert_eq!(corner.parts.len(), 1); - assert_eq!(corner.single(), None); - } - - #[test] - fn a_span_takes_the_largest_scale() { - let a = out("A", 0, 0, 100, 100); - let b = out("B", 100, 0, 100, 100); - let outputs = [(&a, (200, 200)), (&b, (100, 100))]; - let s = span(p(50.0, 10.0), p(150.0, 60.0), &outputs).unwrap(); - assert_eq!(s.size, (200, 100)); - assert_eq!( - s.parts, - vec![ - Part { output: 0, rect: rect(100, 20, 100, 100), at: (0, 0), size: (100, 100) }, - Part { output: 1, rect: rect(0, 10, 50, 50), at: (100, 0), size: (100, 100) }, - ] - ); - } - - #[test] - fn a_tiny_span_is_a_click() { - let (laptop, monitor) = layout(); - let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))]; - assert_eq!(span(p(10.0, 10.0), p(11.0, 30.0), &outputs), None); - } - - #[test] - fn locate_finds_the_output_or_the_nearest() { - let (laptop, monitor) = layout(); - let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))]; - assert_eq!(locate(p(500.0, 500.0), &outputs), 0); - assert_eq!(locate(p(500.0, -500.0), &outputs), 1); - // Left of the laptop, under the monitor: the laptop is 100 px away. - assert_eq!(locate(p(-100.0, 300.0), &outputs), 0); - } -``` - -- [ ] **Step 2: Run them to verify they fail** - -Run: `cargo nextest run -E 'test(span) | test(locate)'` -Expected: compile errors, `cannot find function span` / `locate`, `cannot find struct Part`. - -- [ ] **Step 3: Implement** (after `pub fn resolve`) - -```rust -/// One output's share of a selection and where it goes in the whole image. -#[derive(Debug, Clone, PartialEq)] -pub struct Part { - pub output: usize, - /// In that output's frame, physical pixels. - pub rect: PixelRect, - /// Its top-left corner and size in the whole image. - pub at: (u32, u32), - pub size: (u32, u32), -} - -/// A selection over one or more outputs: its parts and the whole image size. -#[derive(Debug, Clone, PartialEq)] -pub struct Span { - pub parts: Vec, - pub size: (u32, u32), -} - -impl Span { - /// The output and rectangle when the selection is a plain crop of one - /// output (today's region shot); `None` when it needs stitching. - pub fn single(&self) -> Option<(usize, PixelRect)> { - match self.parts.as_slice() { - [p] if p.at == (0, 0) && p.size == self.size && (p.rect.width, p.rect.height) == self.size => { - Some((p.output, p.rect)) - } - _ => None, - } - } -} - -/// The drag from `a` to `b` over `outputs` (each output's geometry and frame -/// size), at the largest scale among the outputs it covers. `None` for a -/// click (under [`MIN_PIXELS`]) or a rectangle over no output. -pub fn span(a: Point, b: Point, outputs: &[(&OutputGeom, (u32, u32))]) -> Option { - let (x0, y0) = (a.x.min(b.x), a.y.min(b.y)); - let (x1, y1) = (a.x.max(b.x), a.y.max(b.y)); - let covered: Vec<(usize, PixelRect, f64)> = outputs - .iter() - .enumerate() - .filter_map(|(i, (g, (w, h)))| { - let r = clip(a, b, g, *w, *h)?; - Some((i, r, *w as f64 / g.width as f64)) - }) - .collect(); - let scale = covered.iter().map(|c| c.2).fold(0.0, f64::max); - if scale == 0.0 { - return None; - } - let size = (((x1 - x0) * scale).ceil() as u32, ((y1 - y0) * scale).ceil() as u32); - if size.0 < MIN_PIXELS || size.1 < MIN_PIXELS { - return None; - } - let parts = covered - .into_iter() - .map(|(i, rect, own)| { - let g = outputs[i].0; - let at = ( - ((x0.max(g.x as f64) - x0) * scale).floor() as u32, - ((y0.max(g.y as f64) - y0) * scale).floor() as u32, - ); - let k = scale / own; - let part = ( - ((rect.width as f64 * k).round() as u32).min(size.0 - at.0.min(size.0)), - ((rect.height as f64 * k).round() as u32).min(size.1 - at.1.min(size.1)), - ); - Part { output: i, rect, at, size: part } - }) - .collect(); - Some(Span { parts, size }) -} - -/// The output a global point is over, or the nearest one (`outputs` is never -/// empty: there is one surface per output). -pub fn locate(p: Point, outputs: &[(&OutputGeom, (u32, u32))]) -> usize { - let distance = |g: &OutputGeom| { - let dx = (g.x as f64 - p.x).max(p.x - (g.x + g.width) as f64).max(0.0); - let dy = (g.y as f64 - p.y).max(p.y - (g.y + g.height) as f64).max(0.0); - dx * dx + dy * dy - }; - (0..outputs.len()) - .min_by(|&i, &j| distance(outputs[i].0).total_cmp(&distance(outputs[j].0))) - .unwrap_or(0) -} -``` - -- [ ] **Step 4: Run them to verify they pass** - -Run: `cargo fmt && cargo nextest run -E 'test(selection)'` -Expected: all selection tests pass (the existing ones and the 6 new ones). - -- [ ] **Step 5: Commit** - -```bash -jj commit -m "selection: span a rectangle over several outputs, locate a point" src/selection.rs -``` - -(`span`/`locate` are unused until Task 3: clippy's dead-code lint fails on this commit alone, like Task 1 of the plugin plan; Task 3 makes it clean.) - ---- - -### Task 2: `stitch` - -**Files:** -- Create: `src/stitch.rs` -- Modify: `src/main.rs` (`mod stitch;` in the module list, alphabetical) - -**Interfaces:** -- Consumes: Task 1's `Span`, `Part`; `frame::{Frame, OutputGeom}`, `Frame::to_rgba`. -- Produces: `pub fn stitch(frames: &[Frame], span: &Span) -> RgbaImage`. - -- [ ] **Step 1: Write the failing tests** (`src/stitch.rs`, tests first; the function body comes in Step 3) - -```rust -//! A selection over several outputs as one image. - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use image::RgbaImage; - - use super::*; - use crate::frame::{Frame, OutputGeom, PixelRect}; - use crate::selection::{Part, Span}; - - /// A frame of one colour, given as R, G, B (stored as B, G, R, X). - fn frame(w: u32, h: u32, rgb: [u8; 3]) -> Frame { - Frame { - output: OutputGeom { name: "X".into(), x: 0, y: 0, width: w as i32, height: h as i32 }, - pixels: Arc::new(RgbaImage::from_pixel(w, h, image::Rgba([rgb[2], rgb[1], rgb[0], 0]))), - } - } - - fn rect(x: u32, y: u32, width: u32, height: u32) -> PixelRect { - PixelRect { x, y, width, height } - } - - #[test] - fn parts_land_in_place_and_gaps_stay_clear() { - let frames = [frame(10, 10, [255, 0, 0]), frame(10, 10, [0, 0, 255])]; - let span = Span { - size: (8, 6), - parts: vec![ - Part { output: 0, rect: rect(0, 0, 4, 3), at: (4, 3), size: (4, 3) }, - Part { output: 1, rect: rect(2, 2, 8, 3), at: (0, 0), size: (8, 3) }, - ], - }; - let img = stitch(&frames, &span); - assert_eq!(img.dimensions(), (8, 6)); - assert_eq!(img.get_pixel(0, 0).0, [0, 0, 255, 255], "the monitor's part, opaque"); - assert_eq!(img.get_pixel(7, 2).0, [0, 0, 255, 255]); - assert_eq!(img.get_pixel(5, 4).0, [255, 0, 0, 255], "the laptop's part"); - assert_eq!(img.get_pixel(1, 4).0[3], 0, "no output there: transparent"); - } - - #[test] - fn a_smaller_scale_part_is_resized_to_its_place() { - let frames = [frame(4, 4, [0, 255, 0])]; - let span = Span { - size: (8, 8), - parts: vec![Part { output: 0, rect: rect(0, 0, 4, 4), at: (0, 0), size: (8, 8) }], - }; - let img = stitch(&frames, &span); - assert_eq!(img.get_pixel(7, 7).0, [0, 255, 0, 255], "filled to the corner"); - } -} -``` - -and add `mod stitch;` to `src/main.rs` between `mod stack;` and `mod theme;`. - -- [ ] **Step 2: Run them to verify they fail** - -Run: `cargo nextest run -E 'test(stitch)'` -Expected: compile error, `cannot find function stitch`. - -- [ ] **Step 3: Implement** (above `#[cfg(test)]` in `src/stitch.rs`) - -```rust -use image::{RgbaImage, imageops}; - -use crate::frame::Frame; -use crate::selection::Span; - -/// The whole selection: each output's part from its frozen frame at its -/// place (resized when its output's scale is smaller), transparent where -/// no output is. -pub fn stitch(frames: &[Frame], span: &Span) -> RgbaImage { - let mut image = RgbaImage::new(span.size.0, span.size.1); - for part in &span.parts { - let mut crop = frames[part.output].to_rgba(part.rect); - if crop.dimensions() != part.size { - crop = imageops::resize(&crop, part.size.0, part.size.1, imageops::FilterType::Triangle); - } - imageops::replace(&mut image, &crop, part.at.0 as i64, part.at.1 as i64); - } - image -} -``` - -- [ ] **Step 4: Run them to verify they pass** - -Run: `cargo fmt && cargo nextest run -E 'test(stitch)'` -Expected: 2 passed. - -- [ ] **Step 5: Commit** - -```bash -jj commit -m "stitch: a selection over several outputs as one image" src/stitch.rs src/main.rs -``` - ---- - -### Task 3: The overlay across outputs, `Choice::Span`, the capture - -**Files:** -- Modify: `src/region.rs` (`Choice`, `Overlay` fields, `Surface` fields, `selection_on`, new `label_on`/`outputs`/`redraw_changed`/`choose`, `pointer`, `apply_mods`, `redraw`; a `tests` module) -- Modify: `src/main.rs` (the `Mode::Region` arm) -- Modify: `docs/test-checklist.md` (a "Region across monitors" section) - -**Interfaces:** -- Consumes: Task 1's `span`, `locate`, `Span::single`; Task 2's `stitch`. -- Produces: `Choice::Span(selection::Span, usize)` (the span and the output the pointer was over at release, for the preview). - -- [ ] **Step 1: Write the failing tests** (append to `src/region.rs`) - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn out(name: &str, x: i32, y: i32, width: i32, height: i32) -> OutputGeom { - OutputGeom { name: name.into(), x, y, width, height } - } - - fn p(x: f64, y: f64) -> Point { - Point { x, y } - } - - #[test] - fn one_output_is_todays_region_and_several_a_span() { - let laptop = out("LVDS-1", 0, 0, 1366, 768); - let monitor = out("HDMI-A-1", -277, -1080, 1920, 1080); - let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))]; - assert_eq!( - choose(p(100.0, 100.0), p(300.0, 250.0), &outputs, p(300.0, 250.0)), - Some(Choice::Region( - 0, - PixelRect { x: 100, y: 100, width: 200, height: 150 } - )) - ); - let across = choose(p(100.0, 500.0), p(600.0, -300.0), &outputs, p(600.0, -300.0)); - let Some(Choice::Span(span, on)) = across else { - panic!("expected a span, got {across:?}"); - }; - assert_eq!((span.size, span.parts.len(), on), ((500, 800), 2, 1), "preview on the monitor"); - // A box touching only the laptop but reaching past its left edge. - assert!(matches!( - choose(p(-50.0, 100.0), p(200.0, 300.0), &outputs, p(200.0, 300.0)), - Some(Choice::Span(_, 0)) - )); - assert_eq!(choose(p(10.0, 10.0), p(11.0, 30.0), &outputs, p(11.0, 30.0)), None, "a click"); - } -} -``` - -- [ ] **Step 2: Run it to verify it fails** - -Run: `cargo nextest run -E 'test(one_output_is)'` -Expected: compile errors, `cannot find function choose`, no variant `Choice::Span`. - -- [ ] **Step 3: Implement** - -In `src/region.rs`: - -1. Imports: `use crate::frame::{OutputGeom, PixelRect};` (add `OutputGeom` to the existing `crate::frame` import). -2. `Choice` gains: - ```rust - /// A rectangle over several outputs (or past one's edge), with the - /// output the pointer was over at release (for the preview). - Span(selection::Span, usize), - ``` -3. `Overlay`: remove `anchor`; replace `pointer: Option<(usize, (f64, f64))>` with - ```rust - /// The pointer in global logical coordinates. A drag's events keep - /// arriving on the surface it started on, with positions past its - /// edges, so this stays right across outputs. - pointer: Option, - ``` - and drop `anchor: None,` from the constructor in `select`. -4. `Surface` gains (and `Surface::new` sets them to `None` / `false`): - ```rust - /// The selection part and whether the label were last drawn here. - shown: Option, - labelled: bool, - ``` -5. Replace `selection_on`, `redraw` and `apply_mods`'s body, and add the helpers: - ```rust - fn outputs(&self) -> Vec<(&OutputGeom, (u32, u32))> { - self.surfaces - .iter() - .map(|s| (&s.geom, (s.width, s.height))) - .collect() - } - - /// The selection's part on surface `i`. - fn selection_on(&self, i: usize) -> Option { - let (start, end) = self.selection.corners()?; - let s = &self.surfaces[i]; - selection::clip(start, end, &s.geom, s.width, s.height) - } - - /// The size label on surface `i`: the whole shot's size next to the - /// pointer, on the output the pointer is over. - fn label_on(&self, i: usize) -> Option<(PixelRect, (f64, f64))> { - let (start, end) = self.selection.corners()?; - let p = self.pointer?; - let outputs = self.outputs(); - if selection::locate(p, &outputs) != i { - return None; - } - let (width, height) = selection::span(start, end, &outputs)?.size; - let g = &self.surfaces[i].geom; - Some(( - PixelRect { x: 0, y: 0, width, height }, - (p.x - g.x as f64, p.y - g.y as f64), - )) - } - - fn redraw(&mut self, i: usize, qh: &QueueHandle) { - let sel = self.selection_on(i); - let label = self.label_on(i); - let s = &mut self.surfaces[i]; - s.shown = sel; - s.labelled = label.is_some(); - s.dirty = true; - if s.configured && !s.frame_pending { - s.draw(sel, label, qh); - } - } - - /// Redraws the surfaces whose part of the selection or label changed. - fn redraw_changed(&mut self, qh: &QueueHandle) { - for i in 0..self.surfaces.len() { - let s = &self.surfaces[i]; - if self.selection_on(i) != s.shown || s.labelled || self.label_on(i).is_some() { - self.redraw(i, qh); - } - } - } - ``` - `apply_mods` becomes `self.selection.set_mods(self.mods); self.redraw_changed(qh);`. -6. Add the pure decision (a free function above `impl Surface`): - ```rust - /// What a finished drag from `a` to `b` captures: one output's crop as - /// before, or the whole rectangle to stitch; `None` for a click. - fn choose( - a: Point, - b: Point, - outputs: &[(&OutputGeom, (u32, u32))], - pointer: Point, - ) -> Option { - let span = selection::span(a, b, outputs)?; - Some(match span.single() { - Some((i, rect)) => Choice::Region(i, rect), - None => Choice::Span(span, selection::locate(pointer, outputs)), - }) - } - ``` -7. `pointer()`: after computing `p`, set `self.pointer = Some(p);` (instead of the old `(i, position)`). `Press(BTN_LEFT)`: `self.selection.press_with(p, self.mods); self.redraw_changed(qh);`. `Motion`: `self.selection.motion(p); self.redraw_changed(qh);`. `Release(BTN_LEFT)`: - ```rust - let Some((start, end)) = self.selection.release(p) else { - continue; - }; - let choice = choose(start, end, &self.outputs(), p); - match choice { - Some(choice) => { - tracing::info!("selected {choice:?} (logical {start:?} to {end:?})"); - self.outcome = Some(Some(choice)); - } - // A click, not a drag: clear it and keep waiting. - None => self.redraw_changed(qh), - } - ``` - (`i` from `index_of` is still needed for the geometry of the event's surface.) - -8. In `src/selection.rs`, remove `resolve` and its test (its only caller was the old release code; the click threshold now lives in `span`, tested by `a_tiny_span_is_a_click`). Update the `MIN_PIXELS` doc comment if it names `resolve`. - -In `src/main.rs`, the `Mode::Region` arm gets: -```rust - region::Choice::Span(span, on) => { - let source = frames[on].output.name.clone(); - (vec![(stitch::stitch(&frames, &span), None)], 0, source) - } -``` - -In `docs/test-checklist.md`, add after the Polish section's items (before its "Known minors"): -```markdown -- [ ] Region across monitors: drag from the laptop up onto the monitor and back; each screen shows its part, the label shows the whole size on the screen under the pointer; Shift, Alt and Space work across the edge; the PNG is the whole rectangle, seams exact, parts over no screen transparent; the preview appears on the screen where you let go; a box on one screen is exactly as before. -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cargo fmt && cargo nextest run && cargo clippy --all-targets -- -D warnings` -Expected: the whole suite passes (the new region test included); clippy clean (Tasks 1–2's functions are now used). - -- [ ] **Step 5: Commit** - -```bash -jj commit -m "region: a selection across monitors, stitched into one image" src/region.rs src/main.rs docs/test-checklist.md -``` diff --git a/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md b/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md deleted file mode 100644 index 40233d5..0000000 --- a/docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md +++ /dev/null @@ -1,517 +0,0 @@ -# valw: Phase 0 + 1 design - -Date: 2026-09-29 -Status: approved in brainstorming, pending written-spec review - -## 1. Context - -valw is a macOS-style (Cmd+Shift+3/4/5) screenshot tool for niri on NixOS, -written in Rust. The full product vision (preview thumbnail, editor, zoom mode, -toolbar) is described in the user's original spec -(`wayland_screenshot_tool_spec.md`, where the project was called `snapr`). -That vision is split into six phases; each phase gets its own -design → plan → implementation cycle. - -This document covers **Phase 0 (discovery + scaffolding)** and -**Phase 1 (core capture)** only. - -### Goals - -- A dendritic Nix flake that builds valw and provides a devShell. -- `valw screen` and `valw region` capture pixel-exact PNGs, save them and copy - them to the clipboard. -- Errors explain what happened, why, and what to do next, and every run leaves - a log good enough to debug from. -- `valw doctor` reports what the running compositor supports. - -### Non-goals (later phases) - -- Preview thumbnail, desktop notifications, `--no-preview` (Phase 2) -- Window mode, Shift/Alt/Space modifiers, size indicator, fonts (Phase 3) -- Zoom mode (Phase 4) -- Editor (Phase 5) -- Toolbar, drag and drop, home-manager module, README (Phase 6) -- `ext-image-copy-capture-v1` backend (not offered by niri 26.04) - -## 2. Verified environment - -Checked on the user's machine on 2026-09-29: - -| Item | Value | -|---|---| -| niri | 26.04 (nixpkgs) | -| `zwlr_screencopy_manager_v1` | v3 | -| `ext_image_copy_capture_*` | **not offered** | -| `zwlr_layer_shell_v1` | v5 | -| `wp_fractional_scale_manager_v1` | v1 | -| `wp_viewporter` | v1 | -| `wp_cursor_shape_manager_v1` | v2 | -| `zxdg_output_manager_v1` | v3 | -| `wl_output` | v4 | -| `wl_seat` / `wl_shm` | v9 / v2 | -| `ext_data_control_manager_v1` / `zwlr_data_control_manager_v1` | v1 / v2 | -| Outputs | LVDS-1 1366×768 @ scale 1, HDMI-A-1 1920×1080 @ scale 1 | -| Shell | Noctalia | -| jj | 0.45.1, repo is **non-colocated** (no `.git` at the root) | -| direnv | 2.37.1 with nix-direnv (home-manager) | - -Consequences: - -- Screen capture uses wlr-screencopy only. The `Capturer` trait stays so a - second backend can be added later, but only one implementation is written now. -- Fractional scaling must be tested by temporarily setting a scale - (`niri msg output HDMI-A-1 scale 1.25`). - -## 3. Conventions - -- Code, comments, error messages, logs and docs are in **English**. Comments - are plain and short; they explain why, not what. -- Version control is jj. The repo is non-colocated, so Nix treats it as a - `path:` flake. -- Rust toolchain: stable from nixpkgs (no rust-overlay/fenix). -- Test runner: `cargo nextest`. Lints: `cargo clippy -- -D warnings`. - -## 4. Repository layout - -``` -.envrc -.gitignore -flake.nix # flake-parts; auto-discovers nix/**/*.ulu.nix -flake.lock -nix/ - package.ulu.nix # perSystem.packages.default (buildRustPackage) - devshell.ulu.nix # perSystem.devShells.default - formatter.ulu.nix # nixfmt-tree, same as copland - checks.ulu.nix # perSystem.checks: clippy, fmt, nextest, headless sway -Cargo.toml -Cargo.lock -src/ - main.rs # clap CLI, subcommand dispatch - config.rs # config file loading - error.rs # user-facing error type and rendering - log.rs # tracing setup, per-run log files - lock.rs # single-instance lock - detach.rs # fork a background child (clipboard server) - wayland.rs # SCTK state: registry, outputs, seat, shm - frame.rs # Frame type, pixel format conversion, transforms - capture.rs # Capturer trait + wlr-screencopy implementation - niri.rs # niri IPC wrapper (focused output, version) - selection.rs # selection state machine, logical → pixel math - region.rs # region selection overlay (layer-shell surfaces) - render.rs # overlay pixels: dim, paste selection, outline - output.rs # PNG saving, filename template, clipboard - doctor.rs # `valw doctor` -docs/ -``` - -Files stay flat for now; `overlay/` and `editor/` subdirectories appear once -Phases 3 to 5 give them enough content. Pure logic (`frame`, `selection`, -`render`, `output`, `config`, `error`) is kept apart from Wayland code so it -can be unit tested without a compositor. - -### 4.1 Nix - -`flake.nix` follows copland's pattern (flake-parts + automatic discovery), but -discovery is limited to `./nix` so `src/` is not walked: - -```nix -imports = filter (hasSuffix ".ulu.nix") (listFilesRecursive ./nix); -``` - -Systems: `x86_64-linux`, `aarch64-linux`. - -`package.ulu.nix`: - -- `pkgs.rustPlatform.buildRustPackage`, `cargoLock.lockFile = ./Cargo.lock`. -- `src` is filtered with `lib.cleanSourceWith` to drop `target`, `result`, - `.jj` and `.direnv`. -- `nativeBuildInputs`: `pkg-config`. `buildInputs`: `wayland`, - `libxkbcommon`. -- `meta.mainProgram = "valw"`. -- `doCheck = false`; tests run through `checks` instead. - -`devshell.ulu.nix`: `rustc`, `cargo`, `clippy`, `rustfmt`, `rust-analyzer`, -`cargo-nextest`, `pkg-config`, `wayland`, `libxkbcommon`, `wayland-utils`, -`grim`, `imagemagick` (the last three for manual verification). It sets: - -```sh -CARGO_TARGET_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/valw/target" -``` - -Because the repo has no `.git`, Nix copies the whole directory into the store -on every evaluation and ignores `.gitignore`. Keeping `target/` out of the tree -keeps that copy small. - -`checks.ulu.nix` provides `clippy`, `fmt`, `nextest` and `headless` -(section 10.2). - -### 4.2 direnv - -`.envrc`: - -```sh -watch_file nix/*.ulu.nix -use flake -``` - -nix-direnv only watches `flake.nix` and `flake.lock` by default; the devShell -lives in `nix/devshell.ulu.nix`, so it must be watched explicitly. - -### 4.3 .gitignore - -Whitelist style: - -```gitignore -/* -!/.gitignore -!/.envrc -!/flake.nix -!/flake.lock -!/Cargo.toml -!/Cargo.lock -!/nix/ -!/src/ -!/docs/ -!/README.md -``` - -Any new top-level file must be added here. Otherwise jj silently ignores it, -while the `path:` flake still sees it: the build works locally but the commit -is incomplete. - -## 5. Dependencies - -| Purpose | Crate | -|---|---| -| Wayland client | `smithay-client-toolkit`, `wayland-client`, `wayland-protocols`, `wayland-protocols-wlr` | -| PNG | `image` (png feature only) | -| Clipboard | `wl-clipboard-rs` | -| niri IPC | `niri-ipc` (`=26.4.0`, matches niri 26.04) | -| CLI | `clap` (derive) | -| Config | `serde`, `toml`, `serde_ignored` | -| Time / filenames | `chrono` | -| Errors | `anyhow` | -| Logging | `tracing`, `tracing-subscriber` | -| Lock, poll, stdio | `rustix` | -| fork | `libc` | - -Not needed in Phase 1: `tiny-skia` (the overlay is plain byte copies; shapes -and text arrive in Phase 3) and `tracing-appender` (one file per run needs no -rolling writer). - -Dependencies are built with `opt-level = 3` in the dev profile; unoptimised -PNG encoding is too slow to test with. - -## 6. Capture pipeline - -Every capture command runs these steps in order. - -1. **Log setup** (section 9.2). -2. **Lock.** Take a non-blocking `flock` on `$XDG_RUNTIME_DIR/valw.lock`. If it - is held, fail with "valw is already running" (exit 1). A second press of the - hotkey is ignored rather than queued. The kernel releases the lock if the - process dies. -3. **Delay.** Sleep for `--delay ` if given. -4. **Connect.** Bind the registry with SCTK and collect outputs (name, logical - position and size via xdg-output, physical size, scale), seat, shm, - screencopy and layer-shell. A missing required global is an error that - names the protocol. -5. **Capture.** `Capturer::capture(&[Output], cursor: bool) -> Vec` - issues one screencopy request per output in parallel and waits up to 5 s - for every `ready`. A `Frame` holds upright pixels at physical resolution - in **B, G, R, X byte order** (XRGB8888 in memory, which is what niri - delivers), plus its output name and logical geometry; the scale is - `frame width / logical width`. `XRGB8888` and `ARGB8888` are copied - as-is, `XBGR8888` and `ABGR8888` get R and B swapped, `y_invert` is - honoured, and any other format is an error that names the format. - Conversion to RGBA happens only for what is saved (`Frame::to_rgba`, - after the overlay has closed), and alpha is then always 255, even where - the compositor's buffer says 0. `cursor` comes from `--cursor` or - `capture.show_cursor`. - - **Why BGRX:** on the user's machine a full 1080p pass over the pixels - costs 5–10 ms. Converting to RGBA up front and back to ARGB for the - overlay put the first overlay frame at 270 ms. Keeping the native - order brought it to about 40 ms (headless 1080p, warm). - - **niri quirk (verified in its source):** niri rejects a shm buffer - with `invalid buffer` unless the whole `wl_shm_pool` is exactly the - buffer's size. Every capture therefore gets its own pool. - - **Rotated outputs:** the buffer arrives in the output's native - orientation. It is turned upright the way grim does it: y-invert, - rotate clockwise by the transform's angle, then mirror if flipped. - -### 6.1 `valw screen` - -- Default: ask niri IPC for the focused output (`FocusedOutput`), capture only - that output, save it and copy it to the clipboard. -- `--all`: capture every output and save one file per output, with the output - name appended to the filename (`... (HDMI-A-1).png`). Only the focused - output goes to the clipboard. -- If niri IPC is unreachable (another compositor), log a warning and use the - first output. - -## 7. `valw region` - -### 7.1 Surfaces - -Capture all outputs first (section 6), then open one layer-shell surface per -output: - -- namespace `valw-overlay`, layer `Overlay`, anchored to all four edges, - `exclusive_zone = -1`, `keyboard_interactivity = Exclusive` -- buffer format XRGB8888, the frame's own byte order, so nothing is - converted and alpha is ignored -- background: that output's frozen frame, darkened by 40% (computed once) -- the selection area is copied from the undarkened frame (shared, not - copied) and outlined with a 1 physical pixel white border -- cursor shape: crosshair (`wp_cursor_shape`) - -Surfaces are owned by the process, so the compositor removes them if valw -dies. No overlay can outlive the process. - -### 7.2 HiDPI - -- Each surface's buffer is the frozen frame's size (physical pixels), and - `wp_viewporter` sets the destination to the logical size from the layer - configure. The frame is shown 1:1 with no resampling. The frame already - says how many physical pixels the output has, so `wp_fractional_scale` is - not needed. -- Pointer events arrive in surface-local logical coordinates. They are - converted to global logical coordinates by adding the output's logical - position; the selection is stored in global logical coordinates. - -### 7.3 Selection state machine - -``` -Idle --press--> Dragging{start} --release--> Done(rect) - ^ | - | +--release with size < 2 physical px--> Idle - +--Esc / right click (any state)--> Cancelled -``` - -- While `Dragging`, pointer motion updates the rectangle. Redraws are - throttled with frame callbacks. The darkened base pixmap is computed once; - each redraw copies it and pastes the bright selection on top. -- On release, the rectangle is clipped to the output where the drag started - (v1 rule: one output per capture). -- Cancel closes all surfaces and exits with code 3 without saving anything. - -### 7.4 Logical to physical conversion - -For an output at logical position `(ox, oy)` with scale `s` (per axis: -frame size / logical size) and a selection `(x, y, w, h)` in global logical -coordinates: - -``` -x0 = floor((x - ox) * s) y0 = floor((y - oy) * s) -x1 = ceil((x + w - ox) * s) y1 = ceil((y + h - oy) * s) -``` - -The result is clamped to the frame bounds and cropped from the in-memory -frame. The output always matches the frozen image the user saw, even if the -screen changed during selection. - -## 8. Output, config and CLI - -### 8.1 Config - -Path: `$XDG_CONFIG_HOME/valw/config.toml`. Phase 1 reads: - -```toml -[save] -directory = "~/Pictures/Screenshots" -filename = "Screenshot %Y-%m-%d at %H.%M.%S.png" -copy_to_clipboard = true - -[capture] -show_cursor = false -``` - -- A missing file means defaults (the values above). -- Invalid TOML is an error that includes the line number (exit 1). -- Unknown keys (e.g. `[preview]`, which later phases read) print a warning - through `serde_ignored` and are otherwise ignored. -- `~` and `$HOME` in `directory` are expanded. The directory is created if - missing. - -### 8.2 Saving - -- The filename is rendered with `chrono` strftime. If the file exists, - ` (2)`, ` (3)`, … is inserted before the extension. -- PNGs are written to a temporary file in the target directory and then - renamed, so a partial file never appears. -- Each saved path is printed to stdout on its own line. - -### 8.3 Clipboard - -A Wayland clipboard needs a process that stays alive to answer paste -requests. `wl-clipboard-rs` does **not** fork: its background mode serves from -a thread, which dies when valw exits. So valw does what `wl-copy` does: - -1. `Options::foreground(true).prepare_copy(...)` offers the PNG as - `image/png` (no threads are started). -2. `detach::spawn` releases the capture lock, then forks. The child calls - `setsid`, points stdin/stdout/stderr at `/dev/null`, serves paste requests - until something else is copied, and exits with `_exit`. -3. The parent exits right away. - -The lock is released before the fork because the child would otherwise -inherit it and block the next capture. stdio goes to `/dev/null` because an -inherited stdout pipe would keep `valw region -o - | consumer` waiting until -the clipboard changes. A unit test covers both, and the headless test runs -`screen -o -` through a pipe. - -### 8.4 CLI - -``` -valw screen [--all] [COMMON] -valw region [COMMON] -valw doctor - -COMMON: - --clipboard-only copy to clipboard, do not save a file - -o, --output write to this path instead of directory + template; - "-" writes PNG to stdout - --delay wait before capturing (non-negative, fractions allowed) - --cursor include the cursor in the capture -``` - -Flag interactions (enforced by clap, so they are usage errors, exit 2): - -- `--clipboard-only` conflicts with `--output`. -- `--all` conflicts with `--output` and `--clipboard-only`. -- Otherwise the clipboard follows `save.copy_to_clipboard`, whatever the - output target (including `--output -`). -- `--clipboard-only` copies even if `save.copy_to_clipboard = false`. - -### 8.5 Exit codes - -| Code | Meaning | -|---|---| -| 0 | success | -| 1 | error (missing protocol, lock busy, I/O, …) | -| 2 | usage error (clap) | -| 3 | cancelled by the user | - -## 9. Errors and diagnostics - -### 9.1 User-facing errors - -Every error shown to the user has three parts: - -``` -error: could not capture output HDMI-A-1 - cause: compositor offered only XBGR2101010, which valw can't convert yet - hint: run `valw doctor` and include its output in a bug report - log: ~/.local/state/valw/logs/2026-09-29T19-40-12.log -``` - -- `error` is the top-level `anyhow` context; `cause` lines are the rest of - the context chain. -- `hint` is attached with a small extension trait (`.hint("...")`). Hints are - added only where there is a concrete action; no generic "something went - wrong" hints. -- `log` points at this run's log file. - -### 9.2 Per-run logs - -- `tracing` writes one file per run to `$XDG_STATE_HOME/valw/logs/`; the 10 - most recent are kept. -- Each log starts with an environment summary: valw version and arguments, - niri version, all globals and their versions (one line), outputs (name, - position, size, transform). Each capture logs the buffer formats offered - and the one used, and how long capturing took. -- `region` logs the time from process start to the first overlay commit, and - the selected rectangle in both logical and pixel coordinates. -- The default level is `info`; `VALW_LOG=debug` (EnvFilter syntax) raises it. -- stderr gets warnings from tracing (with colour only on a terminal) and - errors in the three-part format above. Processes spawned by niri log stderr - to niri's journal (`journalctl --user -u niri`), but the log file is the - primary record. -- If the log directory can't be written, valw keeps working and logs to - stderr only; a broken log dir must not block a screenshot. - -### 9.3 Panics - -A panic hook writes the panic and its backtrace to the log and prints the -same three-part format to stderr, with "this is a bug in valw" as the hint. - -### 9.4 `valw doctor` - -Prints one `ok` / `warn` / `fail` line per check and exits 1 if any check -fails: - -- required globals present with a sufficient version (compositor, shm, seat, - xdg-output v2, layer-shell, screencopy, viewporter); optional ones warn - when missing (cursor-shape, ext/wlr data-control) -- outputs with name, logical size, position and transform -- niri IPC reachable, and niri's version compatible with the pinned `niri-ipc` -- config file parses -- save directory is writable - -Phase 0's discovery report is produced by this command. Later phases add -checks. - -Desktop notifications for errors come in Phase 2. - -## 10. Testing - -### 10.1 Unit tests (`cargo nextest`) - -Wayland-independent logic lives in pure functions: - -- logical to physical conversion at scales 1, 1.25 and 1.5, negative output - offsets, clamping to output bounds -- selection state machine: drag, sub-2px click, Esc, right click, drag that - crosses into another output -- pixel format conversion for the four formats, `y_invert`, unsupported - format error -- filename template, collision suffix, `--output -` -- config: defaults when missing, unknown key warning, invalid TOML line number -- lock: acquire, busy error; forked child holds neither the lock nor stdout -- output transforms (all eight) against grim's rules -- error rendering: exact text of the three-part output -- `niri-ipc` pin in Cargo.toml matches `niri::IPC_VERSION` - -### 10.2 Headless integration test (`nix flake check`) - -A check starts **sway** (`sway-unwrapped`; the wrapped one needs D-Bus) with -`WLR_BACKENDS=headless` and `WLR_RENDERER=pixman` inside the build sandbox, -then: - -- runs `valw doctor` and asserts no `fail` lines -- runs `valw screen -o - | cat` under a 10 s timeout (so a clipboard child - holding the pipe fails the check) and asserts the PNG is 1280×720, the - headless output's size - -This exercises the real screencopy path on every build. It runs on wlroots, -not niri. `region` is not covered because it needs synthetic pointer input. - -### 10.3 Manual checks on niri (end of each phase) - -- **Pixel exactness:** capture a region with `valw region` and the same - geometry with `grim -g ""`; `magick compare -metric AE` must report 0. - Repeat at scale 1.25 (`niri msg output HDMI-A-1 scale 1.25`) and on both - outputs. -- **Latency:** the log records time from process start to the first overlay - commit; target under 150 ms. -- **No stuck overlay:** `kill -9` during selection; the overlay disappears and - the next run gets the lock. -- **Noctalia:** the overlay covers the bar. -- **Rotated output:** `niri msg output HDMI-A-1 transform 90`, then - `valw screen` and `grim -o HDMI-A-1` must match pixel for pixel. - -## 11. Phase 0 exit criteria - -- `nix build`, `nix develop` and direnv work. -- `nix build` does not copy `target/` (verified by store path size). -- A minimal layer-shell surface opens and closes on niri. -- The pinned `niri-ipc` version talks to niri 26.04's socket. -- wl-clipboard-rs fork behaviour and its interaction with the lock are - confirmed. -- `valw doctor` produces its first report. - -## 12. Definition of done (every phase) - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` and -`nix build` pass, and the manual checks in 10.3 that apply to the phase are -done on niri. diff --git a/docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md b/docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md deleted file mode 100644 index eb0525a..0000000 --- a/docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md +++ /dev/null @@ -1,310 +0,0 @@ -# valw: Phase 2 design (floating preview) - -Date: 2026-09-30 -Status: approved in brainstorming, pending written-spec review -Builds on: `2026-09-29-valw-phase-0-1-design.md` (Phase 0+1, shipped as `main`) - -## 1. Context and goal - -After a capture, a small thumbnail slides in at the bottom-right corner of -the monitor, like macOS's floating screenshot thumbnail. Clicking it opens -the screenshot in Satty; swiping it right dismisses it; otherwise it slides -away after a few seconds. - -Phase 2 is deliberately minimal. Once it ships, valw is wired into copland -and the user runs it as their only screenshot tool for at least a day. The -bugs and gaps found then decide what comes next. - -### In scope - -- Floating thumbnail per capture, stacking up to 5. -- Click → Satty; swipe right → dismiss; timeout → dismiss. -- Thumbnails never appear in screenshots and never take keyboard focus. -- `[preview]` config (`enabled`, `timeout_secs`) and `--no-preview`. -- Satty on valw's PATH through the Nix package. -- Wiring valw into copland for the test day (section 9). - -### Out of scope (candidates after the test day) - -- Hover pauses the timer. -- Notifications (error notifications, preview-off fallback). -- Right-click menu, drag-and-drop out of the thumbnail. -- Rounded corners, shadow, configurable corner or size. -- Configurable editor command; valw's own editor (Phase 5). -- home-manager / NixOS modules (end of the project). - -## 2. Decisions - -| Question | Decision | Why | -|---|---|---| -| When is the file written? | At capture time, as in Phase 1. The preview is only a shortcut. | Nothing is lost if the preview crashes or the session ends. | -| Next capture while a preview is visible | Previews **stack** (max 5, oldest evicted). | User choice. | -| Who owns the stack? | One on-demand **preview host** process. | A single owner avoids position races between processes. | -| Editing | Satty, hardcoded: `satty --filename --output-filename `. | Satty 0.22.0 has both flags; saving overwrites the screenshot. | - -## 3. Preview host - -### 3.1 Startup - -After a capture has saved its file and set up the clipboard, and if the -preview is enabled (section 7): - -1. Connect to `$XDG_RUNTIME_DIR/valw.sock`. -2. If that fails, start a host: `std::process::Command` on - `/proc/self/exe` with the hidden subcommand `__preview-host`, `setsid` in - `pre_exec`, stdio to `/dev/null`. A fresh exec inherits no Wayland - connection, lock or threads from the capture process. -3. Retry connecting every 20 ms for up to 1 s. If it still fails, log a - warning; the capture itself has already succeeded. - -A new host takes `$XDG_RUNTIME_DIR/valw-preview.lock` (the existing `Lock` -type) before touching the socket. If the lock is busy, another host is -starting or running, and this one exits 0 without output. Holding the lock -makes it safe to delete a stale socket file (left by a crash) before -binding. - -### 3.2 Protocol - -Unix stream socket, one JSON object per line in each direction -(`serde_json`). Every request gets exactly one reply. - -| Request | Host action | Reply | -|---|---|---| -| `{"cmd":"hide"}` | Mark this connection as hiding; if this is the first, hide all thumbnails (4.6); Wayland round trip so the compositor has processed it | `{"ok":true}` | -| `{"cmd":"add","path":"…","output":"HDMI-A-1"}` | Load and downscale the PNG, add a thumbnail on that output | `{"ok":true}` | - -Any malformed or unknown request gets `{"ok":false,"error":"…"}` and the -connection stays open. - -A connection that sent `hide` stops hiding when it **closes**, for any -reason: normal end, Esc, an error, or the process being killed. There is no -explicit `show` message. Thumbnails are visible only while no connection is -hiding. - -### 3.3 Lifetime - -The host exits when its stack is empty and no client connection is open. It -removes the socket file on the way out. If it crashes, the compositor -removes its surfaces and the next capture starts a new host. - -The host writes its own per-run log (`log::init`), like every valw process. - -### 3.4 Event loop - -calloop (SCTK's default `calloop` feature, `WaylandSource`), one loop for: - -- the Wayland connection, -- the listening socket and every client connection (line-buffered), -- a timer per thumbnail (`timeout_secs`), -- animation, paced by frame callbacks. - -After each dispatch the loop drops finished thumbnails, restacks the rest -and checks the exit rule. A new host that nobody connects to within 2 s -exits (the capture that started it died). - -## 4. Thumbnail - -### 4.1 Surface - -One layer-shell surface per thumbnail: - -- namespace `valw-preview`, layer `Overlay`, anchored bottom + right, -- `keyboard_interactivity = None`: the compositor never gives it keyboard - focus, -- input region = the image rectangle only (transparent padding lets clicks - through), -- buffer at the output's physical scale, `wp_viewporter` to logical size - (same approach as the region overlay). - -### 4.2 Size and look - -- Long edge 220 logical px, aspect ratio kept. Images smaller than that are - not upscaled. -- Downscaled once on `add` with `image::imageops::resize` (CatmullRom). -- 1 px light border (`#e0e0e0` at 80% opacity). No rounded corners or - shadow in Phase 2. - -### 4.3 Stack layout - -- Newest thumbnail in the corner: the image sits 16 px from the right and - bottom screen edges. (The surface itself touches the right edge, see 4.4; - the 16 px is padding inside it, outside the input region.) -- Older thumbnails above it, 12 px apart. -- When one closes, those above it move down (no animation). -- A 6th thumbnail evicts the oldest (slides out as on timeout). -- Stacks are per output. - -### 4.4 Animation - -- Slide in from the right edge: 200 ms, ease-out cubic. -- Slide out to the right: 150 ms, ease-in cubic. -- Implemented by moving the image's x offset inside the surface (the surface - reaches the screen edge; its right margin is 0). The layer position never - changes during an animation. - -### 4.5 Input - -- **Click:** press and release with less than 4 px of movement. Spawns - `satty --filename --output-filename ` (detached, stdio to - `/dev/null`) and slides the thumbnail out. A failed spawn is logged. -- **Swipe:** press, then horizontal drag moves the image with the pointer - (right only; leftward drag is clamped to 0). On release, if the offset is - at least 40% of the image width, it slides out; otherwise it animates - back. -- **Timeout:** after `timeout_secs`, slide out. The timer keeps running - during a drag; if it fires mid-drag, the thumbnail closes on release. - It also keeps running while thumbnails are hidden for a capture: after a - long region selection, older thumbnails may already be gone. Simplest - rule; revisit after the test day if it annoys. - -### 4.6 Hiding - -A hidden thumbnail stays mapped: it commits a fully transparent buffer and -an empty input region, so it is invisible in screenshots and clicks pass -through. Showing it again restores the image and the input region. - -Unmapping (attaching no buffer) is not used. Verified on niri 26.04 while -prototyping: after an unmap, a commit without a buffer gets no new -configure, so the thumbnail never came back and the host never exited. - -Hide and show are drawn immediately, even while a frame callback is -pending mid-animation; otherwise a moving thumbnail could still be on -screen when the capture starts. - -## 5. Capture integration - -In `main.rs`'s capture flow: - -1. After the lock and `--delay`, **immediately before screencopy**, open a - `HideGuard`: connect to the host socket if it exists, send `hide`, wait - up to 300 ms for the reply. No host: no-op. Timeout: log a warning and - carry on. -2. Keep the guard (and its connection) alive through region selection, so - thumbnails can't sit above the overlay or take its clicks. -3. After `deliver` has written the file: if a preview applies (5.1), send - `add` over the guard's connection, or, without a running host, start one - (3.1) and send `add` there. -4. Drop the guard: the connection closes and the stack shows again, the new - thumbnail sliding in. This must happen **before** the clipboard fork: the - forked clipboard server would otherwise inherit the connection and keep - the thumbnails hidden until the clipboard changes. - -`deliver` is split accordingly: `save` writes the files and returns the path -to preview; the clipboard copy happens after the guard is dropped. - -Cost: one extra round trip before screencopy, only when a host is running. - -### 5.1 When a preview is shown - -- Only when a file was written: default save location or `-o PATH` to a - regular file. -- Not for `-o -` (stdout), `--clipboard-only`, or `-o` onto a device or - FIFO. -- `--all`: one preview, for the focused output's file (the same one that - goes to the clipboard). -- Target output: `region` → the anchor output; `screen` → the captured - (focused) output; `--all` → the focused output. - -## 6. Code layout - -Flat files, pure logic kept separate: - -| File | Responsibility | -|---|---| -| `src/stack.rs` | Pure: stack positions, eviction, swipe/click decisions, easing, thumbnail size | -| `src/ipc.rs` | Message types (serde), `HideGuard`, `add`, starting the host | -| `src/host.rs` | Host entry point, lock, socket server, calloop loop, hide counter | -| `src/thumbnail.rs` | One thumbnail: surface, drawing, animation state, pointer handling | - -`wayland.rs` gains what the host needs, following the existing `overlay` -pattern: a `preview: Option` field on `State`, routing of -pointer/frame/configure/closed events to it, `State::output_list()` (so the -host can look up outputs from inside the event loop), and an `Output::scale` -field (physical / logical width, from the current mode). `main.rs` gains the -hidden `__preview-host` subcommand and the capture integration. - -The existing config test that used `[preview]` as its example of an unknown -key switches to `[zoom]` (unknown keys are reported in alphabetical order). - -New dependency: `serde_json`. calloop comes with SCTK's default features. - -## 7. Config and CLI - -```toml -[preview] -enabled = true -timeout_secs = 5 -``` - -- `timeout_secs` must be at least 1; 0 or negative is a config error with a - hint. -- `--no-preview` (on `screen` and `region`) disables the preview for that - run. -- `valw doctor`: no new checks in Phase 2 (Satty ships in the package). - -## 8. Packaging - -`nix/package.ulu.nix` wraps the binary with `makeWrapper`: -`--suffix PATH : ${lib.makeBinPath [ pkgs.satty ]}`. A user's own Satty on -PATH still wins because of `--suffix`. - -## 9. copland wiring (test day) - -In the separate `~/copland` repo, done with the user's go-ahead: - -- Flake input: `valw.url = "path:/home/elars/Projects/valw";` with - `inputs.nixpkgs.follows = "nixpkgs"`. After valw changes: - `nix flake update valw`. -- Package: add `inputs.valw.packages.${system}.default` to the user's - packages (a small `modules/home/home-valw.ulu.nix`, no module options). -- Binds in `modules/home/niri/desktop-niri-keybinds.ulu.nix`: - - `Mod+Shift+S` → `valw region` (replaces Noctalia's `screenshot-region`) - - `Print` → `valw screen` - - `Mod+Shift+3/4` stay workspace moves. - -## 10. Testing - -### 10.1 Unit tests (`cargo nextest`) - -- `stack`: positions for 1–5 thumbnails; eviction at 6; swipe 39% → back, - 40% → close, leftward → back; click vs drag (< 4 px); easing endpoints - and monotonicity; thumbnail size (1920×1080 → 220×124, 1080×1920 → - 124×220, 100×50 unchanged). -- `ipc`: JSON round trip for each message; malformed and unknown requests - → `ok:false`. -- host state: hide counter (hide, hide, close, close → visible only at 0); - exit condition (empty stack and no connections). -- host startup: a second host fails the lock and exits quietly. -- config: `[preview]` defaults, `timeout_secs = 0` rejected. - -### 10.2 Headless sway check (`nix flake check`) - -The headless background is solid black (no swaybg in the sandbox), which -makes these deterministic. Config: `timeout_secs = 2`. - -1. `valw screen` with default target → `valw.sock` appears within 1 s. -2. After 0.5 s, `grim` sees more than one colour in the bottom-right - 300×200 px (the thumbnail is really there, so step 3 isn't vacuous). -3. Immediately after, `valw screen --no-preview -o hidden.png` → that corner - is a single colour (thumbnails never end up in screenshots). -4. The socket is gone within 4 s (host exits after the timeout). -5. `--no-preview` → no socket appears. - -Click and swipe need real pointer input and stay manual. - -### 10.3 Test-day checklist (manual, niri) - -- Thumbnail on the correct monitor; sharp at scale 1.25. -- Typing is never interrupted while a thumbnail is visible. -- Click opens Satty; saving there overwrites the screenshot file. -- Swipe right closes; a short drag snaps back. -- Several captures in a row stack up to 5; none appear in later - screenshots. -- During region selection thumbnails are hidden and not clickable. -- After the timeout, `pgrep -f __preview-host` finds nothing. - -## 11. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; the section 10.3 checklist is done on niri; -valw is wired into copland and the test day can start. diff --git a/docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md b/docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md deleted file mode 100644 index f1bd757..0000000 --- a/docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md +++ /dev/null @@ -1,146 +0,0 @@ -# valw: drag the preview out (design) - -Date: 2026-10-01 -Status: approved in brainstorming, pending written-spec review -Builds on: `2026-09-30-valw-phase-2-preview-design.md` (Phase 2, on `main`) - -## 1. Context and goal - -After a day of real use, the one thing missing from the preview was being -able to grab a thumbnail and drop the screenshot into another app (a chat, -a browser upload field, a file manager, an image editor) without looking -for the file. The vision document lists this as "Dışarı sürükle" in 4.4. - -Success: dragging a thumbnail out works with the user's everyday apps, and -click → Satty and swipe → dismiss keep working exactly as before. - -### Out of scope - -- A config switch for drag-and-drop (it is always on). -- Anything else from the post-test-day list (hover pause, notifications, - right-click menu, the Phase 2 deferred minors). - -## 2. Decisions - -| Question | Decision | Why | -|---|---|---| -| Swipe vs drag-out | By direction: mostly rightward = swipe, anything else = drag-out | Thumbnails sit in the bottom-right corner, so drop targets are always left/up; nothing existing changes | -| What is carried | `text/uri-list` (the file) and `image/png` (the bytes) | Each app takes what it understands | -| After a drag | Dropped → the thumbnail closes; cancelled → it comes back | Like macOS; a missed drop can be retried | -| What moves under the pointer | The thumbnail image as the drag icon; the original hides | You see what you carry | -| Action | Copy | The saved file stays where it is | - -## 3. Gesture - -A press on a thumbnail is classified once, as soon as the pointer has moved -at least `CLICK_SLOP` (4 logical px) from the press point, and never -changes afterwards: - -- **Swipe:** `dx > 0` and `|dy| ≤ dx` (rightward, at most 45° off - horizontal). Behaviour unchanged from Phase 2. -- **Drag-out:** any other direction. -- **Click:** released before the slop is reached. Behaviour unchanged - (opens Satty). - -`stack::classify(dx, dy) -> Option` (`None` below the slop) is a -pure function; the thumbnail remembers the first `Some`. - -## 4. Drag flow - -1. On the move that classifies the press as a drag-out, the host creates a - `wl_data_source` (SCTK `create_drag_and_drop_source`, mime types from - section 5, action `Copy`) and calls `start_drag` with the thumbnail's - surface as origin, a new icon surface, and the serial of the pointer - press that started it. -2. The icon surface shows the thumbnail image at thumbnail size (buffer at - the output's scale, viewporter to logical size, 1 px border as before). - Its offset (`wl_surface.offset`, wl_compositor v5+) keeps the point the - user grabbed under the pointer. -3. The original thumbnail hides itself (transparent buffer, empty input - region: the Phase 2 mechanism) for the duration of the drag. Only this - thumbnail; the rest of the stack is unaffected. "Hidden for a capture" - and "hidden for a drag" are separate flags, and a thumbnail is visible - only when neither is set: a capture that ends mid-drag must not bring - back the thumbnail being dragged. -4. While dragging, the compositor owns the pointer. If the thumbnail's - timer fires, it is only recorded (`expired`); the thumbnail doesn't - close mid-drag. -5. `dnd_finished` (dropped and accepted): the thumbnail closes at once (it - is already invisible), and the stack restacks. -6. `cancelled` (released on nothing, rejected, Esc): the icon goes away and - the thumbnail reappears with the slide-in animation, or, if it expired - during the drag, slides out right away. -7. The data source and icon surface are destroyed after either outcome. - -The host binds `wl_data_device_manager` (SCTK `DataDeviceManagerState`) -and gets the seat's data device when the pointer capability appears. The -host never receives drops; SCTK's `DataDeviceHandler` and -`DataOfferHandler` get empty implementations. - -A missing `wl_data_device_manager` (not the case on niri) disables -drag-out: the gesture falls back to snap-back, and a warning is logged -once. - -## 5. Data - -| Mime type | Written to the receiving app's pipe | -|---|---| -| `text/uri-list` | `file_uri(path)` followed by `\r\n` | -| `image/png` | the file's bytes, read from disk when requested | - -`file_uri(path)`: `file://` plus the absolute path with every byte -percent-encoded except the RFC 3986 unreserved characters -(`A-Z a-z 0-9 - . _ ~`) and `/`. Spaces become `%20`, UTF-8 (e.g. Turkish -`ı`, `ş`) is encoded byte by byte, `%` and `#` are encoded. - -Each `send_request` writes on its own short-lived thread, so a slow reader -or a large PNG can't block the host's event loop (and with it every -thumbnail). A failed read or write (file removed, reader gone) is logged; -the pipe is closed and the host keeps running. - -## 6. Code layout - -| File | Change | -|---|---| -| `src/stack.rs` | `enum Gesture { Swipe, DragOut }`, `classify(dx, dy)` | -| `src/dnd.rs` (new) | `file_uri`, `MIME_TYPES`, `write_offer(mime, path, fd)` (the thread body), the after-drag decision | -| `src/thumbnail.rs` | gesture state; drag icon surface; per-thumbnail hide/show around the drag | -| `src/host.rs` | data device, drag source, `DataSourceHandler` callbacks routed to the thumbnail | -| `src/wayland.rs` | `DataDeviceManagerState` in `State`, delegate impls | - -## 7. Testing - -### 7.1 Unit tests (`cargo nextest`) - -- `stack::classify`: under 4 px → `None`; right and 30° off right → - `Swipe`; exactly 45° → `Swipe`; left, up, down, and steep right → - `DragOut`. -- `dnd::file_uri`: plain path; spaces; Turkish characters; `%` and `#`; - `/` kept. -- After-drag decision: dropped → close; cancelled and not expired → come - back; cancelled and expired → slide out. -- `dnd::write_offer` through a real pipe: `text/uri-list` produces the - exact line; `image/png` produces the file bytes; a missing file is an - error, not a panic. - -### 7.2 Headless check - -Unchanged: drag-and-drop needs synthetic pointer input. The existing -checks guard click/swipe/hide against regressions. - -### 7.3 Manual checks on niri - -- A drag can be started from the thumbnail (layer surface) at all - (verified while prototyping, before the plan). -- Drop into Dolphin → file copied; into a browser upload field → uploads; - into a Discord message → attaches. -- Swipe right still dismisses; click still opens Satty. -- Drag left/up and release on nothing → the thumbnail comes back. -- A thumbnail whose timer fires during a drag slides out after the cancel. -- Drag onto the other monitor works. - -## 8. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, -`nix flake check` pass, the 7.3 checks are done on niri, and copland picks -it up with `nix flake update valw`. diff --git a/docs/superpowers/specs/2026-10-02-valw-phase-3-window-mode-design.md b/docs/superpowers/specs/2026-10-02-valw-phase-3-window-mode-design.md deleted file mode 100644 index 53d7cc4..0000000 --- a/docs/superpowers/specs/2026-10-02-valw-phase-3-window-mode-design.md +++ /dev/null @@ -1,103 +0,0 @@ -# valw Phase 3: window mode (design) - -Date: 2026-10-02 -Status: approved in brainstorming, pending written-spec review -Builds on: Phase 0+1 (region/screen), Phase 2 (preview), drag-out (all on `main`) - -## 1. Goal - -Capture a single window the macOS way: `valw window` lets the user click a -window and saves exactly that window; in region mode, Space (before -dragging) switches to window mode, like the camera icon on macOS. - -Success: any window (tiled or floating, partly covered or not) can be -captured with a click, and the shot then goes through the same save, -clipboard, preview and drag-out flow as every other capture. - -### Out of scope (later polish phase) - -- The size indicator (`640 × 480`) next to the cursor. -- Shift / Alt / Space modifiers while dragging a region. -- The window shadow option. -- A blue highlight on the window under the pointer (see 2). - -## 2. Decisions - -| Question | Decision | Why | -|---|---|---| -| Who picks the window | niri (`Request::PickWindow`) | niri 26.04's IPC reports positions only for floating windows (`tile_pos_in_workspace_view` is `None` for tiled ones), so valw can't hit-test or highlight tiled windows itself | -| Who renders it | niri (`Action::ScreenshotWindow` with an explicit `path`) | Exact window pixels even when covered, rounded corners and transparency kept, correct scale | -| How valw gets the pixels | niri writes a temporary PNG; valw waits for the `ScreenshotCaptured` event, decodes the file and deletes it | One path for every target (`--output`, `-`, `--clipboard-only`); the rest of the pipeline stays unchanged | -| Highlight | None; niri's pick cursor is the feedback | Not possible for tiled windows (see above) | - -## 3. Flow of `valw window` - -1. As today: load the config, take the capture lock, wait `--delay`, hide - the thumbnails (`HideGuard`). -2. `niri::pick_window()` sends `Request::PickWindow`; niri changes the - cursor and replies after the click. `None` (Esc) → `Cancelled`. -3. Open a second niri socket with `Request::EventStream` **before** - asking for the screenshot, so the event can't be missed. -4. Send `Action::ScreenshotWindow { id: Some(id), write_to_disk: true, - show_pointer: cursor, path: Some(tmp) }`, where `tmp` is - `$XDG_RUNTIME_DIR/valw-window-.png` (absolute, as niri requires). -5. Read events until `Event::ScreenshotCaptured { path: Some(p) }` with - `p == tmp`. A reader thread sends events over a channel; the main side - waits at most 5 s, then fails with "niri did not deliver the window - screenshot". `ScreenshotCaptured { path: None }` or another path is - ignored (niri also reports other screenshots on the same stream). -6. Decode `tmp` into an `RgbaImage` and delete it (also on every error - after step 4, through a drop guard). -7. The preview's output: the output of the window's workspace (from - `Request::Workspaces`); if that is unknown, niri's focused output, as - in screen mode. -8. Continue with the existing `save` → preview → clipboard steps, exactly - as for a region shot. - -Side effects owned by niri, accepted: niri sets the clipboard itself and -shows its own "Screenshot captured" notification (niri's `dbus` build). -valw then sets the clipboard again with the same image (when configured), -so the end state is unchanged. The notification's image may be gone, -since valw deletes the temporary file. - -## 4. Space in region mode - -- The overlay's keyboard handler: Space while no drag is in progress - ends the overlay with the outcome "switch to window mode". Space during - a drag does nothing (reserved for "move the selection" in the polish - phase). -- `region::select` returns `enum Choice { Region(usize, PixelRect), Window }` - instead of `Picked`. -- `main`: `Window` drops the frozen frames and the Wayland connection and - runs steps 2–8 of section 3 in the same process (lock, delay and hidden - thumbnails carry over; the delay is not applied twice). - -## 5. Code layout - -| File | Change | -|---|---| -| `src/niri.rs` | `pick_window()`, `screenshot_window(id, path, cursor)`, `wait_for_capture(...)`, `output_of(window, workspaces)`, `is_our_capture(event, path)` | -| `src/window.rs` (new) | steps 2–7: temporary path + drop guard, decode, returns `(RgbaImage, output name)` | -| `src/main.rs` | `Command::Window`, `Mode::Window`, `Choice::Window` from region | -| `src/region.rs` | `Choice`; `Overlay::switch_to_window()` (no-op while dragging) | -| `src/wayland.rs` | Space in `press_key` calls `switch_to_window` | -| `src/doctor.rs` | nothing new (the niri version check already covers the IPC) | - -## 6. Testing - -Unit tests (`cargo nextest`): -- `niri::is_our_capture`: our path → true; `None`, another path → false. -- `niri::output_of`: window on a workspace with an output → that name; - no workspace, unknown workspace, workspace without output → `None`. -- `region`: Space before a press → `Window`; Space while dragging → - nothing. -- `window`: the temporary file is removed when the guard drops. - -Manual checks are appended to `docs/test-checklist.md` (tiled, floating, -covered, HiDPI/other monitor, Esc during the pick, `--cursor`, Space from -region mode, `--clipboard-only`, `-o -`). - -## 7. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; the checklist section is written. diff --git a/docs/superpowers/specs/2026-10-02-valw-phase-4-zoom-design.md b/docs/superpowers/specs/2026-10-02-valw-phase-4-zoom-design.md deleted file mode 100644 index fffe7d6..0000000 --- a/docs/superpowers/specs/2026-10-02-valw-phase-4-zoom-design.md +++ /dev/null @@ -1,98 +0,0 @@ -# valw Phase 4: zoom mode (design) - -Date: 2026-10-02 -Status: approved in brainstorming, pending written-spec review -Builds on: Phases 0–3 (on `main`); vision section 4.7 - -## 1. Goal - -A woomer-like magnifier: `valw zoom` freezes the focused output and lets -the user zoom towards the pointer, pan, and spotlight an area, then either -leave or capture what they see. - -Success: zooming and panning stay smooth (60 fps) on the user's Intel HD -4000 at 1920×1080, pixels stay crisp, and `c` hands a crop to the usual -save → preview → clipboard flow. - -### Out of scope - -- Zoom on every output at once, smooth (linear) filtering, several - captures from one zoom session. -- Vulkan or wgpu (the user asked for no Vulkan). - -## 2. Decisions - -| Question | Decision | Why | -|---|---|---| -| Renderer | OpenGL ES 3.0 through EGL (`khronos-egl`, `wayland-egl`, `glow`) | No Vulkan; a single textured triangle doesn't need wgpu. A spike on the user's HD 4000 (Mesa, crocus) got GLES 3.0, a compiling `#version 300 es` shader, `MAX_TEXTURE_SIZE` 16384 and 61 fps at 1920×1080 on a layer surface | -| Wayland backend | `wayland-backend` with `client_system` (libwayland-client) for the whole program | EGL and `wayland-egl` need the C `wl_display*` and `wl_proxy*`; the API valw uses doesn't change | -| Output | The focused output only | Simple, like woomer | -| Filtering | Nearest | Crisp pixels for inspection | -| `c` | Crops the visible area of the original frame (native pixels), runs the normal capture flow, exits | Zoomed region select; no upscaled pixels | -| Esc / `q` | Leave without saving, exit code 0 | Leaving is the normal end of zoom, not a cancelled capture | - -## 3. Behaviour - -Start, as for the other modes: load the config, take the capture lock, -wait `--delay`, hide the thumbnails, capture niri's focused output (with -the cursor if `--cursor`/`show_cursor`). Then a full-screen layer surface -(namespace `valw-zoom`, overlay layer, exclusive keyboard) on that output -shows the frozen frame at 1×. - -| Input | Effect | -|---|---| -| Wheel | Zoom towards the pointer: the frame point under the pointer stays put. One notch = one `zoom.scroll_step` (default 1.15); touchpad scrolling: one step per 15 logical px. Range 1×–32× | -| Left drag | Pan, 1:1 with the pointer | -| `f` | Flashlight on/off: outside a circle around the pointer the image is dimmed to 25 % brightness | -| Ctrl + wheel | Flashlight radius × / ÷ `scroll_step` (default `zoom.flashlight_radius` = 180 logical px, range 20–2000) | -| `0` | Back to 1× (animated) | -| `c` | Capture the visible area (see 2) and exit | -| Esc, `q` | Exit, nothing saved | - -- The view never leaves the image: at 1× it is exactly the frame, and a - zoomed view is clamped to the frame's edges. -- Zoom and reset animate: the shown scale and position approach their - targets exponentially (about 90 % in 120 ms) and snap when close. Pan is - immediate. Frames are drawn on frame callbacks only while something - changes; an idle view draws nothing. -- The cursor is the default pointer; while panning it is `grabbing`. - -## 4. Structure - -| File | Role | -|---|---| -| `src/zoom/view.rs` | Pure view math: `View { scale, origin }` (origin = the frame pixel at the top-left of the screen), `zoom_at`, `pan`, `reset`, `clamp`, the animation step, `visible_rect(frame size) -> PixelRect`, scroll → zoom factor, flashlight radius bounds | -| `src/zoom/gl.rs` | EGL display/context/window surface on the layer surface, the texture upload, one shader program (fullscreen triangle; fragment shader maps screen → frame pixels, `texelFetch`/nearest, swizzles BGRX, flashlight dimming) | -| `src/zoom/mod.rs` | `run(wl, output, frame, config) -> Result>`: surface, input routing, the draw loop; `Some(rect)` for `c`, `None` for Esc/`q` | -| `src/wayland.rs` | `State::zoom`; pointer, axis, key and modifier events go to it while it is open | -| `src/main.rs` | `Command::Zoom`, `Mode::Zoom`; `None` returns `Ok(())` before saving | -| `src/config.rs` | `[zoom] scroll_step = 1.15`, `flashlight_radius = 180` | -| `src/doctor.rs` | A line for EGL + an OpenGL ES 3 config (`warn`, not `fail`, when missing: only zoom needs it) | -| `nix/package.ulu.nix` | `LD_LIBRARY_PATH` suffix with libglvnd (`libEGL.so.1`); Mesa's drivers come from `/run/opengl-driver` on NixOS | - -A missing EGL/GLES 3 makes `valw zoom` fail with a clear error and the -hint to run `valw doctor`; the other modes don't touch EGL. - -## 5. Testing - -Unit tests (`cargo nextest`) for `view.rs`: -- `zoom_at` keeps the frame point under the pointer fixed (several - scales, pointer at a corner and the centre). -- Scale is clamped to 1–32; at 1× the origin is 0,0; a zoomed origin is - clamped to the frame edges; `pan` respects the same clamp. -- The animation reaches the target and then reports "idle". -- `visible_rect` at 1× is the whole frame; at 4× with a known origin it - is the expected rectangle, rounded outwards and inside the frame. -- Scroll → factor for wheel notches and touchpad pixels; flashlight radius - bounds. -- Config: the `[zoom]` defaults and overrides; doctor: the EGL line's - levels. - -The headless sway check keeps covering screencopy and the overlay, now on -`client_system`. Zoom needs a GPU, so it is checked by hand: the Phase 4 -section of `docs/test-checklist.md`. - -## 6. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; the checklist section is written. diff --git a/docs/superpowers/specs/2026-10-02-valw-phase-5a-editor-design.md b/docs/superpowers/specs/2026-10-02-valw-phase-5a-editor-design.md deleted file mode 100644 index 75e1be0..0000000 --- a/docs/superpowers/specs/2026-10-02-valw-phase-5a-editor-design.md +++ /dev/null @@ -1,152 +0,0 @@ -# valw Phase 5a: the built-in editor, basics (design) - -Date: 2026-10-02 -Status: approved in brainstorming, pending written-spec review -Builds on: Phases 0–4 (on `main`); vision section 4.5 - -## 1. Goal - -Replace Satty with valw's own editor for everyday markup: click a preview -thumbnail, draw arrows, boxes and highlights, save or copy, done. Phase 5b -adds text, blur/pixelate, numbered markers and crop on top of this. - -Success: the user can mark up a screenshot as fast as in macOS Markup, and -what is saved is pixel-for-pixel what the canvas shows (at the image's own -resolution). - -### Out of scope (5b or later) - -- Text, blur/pixelate, numbered markers, crop (5b). -- Selecting, moving or editing a drawn shape; filled shapes; zoom inside - the editor; a custom colour picker. - -## 2. Decisions - -| Question | Decision | Why | -|---|---|---| -| Split | 5a basics now, 5b afterwards, each with its own spec and plan | The editor is the largest phase; 5a is already usable on its own | -| Toolkit | `eframe`/`egui` with the `glow` backend (OpenGL ES through EGL), no Vulkan, no wgpu | Ready-made buttons, shortcuts and (for 5b) text input; a normal Wayland window | -| Process | `valw edit `, its own process | Same as Satty today; the preview host stays small | -| Look | macOS Markup: one slim toolbar on top, the image large below | User choice | -| Ctrl+S | Overwrite the file; Ctrl+Shift+S saves a copy next to it | Like macOS; the preview already points at that file | -| Rendering | Shapes become shared geometry (polylines, polygons) in image pixels; egui draws it on screen, `tiny-skia` draws it for export | One geometry, two painters: the export matches the screen | - -## 3. Behaviour - -### 3.1 Window - -`valw edit ` opens a window titled ` — valw`, app id -`valw-editor` (for niri window rules). The image is fitted into the area -below the toolbar, centred, never upscaled beyond 100 %. A file that can't -be read is an error on stderr and exit code 1, before any window appears. - -### 3.2 Toolbar (left to right) - -| Group | Items | -|---|---| -| Tools | Arrow, Rectangle, Ellipse, Line, Pen, Highlighter | -| Colour | red `#ff3b30` (default), orange `#ff9500`, yellow `#ffcc00`, green `#34c759`, blue `#007aff`, black, white | -| Width | S 2 px, M 4 px (default), L 8 px (image pixels) | -| History | Undo, Redo (disabled when there is nothing to do) | -| Output | Copy, Save | - -The active tool, colour and width are highlighted. Default tool: Arrow. - -### 3.3 Drawing - -- Press, drag, release on the image creates one shape from the press point - to the release point (Pen and Highlighter: every point on the way). - A press outside the image does nothing; a drag that leaves the image is - clamped to its edges. -- A shape whose drag is shorter than 2 image px (a click) is discarded, - except for Pen, which keeps a dot. -- Shift while dragging: Line and Arrow snap to 45° steps; Rectangle and - Ellipse become a square and a circle. -- Arrow: a line with a filled triangular head at the release point; the - head grows with the width (length `max(12, 4 × width)`, 30° half-angle). -- Rectangle and Ellipse: outlines only. -- Highlighter: the chosen colour at 40 % opacity, width × 4, round caps; - drawn over everything below it. -- Width is in image pixels, so a 4 px arrow is 4 px in the saved file. - -### 3.4 Keys - -| Key | Effect | -|---|---| -| A, R, O, L, P, H | Arrow, Rectangle, Ellipse (O), Line, Pen, Highlighter | -| Ctrl+Z | Undo | -| Ctrl+Shift+Z, Ctrl+Y | Redo | -| Ctrl+C | Copy the edited image to the clipboard | -| Ctrl+S | Save over the file | -| Ctrl+Shift+S | Save a copy: ` edited.png` next to it (`unique_path` if taken) | -| Esc | Close; with unsaved changes the first Esc shows a bar "Unsaved changes — Esc to discard, Ctrl+S to save", the second closes | - -Closing the window (compositor close) with unsaved changes behaves like -the first Esc: the bar appears and the window stays. - -### 3.5 Undo - -The document is the base image plus a list of shapes. Every finished shape -pushes onto the list and clears the redo stack; undo moves the last shape -to the redo stack, redo moves it back. Saving does not clear history. -"Unsaved" means the shape list differs from what was last saved. - -### 3.6 Save and copy - -Export composites the shapes onto the base image at its full resolution -with `tiny-skia` (anti-aliased), then encodes PNG with the existing -`output::encode_png`. - -- Save: `output::write_atomic` over the file; the window stays open. -- Save a copy: as above to the `edited` path; the window stays open. -- Copy: the existing clipboard path (`output::copy_to_clipboard`, which - forks a server); the window stays open. -- Each prints a short status in the toolbar ("Saved", "Copied") for 2 s; - a failure shows its message there instead and is logged. - -### 3.7 Opening from the preview - -The preview host reads `[editor] backend` when a thumbnail is clicked: -`"builtin"` (default) runs `valw edit ` (the running executable), -`"satty"` keeps today's Satty command. Unknown values are a config error. - -## 4. Structure - -| File | Role | -|---|---| -| `src/editor/mod.rs` | `run(path)`: load, start eframe, the `App` (toolbar, canvas, keys, status bar) | -| `src/editor/doc.rs` | `Doc` (base image, shapes, redo stack, saved marker): add / undo / redo / dirty | -| `src/editor/shape.rs` | `Tool`, `Shape`, `Style`; constraints (Shift), shape → `Geometry` (polylines and polygons in image px) | -| `src/editor/export.rs` | `Geometry` → pixels with `tiny-skia`; `edited_path` | -| `src/editor/view.rs` | Fit the image into the canvas: image ↔ screen transforms | -| `src/config.rs` | `[editor] backend = "builtin" \| "satty"` | -| `src/host.rs` | `open_editor` picks the backend | -| `src/main.rs` | `Command::Edit { file }` | -| `nix/package.ulu.nix` | `LD_LIBRARY_PATH` gains `wayland` and `libxkbcommon` (winit loads them at run time) | - -Dependencies: `eframe` (default features off; `glow`, `wayland`, -`default_fonts`), `tiny-skia`. - -## 5. Testing - -Unit tests (`cargo nextest`): -- `shape`: Shift snapping (angles, square, circle); the arrow head's size - and direction; highlighter width and alpha; a click is discarded but a - Pen dot is kept. -- `doc`: add / undo / redo order, redo cleared by a new shape, dirty after - edits and clean after save, undo past a save makes it dirty. -- `export`: a red 4 px line on a white image has red pixels on the line - and untouched pixels away from it; a highlighter over a known colour - blends at 40 %; the output keeps the image size. -- `view`: fitting (never above 100 %, centred) and the round trip - image → screen → image. -- `edited_path`: `a.png` → `a edited.png`, unique when taken. -- Config: `[editor] backend` default and the unknown-value error; the - preview host's backend choice. - -Manual checks go to the Phase 5a section of `docs/test-checklist.md`. - -## 6. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; the checklist section is written. diff --git a/docs/superpowers/specs/2026-10-03-valw-home-manager-design.md b/docs/superpowers/specs/2026-10-03-valw-home-manager-design.md deleted file mode 100644 index 0becbde..0000000 --- a/docs/superpowers/specs/2026-10-03-valw-home-manager-design.md +++ /dev/null @@ -1,81 +0,0 @@ -# valw home-manager module (design) - -Date: 2026-10-03 -Status: approved in brainstorming, pending written-spec review -Builds on: every phase on `main`; the config format in `src/config.rs` - -## 1. Goal - -Manage valw from home-manager: install it and write its config from Nix, -with mistakes in the settings caught when the configuration is built, not -when valw runs. - -### Out of scope - -- A NixOS module (valw is per user; `packages.default` installs it). -- niri key bindings (they stay in the user's niri config, i.e. copland). -- The toolbar's remembered choices (`~/.local/state/valw/toolbar.toml`), - which stay writable state. - -## 2. Usage - -```nix -programs.valw = { - enable = true; - settings = { - preview.timeout_secs = 3; - sound.volume = 0.4; - capture.window_shadow = true; - }; -}; -``` - -## 3. Options - -| Option | Type | Default | Meaning | -|---|---|---|---| -| `programs.valw.enable` | bool | `false` | Install valw (`home.packages`) | -| `programs.valw.package` | package | this flake's `packages.${system}.default` | The valw to install and to check the settings with | -| `programs.valw.settings` | `(pkgs.formats.toml { }).type` | `{ }` | Written to `$XDG_CONFIG_HOME/valw/config.toml`; nothing is written when empty, so valw uses its defaults | - -## 4. Checking at build time - -- `settings` becomes a TOML file with `pkgs.formats.toml`. -- The file installed as `xdg.configFile."valw/config.toml".source` is a - derivation that runs `${package}/bin/valw __check-config ` and - copies the file to `$out` only if the check passes. -- `valw __check-config ` (hidden): parses the file exactly as valw - does at run time. An invalid value prints valw's own error (for example - `sound.volume must be between 0 and 1`) and exits non-zero. An unknown - key, which valw only warns about at run time, is an error here, listing - every unknown key (`unknown config key: preview.timout_secs`). A valid - file prints nothing and exits 0. It needs no Wayland, niri or home - directory. - -## 5. Structure - -| File | Change | -|---|---| -| `src/main.rs` | hidden `Command::CheckConfig { file }` | -| `src/config.rs` | `check(path) -> Result<()>`: parse + unknown keys as an error | -| `nix/home-module.ulu.nix` (new) | `flake.homeModules.default` | -| `nix/checks.ulu.nix` | `home-module` (evaluates the module with stub `home.packages` / `xdg.configFile` options and builds the checked config) and `home-module-rejects` (a bad setting fails to build, via `pkgs.testers.testBuildFailure`) | -| `README.md` (new) | what valw is, install (flake package or the module), the module example, niri bind examples, the commands | - -No new flake input: the checks evaluate the module with `lib.evalModules` -and stubs instead of pulling in home-manager. - -## 6. Testing - -- Rust (`cargo nextest`): `check` accepts a valid file and the defaults, - rejects an invalid value with valw's message, rejects unknown keys and - names them; the CLI parses `__check-config FILE` and hides it from help. -- Nix (`nix flake check`): the two module checks above. - -Manual: wiring it into copland happens in the end-of-project test pass -(checklist section). - -## 7. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; README written; checklist section added. diff --git a/docs/superpowers/specs/2026-10-03-valw-phase-5b-editor-design.md b/docs/superpowers/specs/2026-10-03-valw-phase-5b-editor-design.md deleted file mode 100644 index 0ca287d..0000000 --- a/docs/superpowers/specs/2026-10-03-valw-phase-5b-editor-design.md +++ /dev/null @@ -1,119 +0,0 @@ -# valw Phase 5b: editor text, pixelate, numbers, crop (design) - -Date: 2026-10-03 -Status: approved in brainstorming, pending written-spec review -Builds on: Phase 5a (`valw edit`), `docs/superpowers/specs/2026-10-02-valw-phase-5a-editor-design.md` - -## 1. Goal - -Finish the editor's tool set from vision 4.5: text, hiding sensitive -parts, numbered markers and crop, with the same guarantee as 5a: the -saved PNG is what the canvas shows. - -### Out of scope - -- Editing, moving or selecting finished shapes (text included). -- Blur (pixelate only), font choice, text background boxes. - -## 2. Decisions - -| Question | Decision | Why | -|---|---|---| -| Text editing | Place and type; Enter or a click elsewhere finishes; no re-editing (undo instead) | No selection model yet | -| Text look | Bold, chosen colour, thin dark outline; S/M/L = 16/24/36 px | Readable on any background | -| Font | DejaVu Sans Bold, vendored in `assets/fonts/` with its licence | Turkish glyphs, permissive licence, works without system fonts | -| Text rendering | Glyph outlines from `ab_glyph`, filled and stroked by tiny-skia | One renderer for canvas and export | -| Hiding | Pixelate only | Can't be undone by deblurring | -| Crop | Drag, adjust edges, Enter applies, Esc cancels; an undoable history step | User choice | - -## 3. Behaviour - -New tools, after the 5a ones in the toolbar: **Text (T), Pixelate (B), -Number (N), Crop (C)**. Ctrl+C stays Copy. - -### 3.1 Text - -- A click on the image starts a text at that point (its top-left). - Typing inserts characters, Backspace deletes, Shift+Enter starts a new - line, Enter or a press anywhere else finishes it. Esc while typing - drops the text (and does not trigger the close flow). -- An empty text is discarded. While typing, the single-letter tool keys - and Ctrl+Z/Ctrl+Y act on the text box only as text (letters) or not at - all (Ctrl shortcuts are ignored until the text finishes). -- Look: the glyphs filled in the chosen colour over an outline in near - black (`#1c1c1e` at 80 % opacity) of width `size / 8`, so it reads on - light and dark backgrounds. White text gets the same dark outline. -- Size from the width control: S 16, M 24, L 36 px (image pixels); line - height 1.2 × size; kerning from the font. -- A caret (a thin line after the last glyph) blinks while typing; it is - never exported. - -### 3.2 Pixelate - -- Drag a rectangle (Shift: square). On release the area turns into - blocks of S 8, M 12, L 20 px, aligned to the rectangle's top-left; each - block takes the average colour (alpha included) of what is under it at - that point in the history, so earlier shapes are pixelated too. Edge - blocks are partial. -- A click (under 2 px) is discarded. - -### 3.3 Numbers - -- A click places a filled circle in the chosen colour with a white bold - number centred in it. Diameter S 24, M 32, L 44 px; the number's size - is 0.55 × diameter. -- The number is 1 + the count of numbers already in the document, so - undo makes the next number reuse the freed one. - -### 3.4 Crop - -- Drag a rectangle: the outside darkens (50 % black over the canvas), the - inside stays clear, with a 1 px white border. Dragging within 8 screen - px of an edge or corner moves that edge (corners move two); the cursor - shows the resize direction. -- Enter applies it, Esc cancels it (Esc then does not start the close - flow). Switching tools cancels it. -- An applied crop is one history step: undo restores the previous view. - The canvas shows only the cropped area, fitted as in 5a. Shapes keep - their original image coordinates; drawing is clamped to the crop. -- A later crop happens inside the current one. -- Export: everything is drawn on the full image, then the last crop's - rectangle is cut out. "Unsaved" compares the history including crops. - -## 4. Structure - -| File | Change | -|---|---| -| `assets/fonts/DejaVuSans-Bold.ttf`, `assets/fonts/LICENSE-DejaVu` | The vendored font | -| `src/editor/text.rs` (new) | The embedded font, layout (lines, advances, kerning), glyph outlines → tiny-skia paths, text width/height | -| `src/editor/shape.rs` | `Tool::{Text, Pixelate, Number, Crop}`; `Shape` gains the text payload; geometry for numbers | -| `src/editor/doc.rs` | History entries become `Item::{Shape(Shape), Crop(PixelRect)}`; `crop()`; next number | -| `src/editor/export.rs` | Text, pixelation, number glyphs; final crop | -| `src/editor/view.rs` | Fitting the crop rectangle instead of the whole image | -| `src/editor/mod.rs` | Text input, crop handles and keys, toolbar entries | - -Dependency: `ab_glyph`. - -## 5. Testing - -Unit tests (`cargo nextest`): -- `text`: line breaking on `\n`, width grows with characters, kerning - applied, Turkish letters (`ğüşıöçİ`) have glyphs, an empty string has no - size. -- `export`: text pixels inside the glyph box use the colour; the outline - darkens the edge; pixelation makes each block uniform with the block's - average; a pixelate after a red line pixelates the line; a number draws - a filled circle with white in its centre; a crop sets the output size - and offset. -- `doc`: numbers count only `Number` shapes and follow undo; crops are - undoable and nest; dirty includes crops. -- `view`: fitting a crop rectangle; screen ↔ image round trip with a - crop. -- `mod`: the crop edge hit-test (edges, corners, inside, outside). - -Manual checks go to the Phase 5b section of `docs/test-checklist.md`. - -## 6. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; the checklist section is written. diff --git a/docs/superpowers/specs/2026-10-03-valw-phase-6-toolbar-design.md b/docs/superpowers/specs/2026-10-03-valw-phase-6-toolbar-design.md deleted file mode 100644 index e8e7165..0000000 --- a/docs/superpowers/specs/2026-10-03-valw-phase-6-toolbar-design.md +++ /dev/null @@ -1,115 +0,0 @@ -# valw Phase 6: the toolbar (design) - -Date: 2026-10-03 -Status: approved in brainstorming, pending written-spec review -Builds on: Phases 0–5 (on `main`); vision section 4.6 - -## 1. Goal - -A Cmd+Shift+5-style bar: `valw toolbar` shows a floating bar at the -bottom of the focused output, the user picks a mode (and, if they like, a -timer, the cursor and the preview), and that mode runs as if started from -the command line. - -Success: every mode starts from the bar with one click, the options are -remembered, and nothing of the bar or its countdown ends up in a shot. - -### Out of scope - -- A remembered region drawn on screen and a separate Capture button (the - macOS "select, then capture" flow). -- Save-location choices; screen recording. - -## 2. Decisions - -| Question | Decision | Why | -|---|---|---| -| Flow | Clicking a mode starts it at once | Simple and fast | -| Options | Timer (None / 5 s / 10 s), Show cursor, Show preview | User choice | -| Rendering | A layer-shell surface drawn with tiny-skia and the editor's bundled font | Floats without niri rules; same stack as the overlay and preview; no new dependency | -| Memory | Last mode and options in `$XDG_STATE_HOME/valw/toolbar.toml` | The config may become a read-only home-manager file | - -## 3. Behaviour - -### 3.1 The bar - -- A layer surface (namespace `valw-toolbar`, overlay layer, exclusive - keyboard) covering niri's focused output. It is transparent except for - the bar, which sits 24 logical px above the bottom edge, horizontally - centred; covering the output is what lets a click beside the bar close - it. -- Contents, left to right: **Screen**, **Window**, **Region**, a - separator, **Zoom**, a separator, **Options ▾**. Labels in English, - 13 px, white; background `#1c1c1e` at 90 % opacity with 12 px corner - radius; buttons 32 px tall with 12 px horizontal padding. -- The last used mode is highlighted (a lighter fill); the button under - the pointer is highlighted more lightly. -- Drawn at the output's scale (buffer at physical size, viewporter to - logical size), as the overlay does. - -### 3.2 Options - -**Options ▾** toggles a small menu above the bar, aligned to the button: - -- Timer: None / 5 s / 10 s (one of three) -- Show cursor (check) -- Show preview (check) - -Clicks in the menu change the option and keep the menu open. The options -apply only to captures started from the bar; the config is untouched. - -### 3.3 Starting a mode - -- A click on Screen, Window, Region or Zoom closes the bar (its surface is - destroyed and the destruction flushed) and starts the mode with the - options: Show cursor → `--cursor`; Show preview off → `--no-preview`. -- With a timer, a countdown pill (namespace `valw-countdown`, the same - style, about 64 × 40 px, bottom-centred where the bar was) shows the - seconds left, 5 → 1 (or 10 → 1), redrawn every second, and is destroyed - before the mode starts so it is never captured. Esc during the - countdown cancels (exit 3); the pill has exclusive keyboard focus. -- The chosen mode and options are saved before the mode starts. - -### 3.4 Leaving - -Esc, a click outside the bar and its menu, or the compositor closing the -surface: nothing is captured, exit code 3 (`Cancelled`), like Esc in -region mode. - -### 3.5 Memory - -`toolbar.toml` holds `mode` (`screen` | `window` | `region` | `zoom`), -`timer` (0, 5, 10), `cursor` and `preview` (booleans). Without a file the -defaults are: mode `region`, timer 0, `cursor = capture.show_cursor`, -`preview = preview.enabled` from the config. An unreadable or invalid file -logs a warning and uses the defaults; it is overwritten on the next save. - -## 4. Structure - -| File | Role | -|---|---| -| `src/toolbar/layout.rs` | Pure: button and menu rectangles from the measured label widths; `hit(point) -> Option` for the bar and the menu | -| `src/toolbar/state.rs` | `ToolbarState` load/save, defaults from the config | -| `src/toolbar/countdown.rs` | The countdown pill and its timing | -| `src/toolbar/mod.rs` | `run(wl, outputs, config) -> Result`: the surface, drawing (tiny-skia + `editor::text`), input | -| `src/main.rs` | `Command::Toolbar`; the choice becomes `capture(mode, common)` | -| `src/wayland.rs` | Routing configure/pointer/keyboard/focus events to the toolbar | - -## 5. Testing - -Unit tests (`cargo nextest`): -- `layout`: buttons in order, non-overlapping, centred; hits on each - button, the separators (none), outside (none); menu rows' hits. -- `state`: defaults from the config, save/load round trip, a broken file - falls back to the defaults, unknown keys are ignored. -- `countdown`: the sequence of numbers for 5 and 10 s, and that 0 means - no countdown. -- `main`: the CLI parses `toolbar`; a choice maps to the right mode and - flags. - -Manual checks go to the Phase 6 section of `docs/test-checklist.md`. - -## 6. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; the checklist section is written. diff --git a/docs/superpowers/specs/2026-10-03-valw-polish-design.md b/docs/superpowers/specs/2026-10-03-valw-polish-design.md deleted file mode 100644 index c5b1e34..0000000 --- a/docs/superpowers/specs/2026-10-03-valw-polish-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# valw polish phase (design) - -Date: 2026-10-03 -Status: approved in brainstorming, pending written-spec review -Builds on: Phases 0–6 (on `main`) - -## 1. Goal - -Three finishing touches, in this order: - -- **P1** Region selection: a size label next to the pointer and macOS's - Shift / Alt / Space modifiers while dragging. -- **P2** An optional soft shadow around window shots. -- **P3** The combo shutter sound: escalating with quick successive shots, - ending in an endlessly rising loop. - -### Out of scope - -The deferred minors in `docs/test-checklist.md` (they wait for the end -test pass). Nothing binds to Caps Lock (the user maps it to Ctrl). - -## 2. P1: Region selection - -### 2.1 Size label - -While a selection is being dragged, a label shows its size in physical -pixels, `W × H` (the same numbers the saved PNG will have), in white -13 px text on a rounded `#1c1c1e` pill at 90 % opacity, 6 px padding. -It sits 16 logical px right of and below the pointer; if it would leave -the output it moves to the other side of the pointer (left and/or above). -It is drawn only on the output where the drag happens, and disappears -with the drag. - -### 2.2 Modifiers while dragging - -The selection is computed from the press point `a`, the pointer `p` and -the modifiers, as a pure function in `selection.rs`: - -| Held | Effect | -|---|---| -| none | rectangle from `a` to `p` | -| Shift | only one dimension follows the pointer: the one that changes more in the first motion after Shift goes down; the other stays as it was when Shift was pressed. Releasing Shift returns to the plain rectangle | -| Alt (Option) | `a` is the centre: the rectangle spans `a ± (p − a)` | -| Space (held) | the whole rectangle moves with the pointer, keeping its size; on release the far corner follows the pointer again from where it is | - -Shift and Alt combine (one dimension, from the centre). Space before a -drag still switches to window mode (Phase 3); Space during a drag now -moves the selection. Releasing the mouse button finishes the selection -with whatever modifiers are held. - -The overlay learns the modifier state from the keyboard (SCTK -`update_modifiers`) and Space press/release events. - -## 3. P2: Window shadow - -`[capture] window_shadow = false` (default off). When on, a window shot -(Phase 3, from `valw window`, Space in region mode, or the toolbar) gets -a shadow before it is saved: - -- The image is padded by 40 px on every side (transparent). -- The shadow is the window's own alpha (so it follows rounded corners), - black at 50 % opacity, blurred with a radius of 20 px (three box blur - passes approximating a Gaussian), offset 12 px down. -- The window is drawn over it unchanged. - -The preview, clipboard and file all get the shadowed image (it has -alpha; Phase 3's preview already handles that). - -## 4. P3: The combo sound - -### 4.1 The sounds - -All sounds are synthesised in code (no samples): 16-bit mono PCM at -44 100 Hz, written as WAV. - -- **The shutter** (~150 ms): a short filtered noise click (5 ms attack, - fast decay) followed after 60 ms by a second, softer and longer - "shk" (noise through a gentle band-pass, 80 ms decay). -- **Shots 1–5**: the shutter played faster and higher: speed - 1.00, 1.12, 1.25, 1.40, 1.60 (resampled; shorter and brighter). -- **Shot 6**: the shutter at speed 1.80 followed by a Shepard-tone - glide rising half an octave over 400 ms. -- **Shot 7**: the same shutter, then the glide continuing over the next - half octave, so that its end equals the start of shot 6's glide. -- From then on shots alternate 6, 7, 6, 7…, which sounds like an endless - rise. The Shepard tone is a sum of octave-spaced partials under a - fixed bell-shaped loudness curve over log-frequency, so a whole-octave - shift sounds identical. - -### 4.2 The combo - -- A shot's number is 1 + the previous one's, if the previous shot was - less than `combo_reset_secs` ago (default 5); otherwise 1. After 7 it - goes back to 6. -- The state (last number, last time) is a small file in - `$XDG_RUNTIME_DIR` (`valw-combo`), so separate `valw` runs share it. - A missing or unreadable file means number 1. - -### 4.3 Playing - -- The sound plays right after the screen was captured (before saving), - for every mode that produces a shot (screen, `--all` once, region, - window, zoom's `c`). Cancelled captures play nothing. -- The seven WAVs are written once to `$XDG_RUNTIME_DIR/valw-sound/` - (regenerated if missing) and played by spawning `pw-play` (PipeWire) - detached, with the volume from the config; valw doesn't wait for it. - `pw-play` comes with the Nix package's `PATH`. If it can't be started, - a warning is logged once per run and the capture goes on. - -### 4.4 Settings - -```toml -[sound] -enabled = true -volume = 0.6 # 0.0–1.0 -combo_reset_secs = 5 # 1–60 -``` - -The toolbar's Options menu gains **Play sound** (a check, remembered in -`toolbar.toml`, default from `sound.enabled`), which wins over the config -for captures started from the bar, like Show cursor. - -## 5. Structure - -| File | Change | -|---|---| -| `src/selection.rs` | modifier-aware selection: `Mods`, the Shift axis lock, centre and move | -| `src/region.rs` | modifiers, Space during a drag, the size label | -| `src/render.rs` | blending a small premultiplied label into the overlay buffer | -| `src/wayland.rs` | `update_modifiers` and Space release to the overlay | -| `src/shadow.rs` (new) | `add_shadow(&RgbaImage) -> RgbaImage` | -| `src/window.rs` / `src/main.rs` | apply the shadow when configured; play the sound after capture | -| `src/sound/` (new) | `synth.rs` (shutter, speed-up, Shepard glide, WAV), `combo.rs` (numbering and state file), `mod.rs` (cache + `pw-play`) | -| `src/config.rs` | `capture.window_shadow`, `[sound]` | -| `src/toolbar/*` | the Play sound option | -| `nix/package.ulu.nix` | `pipewire` on the wrapper's `PATH` | - -## 6. Testing - -Unit tests (`cargo nextest`): -- `selection`: plain, Shift locking each axis, Alt from the centre, Shift - + Alt, Space moving and then resizing again, clipping to the output. -- the size label's placement near each edge and its text. -- `shadow`: output size, the shadow below the window (alpha > 0 under, - ~0 far away), the window's own pixels unchanged, rounded corners - followed. -- `synth`: lengths per speed, no clipping, the WAV header; the Shepard - glide's end matching its start an octave apart (6's start = 7's end). -- `combo`: numbering 1…7 then 6, 7, 6; reset after the gap; a broken - state file. -- config defaults and bounds; the toolbar's sound option. - -Manual checks go to the polish section of `docs/test-checklist.md`. - -## 7. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; the checklist section is written. diff --git a/docs/superpowers/specs/2026-10-03-valw-theme-design.md b/docs/superpowers/specs/2026-10-03-valw-theme-design.md deleted file mode 100644 index 6fd2aa0..0000000 --- a/docs/superpowers/specs/2026-10-03-valw-theme-design.md +++ /dev/null @@ -1,150 +0,0 @@ -# valw theme round: the paint shader, overview backdrop, window picker (design) - -Date: 2026-10-03 -Status: approved in brainstorming (after the throwaway `valw-lab` spike), pending written-spec review -Builds on: every phase on `main`; zoom's EGL/GLES renderer (`src/zoom/gl.rs`) - -## 1. Goal - -Give valw a theme of its own: a Balatro-like liquid-paint background, -written from scratch as a shader, coloured from the user's theme, a -wallpaper or a window. Three uses: - -- **T1** A shared theme module (shader, palettes); the region overlay's - dark dim takes the theme's colour (no shader there: the user must see - what they select). -- **T2** `valw backdrop`: the shader, calm and slow, behind niri's overview, - coloured from the wallpaper (replacing Noctalia's blurred backdrop). -- **T3** An Alt+Tab-style window picker (`valw window` from now on): cards - for every window over the shader, the shader coloured from the selected - window's icon; Enter or a click captures that window. - -### Out of scope - -- Live window thumbnails in the picker: niri 26.04 gives clients no way to - read a window's pixels except `screenshot-window`, which also sets the - clipboard and shows a notification every time. Recorded as a research - item; cards show icons until then. -- The shader on the toolbar, zoom or editor (possible later on the same - module). - -## 2. The paint shader - -The spike's shader, cleaned up (our own code, not Balatro's): - -- Two octaves of domain warping (six sine-warp iterations each, a fixed - rotation between them), banded into a 3-colour palette with soft edges, - dark rims, glossy streaks and a soft vignette. -- Uniforms: time, resolution, `motion` (0 = flow, 1 = swirl around the - centre), `speed`, `scale` (blob size), palette `base`, `body`, - `highlight`. -- Lives in `src/theme/paint.frag` and is drawn by a renderer shared with - zoom's GL code (EGL + glow; no Vulkan). - -## 3. Palettes - -`theme::Palette { base, body, highlight }` (linear 0–1 RGB), from: - -1. **Noctalia's theme**: `noctalia msg color-scheme-get` and - `theme-mode-get`; for a `community` scheme the palette file - `$XDG_STATE_HOME/noctalia/community-palettes/.json` at the current - mode: base = `mSurface`, body = `mSecondary`, highlight = `mPrimary` - (accent `mTertiary` kept for later). Other sources (builtin, - wallpaper, custom) fall back to (3) until their files are mapped. -2. **An image** (a wallpaper, an icon): dominant colours by coarse - bucketing of a downscaled copy; the three most frequent distinct - colours (ignoring transparent and near-grey pixels for icons), sorted - dark → light. -3. **A built-in default** (dark grey-blue), when Noctalia isn't there or - anything above fails (logged at debug level, never an error). - -## 4. T1: themed region dim - -The overlay outside the selection becomes the frozen screen blended 55 % -towards the theme's `base` colour (today: darkened to 60 %), so it stays -see-through. Computed once per output before the overlay appears (no cost -per frame). - -## 5. T2: `valw backdrop` - -- A long-running process: one background-layer surface per output - (namespace `valw-backdrop`, no keyboard, empty input region), drawing - the shader with `motion = flow` at a slow speed, coloured from that - output's wallpaper (`noctalia msg wallpaper-get `, palette - source 2). -- Draws only on frame callbacks, so it costs nothing while niri doesn't - show it. When frames resume after more than 2 s, it re-reads the - wallpaper (a changed wallpaper changes the colours, easing over 1 s). -- Outputs coming and going are followed; a second `valw backdrop` exits - at once (a lock, like the preview host's). -- Wiring (in copland, documented in the README): `spawn-at-startup "valw" - "backdrop"`, niri `layer-rule { match namespace="^valw-backdrop$"; - place-within-backdrop true; }`, Noctalia `[backdrop] enabled = false`. - -## 6. T3: the window picker - -- `valw window` now opens the picker; the click-a-window mode stays as - `valw window --pick` (and Space in region mode keeps using it). -- A full-screen overlay layer surface on the focused output (exclusive - keyboard): the shader full screen, and over it a centred row (wrapping - to more rows when needed) of cards for every window, most recently - focused first (niri's `focus_timestamp`), the focused window's card - selected. -- A card: a rounded translucent panel, the app icon (96 logical px), the - window title (one line, ellipsised) and the app name below. The - selected card is larger (110 %), brighter, with a 2 px light border. -- Keys: Tab / → / ↓ next, Shift+Tab / ← / ↑ previous, Enter capture, - Esc cancel (exit 3). Mouse: hover selects, a click captures, a click - outside the cards cancels. -- The shader's motion alternates per run (swirl, flow, swirl, …; the last - one is remembered in `$XDG_STATE_HOME/valw/picker`), and stays fixed - while the picker is open. Its colours come from the selected window's - icon (palette source 2) and ease to the next card's over 300 ms. -- Icons: the window's `app_id` → its `.desktop` file (`$XDG_DATA_DIRS` - applications, `StartupWMClass` or file name) → `Icon=` → the icon - theme from `~/.config/gtk-3.0/settings.ini` (`gtk-icon-theme-name`), - its `Inherits`, then `hicolor`; SVGs rendered with `resvg`. No icon: a - rounded square with the app name's initial. -- Capturing: the picker closes (surface destroyed, flushed and - round-tripped), then the Phase 3 path (`niri screenshot-window --id`), - shadow, sound, preview, clipboard as usual. - -## 7. Structure - -| File | Role | -|---|---| -| `src/theme/mod.rs` | `Palette`, sources (Noctalia, image, default) | -| `src/theme/paint.frag`, `src/theme/gl.rs` | the shader and its renderer (shared EGL setup with `zoom::gl`) | -| `src/render.rs`, `src/region.rs` | themed dim | -| `src/backdrop.rs` | `valw backdrop` | -| `src/picker/{mod,layout,icons}.rs` | the picker, card layout, icon lookup | -| `src/main.rs` | `Command::Backdrop`, `Command::Window { pick }` | -| `src/niri.rs` | `windows()` (all windows with focus timestamps) | -| `README.md` | backdrop wiring | - -New dependency: `resvg` (SVG icons; built on tiny-skia). - -## 8. Testing - -Unit tests (`cargo nextest`): -- palettes: Noctalia palette file parsing (light and dark, missing keys → - default), image palette (dark → light order, transparent and grey - pixels ignored for icons), default. -- themed dim: a pixel is blended towards `base` by the right amount. -- picker layout: card positions for 1, 5 and 20 windows on 1366×768 and - 1920×1080 (rows wrap, centred, selected card scaled), hit-testing, - keyboard navigation wrapping, MRU order from focus timestamps. -- picker motion alternation from the state file (missing/broken file → - swirl). -- icon lookup against a fake data dir (desktop file by name and by - `StartupWMClass`, theme inheritance, hicolor fallback, SVG and PNG). -- backdrop: the "resume after a pause" rule and colour easing. - -Manual checks go to the theme section of `docs/test-checklist.md` -(including how it feels on the HD 4000). - -## 9. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; README and checklist updated; copland wiring -done with the user's approval. diff --git a/docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md b/docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md deleted file mode 100644 index e337da0..0000000 --- a/docs/superpowers/specs/2026-10-04-valw-noctalia-plugin-design.md +++ /dev/null @@ -1,188 +0,0 @@ -# valw Noctalia plugin (design) - -Date: 2026-10-04 -Status: approved in brainstorming, pending written-spec review -Builds on: every phase on `main`; the toolbar (`src/toolbar/`), the -home-manager module (`nix/home-module.ulu.nix`) - -## 1. Goal - -valw's own toolbar is drawn by valw and does not look like the user's -Noctalia shell. Instead of imitating Noctalia, give it a Noctalia plugin: -Noctalia draws the toolbar with its own components, so the theme, font, -corners and border follow Noctalia's settings with no theming code in -valw. - -- A bar widget (a camera glyph) and a control-center tile. -- The toolbar as a Noctalia panel: the four modes, the timer and the - three options. -- One source of truth: the plugin and `valw toolbar` share - `$XDG_STATE_HOME/valw/toolbar.toml`, through three new `valw toolbar` - flags; the plugin holds no capture logic. - -`valw toolbar` (the self-drawn bar) stays as it is, for setups without -Noctalia. - -### Out of scope - -- A countdown on the bar while a timer runs. -- Dragging a shot out of the panel (`ui.dragSource` exists; a later idea). -- Noctalia styling for valw's own surfaces (preview, picker, region label). -- Publishing to Noctalia's community plugin source. - -## 2. The bridge: new `valw toolbar` flags - -Mutually exclusive with each other; without any of them `valw toolbar` -opens the bar as today. - -- `valw toolbar --state`: prints the remembered state as one line of - JSON, then exits 0: - `{"mode":"region","timer":0,"cursor":false,"preview":true,"sound":true,"timers":[0,5,10]}`. - A missing or broken file prints the defaults (as `load` does today). -- `valw toolbar --set KEY=VALUE` (repeatable): validates every pair, then - writes them all at once (atomic write, the same file and format the bar - saves). Keys: `mode` (`screen|window|region|zoom`), `timer` (one of - `TIMERS`), `cursor`, `preview`, `sound` (`true|false`). Any unknown key - or bad value: an error naming it, exit 1 (like every valw error), - nothing written. -- `valw toolbar --run [MODE]`: captures at once with the remembered - options, exactly as choosing that mode in the bar would (timer as the - delay, the `toolbar_cursor`/`toolbar_preview`/`toolbar_sound` - overrides). A given `MODE` is remembered as the new mode; without one - the remembered mode is used. No bar surface is created. Exit codes as - for the capture commands (3 = cancelled). - -The capture path the bar already uses is shared, not duplicated: the -bar's "mode chosen" step and `--run` call the same function. - -## 3. The plugin - -`noctalia-plugin/` in the repo, id `elars/valw`, `plugin_api = 3` (what -the installed Noctalia 5.2 and its official plugins use), MIT, -`dependencies = ["valw"]`, icon `camera`. - -``` -noctalia-plugin/ - plugin.toml - bar.luau [[widget]] id = "bar" - panel.luau [[panel]] id = "toolbar" - shortcut.luau [[shortcut]] id = "tile" - translations/en.json, translations/tr.json - README.md -``` - -Commands run through `noctalia.runAsync` with fixed command strings (no -user text is ever interpolated into them). - -### 3.1 Bar widget - -- A `camera` glyph; tooltip "Screenshot". -- Left click: `noctalia.togglePanel("elars/valw:toolbar")`. -- Right click: `valw region` (plain, ignoring the toolbar's timer and - options, like the keybind). - -### 3.2 Toolbar panel - -`placement = "attached"`, `position = "auto"`, `open_near_click = true`, -about 440 × 280. - -- On open (`onOpen`): `valw toolbar --state` → `noctalia.json.decode` → - render. If valw is missing (`noctalia.commandExists`) or the call fails: - a single label "valw is not available" and `noctalia.notifyError` with - valw's error message (its `error:` line). -- A row of four buttons with glyphs and labels: Screen (`device-desktop`), - Region (`crop`), Window (`app-window`), Zoom (`zoom-in`). The - remembered mode is `variant = "primary"`, the others `"outline"`. -- A click on a mode: `panel.close()`, then - `sleep 0.3 && valw toolbar --run ` (the wait lets the panel - disappear before the screen is frozen or captured). Captures run with - `noctalia.runStream` (stderr folded in, the exit status echoed last): - `runAsync` with a callback kills its command after 5 s, and a region, - a zoom or the timer can take longer. A non-zero exit other than 3 → - `noctalia.notifyError("valw", )`. -- Below: "Timer" `ui.select` with `Off`, `5 s`, `10 s` (from `timers`), - and three `ui.toggle`s: "Show cursor", "Show preview", "Play sound". - A change re-renders at once and runs `valw toolbar --set` with every - choice (so two quick changes can't undo each other); a failure → - `notifyError`. `--state` and `--set` start no run log (they would push - the capture logs out). -- Colours only by Noctalia role names (`primary`, `on_surface`, - `outline`, …); no hex colours anywhere in the plugin. - -### 3.3 Control-center tile - -- Label "Screenshot", icon `camera`. -- Click: `noctalia msg panel-close; sleep 0.3 && valw region` (the control - center closes first, so it is not in the shot). -- Right click: `noctalia.togglePanel("elars/valw:toolbar")`. - -### 3.4 Translations - -All visible strings through `noctalia.tr`, with `en.json` and `tr.json`. - -## 4. Packaging and home-manager - -- The package installs `noctalia-plugin/` to - `$out/share/valw/noctalia-plugin` (the source filter keeps the folder). -- `programs.valw.noctalia.enable` (bool, default false): links that folder - to `$XDG_DATA_HOME/noctalia/plugins/valw` with `xdg.dataFile`, where - Noctalia finds local plugins. Enabling the plugin and placing the - widget stay in the user's Noctalia settings (copland), since the valw - module does not own Noctalia's config. -- The folder name Noctalia expects under `plugins/` and whether it - follows a symlink into the store are checked against the live Noctalia - during implementation; if a symlink is refused, the module copies the - files instead (`recursive = true`). - -## 5. Wiring (copland, with the user's approval, at the end) - -- `programs.valw.noctalia.enable = true`. -- Noctalia: `"elars/valw"` in `plugins.enabled`; a widget instance - `widget.valw = { type = "elars/valw:bar"; }` and `"valw"` in the bar's - `end` list (the way copland places `notes` and `recorder`). -- niri: `Mod+Shift+T` → `noctalia msg panel-toggle elars/valw:toolbar`. - -## 6. Structure - -| File | Role | -|---|---| -| `src/toolbar/state.rs` | `to_json`, `apply_sets` (parse and validate `KEY=VALUE`) | -| `src/toolbar/mod.rs` | the shared "capture with these options" step | -| `src/main.rs` | `--state`, `--set`, `--run` on `Command::Toolbar` | -| `noctalia-plugin/*` | the plugin | -| `nix/package.ulu.nix` | install the plugin folder | -| `nix/home-module.ulu.nix` | `programs.valw.noctalia.enable` | -| `nix/checks.ulu.nix` | plugin files present; Luau syntax; HM option | -| `README.md`, `docs/test-checklist.md` | wiring; a Noctalia section | - -## 7. Testing - -Unit tests (`cargo nextest`): -- `--state` JSON: the defaults, a saved file, a broken file (defaults). -- `--set`: every key; several pairs at once; unknown key, bad mode, - `timer=7`, `cursor=maybe`, a pair without `=` all rejected with nothing - written; the written file loads back to the same state. -- `--run`: a given mode is remembered; the options it passes to the - capture equal the bar's for the same state (the shared step, tested - without a compositor). -- CLI: the three flags conflict with each other. - -`nix flake check`: -- The package has `share/valw/noctalia-plugin/plugin.toml` with - `id = "elars/valw"`, and every `entry` named in it exists. -- Every `.luau` file compiles with `luau-compile` (syntax only; the - Noctalia globals are not checked). -- The home-manager check: with `noctalia.enable = true` the data file is - set; without it, not. - -Manual checks go to a Noctalia section of `docs/test-checklist.md`: the -widget appears, both clicks, the panel follows the theme (try a palette -and light/dark switch), every mode button, the timer and toggles persist -and match `valw toolbar`, the panel is not in the shot, the tile, the -error label without valw on `PATH`. - -## 8. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` -and `nix flake check` pass; README and checklist updated; the plugin -loads in the live Noctalia; copland wiring done with the user's approval. diff --git a/docs/superpowers/specs/2026-10-04-valw-spanning-region-design.md b/docs/superpowers/specs/2026-10-04-valw-spanning-region-design.md deleted file mode 100644 index 934c0ca..0000000 --- a/docs/superpowers/specs/2026-10-04-valw-spanning-region-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# valw: a region across monitors (design) - -Date: 2026-10-04 -Status: approved in brainstorming (transparent gaps), pending written-spec review -Builds on: region mode (`src/region.rs`, `src/selection.rs`), capture (`src/main.rs`, `src/frame.rs`) - -## 1. Goal - -`valw region` today keeps a selection on the output where the drag -started and clips it there. Let a drag carry on onto other outputs: each -output shows its part of the selection, and the shot is one image of the -whole rectangle, stitched from every output it covers. - -The user's layout: LVDS-1 1366×768 at (0, 0), HDMI-A-1 1920×1080 at -(−277, −1080), both scale 1, so a box dragged from the laptop up onto the -monitor is the main case. - -## 2. Behaviour - -- The selection lives in global logical coordinates (it already does); - Shift, Alt and Space keep working across outputs. -- While dragging, every output whose area the rectangle touches shows its - part of it (bright inside, dimmed outside); the others stay fully dimmed. - Wayland keeps a drag's pointer events on the surface where it started, - with positions beyond its edges, so the global position is still - `start output origin + event position`. -- The `W × H` label shows the size of the whole shot, next to the pointer, - on the output the pointer is over (found from the global position; when - it is over no output, on the nearest one). -- On release: - - inside one output: exactly as today (`Choice::Region(output, rect)`, - opaque, the same pixels); - - across outputs: one image the size of the whole rectangle in physical - pixels at the largest scale among the outputs it covers; each output's - part is cropped from its frozen frame and placed at its offset - (resampled when that output's scale is smaller); areas that no output - covers are transparent (alpha 0). Everything else (sound, clipboard, - file, preview) as for any shot; the preview appears on the output the - pointer was over at release. -- A drag smaller than the click threshold (`MIN_PIXELS`) in the whole - rectangle is still a click. - -## 3. Structure - -| File | Role | -|---|---| -| `src/selection.rs` | `span(a, b, outputs) -> Option`: the rectangle's parts per output (output index, its `PixelRect`, where it goes and at what size in the stitched image) and the image size; `locate(point, outputs)`: the output a global point is over (or the nearest) | -| `src/stitch.rs` (new) | `stitch(frames, span) -> RgbaImage`: transparent canvas, each part's crop pasted (resized when needed) | -| `src/region.rs` | every surface draws its part (`selection_on` no longer anchor-only), redraw only surfaces whose part changed; label on the pointer's output with the whole size; release returns `Choice::Region` for one output or `Choice::Span(Span, preview_output)` | -| `src/main.rs` | `Choice::Span` → `stitch`, source = the preview output | - -## 4. Testing - -Unit tests (`cargo nextest`): -- `span`: a rectangle inside one output → one part equal to today's `clip`; - the user's layout, a box from the laptop up onto the monitor → two - parts, their places, the image size; a box partly over no output → the - image is the whole box; mixed scales (1 and 2) → the image at scale 2, - the scale-1 part placed and sized ×2; nothing under `MIN_PIXELS` → None. -- `locate`: a point on each output, in the gap (nearest output). -- `stitch`: pixels land at the right offsets, gaps are alpha 0, covered - pixels alpha 255, a scaled part is resized to its target size. -- region: the surface where the drag did not start draws its part; the - release decision (one output → `Region`, two → `Span`) as a pure function - of the corners and the outputs. - -Manual checks (a section in `docs/test-checklist.md`): drag from the laptop -up onto the monitor and back, Shift/Alt/Space across the edge, the label on -the right output with the whole size, the stitched PNG (gaps transparent, -seams exact), preview on the release output, single-output shots unchanged. - -## 5. Definition of done - -`cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo nextest run` and -`nix flake check` pass; checklist updated; copland lock updated after the -user's approval. diff --git a/docs/test-checklist.md b/docs/test-checklist.md deleted file mode 100644 index c1f1869..0000000 --- a/docs/test-checklist.md +++ /dev/null @@ -1,206 +0,0 @@ -# valw: end-of-project test checklist - -Manual checks on niri that each phase leaves for one test pass once every -phase is done. Each phase appends its own section; nothing here blocks a -phase from landing. Automated checks (`cargo clippy`, `cargo nextest`, -`nix flake check`) still run per phase. - -Before starting: update copland's lock (`nix flake update valw` in -`~/copland`), rebuild, and check that `valw doctor` is all `ok`. - -## Preview drag-out (2026-10-02) - -Already seen working while building it: a drop into Telegram delivered the -image, a cancel brought the thumbnail back, and a swipe still dismissed it. - -- [ ] Drop into Dolphin → the file is copied (the default name has spaces, so this also checks `%20`). -- [ ] Drop into a browser upload field → it uploads. -- [ ] Drop into a Discord message → it attaches. -- [ ] Drop into an image editor that takes `image/png` → the pixels arrive. -- [ ] Release on nothing, or Esc mid-drag → the thumbnail comes back. -- [ ] With `timeout_secs = 3`, hold a drag past 3 s, then release on nothing → it slides out. -- [ ] While dragging, press Print → after the capture the other thumbnails come back but the dragged one doesn't; finishing the drag then behaves normally. -- [ ] Same, with 5 thumbnails on that output and the oldest one being dragged → the dragged one is never evicted. -- [ ] A fast flick to the left and an immediate release → the thumbnail comes back (or the drag runs normally); it never stays invisible. -- [ ] Drag onto the other monitor and drop there → it works. -- [ ] Swipe right still dismisses; a click still opens Satty (installed build). - -### Known minors (deferred from the drag-out review) - -- A drag-out press that never became a drag (no data device, or a second drag refused) ignores an expired timer; it should slide out on release. -- A receiver that opens its pipe O_NONBLOCK can get a truncated large PNG; clear O_NONBLOCK in the writer thread. -- A press during the slide-in gives a shifted grab point; use (x - offset, y). -- A swipe released back near its start opens Satty (Phase 2 behaviour); a locked swipe should never become a click. -- The (hidden, dragging) visibility/input decision has no unit test; extract it as a pure function. - -## Window mode (Phase 3) - -- [ ] `valw window`, click a tiled window → that window alone is saved, preview on its monitor. -- [ ] Same for a floating window, and for a window partly covered by another (the covered part is still there). -- [ ] A window on the other monitor (different scale, if any) → right size and sharpness; preview on that monitor. -- [ ] Esc during the pick → nothing saved, exit code 3, thumbnails come back. -- [ ] `--cursor` with the pointer over the window → the pointer is in the shot. -- [ ] `valw region`, press Space before dragging → the overlay closes and the window pick starts; Space while dragging does nothing. -- [ ] `--clipboard-only` and `-o -` → clipboard / stdout get the window; no stray `valw-window-*.png` left in `$XDG_RUNTIME_DIR`. -- [ ] niri's own "Screenshot captured" notification appears (expected, see the spec); the clipboard ends up with the window. - -### Known minors (deferred from the window mode review) - -- The 5 s wait includes niri's blocking "Screenshot captured" D-Bus notification; a hung notification daemon fails the shot and leaves the late temp file. Try decoding the temp file once on timeout. -- niri sets the clipboard itself even with `copy_to_clipboard = false` (no niri flag to avoid it); the spec's "end state unchanged" is wrong for that config. Document it. -- The Space path keeps the Wayland connection open through the pick (the spec says it is dropped). Harmless; align the spec or drop it earlier. -- Clicking something that isn't a window during the pick exits 3 silently, like Esc. Log "no window under the click". - -## Zoom (Phase 4) - -- [ ] `valw zoom` → the focused output freezes at 1×; Esc and `q` leave, nothing saved, exit 0, thumbnails come back. -- [ ] The pointer is a magnifier (zoom-in) over the zoom, a grabbing hand while dragging, the magnifier again after. -- [ ] Wheel zooms towards the pointer (the point under it stays put), smoothly, up to 32×; pixels stay crisp. -- [ ] Touchpad scrolling zooms smoothly too. -- [ ] Left drag pans 1:1 and never shows anything outside the screenshot; the cursor turns into a grabbing hand. -- [ ] `f` toggles the flashlight (outside dimmed to 25 %), it follows the pointer; Ctrl+wheel resizes it. -- [ ] `0` animates back to 1×. -- [ ] `c` at some zoom → the saved image is exactly the visible area at native resolution; preview and clipboard as usual. -- [ ] On the 1366×768 laptop panel and the 1920×1080 monitor; stays smooth (no stutter) on the HD 4000. -- [ ] `[zoom] scroll_step` / `flashlight_radius` in the config take effect; `valw doctor` shows the EGL line. - -### Known minors (deferred from the zoom review) - -- `c` during a zoom animation captures where the view is heading, not what is on screen. -- The crop can include an edge column/row that was never shown; use the shader's exact texel bounds. -- Panning during a zoom animation jumps; pan both the shown and target view. -- A cancelled pointer grab (no Release, only Leave) leaves panning on; clear it on Leave. -- The first frame of each animation uses dt = 0 (one frame of extra latency). -- libEGL is unloaded when the renderer drops; keep it loaded for the whole process. -- A panic during zoom frees the Wayland display before EGL is torn down (segfault instead of the panic message). -- `zoom.scroll_step = nan` passes validation. -- With Caps Lock on, `c`, `f` and `q` are ignored. -- `valw doctor` shows only the EGL vendor; a software (llvmpipe) fallback would look fine. Report GL_RENDERER. - -## Editor (Phase 5a) - -- [ ] Clicking a preview opens `valw edit` (title ` — valw`); `[editor] backend = "satty"` still opens Satty. -- [ ] `[editor] backend = "noctalia"`: clicking a preview opens the shot in Noctalia's annotator, in the Noctalia theme; Save writes a new "annotated" file in the screenshots folder (the shot stays as it was), Copy puts the result on the clipboard. -- [ ] Each tool draws (arrow head at the release point, outline rectangle/ellipse, line, pen dot on a click, translucent highlighter); Shift snaps lines to 45° and makes squares/circles. -- [ ] Colours and S/M/L change new shapes only; A/R/O/L/P/H switch tools. -- [ ] Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y and the toolbar buttons undo and redo. -- [ ] Ctrl+S overwrites the file (the preview's file shows the edits); Ctrl+Shift+S writes `… edited.png`; "Saved" appears for 2 s. -- [ ] The saved PNG matches the canvas (same positions and widths), also for a window shot with transparent corners. -- [ ] Ctrl+C then Esc: the edited image is still pasteable afterwards. -- [ ] Esc with unsaved changes shows the bar; Esc again discards; Ctrl+S in the bar saves and closes. Closing the window with Mod+Q behaves the same. -- [ ] A large (1920×1080) shot fits the window; a small one isn't blown up; the window can be resized. - -### Known minors (deferred from the editor review) - -- Esc during a drag commits the half-drawn shape. -- A Pen click always stores two equal points; a touchpad tap in one frame draws nothing; Pen points aren't de-duplicated. -- Any mouse button draws; it should be the primary one. -- "Copied" shows even if the clipboard child fails; finished clipboard children aren't reaped until the editor exits. -- After undoing back to the saved state, Ctrl+S still closes; the unsaved bar can come back without a new Esc. -- The close guard runs only while the window is shown (App::ui, not App::logic). -- Saving round-trips pixels through premultiplied alpha (tiny RGB changes on nearly transparent pixels). -- The dev shell's LD_LIBRARY_PATH applies to every tool; Cargo.lock has duplicate smithay-client-toolkit/calloop/glow versions. - -## Editor tools (Phase 5b) - -- [ ] T: click, type (Turkish: ğüşıöçİ), Shift+Enter for a new line, Enter finishes; a click elsewhere also finishes; Esc drops it; while typing, letters don't switch tools and Ctrl+Z/Esc don't undo or close. -- [ ] Text is bold in the chosen colour with a dark outline, readable on light and dark backgrounds; S/M/L sizes; the saved PNG matches the canvas. -- [ ] B: dragging pixelates the area (also earlier arrows inside it); S/M/L block sizes; edges of the image are fine. -- [ ] N: clicks place 1, 2, 3 …; undo then a new click reuses the freed number. -- [ ] C: drag a crop, adjust its edges and corners (the cursor changes), Enter applies, Esc cancels; the canvas then shows only the crop; drawing after a crop lands where you click; undo restores the full image; the saved PNG has the crop's size. -- [ ] The toolbar shows all 10 tools and still wraps in a narrow window. - -### Known minors (deferred from the editor tools review) - -- A crop thinner than 16 screen px grabs both opposite edges when pressed and collapses. -- Redo while a crop is being adjusted can apply a crop that isn't inside the current one. -- The canvas edge of a crop can show a faint fringe from outside it (export unaffected). -- Every keystroke re-renders the whole image; fine at 1080p, may lag on 4K. -- The pixelate drag preview is hard to see on white. -- The unsaved bar mentions Ctrl+S, which is ignored while typing. - -## Toolbar (Phase 6) - -- [ ] `valw toolbar` shows the bar bottom-centre on the focused output (both monitors), crisp; the last mode is highlighted; hovering highlights buttons. -- [ ] Screen, Window, Region and Zoom each start their mode at once; the bar is not in the shot. -- [ ] Options ▾ opens the menu; timer None/5/10, Show cursor and Show preview toggle (✓) and are remembered next time (`~/.local/state/valw/toolbar.toml`). -- [ ] With a 5 s timer the pill counts 5 → 1 bottom-centre, the desktop stays usable meanwhile, and the pill is not in the shot; clicking the pill cancels (exit 3). -- [ ] Esc or a click beside the bar closes it without capturing (exit 3). -- [ ] A broken `toolbar.toml` doesn't stop the bar (warning in the log, defaults used). -- [ ] Bind it in niri (Mod+Shift+5 is taken in copland; pick a key). - -### Known minors (deferred from the toolbar review) - -- A Wayland error during the countdown counts as a tick: the timer is skipped instead of failing. -- Each hover change allocates a full-output buffer; the pool can grow large on 4K. -- Hover isn't set when the pointer enters and isn't cleared when it leaves. -- On two monitors, a click on the other one doesn't close the bar (Esc does). -- With the menu open, a click elsewhere closes the whole bar, and option changes made before a cancel aren't saved. -- The toolbar doesn't take the capture lock: two bars can stack, and a capture started during the countdown makes the toolbar's capture fail. -- The bar can cover a bottom panel, while the countdown pill sits above it. - -## Polish - -- [ ] Region: while dragging, a square-cornered `W × H` label follows the pointer (physical pixels, same as the saved PNG), flipping near the edges. -- [ ] Shift locks the dimension you move first; Alt grows from the centre; Space held moves the selection, and after letting go resizing continues without a jump; Space before a drag still switches to window mode. -- [ ] `[capture] window_shadow = true`: window shots get a soft shadow following the rounded corners; preview, clipboard and file all have it. -- [ ] Each shot is a two-hit "ka-chunk" (macOS-like) with a metallic clatter that grows from shot 2; shots in quick succession play sounds 1 → 5 getting faster and brighter, then 6, 7, 6, 7 (a climbing bell arpeggio over a glide) rising endlessly; every shot about as loud; after a 5 s pause it starts at 1 again. `valw __combo-demo` plays the whole sequence for tuning. -- [ ] `[sound] enabled = false`, `volume`, `combo_reset_secs` take effect; the toolbar's Play sound option overrides the config; no `pw-play` → no sound, capture still works (warning in the log). - -- [ ] Region across monitors: drag from the laptop up onto the monitor and back; each screen shows its part, the label shows the whole size on the screen under the pointer; Shift, Alt and Space work across the edge; the PNG is the whole rectangle, seams exact, parts over no screen transparent; the preview appears on the screen where you let go; a box on one screen is exactly as before. - -### Known minors (deferred from the polish review) - -- Releasing Shift mid-drag only updates the rectangle on the next pointer motion. -- The Shift axis is decided by the very first motion; a few pixels of threshold would feel steadier. -- `__combo-demo` and a capture regenerating the same WAV at once can race on the temp file (one silent shot). -- Without `XDG_RUNTIME_DIR` the sound cache and combo state in `/tmp` are shared between users. -- `pipewire` on the wrapper's PATH adds its whole closure to the package. - -## home-manager module - -- [ ] copland: `home-valw.ulu.nix` uses `inputs.valw.homeModules.default` with `programs.valw.enable = true` and a few `settings`; rebuild; `~/.config/valw/config.toml` is the store file with those settings. -- [ ] A typo in `settings` (e.g. `preview.timout_secs`) fails the rebuild naming the key. -- [ ] The toolbar still remembers its options (state file stays writable). -- [ ] New binds for `valw window`, `valw zoom`, `valw toolbar` in copland's niri keybinds. - -### Known minors (deferred from the home-manager review) - -- Cross-compiled home configurations can't run the build-time check; skip it when the build platform can't run the host binary. -- The check derivation may be sent to remote builders (no `preferLocalBuild`). -- The README's module example assumes `inputs` is passed to home-manager modules. -- The source filter drops any `docs` or `README.md` at any depth, not just the root ones. -- The README's niri binds clash with niri's default `Print` bind if pasted next to it. - -## Theme - -- [ ] Region (Mod+Shift+S): the dim outside the selection takes the Noctalia theme's colour (light and dark mode), still see-through; it opens as fast as before. -- [ ] `valw window` (Mod+Shift+W): cards for every window, most recently used first, icons right (Ghostty, Dolphin, Telegram, Spotify…), long titles ellipsised; Tab/Shift+Tab/arrows/hover move the selection; Enter or a click captures that window (shadow, sound, preview); Esc or a click outside cancels. -- [ ] The picker's shader alternates swirl/flow per run and takes the selected window's colours, easing between cards; the picker never appears in the shot. -- [ ] 20+ windows still fit (cards shrink); a window without an icon shows its initial. -- [ ] Toolbar → Window opens the picker; Space in region mode still uses niri's click-a-window. -- [ ] Overview (Mod+Tab): the paint flows slowly behind the workspaces, coloured from the current wallpaper; after the wallpaper changes, the next overview eases to the new colours; no GPU load while the overview is closed (`cat /sys/class/drm/card1/gt_act_freq_mhz` stays low). -- [ ] Two monitors: each has its own backdrop; unplugging/replugging one keeps the other working. -- [ ] Picker → Enter on the focused window of a libadwaita/GTK app: the headerbar isn't dimmed in the shot (150 ms settle). - -### Known minors (deferred from the theme review) - -- The wallpaper is decoded again on every overview open after a pause. -- Icon lookup misses Breeze-style `apps//` layouts, relative `Icon=foo.png`, `.svgz`/`.xpm`; non-square icons aren't centred. -- Each selection change rebuilds a full-output overlay; long titles make ellipsising slow. -- Palette names with non-space special characters fall back to the default palette. -- The backdrop calls every lock error "another backdrop is running"; it keeps its first scale after a scale change. -- libEGL is unloaded when the last backdrop surface goes; a removed output leaks its viewport object. -- Holding Tab doesn't repeat in the picker; "no windows" exits as an error instead of a cancel. - -## Noctalia plugin - -- [ ] `programs.valw.noctalia.enable` + `elars/valw` enabled: the camera button appears where the widget is placed; its tooltip says Screenshot. -- [ ] Left click opens the toolbar panel next to the bar; it uses Noctalia's colours, font, corners and border; switching the palette or light/dark restyles it. -- [ ] The remembered mode is highlighted; Screen / Region / Window / Zoom each capture, and the panel is never in the shot. -- [ ] Timer and the three toggles persist, and match what `valw toolbar` (the self-drawn bar) shows, both ways; the timer shows valw's countdown. -- [ ] Right click on the button: a region shot (no timer). -- [ ] Control-center tile: click closes the control center, then a region shot; right click opens the toolbar panel. -- [ ] `noctalia msg panel-toggle elars/valw:toolbar` (Mod+Shift+T) opens and closes the panel. -- [ ] With valw off Noctalia's PATH: "valw is not available" and a notification, no crash. -- [ ] Turkish locale: the Turkish strings appear. diff --git a/docs/vision.md b/docs/vision.md deleted file mode 100644 index 3a4ffcd..0000000 --- a/docs/vision.md +++ /dev/null @@ -1,248 +0,0 @@ -> Original product vision, kept as written. The project was renamed from `snapr` to `valw`; phase designs live in `docs/superpowers/specs/`. - -# SPEC.md: macOS tarzı ekran görüntüsü + zoom aracı (niri / Wayland / NixOS) - -> Bu dosyayı projenin köküne `SPEC.md` (veya `CLAUDE.md`) olarak koy ve Claude Code'a -> "SPEC.md'yi oku, Faz 1'den başla" de. Proje adı şimdilik `snapr`, istediğin gibi değiştir. - -## 1. Amaç - -macOS'taki **Cmd+Shift+3/4/5** deneyimini niri üzerinde birebir hissettiren, Rust ile yazılmış tek bir araç: - -- Bölge / pencere / tam ekran yakalama -- macOS'taki gibi **sağ altta beliren küçük önizleme** (tıkla → düzenle, sürükle → bırak, kaydır → kapat) -- Basit bir **düzenleyici** (ok, kutu, metin, kalem, bulanıklaştırma, kırpma) -- **Zoom / büyüteç modu** (woomer benzeri: zoom, pan, flashlight) -- Cmd+Shift+5 benzeri **araç çubuğu** - -## 2. Hedef ortam ve kısıtlar - -- **Compositor:** niri (öncelikli). Diğer wlroots tabanlı compositor'larda çalışması bonus, zorunlu değil. -- **Shell:** Noctalia. Doğrudan bir Noctalia API'sine bağımlı olma; entegrasyon **CLI + standart Wayland protokolleri + freedesktop bildirimleri** üzerinden olsun, böylece Noctalia'nın iç yapısı değişse de araç çalışır. -- **Dağıtım:** NixOS. Proje bir **flake** olarak paketlenmeli. -- **Dil:** Rust (stable). X11 desteği yok, sadece Wayland. - -> ⚠️ Claude Code için: protokol ve IPC desteğini **varsayma, doğrula.** Kodlamaya başlamadan önce -> kullanıcının makinesinde `niri --version`, `niri msg --help`, `wayland-info` çıktılarına bak ve -> hangi protokollerin sunulduğunu raporla. - -## 3. Teknik yaklaşım - -| Konu | Tercih | Not | -|---|---|---| -| Wayland istemcisi | `smithay-client-toolkit` (SCTK) + `wayland-client` | | -| Overlay yüzeyleri | `wlr-layer-shell` | Seçim ekranı, önizleme, zoom modu hepsi layer-shell yüzeyi | -| Ekran yakalama | `ext-image-copy-capture-v1` varsa onu, yoksa `wlr-screencopy-unstable-v1` | İkisini de bir trait arkasına soy | -| Pencere bilgisi | `niri-ipc` crate'i ile niri IPC | Pencere listesi, odaklı pencere, monitör bilgisi. Pencere geometrisi IPC'den alınamıyorsa pencere yakalamada niri'nin kendi `screenshot-window` aksiyonuna düş | -| Çizim | `tiny-skia` (yazılımsal) | Gerekirse sonradan `wgpu`'ya geçilebilir; zoom modunda performans yetmezse ilk aday orası | -| Metin | `cosmic-text` veya `fontdue` | | -| Pano | `wl-clipboard-rs` | PNG olarak kopyala | -| Bildirim | `notify-rust` | Önizleme penceresi yerine fallback olarak | -| Görüntü | `image` crate'i (PNG kaydetme) | | -| Config | `serde` + `toml`, `~/.config/snapr/config.toml` | | -| CLI | `clap` | | - -**HiDPI / kesirli ölçekleme** en baştan düşünülmeli: `wp-fractional-scale-v1` ve `wp-viewporter` kullan, yakalanan görüntü fiziksel piksel çözünürlüğünde olsun. - -**Çoklu monitör:** Seçim modu tüm monitörlerde aynı anda açılmalı; bölge tek monitörle sınırlı olabilir (v1 için yeterli). - -## 4. Özellikler - -### 4.1 Yakalama modları (CLI alt komutları) - -``` -snapr region # Cmd+Shift+4: sürükleyerek bölge seç -snapr window # Cmd+Shift+4 + Space: üzerine gelinen pencere vurgulanır, tıkla → yakala -snapr screen # Cmd+Shift+3: odaklı monitör (veya --all) -snapr toolbar # Cmd+Shift+5: araç çubuğu -snapr zoom # woomer benzeri zoom modu -snapr edit # düzenleyiciyi doğrudan aç -``` - -Ortak bayraklar: `--clipboard-only`, `--no-preview`, `--delay `, `--output `, `--cursor`. - -### 4.2 Bölge seçimi (macOS davranışı) - -- Ekran donar (yakalanmış kare arka plan olarak gösterilir), hafif karartılır. -- İmleç yanında **koordinat / boyut** göstergesi (örn. `640 × 480`). -- Sürüklerken: - - **Shift**: sadece bir eksende genişlet - - **Alt**: merkezden genişlet - - **Space basılı tut**: seçimi taşı -- **Space (sürüklemeden önce)**: pencere moduna geç (macOS'taki kamera ikonu mantığı). -- **Esc**: iptal. -- Bırakınca yakala. - -### 4.3 Pencere modu - -- İmlecin altındaki pencere mavi tonla vurgulanır. -- Tıkla → yakala. Opsiyonel: pencere çevresine gölge ekleme (config ile, macOS'taki gibi, varsayılan kapalı). - -### 4.4 Sağ alt önizleme (macOS'un "floating thumbnail"ı) - -- Yakalamadan sonra odaklı monitörün sağ alt köşesinde, layer-shell ile küçük bir önizleme belirir (kayarak girer). -- Varsayılan **5 sn** sonra kaybolur ve dosya kaydedilir (süre config'den). -- Etkileşimler: - - **Sol tık** → düzenleyicide aç - - **Sağ tık** → küçük menü: Kaydet / Panoya kopyala / Klasörde göster / Sil / Düzenle - - **Sağa kaydır/sürükle** → hemen kaydet ve kapat - - **Dışarı sürükle** → `wl_data_device` ile sürükle-bırak (dosyayı başka bir uygulamaya bırakabilmek) - - Üzerine gelince zamanlayıcı durur -- Önizleme devre dışıysa `notify-send` benzeri bir bildirim gönder (resim + "Düzenle" aksiyonu). Noctalia bildirimleri bunu gösterecektir. - -### 4.5 Düzenleyici - -Normal bir pencere (xdg-toplevel). Araçlar: - -- Ok, dikdörtgen, elips, çizgi, serbest kalem, işaretleyici (yarı saydam) -- Metin (font boyutu, renk) -- Bulanıklaştır / pikselleştir (hassas bilgileri gizlemek için) -- Numaralı işaret (1, 2, 3... baloncukları) -- Kırp -- Renk paleti + kalınlık -- Geri al / yinele (Ctrl+Z / Ctrl+Shift+Z) -- Ctrl+C panoya kopyala, Ctrl+S kaydet, Esc kapat - -> Alternatif: v1'de düzenleyiciyi yazmak yerine **Satty**'yi çağırmak mümkün. Faz planına bak. - -### 4.6 Araç çubuğu (Cmd+Shift+5) - -Ekranın altında ortalanmış yüzen bir çubuk: - -`[Tam ekran] [Pencere] [Bölge] | [Zoom] | Seçenekler ▾ | [Yakala]` - -Seçenekler: kayıt yeri (Masaüstü / Resimler / Pano / Özel), zamanlayıcı (yok / 5 sn / 10 sn), imleci göster, önizlemeyi göster, son seçimi hatırla. - -> Ekran kaydı (video) **v1 kapsamı dışında**. İleride `gpu-screen-recorder` veya `wf-recorder` çağrılarak eklenebilir. - -### 4.7 Zoom modu (woomer'dan esinlenme) - -- Ekranın anlık görüntüsü üzerinde tam ekran layer-shell yüzeyi. -- Fare tekerleği: imlecin olduğu noktaya doğru zoom -- Sol tık + sürükle: pan -- `f`: flashlight aç/kapat; `Ctrl + tekerlek`: flashlight yarıçapı -- `0`: sıfırla, `Esc`/`q`: çık -- Yumuşak (animasyonlu) zoom ve pan -- **Bonus:** zoom modundayken `c` tuşu → o anki görünümü yakala ve önizleme akışına gönder - -> ⚠️ Lisans: woomer'dan kod kopyalanacaksa önce **lisansını kontrol et** ve uyumluysa atıf ekle. -> Tercih edilen yol: davranışı referans alıp kendi implementasyonunu yazmak. Böylece aynı yakalama ve -> çizim altyapısı paylaşılır, woomer'ın ayrı render bağımlılıklarına ihtiyaç kalmaz. - -## 5. Config örneği - -```toml -# ~/.config/snapr/config.toml -[save] -directory = "~/Pictures/Screenshots" -filename = "Screenshot %Y-%m-%d at %H.%M.%S.png" -copy_to_clipboard = true - -[preview] -enabled = true -timeout_secs = 5 -corner = "bottom-right" # top-left | top-right | bottom-left | bottom-right -size = 220 # px, uzun kenar - -[capture] -show_cursor = false -window_shadow = false - -[editor] -backend = "builtin" # builtin | satty -default_color = "#ff3b30" -stroke_width = 4 - -[zoom] -scroll_step = 1.15 -flashlight_radius = 180 -``` - -## 6. niri entegrasyonu - -```kdl -binds { - Mod+Shift+3 { spawn "snapr" "screen"; } - Mod+Shift+4 { spawn "snapr" "region"; } - Mod+Shift+5 { spawn "snapr" "toolbar"; } - Mod+Z { spawn "snapr" "zoom"; } -} -``` - -Layer-shell yüzeylerinin namespace'leri sabit ve belgelenmiş olsun (`snapr-overlay`, `snapr-preview`, `snapr-zoom`), böylece gerekirse niri'de `layer-rule` ile özelleştirilebilir. - -## 7. NixOS paketleme - -- `flake.nix` içinde: - - `packages..default`: `rustPlatform.buildRustPackage` (veya `crane`) - - `devShells..default`: rust toolchain, `pkg-config`, `wayland`, `libxkbcommon`, `fontconfig`, `wayland-protocols` - - `homeManagerModules.default`: `programs.snapr.enable` + `programs.snapr.settings` (TOML'a çevrilir) -- Runtime'da gereken kütüphaneler için doğru `buildInputs`/`nativeBuildInputs` ve gerekirse `wrapProgram` ile `LD_LIBRARY_PATH`. -- `nix build` ve `nix run . -- region` kutudan çıktığı gibi çalışmalı. - -## 8. Mimari - -``` -src/ - main.rs # clap CLI, alt komut yönlendirme - config.rs - capture/ - mod.rs # trait Capturer - ext_copy.rs # ext-image-copy-capture-v1 - wlr_screencopy.rs - niri.rs # niri-ipc sarmalayıcı (pencereler, monitörler, odak) - overlay/ - region.rs # bölge + pencere seçimi - toolbar.rs - zoom.rs - preview.rs # sağ alt önizleme - editor/ - mod.rs - tools.rs # ok, kutu, metin, blur... - history.rs # undo/redo - output.rs # kaydetme, pano, bildirim - render.rs # tiny-skia yardımcıları -``` - -- Her mod kendi başına çalışan kısa ömürlü bir süreç olabilir (daemon gerekmez). Önizleme yakalamadan sonra aynı süreçte yaşar ve zamanlayıcı bitince çıkar. -- İki yakalama aynı anda başlatılırsa ikincisi birincinin bitmesini bekler veya iptal eder (lock dosyası: `$XDG_RUNTIME_DIR/snapr.lock`). - -## 9. Faz planı - -**Faz 0: Keşif** -Kullanıcının sisteminde protokol desteğini tespit et, raporla. Boş SCTK + layer-shell yüzeyi açıp kapatan minimal örnek. `flake.nix` + devShell. - -**Faz 1: Çekirdek yakalama** -`snapr screen` ve `snapr region` (temel sürükle-seç, Esc iptal). PNG kaydetme + panoya kopyalama. Config dosyası. - -**Faz 2: Önizleme** -Sağ alt önizleme, zamanlayıcı, tık → (şimdilik) Satty'de aç, kaydır → kapat. Bildirim fallback'i. - -**Faz 3: Pencere modu + seçim inceliği** -niri IPC ile pencere vurgulama, Space ile mod değiştirme, Shift/Alt/Space modifikatörleri, boyut göstergesi, HiDPI doğruluğu. - -**Faz 4: Zoom modu** -woomer benzeri zoom/pan/flashlight, zoomdan yakalama. - -**Faz 5: Yerleşik düzenleyici** -Araçlar, undo/redo, kırpma, blur. `editor.backend = "builtin"` varsayılan olur. - -**Faz 6: Araç çubuğu + cila** -Cmd+Shift+5 çubuğu, zamanlayıcı, sürükle-bırak, home-manager modülü, README. - -Her fazın sonunda: `cargo clippy -- -D warnings`, `cargo fmt --check`, `nix build` geçmeli ve fazın özelliği niri üzerinde elle test edilmiş olmalı. - -## 10. Kabul kriterleri - -- 2 monitörlü, biri kesirli ölçekli (örn. 1.25) kurulumda bölge seçimi piksel-doğru yakalar. -- Kısayola basmaktan seçim ekranının görünmesine kadar geçen süre < 150 ms hissi verir. -- Önizleme odak çalmaz (klavye odağı almaz), sadece üzerine tıklanınca etkileşir. -- Hiçbir mod ekranda takılı kalan bir overlay bırakmaz; çökme durumunda bile süreç ölünce yüzey kaybolur. -- Noctalia'nın bildirimleriyle ve barıyla çakışmaz (layer z-sırası doğru). - -## 11. Kapsam dışı (v1) - -- X11, GNOME, KDE desteği -- Video / GIF kaydı -- OCR, bulut yükleme -- Kaydırmalı (scrolling) ekran görüntüsü