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
!/nix/
!/src/
!/docs/
!/assets/
!/noctalia-plugin/
!/README.md
+13 -4
View File
@@ -2,7 +2,7 @@
name = "valw"
version = "0.1.0"
edition = "2024"
description = "macOS-style screenshots for niri"
description = "desktop helper"
license = "MIT"
[dependencies]
@@ -10,7 +10,11 @@ ab_glyph = "0.2.32"
anyhow = "1.0.104"
chrono = "0.4.45"
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"
image = { version = "0.25.10", default-features = false, features = ["png"] }
khronos-egl = { version = "6.0.0", features = ["dynamic"] }
@@ -18,7 +22,13 @@ libc = "0.2.189"
libloading = "0.8"
resvg = "0.48.1"
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_ignored = "0.1.14"
serde_json = "1.0.151"
@@ -37,6 +47,5 @@ wl-clipboard-rs = "0.9.4"
[dev-dependencies]
tempfile = "3.27.0"
# Image encoding is painfully slow unoptimised; keep dev builds usable.
[profile.dev.package."*"]
opt-level = 3
+9 -55
View File
@@ -1,19 +1,16 @@
# valw
macOS-style screenshots for [niri](https://github.com/YaLTeR/niri) on
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.
valw is desktop helper for 3N' [nixos] (https://nixos.org/) [niri](https://github.com/YaLTeR/niri) [noctalia](https://github.com/noctalia-dev/noctalia)
## Install
## installation
Run it once:
run first:
```sh
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:
```nix
@@ -35,13 +32,7 @@ inputs.valw.url = "git+https://git.userevolt.app/elars/valw";
};
}
```
`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
## binding on niri
```kdl
binds {
@@ -53,7 +44,7 @@ binds {
}
```
## Commands
## commands
| Command | What it does |
|---|---|
@@ -69,45 +60,8 @@ binds {
Capture commands take `--clipboard-only`, `-o PATH` (`-` for stdout),
`--delay SECS`, `--cursor` and `--no-preview`.
## The overview backdrop
`valw backdrop` draws a slow liquid-paint background, coloured from the
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
## configuration
Every key is optional; these are the defaults.
@@ -130,7 +84,7 @@ scroll_step = 1.15
flashlight_radius = 180
[editor]
backend = "builtin" # or "satty", or "noctalia" (its annotator)
backend = "builtin" # or "satty","noctalia" choose which one is for you.
[sound]
enabled = true
@@ -138,4 +92,4 @@ volume = 0.9
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.
- [ ] `[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.
+1 -1
View File
@@ -1,5 +1,5 @@
{
description = "valw, macOS-style screenshots for niri";
description = "valw desktop helper";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
-15
View File
@@ -9,9 +9,6 @@
}:
let
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 =
valwConfig:
(lib.evalModules {
@@ -44,7 +41,6 @@
];
}).config;
# Reuses the package's vendored dependencies, runs `cmd` instead of the build.
cargoCheck =
name: tools: cmd:
valw.overrideAttrs (old: {
@@ -52,7 +48,6 @@
nativeBuildInputs = old.nativeBuildInputs ++ tools;
buildPhase = cmd;
installPhase = "touch $out";
# The package patches its binary's RUNPATH; a check has no binary.
postFixup = "";
});
in
@@ -100,9 +95,6 @@
}/testBuildFailure.log
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 =
pkgs.runCommand "valw-noctalia-plugin"
{
@@ -135,8 +127,6 @@
touch $out
'';
# Runs the real screencopy path against a headless sway. This is
# wlroots, not niri, but it catches capture and format bugs on every build.
headless =
pkgs.runCommand "valw-headless"
{
@@ -168,15 +158,10 @@
valw doctor | tee 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'
file shot.png | tee file.txt
grep -q 'PNG image data, 1280 x 720' file.txt
# Preview. The background is solid black, so a thumbnail in the
# bottom-right corner shows up as more than one colour there.
mkdir -p $HOME/.config/valw
printf '[preview]\ntimeout_secs = 2\n' > $HOME/.config/valw/config.toml
socket=$XDG_RUNTIME_DIR/valw.sock
-4
View File
@@ -16,21 +16,17 @@
clippy
rust-analyzer
rustfmt
# Manual verification: grim is the pixel oracle, magick compares.
grim
imagemagick
wayland-utils
];
# What the package gets through its RUNPATH, for `cargo run`.
LD_LIBRARY_PATH = lib.makeLibraryPath [
pkgs.wayland
pkgs.libxkbcommon
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 = ''
export CARGO_TARGET_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/valw/target"
'';
-1
View File
@@ -10,7 +10,6 @@
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
+1 -10
View File
@@ -14,9 +14,7 @@
!(builtins.elem (baseNameOf path) [
".direnv"
".jj"
# Plan ledgers and review notes; not part of the build.
".superpowers"
# Documentation: a doc change shouldn't rebuild the binary.
"docs"
"README.md"
"result"
@@ -35,11 +33,7 @@
pkgs.wayland
];
# Tests run through `nix flake check` instead.
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 = ''
wrapProgram $out/bin/valw --suffix PATH : ${
lib.makeBinPath [
@@ -48,14 +42,11 @@
]
}
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
cp -r noctalia-plugin $out/share/valw/noctalia-plugin
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 = ''
patchelf --add-rpath ${
lib.makeLibraryPath [
@@ -67,7 +58,7 @@
'';
meta = {
description = "macOS-style screenshots for niri";
description = "desktop helper";
mainProgram = "valw";
platforms = lib.platforms.linux;
};
+5
View File
@@ -20,6 +20,7 @@ mod selection;
mod shadow;
mod sound;
mod stack;
mod stitch;
mod theme;
mod thumbnail;
mod toolbar;
@@ -309,6 +310,10 @@ fn capture(mode: Mode, common: Common) -> Result<()> {
let source = frames[i].output.name.clone();
(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 => {
drop(frames);
windowed = true;
+144 -30
View File
@@ -38,6 +38,9 @@ pub const NAMESPACE: &str = "valw-overlay";
pub enum Choice {
/// A rectangle in the frame of output `.0`.
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.
Window,
}
@@ -46,12 +49,12 @@ pub enum Choice {
pub struct Overlay {
surfaces: Vec<Surface>,
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.
mods: Mods,
/// The surface the pointer is on and its surface-local position.
pointer: Option<(usize, (f64, f64))>,
/// 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>,
outcome: Option<Option<Choice>>,
}
@@ -68,6 +71,9 @@ struct Surface {
configured: bool,
frame_pending: 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.
@@ -114,7 +120,6 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<
wl.state.overlay = Some(Overlay {
surfaces,
selection: Selection::default(),
anchor: None,
mods: Mods::default(),
pointer: 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())
}
/// 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 {
fn new(
layer: LayerSurface,
@@ -151,6 +171,8 @@ impl Surface {
configured: false,
frame_pending: false,
dirty: true,
shown: None,
labelled: false,
})
}
@@ -227,9 +249,7 @@ impl Overlay {
fn apply_mods(&mut self, qh: &QueueHandle<State>) {
self.selection.set_mods(self.mods);
if let Some(a) = self.anchor {
self.redraw(a, qh);
}
self.redraw_changed(qh);
}
pub fn switch_to_window(&mut self) {
@@ -245,26 +265,64 @@ impl Overlay {
.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> {
let (start, end) = self.selection.corners()?;
let s = &self.surfaces[i];
(self.anchor == Some(i))
.then(|| selection::clip(start, end, &s.geom, s.width, s.height))
.flatten()
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);
// The size label goes next to the pointer, on the output it is on.
let label = sel.zip(self.pointer.filter(|(on, _)| *on == i).map(|(_, at)| at));
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);
}
}
}
pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle<State>) {
let Some(i) = self.index_of(layer.wl_surface()) else {
return;
@@ -303,12 +361,12 @@ impl Overlay {
let Some(i) = self.index_of(&event.surface) else {
continue;
};
self.pointer = Some((i, event.position));
let g = &self.surfaces[i].geom;
let p = Point {
x: g.x as f64 + event.position.0,
y: g.y as f64 + event.position.1,
};
self.pointer = Some(p);
match event.kind {
PointerEventKind::Enter { serial } => {
if let Some(cursor) = cursor {
@@ -320,35 +378,29 @@ impl Overlay {
} => {
// Modifiers already held apply from the start.
self.selection.press_with(p, self.mods);
self.anchor = Some(i);
self.redraw_changed(qh);
}
PointerEventKind::Press {
button: BTN_RIGHT, ..
} => self.cancel(),
PointerEventKind::Motion { .. } => {
self.selection.motion(p);
if let Some(a) = self.anchor {
self.redraw(a, qh);
}
self.redraw_changed(qh);
}
PointerEventKind::Release {
button: BTN_LEFT, ..
} => {
let (Some((start, end)), Some(a)) = (self.selection.release(p), self.anchor)
else {
let Some((start, end)) = self.selection.release(p) else {
continue;
};
let s = &self.surfaces[a];
match selection::resolve(start, end, &s.geom, s.width, s.height) {
Some(rect) => {
tracing::info!(
"selected {rect:?} on {} (logical {start:?} to {end:?})",
s.geom.name
);
self.outcome = Some(Some(Choice::Region(a, rect)));
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(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.
pub fn resolve(
a: Point,
b: Point,
out: &OutputGeom,
frame_w: u32,
frame_h: u32,
) -> Option<PixelRect> {
clip(a, b, out, frame_w, frame_h).filter(|r| r.width >= MIN_PIXELS && r.height >= MIN_PIXELS)
/// 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 (a corner can sit a
/// pixel left of or above the image when scales differ).
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)]
@@ -312,11 +431,12 @@ mod tests {
#[test]
fn click_is_not_a_selection() {
let o = out("A", 0, 0, 1920, 1080);
assert_eq!(resolve(p(10.0, 10.0), p(10.0, 10.0), &o, 1920, 1080), None);
assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None);
let outputs = [(&o, (1920, 1080))];
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!(
resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080),
Some(rect(10, 10, 2, 2))
span(p(10.0, 10.0), p(12.0, 12.0), &outputs).and_then(|s| s.single()),
Some((0, rect(10, 10, 2, 2)))
);
}
@@ -492,4 +612,171 @@ mod tests {
);
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"
);
}
}