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.
This commit is contained in:
parent
77bca8a1cf
commit
007082374d
7 changed files with 301 additions and 1 deletions
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -148,6 +148,15 @@ dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-capture"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bread-utils",
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-onnx"
|
name = "bread-onnx"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -163,6 +172,15 @@ dependencies = [
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-screenshots"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bread-utils",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"]
|
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
|
||||||
18
bread-capture/Cargo.toml
Normal file
18
bread-capture/Cargo.toml
Normal file
|
|
@ -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 }
|
||||||
58
bread-capture/src/main.rs
Normal file
58
bread-capture/src/main.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//! Orchestrator for the bread ecosystem's UI screenshot tooling.
|
||||||
|
//!
|
||||||
|
//! Drives each target app's `--screenshot <view> --output <path>` 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(())
|
||||||
|
}
|
||||||
14
bread-screenshots/Cargo.toml
Normal file
14
bread-screenshots/Cargo.toml
Normal file
|
|
@ -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 }
|
||||||
50
bread-screenshots/src/lib.rs
Normal file
50
bread-screenshots/src/lib.rs
Normal file
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
@ -152,10 +152,81 @@ pub struct Monitor {
|
||||||
pub y: i32,
|
pub y: i32,
|
||||||
pub width: i32,
|
pub width: i32,
|
||||||
pub height: i32,
|
pub height: i32,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub scale: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub transform: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub focused: bool,
|
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<Layer> {
|
||||||
|
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<Layer> {
|
||||||
|
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>(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
|
/// Query the currently active (focused) window. Returns `None` if the
|
||||||
/// window is fullscreen or no window is focused — same "centre the popup
|
/// window is fullscreen or no window is focused — same "centre the popup
|
||||||
/// instead" contract `breadclip`'s original `get_active_window` had.
|
/// instead" contract `breadclip`'s original `get_active_window` had.
|
||||||
|
|
@ -190,6 +261,77 @@ pub fn active_workspace_name() -> Option<String> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn fullscreen_state_deserializes_from_bool() {
|
fn fullscreen_state_deserializes_from_bool() {
|
||||||
let s: FullscreenState = serde_json::from_str("true").unwrap();
|
let s: FullscreenState = serde_json::from_str("true").unwrap();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue