Compare commits

..
7 Commits
15 changed files with 1223 additions and 134 deletions

No files matched your search

-1
View File
@@ -7,7 +7,6 @@
!/Cargo.lock !/Cargo.lock
!/nix/ !/nix/
!/src/ !/src/
!/docs/
!/assets/ !/assets/
!/noctalia-plugin/ !/noctalia-plugin/
!/README.md !/README.md
+13 -4
View File
@@ -2,7 +2,7 @@
name = "valw" name = "valw"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
description = "macOS-style screenshots for niri" description = "desktop helper"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
@@ -10,7 +10,11 @@ ab_glyph = "0.2.32"
anyhow = "1.0.104" anyhow = "1.0.104"
chrono = "0.4.45" chrono = "0.4.45"
clap = { version = "4.6.7", features = ["derive"] } clap = { version = "4.6.7", features = ["derive"] }
eframe = { version = "0.36.2", default-features = false, features = ["default_fonts", "glow", "wayland"] } eframe = { version = "0.36.2", default-features = false, features = [
"default_fonts",
"glow",
"wayland",
] }
glow = "0.18.0" glow = "0.18.0"
image = { version = "0.25.10", default-features = false, features = ["png"] } image = { version = "0.25.10", default-features = false, features = ["png"] }
khronos-egl = { version = "6.0.0", features = ["dynamic"] } khronos-egl = { version = "6.0.0", features = ["dynamic"] }
@@ -18,7 +22,13 @@ libc = "0.2.189"
libloading = "0.8" libloading = "0.8"
resvg = "0.48.1" resvg = "0.48.1"
niri-ipc = "=26.4.0" niri-ipc = "=26.4.0"
rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] } rustix = { version = "1.1.5", features = [
"event",
"fs",
"pipe",
"process",
"stdio",
] }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_ignored = "0.1.14" serde_ignored = "0.1.14"
serde_json = "1.0.151" serde_json = "1.0.151"
@@ -37,6 +47,5 @@ wl-clipboard-rs = "0.9.4"
[dev-dependencies] [dev-dependencies]
tempfile = "3.27.0" tempfile = "3.27.0"
# Image encoding is painfully slow unoptimised; keep dev builds usable.
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3
+9 -55
View File
@@ -1,19 +1,16 @@
# valw # valw
macOS-style screenshots for [niri](https://github.com/YaLTeR/niri) on valw is desktop helper for 3N' [nixos] (https://nixos.org/) [niri](https://github.com/YaLTeR/niri) [noctalia](https://github.com/noctalia-dev/noctalia)
Wayland: the whole screen, a region, a window, a zoomable freeze-frame, a
Cmd+Shift+5-style toolbar, a floating preview you can drag into other apps,
and a built-in markup editor. Written in Rust, packaged with Nix.
## Install ## installation
Run it once: run first:
```sh ```sh
nix run git+https://git.userevolt.app/elars/valw -- doctor nix run git+https://git.userevolt.app/elars/valw -- doctor
``` ```
As a package from the flake (`packages.<system>.default`), or with the use as package from the flake (`packages.<system>.default`), or with the
home-manager module: home-manager module:
```nix ```nix
@@ -35,13 +32,7 @@ inputs.valw.url = "git+https://git.userevolt.app/elars/valw";
}; };
} }
``` ```
## binding on niri
`settings` becomes `~/.config/valw/config.toml`. valw checks it while the
configuration is built, so a typo or a bad value fails the rebuild instead
of surprising you later. The toolbar's remembered choices live in
`~/.local/state/valw/toolbar.toml` and stay writable.
## Bind it in niri
```kdl ```kdl
binds { binds {
@@ -53,7 +44,7 @@ binds {
} }
``` ```
## Commands ## commands
| Command | What it does | | Command | What it does |
|---|---| |---|---|
@@ -69,45 +60,8 @@ binds {
Capture commands take `--clipboard-only`, `-o PATH` (`-` for stdout), Capture commands take `--clipboard-only`, `-o PATH` (`-` for stdout),
`--delay SECS`, `--cursor` and `--no-preview`. `--delay SECS`, `--cursor` and `--no-preview`.
## The overview backdrop
`valw backdrop` draws a slow liquid-paint background, coloured from the ## configuration
wallpaper (via Noctalia, if it runs), behind niri's overview. It only draws
while the overview shows it. Start it with niri and place it in the
backdrop:
```kdl
spawn-at-startup "valw" "backdrop"
layer-rule {
match namespace="^valw-backdrop$"
place-within-backdrop true
}
```
With Noctalia, turn its own backdrop off (`[backdrop] enabled = false`).
Colours elsewhere (the region dim, the window picker's fallback) follow
Noctalia's current community palette when there is one.
## Noctalia
With [Noctalia](https://noctalia.dev), valw comes as a plugin: a camera
button on the bar (left click: the toolbar, right click: a region shot),
the toolbar as a Noctalia panel and a control-center tile. Noctalia draws
them, so they follow your Noctalia theme.
```nix
programs.valw.noctalia.enable = true;
```
Then enable `elars/valw` in Noctalia, add a widget of type `elars/valw:bar`
to a bar, and bind the toolbar:
```kdl
Mod+Shift+T { spawn "noctalia" "msg" "panel-toggle" "elars/valw:toolbar"; }
```
## Configuration
Every key is optional; these are the defaults. Every key is optional; these are the defaults.
@@ -130,7 +84,7 @@ scroll_step = 1.15
flashlight_radius = 180 flashlight_radius = 180
[editor] [editor]
backend = "builtin" # or "satty", or "noctalia" (its annotator) backend = "builtin" # or "satty","noctalia" choose which one is for you.
[sound] [sound]
enabled = true enabled = true
@@ -138,4 +92,4 @@ volume = 0.9
combo_reset_secs = 5 combo_reset_secs = 5
``` ```
Logs are in `~/.local/state/valw/logs/`. logs are in `~/.local/state/valw/logs/`.
@@ -0,0 +1,556 @@
# 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
```
@@ -0,0 +1,76 @@
# 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.
+2
View File
@@ -147,6 +147,8 @@ image, a cancel brought the thumbnail back, and a swipe still dismissed 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. - [ ] 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). - [ ] `[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) ### Known minors (deferred from the polish review)
- Releasing Shift mid-drag only updates the rectangle on the next pointer motion. - Releasing Shift mid-drag only updates the rectangle on the next pointer motion.
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
description = "valw, macOS-style screenshots for niri"; description = "valw desktop helper";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
-15
View File
@@ -9,9 +9,6 @@
}: }:
let let
valw = self'.packages.default; valw = self'.packages.default;
# The home-manager module, evaluated with stand-ins for the two
# home-manager options it sets (no home-manager input needed).
homeModule = homeModule =
valwConfig: valwConfig:
(lib.evalModules { (lib.evalModules {
@@ -44,7 +41,6 @@
]; ];
}).config; }).config;
# Reuses the package's vendored dependencies, runs `cmd` instead of the build.
cargoCheck = cargoCheck =
name: tools: cmd: name: tools: cmd:
valw.overrideAttrs (old: { valw.overrideAttrs (old: {
@@ -52,7 +48,6 @@
nativeBuildInputs = old.nativeBuildInputs ++ tools; nativeBuildInputs = old.nativeBuildInputs ++ tools;
buildPhase = cmd; buildPhase = cmd;
installPhase = "touch $out"; installPhase = "touch $out";
# The package patches its binary's RUNPATH; a check has no binary.
postFixup = ""; postFixup = "";
}); });
in in
@@ -100,9 +95,6 @@
}/testBuildFailure.log }/testBuildFailure.log
touch $out touch $out
''; '';
# The plugin as packaged: manifest, every entry present, Luau that
# compiles, translations that parse; and its logic against stubbed
# Noctalia globals.
noctalia-plugin = noctalia-plugin =
pkgs.runCommand "valw-noctalia-plugin" pkgs.runCommand "valw-noctalia-plugin"
{ {
@@ -135,8 +127,6 @@
touch $out touch $out
''; '';
# Runs the real screencopy path against a headless sway. This is
# wlroots, not niri, but it catches capture and format bugs on every build.
headless = headless =
pkgs.runCommand "valw-headless" pkgs.runCommand "valw-headless"
{ {
@@ -168,15 +158,10 @@
valw doctor | tee doctor.txt valw doctor | tee doctor.txt
! grep -q '^fail' doctor.txt ! grep -q '^fail' doctor.txt
# Through a pipe: the clipboard child must not keep it open.
# The timeout covers the reader too; the child is in its own
# session, so bounding valw alone would not catch a leak.
timeout 10 sh -c 'valw screen -o - | cat > shot.png' timeout 10 sh -c 'valw screen -o - | cat > shot.png'
file shot.png | tee file.txt file shot.png | tee file.txt
grep -q 'PNG image data, 1280 x 720' file.txt grep -q 'PNG image data, 1280 x 720' file.txt
# Preview. The background is solid black, so a thumbnail in the
# bottom-right corner shows up as more than one colour there.
mkdir -p $HOME/.config/valw mkdir -p $HOME/.config/valw
printf '[preview]\ntimeout_secs = 2\n' > $HOME/.config/valw/config.toml printf '[preview]\ntimeout_secs = 2\n' > $HOME/.config/valw/config.toml
socket=$XDG_RUNTIME_DIR/valw.sock socket=$XDG_RUNTIME_DIR/valw.sock
-4
View File
@@ -16,21 +16,17 @@
clippy clippy
rust-analyzer rust-analyzer
rustfmt rustfmt
# Manual verification: grim is the pixel oracle, magick compares.
grim grim
imagemagick imagemagick
wayland-utils wayland-utils
]; ];
# What the package gets through its RUNPATH, for `cargo run`.
LD_LIBRARY_PATH = lib.makeLibraryPath [ LD_LIBRARY_PATH = lib.makeLibraryPath [
pkgs.wayland pkgs.wayland
pkgs.libxkbcommon pkgs.libxkbcommon
pkgs.libglvnd pkgs.libglvnd
]; ];
# The repo is a path: flake, so Nix copies the whole tree on every
# evaluation. Keep the multi-gigabyte target dir out of it.
shellHook = '' shellHook = ''
export CARGO_TARGET_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/valw/target" export CARGO_TARGET_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/valw/target"
''; '';
-1
View File
@@ -10,7 +10,6 @@
let let
cfg = config.programs.valw; cfg = config.programs.valw;
toml = pkgs.formats.toml { }; toml = pkgs.formats.toml { };
# valw checks its own config: a typo or a bad value fails the build.
checked = checked =
settings: settings:
let let
+1 -10
View File
@@ -14,9 +14,7 @@
!(builtins.elem (baseNameOf path) [ !(builtins.elem (baseNameOf path) [
".direnv" ".direnv"
".jj" ".jj"
# Plan ledgers and review notes; not part of the build.
".superpowers" ".superpowers"
# Documentation: a doc change shouldn't rebuild the binary.
"docs" "docs"
"README.md" "README.md"
"result" "result"
@@ -35,11 +33,7 @@
pkgs.wayland pkgs.wayland
]; ];
# Tests run through `nix flake check` instead.
doCheck = false; doCheck = false;
# Clicking a preview opens Satty if configured. --suffix keeps a user's own Satty first.
# The editor embeds DejaVu Sans Bold; its licence wants the notice shipped.
postInstall = '' postInstall = ''
wrapProgram $out/bin/valw --suffix PATH : ${ wrapProgram $out/bin/valw --suffix PATH : ${
lib.makeBinPath [ lib.makeBinPath [
@@ -48,14 +42,11 @@
] ]
} }
install -Dm644 assets/fonts/LICENSE-DejaVu $out/share/licenses/valw/LICENSE-DejaVu install -Dm644 assets/fonts/LICENSE-DejaVu $out/share/licenses/valw/LICENSE-DejaVu
# The Noctalia plugin (programs.valw.noctalia), without its tests.
mkdir -p $out/share/valw mkdir -p $out/share/valw
cp -r noctalia-plugin $out/share/valw/noctalia-plugin cp -r noctalia-plugin $out/share/valw/noctalia-plugin
rm -r $out/share/valw/noctalia-plugin/tests rm -r $out/share/valw/noctalia-plugin/tests
''; '';
# dlopen()ed at run time: libwayland (winit, valw), libxkbcommon (winit),
# libEGL (zoom, editor; Mesa's drivers come from the system).
postFixup = '' postFixup = ''
patchelf --add-rpath ${ patchelf --add-rpath ${
lib.makeLibraryPath [ lib.makeLibraryPath [
@@ -67,7 +58,7 @@
''; '';
meta = { meta = {
description = "macOS-style screenshots for niri"; description = "desktop helper";
mainProgram = "valw"; mainProgram = "valw";
platforms = lib.platforms.linux; platforms = lib.platforms.linux;
}; };
+5
View File
@@ -20,6 +20,7 @@ mod selection;
mod shadow; mod shadow;
mod sound; mod sound;
mod stack; mod stack;
mod stitch;
mod theme; mod theme;
mod thumbnail; mod thumbnail;
mod toolbar; mod toolbar;
@@ -309,6 +310,10 @@ fn capture(mode: Mode, common: Common) -> Result<()> {
let source = frames[i].output.name.clone(); let source = frames[i].output.name.clone();
(vec![(frames[i].to_rgba(r), None)], 0, source) (vec![(frames[i].to_rgba(r), None)], 0, source)
} }
region::Choice::Span(span, on) => {
let source = frames[on].output.name.clone();
(vec![(stitch::stitch(&frames, &span), None)], 0, source)
}
region::Choice::Window => { region::Choice::Window => {
drop(frames); drop(frames);
windowed = true; windowed = true;
+144 -30
View File
@@ -38,6 +38,9 @@ pub const NAMESPACE: &str = "valw-overlay";
pub enum Choice { pub enum Choice {
/// A rectangle in the frame of output `.0`. /// A rectangle in the frame of output `.0`.
Region(usize, PixelRect), Region(usize, PixelRect),
/// 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),
/// Space before dragging: capture a window instead. /// Space before dragging: capture a window instead.
Window, Window,
} }
@@ -46,12 +49,12 @@ pub enum Choice {
pub struct Overlay { pub struct Overlay {
surfaces: Vec<Surface>, surfaces: Vec<Surface>,
selection: Selection, selection: Selection,
/// Output where the current drag started; the capture is clipped to it.
anchor: Option<usize>,
/// Shift, Alt and Space as the keyboard last reported them. /// Shift, Alt and Space as the keyboard last reported them.
mods: Mods, mods: Mods,
/// The surface the pointer is on and its surface-local position. /// The pointer in global logical coordinates. A drag's events keep
pointer: Option<(usize, (f64, f64))>, /// arriving on the surface it started on, with positions past its
/// edges, so this stays right across outputs.
pointer: Option<Point>,
outcome: Option<Option<Choice>>, outcome: Option<Option<Choice>>,
} }
@@ -68,6 +71,9 @@ struct Surface {
configured: bool, configured: bool,
frame_pending: bool, frame_pending: bool,
dirty: bool, dirty: bool,
/// The selection part and whether the label were last drawn here.
shown: Option<PixelRect>,
labelled: bool,
} }
/// Shows the overlay on every output and waits for a selection. /// Shows the overlay on every output and waits for a selection.
@@ -114,7 +120,6 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<
wl.state.overlay = Some(Overlay { wl.state.overlay = Some(Overlay {
surfaces, surfaces,
selection: Selection::default(), selection: Selection::default(),
anchor: None,
mods: Mods::default(), mods: Mods::default(),
pointer: None, pointer: None,
outcome: None, outcome: None,
@@ -127,6 +132,21 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<
outcome.flatten().ok_or_else(|| Cancelled.into()) outcome.flatten().ok_or_else(|| Cancelled.into())
} }
/// 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)),
})
}
impl Surface { impl Surface {
fn new( fn new(
layer: LayerSurface, layer: LayerSurface,
@@ -151,6 +171,8 @@ impl Surface {
configured: false, configured: false,
frame_pending: false, frame_pending: false,
dirty: true, dirty: true,
shown: None,
labelled: false,
}) })
} }
@@ -227,9 +249,7 @@ impl Overlay {
fn apply_mods(&mut self, qh: &QueueHandle<State>) { fn apply_mods(&mut self, qh: &QueueHandle<State>) {
self.selection.set_mods(self.mods); self.selection.set_mods(self.mods);
if let Some(a) = self.anchor { self.redraw_changed(qh);
self.redraw(a, qh);
}
} }
pub fn switch_to_window(&mut self) { pub fn switch_to_window(&mut self) {
@@ -245,26 +265,64 @@ impl Overlay {
.position(|s| s.layer.wl_surface() == surface) .position(|s| s.layer.wl_surface() == surface)
} }
/// The selection as drawn on surface `i`: only the anchor output shows it. 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> { fn selection_on(&self, i: usize) -> Option<PixelRect> {
let (start, end) = self.selection.corners()?; let (start, end) = self.selection.corners()?;
let s = &self.surfaces[i]; let s = &self.surfaces[i];
(self.anchor == Some(i)) selection::clip(start, end, &s.geom, s.width, s.height)
.then(|| selection::clip(start, end, &s.geom, s.width, s.height)) }
.flatten()
/// 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>) { fn redraw(&mut self, i: usize, qh: &QueueHandle<State>) {
let sel = self.selection_on(i); let sel = self.selection_on(i);
// The size label goes next to the pointer, on the output it is on. let label = self.label_on(i);
let label = sel.zip(self.pointer.filter(|(on, _)| *on == i).map(|(_, at)| at));
let s = &mut self.surfaces[i]; let s = &mut self.surfaces[i];
s.shown = sel;
s.labelled = label.is_some();
s.dirty = true; s.dirty = true;
if s.configured && !s.frame_pending { if s.configured && !s.frame_pending {
s.draw(sel, label, qh); 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);
}
}
}
pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle<State>) { pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle<State>) {
let Some(i) = self.index_of(layer.wl_surface()) else { let Some(i) = self.index_of(layer.wl_surface()) else {
return; return;
@@ -303,12 +361,12 @@ impl Overlay {
let Some(i) = self.index_of(&event.surface) else { let Some(i) = self.index_of(&event.surface) else {
continue; continue;
}; };
self.pointer = Some((i, event.position));
let g = &self.surfaces[i].geom; let g = &self.surfaces[i].geom;
let p = Point { let p = Point {
x: g.x as f64 + event.position.0, x: g.x as f64 + event.position.0,
y: g.y as f64 + event.position.1, y: g.y as f64 + event.position.1,
}; };
self.pointer = Some(p);
match event.kind { match event.kind {
PointerEventKind::Enter { serial } => { PointerEventKind::Enter { serial } => {
if let Some(cursor) = cursor { if let Some(cursor) = cursor {
@@ -320,35 +378,29 @@ impl Overlay {
} => { } => {
// Modifiers already held apply from the start. // Modifiers already held apply from the start.
self.selection.press_with(p, self.mods); self.selection.press_with(p, self.mods);
self.anchor = Some(i); self.redraw_changed(qh);
} }
PointerEventKind::Press { PointerEventKind::Press {
button: BTN_RIGHT, .. button: BTN_RIGHT, ..
} => self.cancel(), } => self.cancel(),
PointerEventKind::Motion { .. } => { PointerEventKind::Motion { .. } => {
self.selection.motion(p); self.selection.motion(p);
if let Some(a) = self.anchor { self.redraw_changed(qh);
self.redraw(a, qh);
}
} }
PointerEventKind::Release { PointerEventKind::Release {
button: BTN_LEFT, .. button: BTN_LEFT, ..
} => { } => {
let (Some((start, end)), Some(a)) = (self.selection.release(p), self.anchor) let Some((start, end)) = self.selection.release(p) else {
else {
continue; continue;
}; };
let s = &self.surfaces[a]; let choice = choose(start, end, &self.outputs(), p);
match selection::resolve(start, end, &s.geom, s.width, s.height) { match choice {
Some(rect) => { Some(choice) => {
tracing::info!( tracing::info!("selected {choice:?} (logical {start:?} to {end:?})");
"selected {rect:?} on {} (logical {start:?} to {end:?})", self.outcome = Some(Some(choice));
s.geom.name
);
self.outcome = Some(Some(Choice::Region(a, rect)));
} }
// A click, not a drag: clear it and keep waiting. // A click, not a drag: clear it and keep waiting.
None => self.redraw(a, qh), None => self.redraw_changed(qh),
} }
} }
_ => {} _ => {}
@@ -356,3 +408,65 @@ impl Overlay {
} }
} }
} }
#[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"
);
}
}
+300 -13
View File
@@ -183,15 +183,134 @@ pub fn clip(a: Point, b: Point, out: &OutputGeom, frame_w: u32, frame_h: u32) ->
}) })
} }
/// Like [`clip`], but treats anything under [`MIN_PIXELS`] as a click. /// One output's share of a selection and where it goes in the whole image.
pub fn resolve( #[derive(Debug, Clone, PartialEq)]
a: Point, pub struct Part {
b: Point, pub output: usize,
out: &OutputGeom, /// In that output's frame, physical pixels.
frame_w: u32, pub rect: PixelRect,
frame_h: u32, /// Its top-left corner and size in the whole image (a corner can sit a
) -> Option<PixelRect> { /// pixel left of or above the image when scales differ).
clip(a, b, out, frame_w, frame_h).filter(|r| r.width >= MIN_PIXELS && r.height >= MIN_PIXELS) pub at: (i64, i64),
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.
///
/// A box inside one output is that output's plain crop, pixel for pixel.
/// Otherwise the image lies on the physical pixel grid at that scale: its
/// edges are the corners rounded outwards, and each part sits where its own
/// pixels fall on that grid, so neighbouring parts meet without a gap or an
/// overlap even when the corners are fractional (as pointer positions are).
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 inside = |g: &OutputGeom| {
x0 >= g.x as f64
&& y0 >= g.y as f64
&& x1 <= (g.x + g.width) as f64
&& y1 <= (g.y + g.height) as f64
};
if let Some(&(i, rect, _)) = covered.iter().find(|c| inside(outputs[c.0].0)) {
let size = (rect.width, rect.height);
if size.0 < MIN_PIXELS || size.1 < MIN_PIXELS {
return None;
}
let part = Part {
output: i,
rect,
at: (0, 0),
size,
};
return Some(Span {
parts: vec![part],
size,
});
}
let scale = covered.iter().map(|c| c.2).fold(0.0, f64::max);
if scale == 0.0 {
return None;
}
let (left, top) = ((x0 * scale).floor(), (y0 * scale).floor());
let size = (
((x1 * scale).ceil() - left) as u32,
((y1 * scale).ceil() - top) 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 k = scale / own;
let at = (
(g.x as f64 * scale + rect.x as f64 * k).round() as i64 - left as i64,
(g.y as f64 * scale + rect.y as f64 * k).round() as i64 - top as i64,
);
let size = (
(rect.width as f64 * k).round() as u32,
(rect.height as f64 * k).round() as u32,
);
Part {
output: i,
rect,
at,
size,
}
})
.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)
} }
#[cfg(test)] #[cfg(test)]
@@ -312,11 +431,12 @@ mod tests {
#[test] #[test]
fn click_is_not_a_selection() { fn click_is_not_a_selection() {
let o = out("A", 0, 0, 1920, 1080); let o = out("A", 0, 0, 1920, 1080);
assert_eq!(resolve(p(10.0, 10.0), p(10.0, 10.0), &o, 1920, 1080), None); let outputs = [(&o, (1920, 1080))];
assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None); assert_eq!(span(p(10.0, 10.0), p(10.0, 10.0), &outputs), None);
assert_eq!(span(p(10.0, 10.0), p(11.0, 30.0), &outputs), None);
assert_eq!( assert_eq!(
resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080), span(p(10.0, 10.0), p(12.0, 12.0), &outputs).and_then(|s| s.single()),
Some(rect(10, 10, 2, 2)) Some((0, rect(10, 10, 2, 2)))
); );
} }
@@ -492,4 +612,171 @@ mod tests {
); );
assert_eq!(s, Selection::Idle); assert_eq!(s, Selection::Idle);
} }
/// 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 as i64, 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_fractional_box_on_one_output_is_its_plain_crop() {
let (laptop, monitor) = layout();
let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))];
let (a, b) = (p(100.6, 100.6), p(300.2, 250.2));
let s = span(a, b, &outputs).unwrap();
let crop = clip(a, b, &laptop, 1366, 768).unwrap();
assert_eq!(s.single(), Some((0, crop)));
assert_eq!(s.size, (crop.width, crop.height));
// A 1.25-scale output: the crop is what clip gives, not a resampled one.
let o = out("A", 0, 0, 1000, 800);
let scaled = [(&o, (1250, 1000))];
let (a, b) = (p(101.0, 101.0), p(301.0, 251.0));
assert_eq!(
span(a, b, &scaled).unwrap().single(),
Some((0, clip(a, b, &o, 1250, 1000).unwrap()))
);
}
#[test]
fn fractional_seams_meet_exactly_and_fill_the_image() {
let (laptop, monitor) = layout();
let outputs = [(&laptop, (1366, 768)), (&monitor, (1920, 1080))];
for (top, bottom) in [(-300.4, 500.6), (-300.6, 500.6), (-300.5, 500.2)] {
let s = span(p(100.3, bottom), p(600.7, top), &outputs).unwrap();
let (laptop_part, monitor_part) = (&s.parts[0], &s.parts[1]);
assert_eq!(monitor_part.at.1, 0, "{top}: the monitor starts the image");
assert_eq!(
monitor_part.at.1 + monitor_part.size.1 as i64,
laptop_part.at.1,
"{top}: no doubled or missing row at the seam"
);
assert_eq!(
laptop_part.at.1 + laptop_part.size.1 as i64,
s.size.1 as i64,
"{top}: the last row is covered"
);
assert_eq!(laptop_part.size.0, s.size.0, "{top}: full width");
}
// Mixed scales with a fractional corner: the parts tile the width.
let a = out("A", 0, 0, 100, 100);
let b = out("B", 100, 0, 100, 100);
let mixed = [(&a, (200, 200)), (&b, (100, 100))];
let s = span(p(50.3, 10.0), p(150.7, 60.0), &mixed).unwrap();
assert_eq!(s.parts[0].at.0, 0);
assert_eq!(s.parts[0].at.0 + s.parts[0].size.0 as i64, s.parts[1].at.0);
assert_eq!(s.parts[1].at.0 + s.parts[1].size.0 as i64, s.size.0 as i64);
}
#[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);
}
} }
+116
View File
@@ -0,0 +1,116 @@
//! A selection over several outputs as one image.
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, part.at.1);
}
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"
);
}
}