diff --git a/src/main.rs b/src/main.rs index 82377e5..6ea7ded 100644 --- a/src/main.rs +++ b/src/main.rs @@ -645,9 +645,16 @@ impl SimpleComponent for App { widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); - // Captured before `control_popover` moves into `model` below — needed - // by the screenshot dispatch just before this function returns. + // Captured before these move into `model` (or are otherwise dropped + // as bare locals, never stored on `App` at all) — needed by the + // screenshot dispatch just before this function returns. let control_popover_for_screenshot = control_popover.clone(); + let connectivity_popover_for_screenshot = connectivity_popover.clone(); + let wifi_tab_btn_for_screenshot = wifi_tab_btn.clone(); + let bt_tab_btn_for_screenshot = bt_tab_btn.clone(); + let media_popover_for_screenshot = media_popover.clone(); + let media_widget_for_screenshot = media_widget.clone(); + let media_track_lbl_for_screenshot = media_track_lbl.clone(); let model = App { workspaces: vec![], @@ -715,11 +722,43 @@ impl SimpleComponent for App { bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); widgets::client::spawn(sender.clone()); - notifications::spawn(); - osd::spawn(); + + // Screenshot mode primes these with sample content instead of the + // real D-Bus/pactl/backlight sources — see notifications::SampleKind + // and osd::SampleKind's doc comments. + let notif_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { + "notification" => Some(notifications::SampleKind::Normal), + "notification-critical" => Some(notifications::SampleKind::Critical), + _ => None, + }); + let notification_window = notifications::spawn(notif_sample); + let osd_sample = screenshot_req.as_ref().and_then(|r| match r.view.as_str() { + "osd-volume" => Some(osd::SampleKind::Volume), + "osd-brightness" => Some(osd::SampleKind::Brightness), + _ => None, + }); + let osd_window = osd::spawn(osd_sample); if let Some(req) = screenshot_req { - screenshot::dispatch(&root, req, control_popover_for_screenshot); + let notification_window = matches!(req.view.as_str(), "notification" | "notification-critical") + .then_some(notification_window); + let osd_window = matches!(req.view.as_str(), "osd-volume" | "osd-brightness") + .then_some(osd_window); + screenshot::dispatch( + &root, + req, + screenshot::Handles { + control_popover: control_popover_for_screenshot, + connectivity_popover: connectivity_popover_for_screenshot, + wifi_tab_btn: wifi_tab_btn_for_screenshot, + bt_tab_btn: bt_tab_btn_for_screenshot, + media_popover: media_popover_for_screenshot, + media_widget: media_widget_for_screenshot, + media_track_lbl: media_track_lbl_for_screenshot, + notification_window, + osd_window, + }, + ); } ComponentParts { model, widgets } @@ -1145,7 +1184,7 @@ impl App { if saved { bar::wifi::spawn_join(ssid_clone.clone()); } else { - show_add_network_dialog(btn, ssid_clone.clone()); + show_add_network_dialog(btn, ssid_clone.clone(), |_| {}); } close_parent_popover(btn); }); @@ -1302,8 +1341,11 @@ fn close_parent_popover(widget: >k4::Button) { } /// Small modal prompting for a password, then saves + joins the network via -/// `breadcrumbs add` + `breadcrumbs join`. -fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { +/// `breadcrumbs add` + `breadcrumbs join`. `on_build` runs on the freshly +/// built dialog *before* it's presented — screenshot mode's only hook point, +/// since `connect_map` registered any later would miss a map that already +/// happened. The real call site passes a no-op. +fn show_add_network_dialog(anchor: &impl IsA, ssid: String, on_build: impl FnOnce(>k4::Window)) { let dialog = gtk4::Window::new(); dialog.set_title(Some(&format!("Add “{ssid}”"))); dialog.set_resizable(false); @@ -1367,6 +1409,7 @@ fn show_add_network_dialog(anchor: >k4::Button, ssid: String) { dialog_for_activate.close(); }); + on_build(&dialog); dialog.present(); entry.grab_focus(); } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b1f1760..0c25c0d 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -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) -> 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)] diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 69d1f6c..91adec0 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -23,7 +23,11 @@ mod close_reason { pub const CLOSE_NOTIFICATION_CALL: u32 = 3; } -pub async fn run(mut rx: Receiver, 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, 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, + conn: Option, +) { 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: >k4::Box, window: >k4::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, id: u32, reason: u32) { + let Some(conn) = conn else { return }; let result = conn .emit_signal( None::<&str>, diff --git a/src/osd.rs b/src/osd.rs index d7520fc..5deb1d4 100644 --- a/src/osd.rs +++ b/src/osd.rs @@ -9,14 +9,50 @@ enum OsdEvent { Brightness { pct: u8 }, } -pub fn spawn() { +/// A fixed sample event for `--screenshot osd-volume`/`osd-brightness` — +/// substitutes for the real `pactl subscribe`/backlight-sysfs watchers so a +/// capture doesn't depend on this machine's actual volume/brightness at +/// capture time. +pub enum SampleKind { + Volume, + Brightness, +} + +impl SampleKind { + fn sample_event(&self) -> OsdEvent { + match self { + SampleKind::Volume => OsdEvent::Volume { pct: 65, muted: false }, + SampleKind::Brightness => OsdEvent::Brightness { pct: 80 }, + } + } +} + +/// Builds the OSD window synchronously (so a caller — screenshot mode, via +/// `sample`, in particular — has a real window to hook `connect_map` on +/// before the async event loop below ever runs) and spawns the event loop +/// that shows/updates/hides it. +/// +/// `sample`: `Some` skips the real volume/brightness watchers entirely and +/// seeds the loop with one fixed sample event instead — screenshot mode +/// only, so a capture never depends on (or is disrupted by) this machine's +/// actual audio/backlight state. +pub fn spawn(sample: Option) -> gtk4::Window { let (tx, rx) = mpsc::channel::(8); - let tx1 = tx.clone(); - std::thread::spawn(move || volume_watcher(tx1)); - std::thread::spawn(move || brightness_watcher(tx)); + match sample { + Some(kind) => { + let _ = tx.try_send(kind.sample_event()); + } + None => { + let tx1 = tx.clone(); + std::thread::spawn(move || volume_watcher(tx1)); + std::thread::spawn(move || brightness_watcher(tx)); + } + } - relm4::spawn_local(run_osd(rx)); + let window = create_window(); + relm4::spawn_local(run_osd(window.clone(), rx)); + window } fn volume_watcher(tx: mpsc::Sender) { @@ -119,9 +155,7 @@ fn brightness_watcher(tx: mpsc::Sender) { } } -async fn run_osd(mut rx: mpsc::Receiver) { - let window = create_window(); - +async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver) { let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); container.set_margin_top(10); container.set_margin_bottom(10); diff --git a/src/screenshot.rs b/src/screenshot.rs index d276be8..8ccdd87 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -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, @@ -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, + /// Same deal as `notification_window`, via `osd::spawn(Some(kind))`. + pub osd_window: Option, +} + /// 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: >k4::ApplicationWindow, req: ScreenshotRequest, control_popover: gtk4::Popover) { +pub fn dispatch(root: >k4::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: >k4::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: >k4::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),