Phase 6c screenshot coverage for spotlight's new query sections and = calc mode, alongside the existing capsule-collapsed/capsule-expanded pair.
262 lines
10 KiB
Rust
262 lines
10 KiB
Rust
//! 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/app. Plain `bread-capture` with no flags captures
|
|
//! every known app's every view in one run — each app's binary is resolved
|
|
//! by its own bare name via `$PATH`, same as running it directly by name
|
|
//! would. `--app <name>` restricts to one app; `--app-path <path>`
|
|
//! overrides where its binary is found (and, without `--app`, also selects
|
|
//! which app by its file stem — so `--app-path ./target/release/breadbox`
|
|
//! alone still works); `--view <name>` further restricts to one view. The
|
|
//! view list for each app is looked up from [`TARGETS`] below. Each app
|
|
//! gets its own subdirectory under `--out-dir` (`<out-dir>/<app>/<view>.png`)
|
|
//! — no versioned `screenshots/vX.Y.Z/latest` structure or manifest file
|
|
//! yet, since that's still not earning its complexity over a handful of
|
|
//! apps.
|
|
//!
|
|
//! By default every capture runs inside a throwaway headless Sway instance
|
|
//! (see [`isolation`]) rather than the operator's live desktop, so another
|
|
//! window (or their own differently-themed real bar) can't leak into a
|
|
//! capture. `--no-isolate` skips that and captures directly against whatever
|
|
//! session bread-capture itself is running in — useful for debugging the
|
|
//! capture sequence itself, since you can then actually watch it happen.
|
|
|
|
mod isolation;
|
|
|
|
use anyhow::{bail, Result};
|
|
use clap::Parser;
|
|
use std::path::PathBuf;
|
|
use std::process::ExitCode;
|
|
use std::time::Duration;
|
|
|
|
const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Per-app (view name, output filename) lists. Keyed by the app's binary
|
|
/// name — see `--app-name`. Filenames are plain (no app prefix): each app
|
|
/// gets its own subdirectory under `--out-dir` (`<out-dir>/<app>/<file>`),
|
|
/// so the prefix would just be redundant with the folder name.
|
|
const TARGETS: &[(&str, &[(&str, &str)])] = &[
|
|
(
|
|
"breadbar",
|
|
&[
|
|
("bar", "bar.png"),
|
|
("control-panel", "control-panel.png"),
|
|
("connectivity-wifi", "connectivity-wifi.png"),
|
|
("connectivity-bluetooth", "connectivity-bluetooth.png"),
|
|
("media-popover", "media-popover.png"),
|
|
("notification", "notification.png"),
|
|
("notification-critical", "notification-critical.png"),
|
|
("osd-volume", "osd-volume.png"),
|
|
("osd-brightness", "osd-brightness.png"),
|
|
("wifi-add-dialog", "wifi-add-dialog.png"),
|
|
// Theme 04/spotlight's embedded capsule (only rendered under
|
|
// `BREAD_SHELL_THEME=spotlight` — every other theme's [bar.slots]
|
|
// never places launcher_entry/launcher_results anywhere).
|
|
("capsule-collapsed", "capsule-collapsed.png"),
|
|
("capsule-expanded", "capsule-expanded.png"),
|
|
// Phase 6c: query sections (idle "Recent"/"Apps" headers) and
|
|
// the `=` calc mode — see breadbar's own `screenshot::KNOWN_VIEWS`
|
|
// doc comment for why the search-state width/radius change
|
|
// (item E) doesn't need a view of its own.
|
|
("capsule-sections", "capsule-sections.png"),
|
|
("capsule-calc", "capsule-calc.png"),
|
|
],
|
|
),
|
|
("breadbox", &[("launcher", "launcher.png")]),
|
|
("breadclip", &[("history", "history.png")]),
|
|
("breadsearch", &[("search", "search.png")]),
|
|
(
|
|
"breadpad",
|
|
&[
|
|
("popup", "popup.png"),
|
|
("reminder", "reminder.png"),
|
|
("reminder-snooze", "reminder-snooze.png"),
|
|
],
|
|
),
|
|
(
|
|
"breadhelp",
|
|
&[
|
|
("home", "home.png"),
|
|
("learn", "learn.png"),
|
|
("ask", "ask.png"),
|
|
("troubleshoot-wizard", "troubleshoot-wizard.png"),
|
|
],
|
|
),
|
|
(
|
|
"breadman",
|
|
&[
|
|
("all", "all.png"),
|
|
("upcoming", "upcoming.png"),
|
|
("todo", "todo.png"),
|
|
("reminder", "reminder.png"),
|
|
("idea", "idea.png"),
|
|
("note", "note.png"),
|
|
("question", "question.png"),
|
|
("archive", "archive.png"),
|
|
("settings", "settings.png"),
|
|
("errors", "errors.png"),
|
|
("editor", "editor.png"),
|
|
("new-note", "new-note.png"),
|
|
],
|
|
),
|
|
(
|
|
"bos-settings",
|
|
&[
|
|
("network", "network.png"),
|
|
("breadcrumbs", "breadcrumbs.png"),
|
|
("bluetooth", "bluetooth.png"),
|
|
("firewall", "firewall.png"),
|
|
("sound", "sound.png"),
|
|
("power", "power.png"),
|
|
("datetime", "datetime.png"),
|
|
("hyprland", "hyprland.png"),
|
|
("keybinds", "keybinds.png"),
|
|
("autostart", "autostart.png"),
|
|
("users", "users.png"),
|
|
("appearance", "appearance.png"),
|
|
("breadpaper", "breadpaper.png"),
|
|
("breadbar", "breadbar.png"),
|
|
("breadbox", "breadbox.png"),
|
|
("breadclip", "breadclip.png"),
|
|
("breadpad", "breadpad.png"),
|
|
("breadsearch", "breadsearch.png"),
|
|
("bread", "bread.png"),
|
|
("packages", "packages.png"),
|
|
("aur", "aur.png"),
|
|
("firmware", "firmware.png"),
|
|
("snapshots", "snapshots.png"),
|
|
("about", "about.png"),
|
|
],
|
|
),
|
|
];
|
|
|
|
#[derive(Parser)]
|
|
struct Cli {
|
|
/// Restrict to one app (see `TARGETS` for known names). Omit to capture
|
|
/// every known app's every view in one run.
|
|
#[arg(long)]
|
|
app: Option<String>,
|
|
|
|
/// Path to that app's binary (resolved via $PATH if not a path).
|
|
/// Without `--app`, this also selects *which* app by its file stem
|
|
/// (e.g. `./target/release/breadbox` -> `breadbox`) — so a single-app
|
|
/// run never needs both flags. Ignored (with a warning) if given
|
|
/// together with a multi-app run (no `--app`, and the path isn't
|
|
/// resolvable to exactly one app).
|
|
#[arg(long)]
|
|
app_path: Option<String>,
|
|
|
|
/// Restrict to one view within the selected app(s) (see each app's
|
|
/// entry in `TARGETS` for known view names). Apps that don't have a
|
|
/// view by this name are skipped, not treated as an error, since a
|
|
/// multi-app run's view names naturally don't all overlap.
|
|
#[arg(long)]
|
|
view: Option<String>,
|
|
|
|
/// Directory to write captured PNGs into.
|
|
#[arg(long, default_value = "./screenshots")]
|
|
out_dir: PathBuf,
|
|
|
|
/// Capture directly against the current session instead of a headless,
|
|
/// throwaway Sway instance. Off by default so captures can't pick up
|
|
/// whatever else is on the operator's desktop.
|
|
#[arg(long)]
|
|
no_isolate: bool,
|
|
|
|
/// Width of the isolated session's capture canvas.
|
|
#[arg(long, default_value_t = 1920)]
|
|
isolate_width: u32,
|
|
|
|
/// Height of the isolated session's capture canvas.
|
|
#[arg(long, default_value_t = 1080)]
|
|
isolate_height: u32,
|
|
}
|
|
|
|
fn known_app_names() -> String {
|
|
TARGETS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
|
|
}
|
|
|
|
/// (app_name, binary_path, views) per selected app.
|
|
type SelectedTarget = (&'static str, String, &'static [(&'static str, &'static str)]);
|
|
|
|
/// Resolves which `TARGETS` entries this run covers, and the binary path
|
|
/// to use for each.
|
|
fn selected_targets(cli: &Cli) -> Result<Vec<SelectedTarget>> {
|
|
if let Some(app) = &cli.app {
|
|
let Some((name, views)) = TARGETS.iter().find(|(n, _)| n == app) else {
|
|
bail!("no known view list for app '{app}' (known: {})", known_app_names());
|
|
};
|
|
let path = cli.app_path.clone().unwrap_or_else(|| name.to_string());
|
|
return Ok(vec![(name, path, views)]);
|
|
}
|
|
|
|
if let Some(path) = &cli.app_path {
|
|
let stem = PathBuf::from(path)
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| path.clone());
|
|
let Some((name, views)) = TARGETS.iter().find(|(n, _)| *n == stem) else {
|
|
bail!("no known view list for app '{stem}' (known: {})", known_app_names());
|
|
};
|
|
return Ok(vec![(name, path.clone(), views)]);
|
|
}
|
|
|
|
// No --app / --app-path at all: every known app, resolved by its own
|
|
// bare name via $PATH.
|
|
Ok(TARGETS.iter().map(|(name, views)| (*name, name.to_string(), *views)).collect())
|
|
}
|
|
|
|
fn main() -> Result<ExitCode> {
|
|
let cli = Cli::parse();
|
|
let targets = selected_targets(&cli)?;
|
|
|
|
if let Some(view) = &cli.view {
|
|
if !targets.iter().any(|(_, _, views)| views.iter().any(|(v, _)| v == view)) {
|
|
bail!("view '{view}' doesn't match any selected app's views");
|
|
}
|
|
}
|
|
|
|
// Bound, not dropped-and-discarded: `_isolation`'s teardown (kill the
|
|
// compositor, remove its socket/config) must run via Drop regardless of
|
|
// how this function returns below — returning an ExitCode rather than
|
|
// calling `std::process::exit` (which skips destructors entirely) is
|
|
// what makes that true on the failure path too.
|
|
let _isolation = if cli.no_isolate {
|
|
None
|
|
} else {
|
|
Some(isolation::Isolation::start(cli.isolate_width, cli.isolate_height)?)
|
|
};
|
|
|
|
let width_str = cli.isolate_width.to_string();
|
|
let height_str = cli.isolate_height.to_string();
|
|
|
|
let mut failed = false;
|
|
for (app_name, app_path, views) in &targets {
|
|
for (view, filename) in *views {
|
|
if cli.view.as_deref().is_some_and(|v| v != *view) {
|
|
continue;
|
|
}
|
|
let out_path = cli.out_dir.join(app_name).join(filename);
|
|
let out_str = out_path.to_string_lossy();
|
|
let result = bread_utils::proc::run(
|
|
app_path,
|
|
&[
|
|
"--screenshot", view,
|
|
"--output", &out_str,
|
|
"--width", &width_str,
|
|
"--height", &height_str,
|
|
],
|
|
CAPTURE_TIMEOUT,
|
|
);
|
|
if result.success {
|
|
println!("ok {app_name}/{view} -> {}", out_path.display());
|
|
} else {
|
|
failed = true;
|
|
println!("FAIL {app_name}/{view}: {}", result.stderr.trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS })
|
|
}
|