docs: keep frames in BGRX order for a fast overlay; plan regenerated

This commit is contained in:
elwrcl committed 2026-09-29 21:37:39 +03:00
1 parent 3976ce6fd9
commit fbdc8da28f
2 files changed
+210 -133

No files matched your search

@@ -4,7 +4,7 @@
**Goal:** Ship `valw screen`, `valw region` and `valw doctor`: pixel-exact PNG screenshots on niri that are saved, copied to the clipboard, logged, and explained when they fail. Everything is packaged as a dendritic Nix flake. **Goal:** Ship `valw screen`, `valw region` and `valw doctor`: pixel-exact PNG screenshots on niri that are saved, copied to the clipboard, logged, and explained when they fail. Everything is packaged as a dendritic Nix flake.
**Architecture:** One short-lived Rust process per command. The process captures every needed output up front through wlr-screencopy. For `region` it shows the frozen frames in layer-shell overlays and crops in memory. It then writes the PNG and forks a small child that serves the clipboard. Pure logic (pixel formats, selection math, overlay pixels, file names, config, errors) is kept apart from the SCTK/Wayland glue so it can be unit tested without a compositor. **Architecture:** One short-lived Rust process per command. The process captures every needed output up front through wlr-screencopy and keeps the pixels in the compositor's own byte order (B, G, R, X), so the overlay appears fast even on a slow machine. For `region` it shows the frozen frames in layer-shell overlays and crops in memory; conversion to RGBA happens only for what gets saved. It then writes the PNG and forks a small child that serves the clipboard. Pure logic (pixel formats, selection math, overlay pixels, file names, config, errors) is kept apart from the SCTK/Wayland glue so it can be unit tested without a compositor.
**Tech Stack:** Rust 2024 (stable, from nixpkgs), smithay-client-toolkit 0.21, wayland-protocols(-wlr), niri-ipc 26.4.0, wl-clipboard-rs, image (png), clap, serde/toml, tracing, rustix/libc, anyhow; Nix flake-parts with `*.ulu.nix` auto-discovery; jj. **Tech Stack:** Rust 2024 (stable, from nixpkgs), smithay-client-toolkit 0.21, wayland-protocols(-wlr), niri-ipc 26.4.0, wl-clipboard-rs, image (png), clap, serde/toml, tracing, rustix/libc, anyhow; Nix flake-parts with `*.ulu.nix` auto-discovery; jj.
@@ -53,9 +53,9 @@ These are the input classes most likely to bite a real user that no unit test fu
| `src/log.rs` | per-run log file, pruning, stderr warnings, panic hook | | `src/log.rs` | per-run log file, pruning, stderr warnings, panic hook |
| `src/lock.rs` | single-instance `flock` | | `src/lock.rs` | single-instance `flock` |
| `src/detach.rs` | fork a child that outlives valw with no lock and no stdio | | `src/detach.rs` | fork a child that outlives valw with no lock and no stdio |
| `src/frame.rs` | `OutputGeom`, `Frame`, shm format → RGBA, output transforms | | `src/frame.rs` | `OutputGeom`, `PixelRect`, `Frame` (B,G,R,X pixels), shm format → BGRX, RGBA for saving, output transforms |
| `src/selection.rs` | drag state machine; logical → physical pixel rectangles | | `src/selection.rs` | drag state machine; logical → physical pixel rectangles |
| `src/render.rs` | overlay pixels: dim, paste bright selection, outline | | `src/render.rs` | overlay pixels: dim, paste undimmed selection, outline |
| `src/output.rs` | file names, unique paths, PNG encode, atomic write, stdout, clipboard | | `src/output.rs` | file names, unique paths, PNG encode, atomic write, stdout, clipboard |
| `src/niri.rs` | niri IPC: focused output, version, version parsing | | `src/niri.rs` | niri IPC: focused output, version, version parsing |
| `src/wayland.rs` | SCTK connection, state, handlers, dispatch helpers | | `src/wayland.rs` | SCTK connection, state, handlers, dispatch helpers |
@@ -1136,7 +1136,7 @@ jj commit -m "feat: single-instance lock and detached background child"
**Interfaces:** **Interfaces:**
- Consumes: nothing. - Consumes: nothing.
- Produces: `pub struct OutputGeom { pub name: String, pub x: i32, pub y: i32, pub width: i32, pub height: i32 }` (logical, global coordinates); `pub struct Frame { pub output: OutputGeom, pub image: RgbaImage }`; `pub const SUPPORTED: [Format; 4]` (preference order); `pub fn to_rgba(format: Format, width: u32, height: u32, stride: u32, data: &[u8], y_invert: bool) -> Result<RgbaImage>`; `pub fn apply_transform(img: RgbaImage, transform: Transform) -> RgbaImage`. `Format` is `wayland_client::protocol::wl_shm::Format`, `Transform` is `wayland_client::protocol::wl_output::Transform`. - Produces: `pub struct OutputGeom { pub name: String, pub x: i32, pub y: i32, pub width: i32, pub height: i32 }` (logical, global coordinates); `pub struct PixelRect { pub x: u32, pub y: u32, pub width: u32, pub height: u32 }` (a frame's physical pixels); `pub type Bgrx = RgbaImage` (container whose bytes are B, G, R, X, i.e. XRGB8888 in memory); `pub struct Frame { pub output: OutputGeom, pub pixels: Arc<Bgrx> }` with `full(&self) -> PixelRect` and `to_rgba(&self, rect: PixelRect) -> RgbaImage` (opaque); `pub const SUPPORTED: [Format; 4]` (preference order); `pub fn to_bgrx(format: Format, width: u32, height: u32, stride: u32, data: &[u8], y_invert: bool) -> Result<Bgrx>`; `pub fn apply_transform(img: Bgrx, transform: Transform) -> Bgrx`. `Format` is `wayland_client::protocol::wl_shm::Format`, `Transform` is `wayland_client::protocol::wl_output::Transform`.
- [ ] **Step 1: Write the failing tests for `frame`** - [ ] **Step 1: Write the failing tests for `frame`**
@@ -1155,12 +1155,17 @@ mod tests {
} }
} }
fn bgr(img: &Bgrx, x: u32, y: u32) -> [u8; 3] {
let p = img.get_pixel(x, y).0;
[p[0], p[1], p[2]]
}
#[test] #[test]
fn converts_all_supported_formats_to_opaque_rgba() { fn reads_all_supported_formats_as_bgrx() {
for format in SUPPORTED { for format in SUPPORTED {
let img = to_rgba(format, 2, 1, 8, &two_pixels(format), false).unwrap(); let img = to_bgrx(format, 2, 1, 8, &two_pixels(format), false).unwrap();
assert_eq!(img.get_pixel(0, 0).0, [255, 0, 0, 255], "{format:?}"); assert_eq!(bgr(&img, 0, 0), [0, 0, 255], "red, {format:?}");
assert_eq!(img.get_pixel(1, 0).0, [0, 0, 255, 255], "{format:?}"); assert_eq!(bgr(&img, 1, 0), [255, 0, 0], "blue, {format:?}");
} }
} }
@@ -1168,38 +1173,76 @@ mod tests {
fn honours_stride_padding() { fn honours_stride_padding() {
// One pixel per row, 8-byte stride. // One pixel per row, 8-byte stride.
let data = [0, 0, 255, 0, 9, 9, 9, 9, 255, 0, 0, 0, 9, 9, 9, 9]; let data = [0, 0, 255, 0, 9, 9, 9, 9, 255, 0, 0, 0, 9, 9, 9, 9];
let img = to_rgba(Format::Xrgb8888, 1, 2, 8, &data, false).unwrap(); let img = to_bgrx(Format::Xrgb8888, 1, 2, 8, &data, false).unwrap();
assert_eq!(img.get_pixel(0, 0).0, [255, 0, 0, 255]); assert_eq!(img.as_raw().len(), 8);
assert_eq!(img.get_pixel(0, 1).0, [0, 0, 255, 255]); assert_eq!(bgr(&img, 0, 0), [0, 0, 255]);
assert_eq!(bgr(&img, 0, 1), [255, 0, 0]);
} }
#[test] #[test]
fn y_invert_flips_rows() { fn y_invert_flips_rows() {
let data = [0, 0, 255, 0, 255, 0, 0, 0]; let data = [0, 0, 255, 0, 255, 0, 0, 0];
let img = to_rgba(Format::Xrgb8888, 1, 2, 4, &data, true).unwrap(); let img = to_bgrx(Format::Xrgb8888, 1, 2, 4, &data, true).unwrap();
assert_eq!(img.get_pixel(0, 0).0, [0, 0, 255, 255]); assert_eq!(bgr(&img, 0, 0), [255, 0, 0]);
assert_eq!(img.get_pixel(0, 1).0, [255, 0, 0, 255]); assert_eq!(bgr(&img, 0, 1), [0, 0, 255]);
} }
#[test] #[test]
fn rejects_unknown_format_by_name() { fn rejects_unknown_format_by_name() {
let err = to_rgba(Format::Xbgr2101010, 1, 1, 4, &[0; 4], false).unwrap_err(); let err = to_bgrx(Format::Xbgr2101010, 1, 1, 4, &[0; 4], false).unwrap_err();
assert_eq!(err.to_string(), "unsupported buffer format Xbgr2101010"); assert_eq!(err.to_string(), "unsupported buffer format Xbgr2101010");
} }
#[test] #[test]
fn rejects_short_buffer() { fn rejects_short_buffer() {
assert!(to_rgba(Format::Xrgb8888, 2, 2, 8, &[0; 8], false).is_err()); assert!(to_bgrx(Format::Xrgb8888, 2, 2, 8, &[0; 8], false).is_err());
} }
fn marked() -> RgbaImage { fn frame(width: u32, height: u32, data: Vec<u8>) -> Frame {
Frame {
output: OutputGeom {
name: "A".into(),
x: 0,
y: 0,
width: width as i32,
height: height as i32,
},
pixels: Arc::new(Bgrx::from_raw(width, height, data).unwrap()),
}
}
#[test]
fn to_rgba_swaps_channels_and_makes_opaque() {
// Red with alpha 0, as an ARGB8888 buffer may deliver it.
let f = frame(1, 1, vec![0, 0, 255, 0]);
assert_eq!(f.to_rgba(f.full()).get_pixel(0, 0).0, [255, 0, 0, 255]);
}
#[test]
fn to_rgba_crops() {
// 3x2: pixel value = its index, in the blue byte.
let data = (0u8..6).flat_map(|i| [i, 0, 0, 0]).collect();
let f = frame(3, 2, data);
let rect = PixelRect {
x: 1,
y: 1,
width: 2,
height: 1,
};
let out = f.to_rgba(rect);
assert_eq!(out.dimensions(), (2, 1));
assert_eq!(out.get_pixel(0, 0).0, [0, 0, 4, 255]);
assert_eq!(out.get_pixel(1, 0).0, [0, 0, 5, 255]);
}
fn marked() -> Bgrx {
// 3x2, top-left pixel marked. // 3x2, top-left pixel marked.
let mut img = RgbaImage::new(3, 2); let mut img = Bgrx::new(3, 2);
img.put_pixel(0, 0, image::Rgba([255, 255, 255, 255])); img.put_pixel(0, 0, image::Rgba([255, 255, 255, 255]));
img img
} }
fn marked_at(img: &RgbaImage) -> (u32, u32, u32, u32) { fn marked_at(img: &Bgrx) -> (u32, u32, u32, u32) {
let (x, y, _) = img let (x, y, _) = img
.enumerate_pixels() .enumerate_pixels()
.find(|(_, _, p)| p.0[0] == 255) .find(|(_, _, p)| p.0[0] == 255)
@@ -1232,13 +1275,15 @@ Add `mod frame;` to the `mod` list at the top of `src/main.rs` (keep it sorted).
- [ ] **Step 2: Run the tests and watch them fail** - [ ] **Step 2: Run the tests and watch them fail**
Run: `cargo nextest run frame::` Run: `cargo nextest run frame::`
Expected: compilation fails, e.g. `` cannot find function `to_rgba` in this scope ``. Expected: compilation fails, e.g. `` cannot find function `to_bgrx` in this scope ``.
- [ ] **Step 3: Implement `frame`** - [ ] **Step 3: Implement `frame`**
Put this above the test module in `src/frame.rs`: Put this above the test module in `src/frame.rs`:
```rust ```rust
use std::sync::Arc;
use anyhow::{Result, bail, ensure}; use anyhow::{Result, bail, ensure};
use image::{RgbaImage, imageops}; use image::{RgbaImage, imageops};
use wayland_client::protocol::{wl_output::Transform, wl_shm::Format}; use wayland_client::protocol::{wl_output::Transform, wl_shm::Format};
@@ -1253,11 +1298,57 @@ pub struct OutputGeom {
pub height: i32, pub height: i32,
} }
/// One captured output: upright RGBA pixels at physical resolution. /// A rectangle in a frame's physical pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PixelRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
/// Pixels in wl_shm XRGB8888 memory order: B, G, R, X. This is what niri
/// hands out and what the overlay shows, so nothing on the way to the first
/// overlay frame has to reorder bytes. `RgbaImage` is only the container;
/// its channels do not mean R, G, B, A here.
pub type Bgrx = RgbaImage;
/// One captured output: upright pixels at physical resolution.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Frame { pub struct Frame {
pub output: OutputGeom, pub output: OutputGeom,
pub image: RgbaImage, /// Shared with the overlay, which shows it as the undimmed selection.
pub pixels: Arc<Bgrx>,
}
impl Frame {
/// The whole frame as a rectangle.
pub fn full(&self) -> PixelRect {
PixelRect {
x: 0,
y: 0,
width: self.pixels.width(),
height: self.pixels.height(),
}
}
/// The `rect` part as opaque RGBA, for saving.
///
/// Alpha is always 255: a screenshot of what's on screen is opaque, even
/// where the compositor's buffer has alpha 0.
pub fn to_rgba(&self, rect: PixelRect) -> RgbaImage {
let stride = self.pixels.width() as usize * 4;
let raw = self.pixels.as_raw();
let mut out = RgbaImage::new(rect.width, rect.height);
for (y, row) in out.rows_mut().enumerate() {
let start = (rect.y as usize + y) * stride + rect.x as usize * 4;
let (src, _) = raw[start..start + rect.width as usize * 4].as_chunks::<4>();
for (px, s) in row.zip(src) {
px.0 = [s[2], s[1], s[0], 255];
}
}
out
}
} }
/// Shm formats valw can read, in order of preference. /// Shm formats valw can read, in order of preference.
@@ -1268,22 +1359,18 @@ pub const SUPPORTED: [Format; 4] = [
Format::Abgr8888, Format::Abgr8888,
]; ];
/// Converts a screencopy buffer to opaque RGBA. /// Copies a screencopy buffer into B, G, R, X order, dropping stride padding.
/// pub fn to_bgrx(
/// Alpha is always set to 255: a screenshot of what's on screen is opaque,
/// even where the compositor's buffer has alpha 0.
pub fn to_rgba(
format: Format, format: Format,
width: u32, width: u32,
height: u32, height: u32,
stride: u32, stride: u32,
data: &[u8], data: &[u8],
y_invert: bool, y_invert: bool,
) -> Result<RgbaImage> { ) -> Result<Bgrx> {
// Little-endian byte order of each 32-bit pixel. let swap = match format {
let (r, g, b) = match format { Format::Xrgb8888 | Format::Argb8888 => false,
Format::Xrgb8888 | Format::Argb8888 => (2, 1, 0), Format::Xbgr8888 | Format::Abgr8888 => true,
Format::Xbgr8888 | Format::Abgr8888 => (0, 1, 2),
other => bail!("unsupported buffer format {other:?}"), other => bail!("unsupported buffer format {other:?}"),
}; };
ensure!( ensure!(
@@ -1297,12 +1384,16 @@ pub fn to_rgba(
stride * height stride * height
); );
let mut out = RgbaImage::new(width, height); let row_len = width as usize * 4;
for (y, row) in out.rows_mut().enumerate() { let mut out = Bgrx::new(width, height);
for (y, dst) in out.chunks_exact_mut(row_len).enumerate() {
let src_y = if y_invert { height as usize - 1 - y } else { y }; let src_y = if y_invert { height as usize - 1 - y } else { y };
let (src, _) = data[src_y * stride as usize..][..width as usize * 4].as_chunks::<4>(); let src = &data[src_y * stride as usize..][..row_len];
for (px, s) in row.zip(src) { dst.copy_from_slice(src);
px.0 = [s[r], s[g], s[b], 255]; if swap {
for px in dst.as_chunks_mut::<4>().0 {
px.swap(0, 2);
}
} }
} }
Ok(out) Ok(out)
@@ -1310,7 +1401,7 @@ pub fn to_rgba(
/// Turns a buffer in the output's native orientation upright, the same way /// Turns a buffer in the output's native orientation upright, the same way
/// grim does: rotate clockwise by the transform's angle, then mirror. /// grim does: rotate clockwise by the transform's angle, then mirror.
pub fn apply_transform(img: RgbaImage, transform: Transform) -> RgbaImage { pub fn apply_transform(img: Bgrx, transform: Transform) -> Bgrx {
let rotated = match transform { let rotated = match transform {
Transform::_90 | Transform::Flipped90 => imageops::rotate90(&img), Transform::_90 | Transform::Flipped90 => imageops::rotate90(&img),
Transform::_180 | Transform::Flipped180 => imageops::rotate180(&img), Transform::_180 | Transform::Flipped180 => imageops::rotate180(&img),
@@ -1330,7 +1421,7 @@ pub fn apply_transform(img: RgbaImage, transform: Transform) -> RgbaImage {
- [ ] **Step 4: Run the tests and watch them pass** - [ ] **Step 4: Run the tests and watch them pass**
Run: `cargo nextest run frame::` Run: `cargo nextest run frame::`
Expected: all 6 tests in `frame::tests` pass. Expected: all 8 tests in `frame::tests` pass.
- [ ] **Step 5: Lint** - [ ] **Step 5: Lint**
@@ -1340,7 +1431,7 @@ Expected: no warnings; every test passes.
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```sh ```sh
jj commit -m "feat: screencopy pixel conversion and output transforms" jj commit -m "feat: screencopy frames, pixel formats and output transforms"
``` ```
--- ---
@@ -1354,8 +1445,8 @@ jj commit -m "feat: screencopy pixel conversion and output transforms"
- Modify: `src/main.rs` (add `selection`, `render` to the `mod` list) - Modify: `src/main.rs` (add `selection`, `render` to the `mod` list)
**Interfaces:** **Interfaces:**
- Consumes: `frame::OutputGeom` (Task 6). - Consumes: `frame::{OutputGeom, PixelRect}` (Task 6).
- Produces: `selection::{MIN_PIXELS, Point { x: f64, y: f64 }, PixelRect { x, y, width, height: u32 }, Selection { Idle, Dragging { start, end } }}` with `press`, `motion`, `release(p) -> Option<(Point, Point)>`; `selection::clip(a, b, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option<PixelRect>`; `selection::resolve(...)` (same, but `None` under `MIN_PIXELS`); `render::{DIM, to_argb8888(img: &RgbaImage, brightness: u8) -> Vec<u8>, compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option<PixelRect>)}`. - Produces: `selection::{MIN_PIXELS, Point { x: f64, y: f64 }, Selection { Idle, Dragging { start, end } }}` with `press`, `motion`, `release(p) -> Option<(Point, Point)>`; `selection::clip(a, b, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option<PixelRect>`; `selection::resolve(...)` (same, but `None` under `MIN_PIXELS`); `render::{DIM, dim(pixels: &[u8], brightness: u8) -> Vec<u8>, compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option<PixelRect>)}` (all buffers are B, G, R, X).
- [ ] **Step 1: Write the failing tests for `selection`** - [ ] **Step 1: Write the failing tests for `selection`**
@@ -1513,7 +1604,7 @@ Expected: compilation fails, e.g. `` cannot find struct, variant or union type `
Put this above the test module in `src/selection.rs`: Put this above the test module in `src/selection.rs`:
```rust ```rust
use crate::frame::OutputGeom; use crate::frame::{OutputGeom, PixelRect};
/// Selections smaller than this (in physical pixels) count as a click. /// Selections smaller than this (in physical pixels) count as a click.
pub const MIN_PIXELS: u32 = 2; pub const MIN_PIXELS: u32 = 2;
@@ -1525,15 +1616,6 @@ pub struct Point {
pub y: f64, pub y: f64,
} }
/// A rectangle in a frame's physical pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PixelRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)] #[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum Selection { pub enum Selection {
#[default] #[default]
@@ -1613,10 +1695,10 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn converts_and_dims() { fn dims_every_byte() {
let img = RgbaImage::from_raw(1, 1, vec![200, 100, 50, 255]).unwrap(); assert_eq!(dim(&[200, 100, 50, 0], 255), vec![200, 100, 50, 0]);
assert_eq!(to_argb8888(&img, 255), vec![50, 100, 200, 255]); assert_eq!(dim(&[200, 100, 50, 255], DIM), vec![120, 60, 30, 153]);
assert_eq!(to_argb8888(&img, DIM), vec![30, 60, 120, 255]); assert_eq!(dim(&[255, 0, 1, 2], 0), vec![0, 0, 0, 0]);
} }
fn px(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { fn px(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
@@ -1676,26 +1758,28 @@ Add `mod render;` to the `mod` list at the top of `src/main.rs` (keep it sorted)
- [ ] **Step 6: Run the tests and watch them fail** - [ ] **Step 6: Run the tests and watch them fail**
Run: `cargo nextest run render::` Run: `cargo nextest run render::`
Expected: compilation fails, e.g. `` cannot find function `to_argb8888` in this scope ``. Expected: compilation fails, e.g. `` cannot find function `dim` in this scope ``.
- [ ] **Step 7: Implement `render`** - [ ] **Step 7: Implement `render`**
Put this above the test module in `src/render.rs`: Put this above the test module in `src/render.rs`:
```rust ```rust
use image::RgbaImage; use crate::frame::PixelRect;
use crate::selection::PixelRect;
/// Overlay brightness outside the selection (60%, i.e. darkened by 40%). /// Overlay brightness outside the selection (60%, i.e. darkened by 40%).
pub const DIM: u8 = 153; pub const DIM: u8 = 153;
/// `img` as wl_shm ARGB8888 bytes (B, G, R, A in memory), with RGB scaled /// B, G, R, X pixels with every byte scaled by about `brightness / 255`.
/// by `brightness / 255`. ///
pub fn to_argb8888(img: &RgbaImage, brightness: u8) -> Vec<u8> { /// This runs on every output before the overlay can appear, so it is one
let scale = |c: u8| (c as u16 * brightness as u16 / 255) as u8; /// branch-free pass the compiler can vectorise. Scaling X too is harmless:
img.pixels() /// the overlay buffer is XRGB8888, which ignores it.
.flat_map(|p| [scale(p.0[2]), scale(p.0[1]), scale(p.0[0]), 255]) pub fn dim(pixels: &[u8], brightness: u8) -> Vec<u8> {
let factor = brightness as u16 + 1;
pixels
.iter()
.map(|&b| ((b as u16 * factor) >> 8) as u8)
.collect() .collect()
} }
@@ -2272,9 +2356,12 @@ impl Wayland {
} }
fn log_environment(&self) { fn log_environment(&self) {
for (interface, version) in self.global_list() { let globals: Vec<String> = self
tracing::debug!("global {interface} v{version}"); .global_list()
} .into_iter()
.map(|(interface, version)| format!("{interface} v{version}"))
.collect();
tracing::info!("globals: {}", globals.join(", "));
for o in self.outputs() { for o in self.outputs() {
let g = &o.geom; let g = &o.geom;
tracing::info!( tracing::info!(
@@ -2961,7 +3048,7 @@ jj commit -m "feat: wayland connection, niri IPC and valw doctor"
- Replace: `src/main.rs` - Replace: `src/main.rs`
**Interfaces:** **Interfaces:**
- Consumes: `wayland::{Wayland, State, Output}` and `Wayland::dispatch_until` (Task 9); `frame::{Frame, SUPPORTED, to_rgba, apply_transform}` (Task 6); `output::*` (Task 8); `lock::Lock` (Task 5); `niri::{focused_output, version}` (Task 9); `config` (Task 3). - Consumes: `wayland::{Wayland, State, Output}` and `Wayland::dispatch_until` (Task 9); `frame::{Frame, SUPPORTED, to_bgrx, apply_transform, Frame::to_rgba, Frame::full}` (Task 6); `output::*` (Task 8); `lock::Lock` (Task 5); `niri::{focused_output, version}` (Task 9); `config` (Task 3).
- Produces: `pub trait Capturer { fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result<Vec<Frame>>; }` implemented for `Wayland`; `pub struct Pending` (one in-flight screencopy, stored in `State::captures`); CLI `valw screen [--all] [--clipboard-only] [-o PATH|-] [--delay SECS] [--cursor]`. - Produces: `pub trait Capturer { fn capture(&mut self, outputs: &[Output], cursor: bool) -> Result<Vec<Frame>>; }` implemented for `Wayland`; `pub struct Pending` (one in-flight screencopy, stored in `State::captures`); CLI `valw screen [--all] [--clipboard-only] [-o PATH|-] [--delay SECS] [--cursor]`.
Two facts the code depends on, both verified on niri 26.04: Two facts the code depends on, both verified on niri 26.04:
@@ -2973,6 +3060,7 @@ Two facts the code depends on, both verified on niri 26.04:
Add `mod capture;` to the `mod` list in `src/main.rs`. The pixel logic here was unit-tested in Task 6; this file is protocol plumbing. Add `mod capture;` to the `mod` list in `src/main.rs`. The pixel logic here was unit-tested in Task 6; this file is protocol plumbing.
```rust ```rust
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
@@ -3050,11 +3138,12 @@ impl Capturer for Wayland {
self.dispatch_until(started + TIMEOUT, |s| { self.dispatch_until(started + TIMEOUT, |s| {
s.captures.iter().all(|c| c.result.is_some()) s.captures.iter().all(|c| c.result.is_some())
}) })
.context("screen capture timed out")?; .context("screen capture failed")?;
tracing::info!( tracing::info!(
"captured {} output(s) in {:?}", "captured {} output(s) in {:?} ({:?} after start)",
outputs.len(), outputs.len(),
started.elapsed() started.elapsed(),
crate::log::since_start()
); );
std::mem::take(&mut self.state.captures) std::mem::take(&mut self.state.captures)
@@ -3183,9 +3272,9 @@ fn finish(c: &mut Pending) -> Result<Frame> {
let l = c.layout.context("ready without a buffer")?; let l = c.layout.context("ready without a buffer")?;
let pool = c.pool.as_mut().context("ready without a buffer")?; let pool = c.pool.as_mut().context("ready without a buffer")?;
let data = &pool.mmap()[..]; let data = &pool.mmap()[..];
let image = frame::to_rgba(l.format, l.width, l.height, l.stride, data, c.y_invert)?; let pixels = frame::to_bgrx(l.format, l.width, l.height, l.stride, data, c.y_invert)?;
Ok(Frame { Ok(Frame {
image: frame::apply_transform(image, c.output.transform), pixels: Arc::new(frame::apply_transform(pixels, c.output.transform)),
output: c.output.geom.clone(), output: c.output.geom.clone(),
}) })
} }
@@ -3232,7 +3321,7 @@ Apply this change to `src/wayland.rs`:
}; };
// Two round trips: one for wl_output, one for the xdg-output details. // Two round trips: one for wl_output, one for the xdg-output details.
queue queue
@@ -422,3 +426,4 @@ @@ -425,3 +429,4 @@
wayland_client::delegate_noop!(State: ZwlrScreencopyManagerV1); wayland_client::delegate_noop!(State: ZwlrScreencopyManagerV1);
wayland_client::delegate_noop!(State: WpViewporter); wayland_client::delegate_noop!(State: WpViewporter);
wayland_client::delegate_noop!(State: WpViewport); wayland_client::delegate_noop!(State: WpViewport);
@@ -3377,12 +3466,12 @@ fn capture(mode: Mode, common: Common) -> Result<()> {
let frames = wl.capture(&outputs, cursor)?; let frames = wl.capture(&outputs, cursor)?;
let shots = frames let shots = frames
.into_iter() .into_iter()
.map(|f| (f.image, Some(f.output.name))) .map(|f| (f.to_rgba(f.full()), Some(f.output.name)))
.collect(); .collect();
(shots, focused) (shots, focused)
} else { } else {
let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0);
(vec![(frame.image, None)], 0) (vec![(frame.to_rgba(frame.full()), None)], 0)
} }
} }
}; };
@@ -3549,16 +3638,18 @@ jj commit -m "feat: wlr-screencopy capture and valw screen"
- Modify: `src/wayland.rs` (route overlay events), `src/main.rs` (add `region`, drop `#![allow(dead_code)]`) - Modify: `src/wayland.rs` (route overlay events), `src/main.rs` (add `region`, drop `#![allow(dead_code)]`)
**Interfaces:** **Interfaces:**
- Consumes: `selection::{Selection, Point, PixelRect, clip, resolve}` (Task 7); `render::{to_argb8888, compose, DIM}` (Task 7); `frame::{Frame, OutputGeom}` (Task 6); `wayland::{Wayland, State, Output}`, `Wayland::dispatch_blocking` (Task 9); `log::since_start` (Task 4); `error::{Cancelled, HintExt}` (Task 2). - Consumes: `selection::{Selection, Point, clip, resolve}` (Task 7); `render::{dim, compose, DIM}` (Task 7); `frame::{Bgrx, Frame, OutputGeom, PixelRect}` (Task 6); `wayland::{Wayland, State, Output}`, `Wayland::dispatch_blocking` (Task 9); `log::since_start` (Task 4); `error::{Cancelled, HintExt}` (Task 2).
- Produces: `pub const NAMESPACE: &str = "valw-overlay"`; `pub type Picked = (usize, PixelRect)`; `pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<Picked>` (returns `Cancelled` on Esc or right click); `pub struct Overlay` with `cancel`, `configure`, `frame_done`, `pointer`, called from `State`'s handlers. CLI: `valw region [COMMON]`. - Produces: `pub const NAMESPACE: &str = "valw-overlay"`; `pub type Picked = (usize, PixelRect)`; `pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<Picked>` (returns `Cancelled` on Esc or right click); `pub struct Overlay` with `cancel`, `configure`, `frame_done`, `pointer`, called from `State`'s handlers. CLI: `valw region [COMMON]`.
How it works: one fullscreen layer surface per output shows that output's frozen frame, darkened. The buffer has the frame's physical size, and `wp_viewporter` maps it onto the surface's logical size, so it is shown 1:1 at any scale. Pointer positions are converted to global logical coordinates. The drag is drawn (and later cropped) only on the output where it started. Redraws are paced by frame callbacks. How it works: one fullscreen layer surface per output shows that output's frozen frame, darkened. The buffer is XRGB8888 (the frame's own byte order, so no conversion) at the frame's physical size, and `wp_viewporter` maps it onto the surface's logical size, so it is shown 1:1 at any scale. The undimmed selection is pasted straight from the shared frame (`Arc<Bgrx>`, no copy). Pointer positions are converted to global logical coordinates. The drag is drawn (and later cropped) only on the output where it started. Redraws are paced by frame callbacks.
- [ ] **Step 1: Write `src/region.rs`** - [ ] **Step 1: Write `src/region.rs`**
Add `mod region;` to the `mod` list in `src/main.rs`. Add `mod region;` to the `mod` list in `src/main.rs`.
```rust ```rust
use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use smithay_client_toolkit::{ use smithay_client_toolkit::{
compositor::FrameCallbackData, compositor::FrameCallbackData,
@@ -3582,9 +3673,9 @@ use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::
use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport;
use crate::error::{Cancelled, HintExt}; use crate::error::{Cancelled, HintExt};
use crate::frame::{Frame, OutputGeom}; use crate::frame::{Bgrx, Frame, OutputGeom, PixelRect};
use crate::render; use crate::render;
use crate::selection::{self, PixelRect, Point, Selection}; use crate::selection::{self, Point, Selection};
use crate::wayland::{Output, State, Wayland}; use crate::wayland::{Output, State, Wayland};
pub const NAMESPACE: &str = "valw-overlay"; pub const NAMESPACE: &str = "valw-overlay";
@@ -3608,7 +3699,7 @@ struct Surface {
width: u32, width: u32,
height: u32, height: u32,
dark: Vec<u8>, dark: Vec<u8>,
bright: Vec<u8>, bright: Arc<Bgrx>,
pool: SlotPool, pool: SlotPool,
buffer: Option<Buffer>, buffer: Option<Buffer>,
configured: bool, configured: bool,
@@ -3650,6 +3741,10 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<
surfaces.push(Surface::new(layer, viewport, frame, &s.shm)?); surfaces.push(Surface::new(layer, viewport, frame, &s.shm)?);
} }
tracing::debug!(
"overlay buffers ready {:?} after start",
crate::log::since_start()
);
wl.state.overlay = Some(Overlay { wl.state.overlay = Some(Overlay {
surfaces, surfaces,
selection: Selection::default(), selection: Selection::default(),
@@ -3666,7 +3761,7 @@ pub fn select(wl: &mut Wayland, outputs: &[Output], frames: &[Frame]) -> Result<
impl Surface { impl Surface {
fn new(layer: LayerSurface, viewport: WpViewport, frame: &Frame, shm: &Shm) -> Result<Surface> { fn new(layer: LayerSurface, viewport: WpViewport, frame: &Frame, shm: &Shm) -> Result<Surface> {
let (width, height) = frame.image.dimensions(); let (width, height) = frame.pixels.dimensions();
let len = (width * height * 4) as usize; let len = (width * height * 4) as usize;
Ok(Surface { Ok(Surface {
layer, layer,
@@ -3674,8 +3769,8 @@ impl Surface {
geom: frame.output.clone(), geom: frame.output.clone(),
width, width,
height, height,
dark: render::to_argb8888(&frame.image, render::DIM), dark: render::dim(frame.pixels.as_raw(), render::DIM),
bright: render::to_argb8888(&frame.image, 255), bright: Arc::clone(&frame.pixels),
// Room for two buffers so one can be on screen while we draw the next. // Room for two buffers so one can be on screen while we draw the next.
pool: SlotPool::new(len * 2, shm).context("could not create shm pool")?, pool: SlotPool::new(len * 2, shm).context("could not create shm pool")?,
buffer: None, buffer: None,
@@ -3691,12 +3786,12 @@ impl Surface {
self.width as i32, self.width as i32,
self.height as i32, self.height as i32,
stride, stride,
wl_shm::Format::Argb8888, wl_shm::Format::Xrgb8888,
) else { ) else {
tracing::warn!("could not allocate an overlay buffer"); tracing::warn!("could not allocate an overlay buffer");
return; return;
}; };
render::compose(canvas, &self.dark, &self.bright, self.width, sel); render::compose(canvas, &self.dark, self.bright.as_raw(), self.width, sel);
let surface = self.layer.wl_surface(); let surface = self.layer.wl_surface();
surface.damage_buffer(0, 0, self.width as i32, self.height as i32); surface.damage_buffer(0, 0, self.width as i32, self.height as i32);
@@ -3866,23 +3961,7 @@ Apply this change to `src/wayland.rs`:
}; };
// Two round trips: one for wl_output, one for the xdg-output details. // Two round trips: one for wl_output, one for the xdg-output details.
queue queue
@@ -148,9 +152,12 @@ @@ -233,7 +237,17 @@
}
fn log_environment(&self) {
- for (interface, version) in self.global_list() {
- tracing::debug!("global {interface} v{version}");
- }
+ let globals: Vec<String> = self
+ .global_list()
+ .into_iter()
+ .map(|(interface, version)| format!("{interface} v{version}"))
+ .collect();
+ tracing::info!("globals: {}", globals.join(", "));
for o in self.outputs() {
let g = &o.geom;
tracing::info!(
@@ -230,7 +237,17 @@
) { ) {
} }
@@ -3901,7 +3980,7 @@ Apply this change to `src/wayland.rs`:
fn surface_enter( fn surface_enter(
&mut self, &mut self,
@@ -264,16 +281,23 @@ @@ -267,16 +281,23 @@
} }
impl LayerShellHandler for State { impl LayerShellHandler for State {
@@ -3931,7 +4010,7 @@ Apply this change to `src/wayland.rs`:
} }
} }
@@ -360,8 +384,13 @@ @@ -363,8 +384,13 @@
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard, _: &wl_keyboard::WlKeyboard,
_: u32, _: u32,
@@ -3947,7 +4026,7 @@ Apply this change to `src/wayland.rs`:
} }
fn repeat_key( fn repeat_key(
@@ -401,10 +430,13 @@ @@ -404,10 +430,13 @@
fn pointer_frame( fn pointer_frame(
&mut self, &mut self,
_: &Connection, _: &Connection,
@@ -3988,15 +4067,6 @@ Apply this change to `src/main.rs`:
mod render; mod render;
mod selection; mod selection;
mod wayland; mod wayland;
@@ -22,7 +20,7 @@
use anyhow::{Context, Result};
use chrono::Local;
use clap::{Args, Parser, Subcommand};
-use image::RgbaImage;
+use image::{RgbaImage, imageops};
use crate::capture::Capturer;
use crate::config::Config;
@@ -45,6 +43,11 @@ @@ -45,6 +43,11 @@
/// Capture every output, one file each. /// Capture every output, one file each.
#[arg(long, conflicts_with_all = ["output", "clipboard_only"])] #[arg(long, conflicts_with_all = ["output", "clipboard_only"])]
@@ -4025,21 +4095,19 @@ Apply this change to `src/main.rs`:
} }
} }
@@ -139,6 +144,13 @@ @@ -139,6 +144,11 @@
(vec![(frame.image, None)], 0) (vec![(frame.to_rgba(frame.full()), None)], 0)
} }
} }
+ Mode::Region => { + Mode::Region => {
+ let frames = wl.capture(&outputs, cursor)?; + let frames = wl.capture(&outputs, cursor)?;
+ let (i, r) = region::select(&mut wl, &outputs, &frames)?; + let (i, r) = region::select(&mut wl, &outputs, &frames)?;
+ let image = + (vec![(frames[i].to_rgba(r), None)], 0)
+ imageops::crop_imm(&frames[i].image, r.x, r.y, r.width, r.height).to_image();
+ (vec![(image, None)], 0)
+ } + }
}; };
drop(wl); drop(wl);
@@ -224,10 +236,10 @@ @@ -224,10 +234,10 @@
fn flag_conflicts() { fn flag_conflicts() {
assert!(parses(&["screen", "--all"])); assert!(parses(&["screen", "--all"]));
assert!(parses(&["screen", "-o", "-"])); assert!(parses(&["screen", "-o", "-"]));
@@ -4240,7 +4308,7 @@ Expected: `0` every time. At 1.25 the overlay must also look sharp, with text in
- [ ] **Step 3: Latency** - [ ] **Step 3: Latency**
Run `$V region -o /dev/null` and press Esc; repeat three times. Run `$V region -o /dev/null` and press Esc; repeat three times.
Expected: every `overlay on … committed …ms after start` log line is under 150 ms. Expected: every `overlay on … committed …ms after start` log line is under 150 ms. (Prototype measurements: 38–42 ms warm and 90 ms cold on a headless 1080p output; 270 ms before frames were kept in B, G, R, X order.) If it's over, the `captured … (… after start)` and `overlay buffers ready …` lines (`VALW_LOG=debug`) show which phase is slow.
- [ ] **Step 4: Rotated output (Review Focus 1)** - [ ] **Step 4: Rotated output (Review Focus 1)**
@@ -218,13 +218,20 @@ Every capture command runs these steps in order.
names the protocol. names the protocol.
5. **Capture.** `Capturer::capture(&[Output], cursor: bool) -> Vec<Frame>` 5. **Capture.** `Capturer::capture(&[Output], cursor: bool) -> Vec<Frame>`
issues one screencopy request per output in parallel and waits up to 5 s issues one screencopy request per output in parallel and waits up to 5 s
for every `ready`. A `Frame` holds upright, opaque RGBA8 pixels at physical for every `ready`. A `Frame` holds upright pixels at physical resolution
resolution plus its output name and logical geometry; the scale is in **B, G, R, X byte order** (XRGB8888 in memory, which is what niri
`frame width / logical width`. `XRGB8888`, `ARGB8888`, `XBGR8888` and delivers), plus its output name and logical geometry; the scale is
`ABGR8888` are converted; `y_invert` is honoured; any other format is an `frame width / logical width`. `XRGB8888` and `ARGB8888` are copied
error that names the format. Alpha is always 255, even where the as-is, `XBGR8888` and `ABGR8888` get R and B swapped, `y_invert` is
compositor's buffer says 0. `cursor` comes from `--cursor` or honoured, and any other format is an error that names the format.
Conversion to RGBA happens only for what is saved (`Frame::to_rgba`,
after the overlay has closed), and alpha is then always 255, even where
the compositor's buffer says 0. `cursor` comes from `--cursor` or
`capture.show_cursor`. `capture.show_cursor`.
- **Why BGRX:** on the user's machine a full 1080p pass over the pixels
costs 5–10 ms. Converting to RGBA up front and back to ARGB for the
overlay put the first overlay frame at 270 ms. Keeping the native
order brought it to about 40 ms (headless 1080p, warm).
- **niri quirk (verified in its source):** niri rejects a shm buffer - **niri quirk (verified in its source):** niri rejects a shm buffer
with `invalid buffer` unless the whole `wl_shm_pool` is exactly the with `invalid buffer` unless the whole `wl_shm_pool` is exactly the
buffer's size. Every capture therefore gets its own pool. buffer's size. Every capture therefore gets its own pool.
@@ -251,9 +258,11 @@ output:
- namespace `valw-overlay`, layer `Overlay`, anchored to all four edges, - namespace `valw-overlay`, layer `Overlay`, anchored to all four edges,
`exclusive_zone = -1`, `keyboard_interactivity = Exclusive` `exclusive_zone = -1`, `keyboard_interactivity = Exclusive`
- background: that output's frozen frame, darkened by 40% - buffer format XRGB8888, the frame's own byte order, so nothing is
- the selection area is copied from the undarkened frame and outlined with a converted and alpha is ignored
1 physical pixel white border - background: that output's frozen frame, darkened by 40% (computed once)
- the selection area is copied from the undarkened frame (shared, not
copied) and outlined with a 1 physical pixel white border
- cursor shape: crosshair (`wp_cursor_shape`) - cursor shape: crosshair (`wp_cursor_shape`)
Surfaces are owned by the process, so the compositor removes them if valw Surfaces are owned by the process, so the compositor removes them if valw