Compare commits
2
Commits
2028a9aaa7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2edc8c18cc | ||
|
|
e320f7a1b6 |
No files matched your search
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.
|
||||
@@ -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
@@ -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ü
|
||||
Reference in new issue
Block a user