breadbar: capture every view, not just bar/control-panel

breadbar is "a bar + the notification daemon + the OSD" — the screenshot
mode only covered the bar and its control-panel popover, missing seven
more distinct surfaces: the WiFi/Bluetooth connectivity popover (both
tabs), the media-controls popover, the standalone notification window
(both normal and critical urgency), the standalone OSD window (volume
and brightness), and the wifi add-network dialog. All ten views are now
--screenshot targets.

Two real refactors needed to make the standalone notification/OSD
windows screenshot-able at all, not just bigger match arms:

- Both windows are built deep inside an async task (`run_osd`/
  `popup::run`), only reachable after the real event loop starts — no
  window handle ever existed for a caller to hook `connect_map` on before
  that. Window construction is now synchronous in `osd::spawn`/
  `notifications::spawn`, handed to the async loop as a parameter instead
  of created inside it.

- Screenshot mode seeds each with one fixed sample event (SampleKind) via
  the same channel the real pactl/backlight/D-Bus sources feed, instead
  of waiting for real hardware/dbus activity. For notifications
  specifically this also means skipping the real
  org.freedesktop.Notifications D-Bus registration entirely in screenshot
  mode — claiming that well-known name would just race the real breadbar
  (if running) for it, for no benefit, since nothing external needs to
  reach a screenshot-only instance.

show_add_network_dialog gained an `on_build` hook (called before
`.present()`, the only point `connect_map` can still catch the map) so
screenshot mode can capture it without changing its one real call site's
behavior — and its `anchor` parameter widened from `&Button` to
`&impl IsA<Widget>` since the screenshot path anchors off a
`ToggleButton`, not a `Button`.
This commit is contained in:
Breadway 2026-07-29 17:17:17 +08:00
parent 059e11cdeb
commit 566aeeed8b
5 changed files with 335 additions and 84 deletions

View file

