From 007082374d335b277ad715a5f436a988b41d314e Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 14:15:49 +0800 Subject: [PATCH] Add bread-screenshots + bread-capture: foundation for UI screenshot tooling New bread-screenshots crate captures a layer-shell surface (by namespace+pid, to disambiguate from an already-running instance) or the whole focused output via grim, using bread-utils::hypr/proc. bread-utils::Monitor gains a scale field and logical_size() so output geometry accounts for HiDPI/ transform, matching breadshot's proven math. bread-utils::hypr gains find_layer() over hyprctl layers -j. bread-capture is a small orchestrator that drives an app's --screenshot mode and collects the resulting PNGs; hardcoded to breadbar's two views for now. --- Cargo.lock | 18 +++++ Cargo.toml | 2 +- bread-capture/Cargo.toml | 18 +++++ bread-capture/src/main.rs | 58 ++++++++++++++ bread-screenshots/Cargo.toml | 14 ++++ bread-screenshots/src/lib.rs | 50 ++++++++++++ bread-utils/src/hypr.rs | 142 +++++++++++++++++++++++++++++++++++ 7 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 bread-capture/Cargo.toml create mode 100644 bread-capture/src/main.rs create mode 100644 bread-screenshots/Cargo.toml create mode 100644 bread-screenshots/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b1812e2..e32afd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-capture" +version = "0.3.1" +dependencies = [ + "anyhow", + "bread-utils", + "clap", +] + [[package]] name = "bread-onnx" version = "0.3.1" @@ -163,6 +172,15 @@ dependencies = [ "ureq", ] +[[package]] +name = "bread-screenshots" +version = "0.3.1" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + [[package]] name = "bread-shared" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 4957702..968c4d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"] resolver = "2" [workspace.package] diff --git a/bread-capture/Cargo.toml b/bread-capture/Cargo.toml new file mode 100644 index 0000000..b87cbda --- /dev/null +++ b/bread-capture/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-capture" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Orchestrator for the bread ecosystem's UI screenshot tooling: drives each app's --screenshot mode and collects the resulting PNGs" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["screenshot", "ci", "tooling"] + +[[bin]] +name = "bread-capture" +path = "src/main.rs" + +[dependencies] +bread-utils = { path = "../bread-utils" } +clap = { workspace = true } +anyhow = { workspace = true } diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs new file mode 100644 index 0000000..63231f9 --- /dev/null +++ b/bread-capture/src/main.rs @@ -0,0 +1,58 @@ +//! Orchestrator for the bread ecosystem's UI screenshot tooling. +//! +//! Drives each target app's `--screenshot --output ` mode (see +//! `bread-screenshots` for what that mode does inside the app) and reports +//! pass/fail per view. Foundation-phase scope: one target (breadbar), a +//! hardcoded view list, and a flat output directory — no versioned +//! `screenshots/vX.Y.Z/latest` structure or manifest file yet, since those +//! only earn their complexity once more apps are wired up. + +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; +use std::time::Duration; + +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); + +/// (view name, output filename) +const BREADBAR_TARGETS: &[(&str, &str)] = &[ + ("bar", "breadbar-bar.png"), + ("control-panel", "breadbar-control-panel.png"), +]; + +#[derive(Parser)] +struct Cli { + /// Path to the breadbar binary (resolved via $PATH if not a path). + #[arg(long, default_value = "breadbar")] + app_path: String, + + /// Directory to write captured PNGs into. + #[arg(long, default_value = "./screenshots")] + out_dir: PathBuf, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let mut failed = false; + for (view, filename) in BREADBAR_TARGETS { + let out_path = cli.out_dir.join(filename); + let out_str = out_path.to_string_lossy(); + let result = bread_utils::proc::run( + &cli.app_path, + &["--screenshot", view, "--output", &out_str], + CAPTURE_TIMEOUT, + ); + if result.success { + println!("ok breadbar/{view} -> {}", out_path.display()); + } else { + failed = true; + println!("FAIL breadbar/{view}: {}", result.stderr.trim()); + } + } + + if failed { + std::process::exit(1); + } + Ok(()) +} diff --git a/bread-screenshots/Cargo.toml b/bread-screenshots/Cargo.toml new file mode 100644 index 0000000..0d5670c --- /dev/null +++ b/bread-screenshots/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bread-screenshots" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Shared capture plumbing for the bread ecosystem's UI screenshot tooling: layer-surface and output geometry via Hyprland IPC, capture via grim" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["hyprland", "wayland", "screenshot", "grim"] + +[dependencies] +bread-utils = { path = "../bread-utils" } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/bread-screenshots/src/lib.rs b/bread-screenshots/src/lib.rs new file mode 100644 index 0000000..b6bb610 --- /dev/null +++ b/bread-screenshots/src/lib.rs @@ -0,0 +1,50 @@ +//! Capture primitives for the bread ecosystem's UI screenshot tooling (see +//! `bread-capture`, the orchestrator that drives this crate's consumers). +//! +//! A "view" being screenshotted is either: +//! - its own layer-shell surface (a bar, launcher, ...) — captured tightly via +//! [`capture_layer`], geometry from `hyprctl layers`. +//! - a transient popover/popup — not a separate layer surface under +//! gtk4-layer-shell (it's an xdg_popup Hyprland doesn't list individually), +//! so the only reliable capture is [`capture_output`]: whatever's currently +//! on the focused monitor. + +use anyhow::{bail, Context, Result}; +use std::path::Path; +use std::time::Duration; + +const GRIM_TIMEOUT: Duration = Duration::from_secs(5); + +/// Capture a named layer-shell surface belonging to *this* process +/// (`std::process::id()`), identified by its `namespace` (e.g. `"breadbar"`). +/// Namespace alone can't identify "our own" surface when another instance +/// under the same namespace is already running (breadbar commonly is), so +/// this matches by pid too — see `bread_utils::hypr::find_layer`. +pub fn capture_layer(namespace: &str, out: &Path) -> Result<()> { + let layer = bread_utils::hypr::find_layer(namespace, std::process::id()) + .with_context(|| format!("no layer surface found for namespace={namespace}"))?; + let geometry = format!("{},{} {}x{}", layer.x, layer.y, layer.w, layer.h); + run_grim(&geometry, out) +} + +/// Capture the entire focused output (monitor). Used for views whose +/// interesting content isn't its own layer surface — see the module doc. +pub fn capture_output(out: &Path) -> Result<()> { + let monitor = bread_utils::hypr::focused_monitor().context("no focused monitor found")?; + let (w, h) = monitor.logical_size(); + let geometry = format!("{},{} {}x{}", monitor.x, monitor.y, w, h); + run_grim(&geometry, out) +} + +fn run_grim(geometry: &str, out: &Path) -> Result<()> { + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let out_str = out.to_str().context("output path is not valid UTF-8")?; + let result = bread_utils::proc::run("grim", &["-g", geometry, out_str], GRIM_TIMEOUT); + if !result.success { + bail!("grim failed for geometry {geometry}: {}", result.stderr); + } + Ok(()) +} diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs index def3e98..228c53c 100644 --- a/bread-utils/src/hypr.rs +++ b/bread-utils/src/hypr.rs @@ -152,10 +152,81 @@ pub struct Monitor { pub y: i32, pub width: i32, pub height: i32, + #[serde(default = "one")] + pub scale: f64, + #[serde(default)] + pub transform: i64, #[serde(default)] pub focused: bool, } +fn one() -> f64 { + 1.0 +} + +impl Monitor { + /// Logical (scaled, transform-aware) output size, matching what `grim -g` + /// expects — `width`/`height` above are physical pixels. A 90/270-degree + /// transform swaps the axes before the scale divide, same as breadshot's + /// `monitor_geometry`, which this mirrors. + pub fn logical_size(&self) -> (i32, i32) { + if self.transform % 2 == 0 { + ( + (self.width as f64 / self.scale).round() as i32, + (self.height as f64 / self.scale).round() as i32, + ) + } else { + ( + (self.height as f64 / self.scale).round() as i32, + (self.width as f64 / self.scale).round() as i32, + ) + } + } +} + +/// One entry from `hyprctl layers -j` — a layer-shell surface (bar, launcher, +/// lock screen, ...), keyed by the `namespace` its client registered. +#[derive(Debug, Clone, Deserialize)] +pub struct Layer { + pub namespace: String, + pub pid: u32, + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +/// Find a layer-shell surface by namespace *and* pid. Namespace alone isn't +/// enough to identify "our own" surface — several bread apps (breadbar +/// notably) are typically already running under the same namespace when a +/// second instance starts up for some other purpose (e.g. screenshot mode), +/// so callers pass their own `std::process::id()` to disambiguate. +pub fn find_layer(namespace: &str, pid: u32) -> Option { + find_layer_in(&request_json("j/layers")?, namespace, pid) +} + +/// Parsing half of [`find_layer`], split out so it's testable without a live +/// Hyprland socket. +fn find_layer_in(root: &serde_json::Value, namespace: &str, pid: u32) -> Option { + let monitors = root.as_object()?; + for monitor in monitors.values() { + let Some(levels) = monitor.get("levels").and_then(|v| v.as_object()) else { + continue; + }; + for layers in levels.values() { + for layer in layers.as_array().into_iter().flatten() { + let Ok(l) = serde_json::from_value::(layer.clone()) else { + continue; + }; + if l.namespace == namespace && l.pid == pid { + return Some(l); + } + } + } + } + None +} + /// Query the currently active (focused) window. Returns `None` if the /// window is fullscreen or no window is focused — same "centre the popup /// instead" contract `breadclip`'s original `get_active_window` had. @@ -190,6 +261,77 @@ pub fn active_workspace_name() -> Option { mod tests { use super::*; + #[test] + fn logical_size_divides_by_scale() { + let m = Monitor { + name: "eDP-1".into(), + x: 0, + y: 0, + width: 3840, + height: 2400, + scale: 2.0, + transform: 0, + focused: true, + }; + assert_eq!(m.logical_size(), (1920, 1200)); + } + + #[test] + fn logical_size_swaps_axes_on_rotated_transform() { + let m = Monitor { + name: "eDP-1".into(), + x: 0, + y: 0, + width: 1920, + height: 1200, + scale: 1.0, + transform: 1, + focused: true, + }; + assert_eq!(m.logical_size(), (1200, 1920)); + } + + // Real shape confirmed live via `hyprctl layers -j`: per-monitor map -> + // "levels" map (layer index "0".."3") -> array of layer objects. + const LAYERS_JSON: &str = r#"{ + "eDP-1": { + "levels": { + "0": [ + {"address":"0x1","x":0,"y":0,"w":1920,"h":1200,"namespace":"awww-daemon","pid":2481} + ], + "1": [], + "2": [ + {"address":"0x2","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":1601316}, + {"address":"0x3","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":9999} + ], + "3": [] + } + } + }"#; + + #[test] + fn find_layer_in_matches_namespace_and_pid() { + let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); + let layer = find_layer_in(&root, "breadbar", 9999).unwrap(); + assert_eq!(layer.pid, 9999); + assert_eq!(layer.w, 1920); + assert_eq!(layer.h, 32); + } + + #[test] + fn find_layer_in_ignores_same_namespace_wrong_pid() { + let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); + // pid 1601316 is a real breadbar layer in the fixture, but not the one + // we're asking for — must not fall back to it. + assert!(find_layer_in(&root, "breadbar", 42).is_none()); + } + + #[test] + fn find_layer_in_returns_none_for_missing_namespace() { + let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); + assert!(find_layer_in(&root, "breadbox", 1601316).is_none()); + } + #[test] fn fullscreen_state_deserializes_from_bool() { let s: FullscreenState = serde_json::from_str("true").unwrap();