breadbar/src/osd.rs
Breadway 566aeeed8b 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`.
2026-07-29 17:17:17 +08:00

217 lines
6.3 KiB
Rust

use std::{cell::Cell, rc::Rc, time::Duration};
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use tokio::sync::mpsc;
enum OsdEvent {
Volume { pct: u8, muted: bool },
Brightness { pct: u8 },
}
/// 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<SampleKind>) -> gtk4::Window {
let (tx, rx) = mpsc::channel::<OsdEvent>(8);
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));
}
}
let window = create_window();
relm4::spawn_local(run_osd(window.clone(), rx));
window
}
fn volume_watcher(tx: mpsc::Sender<OsdEvent>) {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
let Ok(mut child) = Command::new("pactl")
.args(["subscribe"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
else {
return;
};
let Some(stdout) = child.stdout.take() else { return };
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if line.contains("'change' on sink") {
if let Some(evt) = query_volume() {
let _ = tx.blocking_send(evt);
}
}
}
}
fn query_volume() -> Option<OsdEvent> {
use std::process::Command;
let vol = Command::new("pactl")
.args(["get-sink-volume", "@DEFAULT_SINK@"])
.output()
.ok()?;
let mute = Command::new("pactl")
.args(["get-sink-mute", "@DEFAULT_SINK@"])
.output()
.ok()?;
let vol_str = String::from_utf8_lossy(&vol.stdout);
let mute_str = String::from_utf8_lossy(&mute.stdout);
// "Volume: front-left: 45875 / 70% / -8.58 dB, ..."
let pct: u8 = vol_str
.split('/')
.nth(1)?
.trim()
.trim_end_matches('%')
.trim()
.parse()
.ok()?;
let muted = mute_str.contains(": yes");
Some(OsdEvent::Volume { pct, muted })
}
fn brightness_watcher(tx: mpsc::Sender<OsdEvent>) {
use std::fs;
let base = match fs::read_dir("/sys/class/backlight")
.ok()
.and_then(|mut d| d.next())
.and_then(|e| e.ok())
.map(|e| e.path())
{
Some(p) => p,
None => return,
};
let bright_path = base.join("brightness");
let max_path = base.join("max_brightness");
let max: u64 = match fs::read_to_string(&max_path)
.ok()
.and_then(|s| s.trim().parse().ok())
{
Some(v) if v > 0 => v,
_ => return,
};
// Initialize to current value so startup doesn't trigger OSD.
let mut last: u64 = fs::read_to_string(&bright_path)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(u64::MAX);
loop {
if let Some(val) = fs::read_to_string(&bright_path)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
{
if val != last {
last = val;
let pct = ((val * 100) / max).min(100) as u8;
let _ = tx.blocking_send(OsdEvent::Brightness { pct });
}
}
std::thread::sleep(Duration::from_millis(200));
}
}
async fn run_osd(window: gtk4::Window, mut rx: mpsc::Receiver<OsdEvent>) {
let container = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
container.set_margin_top(10);
container.set_margin_bottom(10);
container.set_margin_start(14);
container.set_margin_end(14);
window.set_child(Some(&container));
let icon = gtk4::Image::from_paintable(Some(&crate::svg_texture(
crate::bar::stats::ICON_VOLUME,
)));
icon.add_css_class("osd-icon");
container.append(&icon);
let pbar = gtk4::ProgressBar::new();
pbar.add_css_class("osd-bar");
pbar.set_hexpand(true);
pbar.set_valign(gtk4::Align::Center);
container.append(&pbar);
let dismiss_token = Rc::new(Cell::new(0u32));
while let Some(event) = rx.recv().await {
let (icon_svg, pct, muted) = match event {
OsdEvent::Volume { pct, muted } => (crate::bar::stats::ICON_VOLUME, pct, muted),
OsdEvent::Brightness { pct } => (crate::bar::stats::ICON_BRIGHTNESS, pct, false),
};
icon.set_paintable(Some(&crate::svg_texture(icon_svg)));
if muted {
icon.add_css_class("osd-icon-muted");
} else {
icon.remove_css_class("osd-icon-muted");
}
pbar.set_fraction(pct as f64 / 100.0);
window.set_visible(true);
let token = dismiss_token.get().wrapping_add(1);
dismiss_token.set(token);
let dtok = dismiss_token.clone();
let win = window.clone();
relm4::spawn_local(async move {
gtk4::glib::timeout_future(Duration::from_millis(2000)).await;
if dtok.get() == token {
win.set_visible(false);
}
});
}
}
fn create_window() -> gtk4::Window {
let window = gtk4::Window::new();
window.add_css_class("breadbar-osd");
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Bottom, true);
window.set_margin(Edge::Bottom, 80);
window.set_default_width(180);
window
}