//! 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/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 ` restricts to one app; `--app-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 ` 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` (`//.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` (`//`), /// 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, /// 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, /// 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, /// 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::>().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> { 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 { 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 }) }