Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2028a9aaa7 | ||
|
|
42bca37ed8 | ||
|
|
857c1f0b65 | ||
|
|
3495b42afa | ||
|
|
b47e5d8250 | ||
|
|
5dd040af21 | ||
|
|
ff1b28e9ef |
No files matched your search
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user