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`.
217 lines
6.3 KiB
Rust
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
|
|
}
|