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

@ -172,38 +172,90 @@ impl NotifServer {
}
}
pub fn spawn() {
let (tx, rx) = mpsc::channel(32);
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
/// A fixed sample notification for `--screenshot notification`/
/// `notification-critical` — substitutes for a real `Notify` D-Bus call so a
/// capture doesn't depend on some external sender firing one at just the
/// right moment.
pub enum SampleKind {
Normal,
Critical,
}
relm4::spawn(async move {
let server = NotifServer {
tx,
next_id: AtomicU32::new(1),
sync_tags: Mutex::new(HashMap::new()),
impl SampleKind {
fn sample_event(&self) -> NotifEvent {
let urgency = match self {
SampleKind::Normal => Urgency::Normal,
SampleKind::Critical => Urgency::Critical,
};
// Builder failures here would only occur with invalid static strings — safe to unwrap.
let conn = zbus::connection::Builder::session()
.unwrap()
.name("org.freedesktop.Notifications")
.unwrap()
.serve_at("/org/freedesktop/Notifications", server)
.unwrap()
.build()
.await
.expect("failed to claim org.freedesktop.Notifications on D-Bus session bus");
// Hand the connection to popup::run so it can emit `NotificationClosed`
// (spec-mandated whenever a notification actually goes away) — the
// dismiss decisions all happen over there, not in this interface impl.
let _ = conn_tx.send(conn);
std::future::pending::<()>().await
});
relm4::spawn_local(async move {
if let Ok(conn) = conn_rx.await {
popup::run(rx, conn).await;
NotifEvent::Show {
id: 1,
app_name: "Sample App".into(),
summary: "Sample notification".into(),
body: "This is what a notification card looks like.".into(),
urgency,
expire: Expire::Never,
}
});
}
}
/// Builds the notification window synchronously (see
/// `popup::build_window`'s doc comment) and spawns the event loop that
/// shows/updates/hides it.
///
/// `sample`: `Some` skips real D-Bus registration entirely and seeds the
/// loop with one fixed sample event instead — screenshot mode only. Doing
/// the real `org.freedesktop.Notifications` registration in every
/// screenshot run would race the real breadbar (if running) for the same
/// well-known name for no benefit, since nothing needs to reach this
/// instance externally.
pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
let (window, cards_box) = popup::build_window();
let (tx, rx) = mpsc::channel(32);
match sample {
Some(kind) => {
let _ = tx.try_send(kind.sample_event());
let window_for_loop = window.clone();
relm4::spawn_local(async move {
popup::run(window_for_loop, cards_box, rx, None).await;
});
}
None => {
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
relm4::spawn(async move {
let server = NotifServer {
tx,
next_id: AtomicU32::new(1),
sync_tags: Mutex::new(HashMap::new()),
};
// Builder failures here would only occur with invalid static strings — safe to unwrap.
let conn = zbus::connection::Builder::session()
.unwrap()
.name("org.freedesktop.Notifications")
.unwrap()
.serve_at("/org/freedesktop/Notifications", server)
.unwrap()
.build()
.await
.expect("failed to claim org.freedesktop.Notifications on D-Bus session bus");
// Hand the connection to popup::run so it can emit `NotificationClosed`
// (spec-mandated whenever a notification actually goes away) — the
// dismiss decisions all happen over there, not in this interface impl.
let _ = conn_tx.send(conn);
std::future::pending::<()>().await
});
let window_for_loop = window.clone();
relm4::spawn_local(async move {
if let Ok(conn) = conn_rx.await {
popup::run(window_for_loop, cards_box, rx, Some(conn)).await;
}
});
}
}
window
}
#[cfg(test)]

View file

@ -23,7 +23,11 @@ mod close_reason {
pub const CLOSE_NOTIFICATION_CALL: u32 = 3;
}
pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
/// Builds the notification window synchronously — so a caller (screenshot
/// mode in particular) has a real window to hook `connect_map` on before
/// `run`'s event loop, which needs an async `zbus::Connection` handshake in
/// the real path, ever starts.
pub fn build_window() -> (gtk4::Window, gtk4::Box) {
let window = create_window();
let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
cards_box.set_margin_top(8);
@ -31,7 +35,21 @@ pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
cards_box.set_margin_start(8);
cards_box.set_margin_end(8);
window.set_child(Some(&cards_box));
(window, cards_box)
}
/// `conn`: `None` in screenshot mode, which skips real D-Bus registration
/// entirely (see `super::spawn`) — there's no external client that needs to
/// reach a screenshot-only instance, and registering the well-known name
/// would just race the real breadbar for it. `NotificationClosed` is a
/// spec-mandated signal for real clients only, so it's simply not emitted
/// when there's no real connection to emit it on.
pub async fn run(
window: gtk4::Window,
cards_box: gtk4::Box,
mut rx: Receiver<NotifEvent>,
conn: Option<zbus::Connection>,
) {
let cards: Cards = Rc::new(RefCell::new(HashMap::new()));
let generations: Generations = Rc::new(RefCell::new(HashMap::new()));
@ -109,8 +127,10 @@ fn dismiss(cards_box: &gtk4::Box, window: &gtk4::Window, cards: &Cards, id: u32)
/// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent
/// directly over the connection rather than through the zbus interface
/// macro's generated helper, since the dismiss decision happens here in the
/// popup task, not inside `NotifServer`'s own method bodies.
async fn emit_closed(conn: &zbus::Connection, id: u32, reason: u32) {
/// popup task, not inside `NotifServer`'s own method bodies. No-op when
/// `conn` is `None` (screenshot mode — see `run`'s doc comment).
async fn emit_closed(conn: &Option<zbus::Connection>, id: u32, reason: u32) {
let Some(conn) = conn else { return };
let result = conn
.emit_signal(
None::<&str>,