Compare commits

...
2 Commits
Author SHA1 Message Date
elwrcl 2edc8c18cc tyo 2026-10-09 15:58:50 +03:00
elwrcl e320f7a1b6 docs: keep the design notes local (untracked, ignored) 2026-10-09 15:51:59 +03:00
29 changed files with 1 additions and 19268 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# valw # valw
valw is desktop helper for 3N' [nixos] (https://nixos.org/) [niri](https://github.com/YaLTeR/niri) [noctalia](https://github.com/noctalia-dev/noctalia) valw is desktop helper for 3N' [nixos](https://nixos.org/) [niri](https://github.com/YaLTeR/niri) [noctalia](https://github.com/noctalia-dev/noctalia)
## installation ## installation
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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<Option<niri_ipc::Window>>`
- `pub fn workspaces() -> Result<Vec<niri_ipc::Workspace>>`
- `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<u64>, workspaces: &[Workspace]) -> Option<String>`
- [ ] **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> {
Socket::connect().context("could not connect to niri (is NIRI_SOCKET set?)")
}
fn request(request: Request) -> Result<Response> {
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<Option<Window>> {
match request(Request::PickWindow)? {
Response::PickedWindow(window) => Ok(window),
other => Err(anyhow!("unexpected niri reply: {other:?}")),
}
}
pub fn workspaces() -> Result<Vec<Workspace>> {
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<u64>, workspaces: &[Workspace]) -> Option<String> {
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<RgbaImage>`; `lock::default_path()`; `error::Cancelled`.
- Produces: `pub fn window::capture(cursor: bool) -> Result<(RgbaImage, Option<String>)>` (image, output name if known); `fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result<(Vec<(RgbaImage, Option<String>)>, 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<String>)> {
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<String>)>, 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<Choice>`; `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<Option<Choice>>,`.
`select`'s signature becomes `-> Result<Choice>` (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"
```
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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`.
@@ -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<f32> /* width of each line */ }`; `text::path(text: &str, size: f32, origin: P) -> Option<tiny_skia::Path>` (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<FontRef<'static>> =
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<f32>,
}
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<Path> {
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<ab_glyph::Point> = 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<Item = &'a Shape>) -> RgbaImage`; `export::crop(image: RgbaImage, rect: Option<PixelRect>) -> 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<Item = &'a Shape>` 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<PixelRect>) -> 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<PixelRect>, shapes() -> impl Iterator<Item = &Shape>, 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::<Vec<_>>()`):
```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<Shape>` fields with `Vec<Item>`:
```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<Item>,
redo: Vec<Item>,
saved: Vec<Item>,
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<Item = &Shape> {
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<PixelRect> {
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> }`, `Edges { left, right, top, bottom: bool }`, `fn crop_edges(rect_screen: (f32, f32, f32, f32), p: P, tolerance: f32) -> Option<Edges>`.
- [ ] **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<Edges> {
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<Typing>`, `cropping: Option<CropEdit>` (rect in image px as x0, y0, x1, y1, unnormalised while dragging), plus the composite key becomes `shown: (u64, Option<String>)` — 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`.
File diff suppressed because it is too large. Load diff
@@ -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<Axis>)>,
}
#[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<u8>, bottom: Rgba<u8>) -> Rgba<u8> {
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<f32> {
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<f32>, speed(&[f32], f32) -> Vec<f32>, partials(position: f32) -> Vec<(f32, f32)>, glide(from: f32, to: f32, secs: f32) -> Vec<f32>, sound(n: u8) -> Vec<f32>, wav(&[f32]) -> Vec<u8>}`; `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<usize> = (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<f32> {
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<f32> {
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<f32> {
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<f32> {
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<u8> {
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<bool>` + `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<PathBuf> {
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<bool>` (`#[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<bool>`, 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`.
@@ -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<Palette>`; `theme::from_image(img: &RgbaImage, icon: bool) -> Palette`; `theme::current() -> Palette` (Noctalia or default); `theme::wallpaper(connector: &str) -> Option<PathBuf>`; private `noctalia(args) -> Option<String>` (1 s timeout).
- [ ] **Step 1: Failing tests**
```rust
#[cfg(test)]
mod tests {
use super::*;
use image::{Rgba, RgbaImage};
const FILE: &str = r##"{"dark":{"mPrimary":"#cabaaa","mSecondary":"#73685F","mSurface":"#1e1d1b"},
"light":{"mPrimary":"#262524","mSecondary":"#736B5E","mSurface":"#D4C1A8"}}"##;
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() < 0.01)
}
#[test]
fn hex_parses() {
assert!(close(hex("#ff8000").unwrap(), [1.0, 0.502, 0.0]));
assert!(close(hex("D4C1A8").unwrap(), [0.831, 0.757, 0.659]));
assert_eq!(hex("#12"), None);
}
#[test]
fn noctalia_palette_per_mode() {
let light = from_noctalia_file(FILE, "light").unwrap();
assert!(close(light.base, hex("#D4C1A8").unwrap()));
assert!(close(light.body, hex("#736B5E").unwrap()));
assert!(close(light.highlight, hex("#262524").unwrap()));
let dark = from_noctalia_file(FILE, "dark").unwrap();
assert!(close(dark.base, hex("#1e1d1b").unwrap()));
assert_eq!(from_noctalia_file(FILE, "sepia"), None);
assert_eq!(from_noctalia_file("{}", "light"), None);
assert_eq!(from_noctalia_file("not json", "light"), None);
}
#[test]
fn image_palette_is_dark_to_light() {
let img = RgbaImage::from_fn(30, 30, |x, _| match x / 10 {
0 => Rgba([250, 240, 20, 255]),
1 => Rgba([20, 30, 160, 255]),
_ => Rgba([200, 30, 30, 255]),
});
let p = from_image(&img, false);
let lum = |c: [f32; 3]| c[0] + c[1] + c[2];
assert!(lum(p.base) < lum(p.body) && lum(p.body) < lum(p.highlight), "{p:?}");
}
#[test]
fn icon_palettes_ignore_transparent_and_grey() {
let img = RgbaImage::from_fn(30, 30, |x, y| {
if x < 15 { Rgba([0, 0, 0, 0]) } else if y < 10 { Rgba([128, 128, 128, 255]) } else { Rgba([30, 160, 220, 255]) }
});
let p = from_image(&img, true);
for c in [p.base, p.body, p.highlight] {
assert!(c[2] > c[0], "blue family, not grey or black: {p:?}");
}
}
#[test]
fn lerp_eases() {
let a = Palette::DEFAULT;
let b = Palette { base: [1.0; 3], body: [1.0; 3], highlight: [1.0; 3] };
assert_eq!(a.lerp(&b, 0.0), a);
assert_eq!(a.lerp(&b, 1.0), b);
}
}
```
Run → compile errors.
- [ ] **Step 2: Implement** (`serde_json` is already a dependency):
```rust
//! valw's colours: palettes for the paint shader and the overlays, from
//! Noctalia's theme, from an image, or a built-in default.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use image::RgbaImage;
/// Three colours, dark to light, as linear 0–1 RGB.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palette {
pub base: [f32; 3],
pub body: [f32; 3],
pub highlight: [f32; 3],
}
impl Palette {
pub const DEFAULT: Palette = Palette {
base: [0.106, 0.122, 0.165],
body: [0.180, 0.227, 0.322],
highlight: [0.561, 0.651, 0.788],
};
pub fn lerp(&self, other: &Palette, t: f32) -> Palette {
let t = t.clamp(0.0, 1.0);
let mix = |a: [f32; 3], b: [f32; 3]| [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t);
Palette { base: mix(self.base, other.base), body: mix(self.body, other.body), highlight: mix(self.highlight, other.highlight) }
}
}
pub fn hex(s: &str) -> Option<[f32; 3]> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 {
return None;
}
let v = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok().map(|v| v as f32 / 255.0);
Some([v(0)?, v(2)?, v(4)?])
}
/// A Noctalia community palette file at `mode`.
pub fn from_noctalia_file(text: &str, mode: &str) -> Option<Palette> {
let json: serde_json::Value = serde_json::from_str(text).ok()?;
let m = json.get(mode)?;
let get = |k: &str| hex(m.get(k)?.as_str()?);
Some(Palette { base: get("mSurface")?, body: get("mSecondary")?, highlight: get("mPrimary")? })
}
/// The dominant colours of `img`, dark to light. Icons skip transparent and
/// near-grey pixels so the brand colour wins.
pub fn from_image(img: &RgbaImage, icon: bool) -> Palette {
let step = ((img.width() * img.height()) as usize / 4096).max(1);
let mut buckets = std::collections::HashMap::<(u8, u8, u8), (u32, [u64; 3])>::new();
for p in img.pixels().step_by(step) {
let [r, g, b, a] = p.0;
if icon {
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
if a < 128 || max - min < 24 {
continue;
}
}
let e = buckets.entry((r >> 5, g >> 5, b >> 5)).or_default();
e.0 += 1;
for (s, v) in e.1.iter_mut().zip([r, g, b]) {
*s += v as u64;
}
}
let mut groups: Vec<_> = buckets.into_values().collect();
groups.sort_by(|a, b| b.0.cmp(&a.0));
let mut pick: Vec<[f32; 3]> = Vec::new();
for (n, sum) in groups {
let c = sum.map(|s| s as f32 / n as f32 / 255.0);
if pick.iter().all(|p| (0..3).map(|i| (p[i] - c[i]).abs()).sum::<f32>() > 0.3) {
pick.push(c);
}
if pick.len() == 3 {
break;
}
}
match pick.len() {
0 => return Palette::DEFAULT,
1 => {
let c = pick[0];
pick = vec![c.map(|v| v * 0.45), c, c.map(|v| v + (1.0 - v) * 0.5)];
}
2 => {
let c = pick[1];
pick.push(c.map(|v| v + (1.0 - v) * 0.5));
}
_ => {}
}
pick.sort_by(|a, b| (a[0] + a[1] + a[2]).total_cmp(&(b[0] + b[1] + b[2])));
Palette { base: pick[0], body: pick[1], highlight: pick[2] }
}
/// `noctalia msg <args>`'s output, or None (not installed, failing, or
/// slower than a second).
fn noctalia(args: &[&str]) -> Option<String> {
let mut child = Command::new("noctalia")
.arg("msg")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(1);
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(Some(_)) | Err(_) => return None,
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
}
}
let mut out = String::new();
std::io::Read::read_to_string(&mut child.stdout.take()?, &mut out).ok()?;
Some(out.trim().to_string())
}
/// Noctalia's current palette, or the default.
pub fn current() -> Palette {
noctalia_palette().unwrap_or_else(|| {
tracing::debug!("using the default palette");
Palette::DEFAULT
})
}
fn noctalia_palette() -> Option<Palette> {
let scheme = noctalia(&["color-scheme-get"])?; // "<source> <name>"
let (source, name) = scheme.split_once(' ')?;
if source != "community" {
return None;
}
let mode = noctalia(&["theme-mode-get"])?;
let dir = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| crate::config::home().join(".local/state"));
let file = dir.join("noctalia/community-palettes").join(format!("{}.json", name.replace(' ', "%20")));
from_noctalia_file(&std::fs::read_to_string(file).ok()?, &mode)
}
/// The wallpaper Noctalia shows on `connector`.
pub fn wallpaper(connector: &str) -> Option<PathBuf> {
noctalia(&["wallpaper-get", connector]).map(PathBuf::from).filter(|p| p.exists())
}
```
(Check `noctalia msg color-scheme-get`'s exact output on this machine and adapt the split; ledger what it printed.)
- [ ] **Step 3: Run**, clippy, fmt. **Step 4: Commit** `feat: theme palettes`.
---
### Task 2: Themed region dim
**Files:** `src/render.rs`, `src/region.rs`.
**Produces:** `render::tint(pixels: &[u8], colour: [f32; 3], amount: f32) -> Vec<u8>` (BGRX bytes; X untouched-ish); `render::DIM` stays for tests only or is removed if unused.
- [ ] **Step 1: Failing test** (`render.rs`):
```rust
#[test]
fn tint_blends_towards_the_colour() {
// B, G, R, X = 200, 100, 0, 255; colour pure red (R = 1).
let out = tint(&[200, 100, 0, 255], [1.0, 0.0, 0.0], 0.55);
assert_eq!(out[0], (200.0 * 0.45f32).round() as u8, "blue fades");
assert_eq!(out[1], (100.0 * 0.45f32).round() as u8);
assert_eq!(out[2], (255.0 * 0.55f32).round() as u8, "red rises");
assert_eq!(tint(&[10, 20, 30, 0], [0.0; 3], 0.0), vec![10, 20, 30, 0]);
}
```
- [ ] **Step 2: Implement**:
```rust
/// B, G, R, X pixels blended `amount` of the way towards `colour` (RGB 0–1).
/// Runs once per output before the overlay shows, like `dim` did.
pub fn tint(pixels: &[u8], colour: [f32; 3], amount: f32) -> Vec<u8> {
let keep = 1.0 - amount;
let [r, g, b] = colour.map(|c| c * 255.0 * amount);
let add = [b, g, r, 0.0];
pixels
.chunks_exact(4)
.flat_map(|p| [0, 1, 2, 3].map(|i| if i == 3 { p[3] } else { (p[i] as f32 * keep + add[i]).round() as u8 }))
.collect()
}
```
(If clippy prefers `as_chunks`, follow it.) `region.rs`: `select` computes `let palette = crate::theme::current();` once and `Surface::new` uses `render::tint(frame.pixels.as_raw(), palette.base, TINT)` with `const TINT: f32 = 0.55;` in `render.rs`; drop `dim`/`DIM` if nothing else uses them (update its test accordingly). Remove Task 1's allowance.
Note the performance: `tint` is a float pass over every pixel; on 1920×1080 that is ~8 M bytes — keep it a tight loop (it replaced `dim`'s integer pass). If it adds more than ~10 ms in release (measure with the existing "overlay buffers ready … after start" log line), switch to an integer form `(p * keep256 + add256) >> 8`.
- [ ] **Step 3: Run**, clippy, fmt. **Step 4: Commit** `feat: theme-coloured region dim`.
---
### Task 3: The paint renderer
**Files:** create `src/theme/paint.frag` (the spike's final `paint()` and `warp()`, with uniforms `res`, `t`, `motion`, `speed`, `scale`, `c1`, `c2`, `c3`, plus an optional overlay texture `overlay` and `use_overlay`), `src/theme/gl.rs`; `src/zoom/gl.rs`: `open`, `config`, `Egl` become `pub(crate)`.
**Produces:** `theme::gl::Paint::new(conn, surface: &WlSurface, size_px: (u32, u32)) -> Result<Paint>`; `Paint::resize(size_px)`; `Paint::draw(&self, frame: &Frame) -> Result<()>` with `pub struct Frame { pub time: f32, pub motion: f32, pub speed: f32, pub scale: f32, pub palette: Palette }`; `Paint::set_overlay(&mut self, pixmap: Option<&tiny_skia::Pixmap>)` (premultiplied RGBA, drawn over the paint with `ONE, ONE_MINUS_SRC_ALPHA` blending). Drop order as in `zoom::gl::Renderer` (EGL surface before `wl_egl_window`).
Fragment shader (final):
```glsl
#version 300 es
precision highp float;
uniform vec2 res;
uniform float t;
uniform float motion; // 0 flow, 1 swirl
uniform float speed;
uniform float scale; // blob size: 1 = the spike's
uniform vec3 c1;
uniform vec3 c2;
uniform vec3 c3;
uniform sampler2D overlay;
uniform bool use_overlay;
out vec4 color;
vec2 warp(vec2 p, float time) {
for (int i = 0; i < 6; i++) {
float fi = float(i);
p += 0.42 * sin(p.yx * vec2(1.55, 1.28) + time * vec2(0.27, 0.33) + fi * vec2(1.3, 2.1));
p = mat2(0.82, -0.57, 0.57, 0.82) * p;
}
return p;
}
vec3 paint(vec2 frag) {
vec2 uv = (frag - 0.5 * res) / res.y;
float time = t * speed;
if (motion > 0.0) {
float r = length(uv);
float a = atan(uv.y, uv.x) + motion * (2.2 / (r + 0.35)) + time * 0.25 * motion;
uv = r * vec2(cos(a), sin(a));
}
vec2 p = warp(uv * 4.0 / scale, time);
p = warp(p * 1.7, time * 1.25 + 3.0);
float v = sin(p.x) * cos(p.y);
float w = sin(length(p) * 0.6 - time * 0.8);
float m = v * 0.75 + w * 0.25;
vec3 col = c1;
col = mix(col, c2, smoothstep(-0.32, -0.18, m));
col = mix(col, c3, smoothstep(0.38, 0.5, m) * 0.85);
float rim = 1.0 - smoothstep(0.0, 0.06, abs(m + 0.25));
col *= 1.0 - 0.3 * rim;
float gloss = pow(abs(cos(p.x * 1.3 + p.y)), 36.0);
col += gloss * 0.18 * (c3 + 0.25);
col *= 1.0 - 0.3 * dot(uv, uv);
return col;
}
void main() {
vec2 frag = vec2(gl_FragCoord.x, res.y - gl_FragCoord.y);
vec3 col = paint(frag);
if (use_overlay) {
vec4 o = texture(overlay, frag / res);
col = o.rgb + col * (1.0 - o.a);
}
color = vec4(col, 1.0);
}
```
(One pass: the overlay is composited in the shader, no blending state needed.)
- [ ] **Step 1** (no unit test for GL; the spike proved the shader on this GPU): implement, `cargo clippy`, and a compile-only test that the shader source contains every uniform `Paint` sets (`#[test] fn shader_declares_its_uniforms`).
- [ ] **Step 2: Commit** `feat: paint shader renderer`.
---
### Task 4: `valw backdrop`
**Files:** create `src/backdrop.rs`; `src/main.rs` (`Command::Backdrop`), `src/wayland.rs` (routing).
**Produces:** pure `backdrop::should_refresh(last_frame: Option<Instant>, now: Instant) -> bool` (true when no frame yet or more than 2 s since the last) and `backdrop::ease(from: &Palette, to: &Palette, since: Duration) -> Palette` (linear over 1 s); `backdrop::run() -> Result<()>`.
- [ ] **Step 1: Failing tests** for `should_refresh` (None → true, 1 s → false, 3 s → true) and `ease` (0 → from, 0.5 s → halfway, 2 s → to).
- [ ] **Step 2: Implement** `run`:
- Single instance: `Lock::acquire(lock::default_path().with_file_name("valw-backdrop.lock"))`, else log and exit 0.
- `Wayland::connect`, one `Backdrop` per output in `State::backdrops: Vec<Backdrop>`: background-layer surface, `Anchor::all()`, exclusive zone -1 (cover panels too — niri draws it inside the backdrop), `KeyboardInteractivity::None`, empty input region, namespace `valw-backdrop`, a `theme::gl::Paint` sized to the output's physical size, a palette (current/target/eased since), `last_frame`.
- Draw on configure and on each frame callback (request the next one before swapping). In the frame handler: if `should_refresh(last_frame, now)`, read `theme::wallpaper(name)` → `image` decode (downscale to ~256 px with `thumbnail`) → `from_image(.., false)` as the new target (keep the old if anything fails).
- Outputs: new outputs get a surface; destroyed ones drop theirs (SCTK `OutputHandler::new_output` / `output_destroyed`).
- Dispatch forever (`dispatch_blocking` loop); exit when the compositor goes away.
- [ ] **Step 3: Run tests**, clippy, fmt. **Step 4: Smoke** (tell the user: without the niri layer rule the backdrop shows over the wallpaper for a few seconds): `timeout -s INT 4 valw backdrop`, grim shot, check the log. **Step 5: Commit** `feat: valw backdrop`.
---
### Task 5: Picker data — windows, icons, layout, state
**Files:** `src/niri.rs` (`windows() -> Result<Vec<Window>>`), create `src/picker/mod.rs` (module list), `src/picker/layout.rs`, `src/picker/icons.rs`, `src/picker/state.rs`; `Cargo.toml`: `resvg`.
**Produces:**
- `niri::windows()`; `picker::mru(windows) -> Vec<Window>` (focused first, then by `focus_timestamp` descending, windows without a timestamp last by id).
- `layout::cards(n: usize, output: (f32, f32), selected: usize) -> Vec<Rect>` (logical px; card base 200 × 200: 96 px icon + title + app name; 24 px gaps; rows wrap to the output width minus 10 % margins; when rows exceed 60 % of the height the whole grid scales down to fit; the selected card scaled 1.1 about its centre); `layout::hit(cards, p) -> Option<usize>`; `layout::step(selected, n, delta: i32) -> usize` (wraps).
- `state::next_motion(path) -> f32` (reads the last motion, returns the other, writes it; missing/broken → swirl = 1.0 and writes it).
- `icons::find(app_id: &str, dirs: &Dirs) -> Option<PathBuf>`; `Dirs { data: Vec<PathBuf>, theme: String }` from `$XDG_DATA_HOME`, `$XDG_DATA_DIRS`, and `gtk-icon-theme-name` in `~/.config/gtk-3.0/settings.ini` (default `hicolor`); `icons::load(path, size_px) -> Option<RgbaImage>` (PNG via `image`, SVG via `resvg`).
- [ ] **Step 1: Failing tests**
- `mru`: focused first, then newest timestamps, then no-timestamp by id.
- `cards`: 1 card centred; 5 cards in one row on 1920×1080; 20 cards on 1366×768 wrap into rows that fit inside 90 % of the width and 60 % of the height (scaled), no overlaps; the selected card is 1.1× and still centred on its slot; `hit` on a card centre and in a gap.
- `step`: +1 at the end wraps to 0, −1 at 0 wraps to n−1, n = 0 → 0.
- `next_motion`: missing → 1.0 then 0.0 then 1.0; garbage file → 1.0.
- `icons::find` against a temp data dir:
- `applications/org.telegram.desktop.desktop` with `Icon=telegram` and `icons/hicolor/256x256/apps/telegram.png` → found for app id `org.telegram.desktop`;
- a desktop file `foo.desktop` with `StartupWMClass=FooApp` → found for `FooApp`;
- theme `mytheme` with `index.theme` `Inherits=hicolor` and the icon only in hicolor → found;
- `Icon=/abs/path.png` → that path;
- prefers `scalable/apps/x.svg` over smaller PNGs;
- unknown app id → None.
- `icons::load`: a tiny SVG (`<svg …><rect fill="#f00" …/></svg>`) renders to the requested size with red pixels; a PNG loads and resizes.
- [ ] **Step 2: Implement.** Desktop lookup: for each data dir `applications/`, try `<app_id>.desktop`, then case-insensitive file name match, then scan for `StartupWMClass=<app_id>` (case-insensitive). Icon theme lookup: for theme, then its `Inherits` chain, then `hicolor`: directories in order `scalable/apps`, `256x256/apps`, `128x128/apps`, `96x96/apps`, `64x64/apps`, `48x48/apps`, and any `*/apps` under the theme (first hit), extensions `svg` then `png`; also `pixmaps/<name>.(png|svg)`. Bound the search (no recursion beyond `theme/size/apps`).
- [ ] **Step 3: Run**, clippy, fmt. **Step 4: Commit** `feat: picker windows, layout, state and icons`.
---
### Task 6: The picker
**Files:** `src/picker/mod.rs`, `src/main.rs`, `src/wayland.rs`.
**Produces:** `picker::run(config: &Config) -> Result<u64>` (the chosen window id; `Cancelled` on Esc / click outside); `Command::Window { pick: bool, common }` — `--pick` keeps the old niri pick; without it, `picker::run` chooses and the existing `window_shot` path captures that id (`window::capture_id(id, cursor)` split out of `window::capture`).
- Surface: overlay layer on the focused output, `Anchor::all()`, exclusive keyboard, namespace `valw-picker`; `theme::gl::Paint` with `motion = state::next_motion(..)`, `speed = 0.6`, `scale = 1.0`; palette eases (300 ms) towards `from_image(icon, true)` of the selected window (icons loaded once at start, at 96 × scale px; a missing icon → the default palette and a letter tile).
- Cards are drawn with tiny-skia into a full-output `Pixmap` (rounded panel `#000000` alpha 90 with 1 px `#ffffff` alpha 40 border; selected panel alpha 140 and a 2 px border in the palette's highlight; the icon; title 14 px white ellipsised to the card width; app name 12 px white alpha 170) and uploaded as the overlay only when the selection or hover changes.
- Frames: frame-callback driven while open (the shader animates), the overlay re-uploaded only on change.
- Input as the spec says (Tab/Shift+Tab/arrows, Enter, Esc, hover, click, click outside).
- On choice: drop the picker (surface destroyed), flush, round-trip, return the id. The caller (`capture`) then runs `window_shot` for that id.
- Tests: `main` CLI (`window`, `window --pick`), and the mapping in `capture` stays covered by existing tests; the picker's own logic is covered by Task 5.
- Smoke (tell the user): open the picker for a few seconds, grim shot, check the cards and the shader.
- Commit `feat: window picker`.
---
### Task 7: Wiring, docs, checks
- [ ] README: the picker (`valw window`, `--pick`), `valw backdrop` with the niri layer rule and Noctalia's `[backdrop] enabled = false`.
- [ ] Checklist section "Theme".
- [ ] copland (with the user's approval): `spawn-at-startup` `valw backdrop`, the niri `layer-rule`, Noctalia backdrop off (wherever copland sets Noctalia's config; if it is not managed by copland, tell the user to toggle it in Noctalia's settings), `nix flake update valw`.
- [ ] `nix flake check`, review, push.
File diff suppressed because it is too large. Load diff
@@ -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 "<msg>" <paths>`), 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<Part>, 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<Span>
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<Part>,
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<Span> {
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<Point>,
```
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<PixelRect>,
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<PixelRect> {
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<State>) {
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<State>) {
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<Choice> {
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
```
@@ -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 <secs>` 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<Frame>`
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 <path> write to this path instead of directory + template;
"-" writes PNG to stdout
--delay <secs> 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 "<geo>"`; `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.
@@ -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 <path> --output-filename <path>`. | 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 <path> --output-filename <path>` (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<Host>` 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.
@@ -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<Gesture>` (`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`.
@@ -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-<pid>.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.
@@ -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<Option<PixelRect>>`: 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.
@@ -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 <file>`, 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 <file>` opens a window titled `<file name> — 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: `<stem> 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 <file>` (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.
@@ -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 <file>` and
copies the file to `$out` only if the check passes.
- `valw __check-config <file>` (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.
@@ -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.
@@ -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<Target>` 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<Choice>`: 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.
@@ -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.
@@ -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/<id>.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 <connector>`, 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.
@@ -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 <mode>` (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", <valw's error: line>)`.
- 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.
@@ -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<Span>`: 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.
-206
View File
@@ -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 `<name> — 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/<size>/` 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.
-248
View File
@@ -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 <dosya> # düzenleyiciyi doğrudan aç
```
Ortak bayraklar: `--clipboard-only`, `--no-preview`, `--delay <sn>`, `--output <yol>`, `--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.<system>.default`: `rustPlatform.buildRustPackage` (veya `crane`)
- `devShells.<system>.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ü