Files
valw/docs/superpowers/specs/2026-10-02-valw-phase-4-zoom-design.md
T
2026-10-02 17:05:19 +03:00

99 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# valw 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.