@ -6,6 +6,13 @@
//! grabbing pixels — the surface (or, for popover views, the popover itself)
//! genuinely isn't on screen yet before that fires, so a fixed delay would
//! either race a slow first paint or pad every fast one for nothing.
//!
//! breadbar is "a bar + the notification daemon + the OSD" (see its own
//! module docs), so its screenshot views span three separate top-level
//! surfaces, not just the bar: the bar itself and its popovers (this
//! module, anchored off `root`), plus the standalone notification and OSD
//! windows (`notifications::spawn`/`osd::spawn`, built and primed with
//! sample data by `main.rs` before `dispatch` runs — see [`Handles`]).
use clap::Parser;
use gtk4::prelude::*;
@ -17,15 +24,12 @@ use std::time::Duration;
/// anything has been drawn into it.
const SETTLE_DELAY: Duration = Duration::from_millis(300);
/// Settle time for the control-panel view specifically: longer than
/// [`SETTLE_DELAY`] because the CPU/RAM/PWR/GPU/network labels there aren't
/// populated by the popover's own load (that only covers volume/brightness/
/// sinks, see `bar::control::spawn_load`) — they're refreshed by
/// `bar::stats::spawn_poller`'s 2-second background loop, gated on
/// `control_popover.is_visible()` at each tick. Capturing any sooner than one
/// full poll interval after the popover opens leaves them at their initial
/// placeholder dashes.
const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200);
/// Settle time for views whose content depends on `bar::stats::spawn_poller`'s
/// 2-second background loop (control-panel's CPU/RAM/PWR/GPU/network labels,
/// gated on popover visibility) or a similar live-data popover load
/// (connectivity's wifi/bluetooth scan) — capturing any sooner leaves
/// placeholder dashes/"Scanning…" instead of real content.
const LIVE_DATA_SETTLE_DELAY: Duration = Duration::from_millis(2_200);
/// Delay between the bar's own `map` and calling `popover.popup()`. Calling
/// `popup()` synchronously from inside the root window's `map` handler
@ -36,11 +40,24 @@ const CONTROL_PANEL_SETTLE_DELAY: Duration = Duration::from_millis(2_200);
/// finish first is what makes it actually render.
const PRE_POPUP_DELAY: Duration = Duration::from_millis(300);
const KNOWN_VIEWS: &[&str] = &[
"bar",
"control-panel",
"connectivity-wifi",
"connectivity-bluetooth",
"media-popover",
"notification",
"notification-critical",
"osd-volume",
"osd-brightness",
"wifi-add-dialog",
];
#[derive(Parser)]
#[command(name = "breadbar")]
pub struct Cli {
/// Render the named view, capture it, then exit instead of running
/// normally. Known views: "bar", "control-panel".
/// normally. See `screenshot::KNOWN_VIEWS` for the full list.
#[arg(long)]
pub screenshot: Option<String>,
@ -80,20 +97,37 @@ impl Cli {
}
}
/// Wire up the given view's screenshot sequence. Called once from `init()`,
/// after the window and its popovers exist but before the component finishes
/// initializing — every path here ends by exiting the process, it never
/// returns control to the normal bar UI.
/// Every widget/window `dispatch` might need, gathered by `main.rs`'s
/// `init()` — most of these are plain locals there that never otherwise
/// outlive `init()` (never stored on `App`), so they have to be cloned out
/// before dispatch time same as `control_popover` always was.
pub struct Handles {
pub control_popover: gtk4::Popover,
pub connectivity_popover: gtk4::Popover,
pub wifi_tab_btn: gtk4::ToggleButton,
pub bt_tab_btn: gtk4::ToggleButton,
pub media_popover: gtk4::Popover,
pub media_widget: gtk4::Box,
pub media_track_lbl: gtk4::Label,
/// Already built and primed with sample content by `main.rs` (via
/// `notifications::spawn(Some(kind))`) when `req.view` calls for it —
/// `None` otherwise.
pub notification_window: Option<gtk4::Window>,
/// Same deal as `notification_window`, via `osd::spawn(Some(kind))`.
pub osd_window: Option<gtk4::Window>,
}
/// The bar's fixed height — matches `root.set_exclusive_zone(32)` /
/// `set_default_height: 32` in `main.rs`. Unlike the control-panel's full
/// `set_default_height: 32` in `main.rs`. Unlike the other views' full
/// canvas, this never varies with `--width`/`--height`.
const BAR_HEIGHT: i32 = 32;
pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, control_popover: gtk4::Popover) {
pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, handles: Handles) {
let output = req.output;
let (width, height) = (req.width as i32, req.height as i32);
match req.view.as_str() {
"bar" => {
let output = req.output;
let width = req.width as i32;
root.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
@ -102,35 +136,103 @@ pub fn dispatch(root: &gtk4::ApplicationWindow, req: ScreenshotRequest, control_
});
}
"control-panel" => {
let output = req.output;
let (width, height) = (req.width as i32, req.height as i32);
let popover_to_open = control_popover.clone();
open_popover_on_root_map(root, handles.control_popover, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"connectivity-wifi" => {
handles.wifi_tab_btn.set_active(true);
open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"connectivity-bluetooth" => {
handles.bt_tab_btn.set_active(true);
open_popover_on_root_map(root, handles.connectivity_popover, LIVE_DATA_SETTLE_DELAY, output, width, height);
}
"media-popover" => {
// Real media state only shows the widget/text when something's
// actually playing (see AppInput::MediaUpdate) — an automated
// run has nothing playing, so fake enough of it directly on the
// widgets to get a representative capture.
handles.media_widget.set_visible(true);
handles.media_track_lbl.set_text("Sample Track — Sample Artist");
open_popover_on_root_map(root, handles.media_popover, SETTLE_DELAY, output, width, height);
}
"notification" | "notification-critical" => {
let Some(window) = handles.notification_window else {
eprintln!("breadbar: internal error — no notification window built for '{}'", req.view);
std::process::exit(1);
};
capture_standalone_window(window, output, width, height);
}
"osd-volume" | "osd-brightness" => {
let Some(window) = handles.osd_window else {
eprintln!("breadbar: internal error — no OSD window built for '{}'", req.view);
std::process::exit(1);
};
capture_standalone_window(window, output, width, height);
}
"wifi-add-dialog" => {
let anchor = handles.wifi_tab_btn;
root.connect_map(move |_| {
// Autohide (the default) tries to grab the Wayland seat on
// popup, keyed to a real input event's serial — a
// programmatic popup() has no such event to grab with.
// Screenshot mode never needs the popover to dismiss itself
// anyway.
popover_to_open.set_autohide(false);
let popover_to_open = popover_to_open.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
popover_to_open.popup();
});
});
control_popover.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(CONTROL_PANEL_SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
let anchor = anchor.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
crate::show_add_network_dialog(&anchor, "Sample Network".to_string(), move |dialog| {
capture_standalone_window(dialog.clone(), output.clone(), width, height);
});
});
});
}
other => {
eprintln!("breadbar: unknown screenshot view '{other}' (known: bar, control-panel)");
eprintln!(
"breadbar: unknown screenshot view '{other}' (known: {})",
KNOWN_VIEWS.join(", ")
);
std::process::exit(1);
}
}
}
/// Shared shape for every popover view: force it open shortly after the bar
/// maps (autohide disabled — a programmatic `popup()` has no real input
/// event serial to grab the Wayland seat with), then capture the whole
/// canvas after `settle` once the popover itself maps.
fn open_popover_on_root_map(
root: &gtk4::ApplicationWindow,
popover: gtk4::Popover,
settle: Duration,
output: PathBuf,
width: i32,
height: i32,
) {
let popover_to_open = popover.clone();
root.connect_map(move |_| {
popover_to_open.set_autohide(false);
let popover_to_open = popover_to_open.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
popover_to_open.popup();
});
});
popover.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(settle, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
/// Shared shape for the standalone notification/OSD windows and the wifi
/// add-network dialog: wait for `map`, settle, capture, exit. These are
/// already-visible-or-about-to-be windows by the time this is called (their
/// sample event is queued before `dispatch` even runs), so this is just the
/// capture half.
fn capture_standalone_window(window: gtk4::Window, output: PathBuf, width: i32, height: i32) {
window.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
}
fn finish(result: anyhow::Result<()>) {
match result {
Ok(()) => std::process::exit(0),