breadbar: reconnect Hyprland event stream, fix notification spec violations, point lock button at breadlock
- src/bar/workspaces.rs: the Hyprland EventStream loop exited permanently on the first Err/end-of-stream (Hyprland restart/reload, IPC hiccup), freezing every workspace button for the bar's remaining life. Now wrapped in a reconnect loop with capped exponential backoff, re-syncing workspace state on every reconnect - src/notifications/mod.rs + popup.rs: three spec deviations fixed — expire_timeout=0 now means never-expire instead of being coerced to 5s (and a critical-urgency notification with no explicit timeout also persists by default); NotificationClosed is now emitted with the correct reason code whenever a notification actually goes away (expiry or an explicit CloseNotification call); replaces_id no longer races its auto-dismiss timer against the replacement's, via a per-id generation counter checked before a stale timer is allowed to dismiss anything - src/main.rs + README.md + assets/icons-needed.txt: lock button (and its docs) now invoke breadlock instead of hyprlock, the thing breadlock was built to replace - src/notifications/mod.rs: added 4 unit tests for the new expire_timeout/ urgency mapping (pulled into a pure compute_expire() for testability) — this crate had zero test coverage before
This commit is contained in:
parent
e8d5fd5a52
commit
1f2d58d97f
6 changed files with 229 additions and 46 deletions
|
|
@ -26,7 +26,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da
|
||||||
- Live CPU%, GPU%, and network throughput (download/upload)
|
- Live CPU%, GPU%, and network throughput (download/upload)
|
||||||
- Audio output selector (lists PulseAudio sinks via `pactl`, switching takes effect immediately)
|
- Audio output selector (lists PulseAudio sinks via `pactl`, switching takes effect immediately)
|
||||||
- System tray (SNI): apps that register with `org.kde.StatusNotifierWatcher` appear as icon buttons
|
- System tray (SNI): apps that register with `org.kde.StatusNotifierWatcher` appear as icon buttons
|
||||||
- Power buttons: lock (`hyprlock`), suspend, reboot, poweroff
|
- Power buttons: lock (`breadlock`), suspend, reboot, poweroff
|
||||||
|
|
||||||
**Notification daemon**:
|
**Notification daemon**:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Brightness.svg
|
||||||
Power section buttons
|
Power section buttons
|
||||||
-----------------------
|
-----------------------
|
||||||
Lock.svg
|
Lock.svg
|
||||||
Padlock icon — triggers hyprlock (lock screen).
|
Padlock icon — triggers breadlock (lock screen).
|
||||||
Currently placeholder: 🔒
|
Currently placeholder: 🔒
|
||||||
|
|
||||||
Sleep.svg
|
Sleep.svg
|
||||||
|
|
|
||||||
|
|
@ -10,28 +10,57 @@ use relm4::ComponentSender;
|
||||||
|
|
||||||
use crate::AppInput;
|
use crate::AppInput;
|
||||||
|
|
||||||
|
/// Fetches the current workspace list + active workspace and pushes both to
|
||||||
|
/// the app — used both for the initial state and to re-sync after the event
|
||||||
|
/// stream reconnects (state may have changed while we were disconnected).
|
||||||
|
async fn sync_state(sender: &ComponentSender<crate::App>) {
|
||||||
|
if let Ok(ws) = Workspaces::get_async().await {
|
||||||
|
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
||||||
|
}
|
||||||
|
if let Ok(active) = Workspace::get_active_async().await {
|
||||||
|
sender.input(AppInput::ActiveWorkspace(active.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
||||||
relm4::spawn(async move {
|
relm4::spawn(async move {
|
||||||
if let Ok(ws) = Workspaces::get_async().await {
|
sync_state(&sender).await;
|
||||||
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
|
||||||
}
|
|
||||||
if let Ok(active) = Workspace::get_active_async().await {
|
|
||||||
sender.input(AppInput::ActiveWorkspace(active.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stream = EventStream::new();
|
// Hyprland's IPC event socket can drop out from under us — a
|
||||||
while let Some(Ok(event)) = stream.next().await {
|
// Hyprland restart/reload, or just a transient hiccup — at which
|
||||||
match event {
|
// point `stream.next()` yields `None` (or an `Err`, also excluded
|
||||||
Event::WorkspaceChanged(data) => {
|
// by this `while let Some(Ok(..))` pattern). That used to just fall
|
||||||
sender.input(AppInput::ActiveWorkspace(data.id));
|
// through and end this whole task permanently, freezing every
|
||||||
}
|
// workspace button for the rest of the bar's life. Reconnect with a
|
||||||
Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => {
|
// capped exponential backoff instead of giving up.
|
||||||
if let Ok(ws) = Workspaces::get_async().await {
|
let mut backoff = std::time::Duration::from_millis(500);
|
||||||
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut stream = EventStream::new();
|
||||||
|
while let Some(Ok(event)) = stream.next().await {
|
||||||
|
backoff = std::time::Duration::from_millis(500);
|
||||||
|
match event {
|
||||||
|
Event::WorkspaceChanged(data) => {
|
||||||
|
sender.input(AppInput::ActiveWorkspace(data.id));
|
||||||
}
|
}
|
||||||
|
Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => {
|
||||||
|
if let Ok(ws) = Workspaces::get_async().await {
|
||||||
|
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"breadbar: Hyprland event stream ended (restart/reload/IPC hiccup); \
|
||||||
|
reconnecting in {:?}",
|
||||||
|
backoff
|
||||||
|
);
|
||||||
|
tokio::time::sleep(backoff).await;
|
||||||
|
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||||
|
sync_state(&sender).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -392,7 +392,10 @@ impl SimpleComponent for App {
|
||||||
let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
|
let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
|
||||||
power_row.add_css_class("power-row");
|
power_row.add_css_class("power-row");
|
||||||
for (label, cmd) in [
|
for (label, cmd) in [
|
||||||
("🔒", vec!["hyprlock"]),
|
// breadlock is the ecosystem's own screen locker — hyprlock is
|
||||||
|
// the thing it was built to replace; the bar shouldn't still
|
||||||
|
// be pointing at it.
|
||||||
|
("🔒", vec!["breadlock"]),
|
||||||
("💤", vec!["systemctl", "suspend"]),
|
("💤", vec!["systemctl", "suspend"]),
|
||||||
("🔄", vec!["systemctl", "reboot"]),
|
("🔄", vec!["systemctl", "reboot"]),
|
||||||
("⏻", vec!["systemctl", "poweroff"]),
|
("⏻", vec!["systemctl", "poweroff"]),
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,53 @@
|
||||||
pub mod popup;
|
pub mod popup;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use zbus::zvariant::OwnedValue;
|
use zbus::zvariant::OwnedValue;
|
||||||
|
|
||||||
|
/// How long a shown notification should stay up before auto-dismissing.
|
||||||
|
/// Distinct from `Option<Duration>` mainly for readability at call sites —
|
||||||
|
/// `Never` covers both the spec's `expire_timeout == 0` ("never expire")
|
||||||
|
/// and a critical-urgency notification with no explicit timeout, which
|
||||||
|
/// conventionally shouldn't auto-dismiss either.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Expire {
|
||||||
|
Never,
|
||||||
|
After(Duration),
|
||||||
|
}
|
||||||
|
|
||||||
pub enum NotifEvent {
|
pub enum NotifEvent {
|
||||||
Show {
|
Show {
|
||||||
id: u32,
|
id: u32,
|
||||||
app_name: String,
|
app_name: String,
|
||||||
summary: String,
|
summary: String,
|
||||||
body: String,
|
body: String,
|
||||||
timeout_ms: u32,
|
expire: Expire,
|
||||||
},
|
},
|
||||||
Close(u32),
|
Close(u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint
|
||||||
|
/// was critical) to our internal `Expire`, per the freedesktop notification
|
||||||
|
/// spec: `0` always means never expire; a negative value means "server
|
||||||
|
/// picks a default" (5s here, except critical notifications, which
|
||||||
|
/// conventionally persist); any non-negative value is taken literally.
|
||||||
|
/// Pulled out of `NotifServer::notify` so this mapping is unit-testable
|
||||||
|
/// without a live D-Bus connection.
|
||||||
|
fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire {
|
||||||
|
match expire_timeout {
|
||||||
|
0 => Expire::Never,
|
||||||
|
t if t < 0 => {
|
||||||
|
if urgency_critical {
|
||||||
|
Expire::Never
|
||||||
|
} else {
|
||||||
|
Expire::After(Duration::from_millis(5000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t => Expire::After(Duration::from_millis(t as u64)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct NotifServer {
|
struct NotifServer {
|
||||||
tx: mpsc::Sender<NotifEvent>,
|
tx: mpsc::Sender<NotifEvent>,
|
||||||
next_id: AtomicU32,
|
next_id: AtomicU32,
|
||||||
|
|
@ -32,7 +65,7 @@ impl NotifServer {
|
||||||
summary: &str,
|
summary: &str,
|
||||||
body: &str,
|
body: &str,
|
||||||
_actions: Vec<String>,
|
_actions: Vec<String>,
|
||||||
_hints: std::collections::HashMap<String, OwnedValue>,
|
hints: std::collections::HashMap<String, OwnedValue>,
|
||||||
expire_timeout: i32,
|
expire_timeout: i32,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
let id = if replaces_id != 0 {
|
let id = if replaces_id != 0 {
|
||||||
|
|
@ -40,11 +73,19 @@ impl NotifServer {
|
||||||
} else {
|
} else {
|
||||||
self.next_id.fetch_add(1, Ordering::Relaxed)
|
self.next_id.fetch_add(1, Ordering::Relaxed)
|
||||||
};
|
};
|
||||||
let timeout_ms = if expire_timeout <= 0 {
|
|
||||||
5000
|
// Per spec: 0 means "never expire" — this used to be lumped in
|
||||||
} else {
|
// with "-1: let the server pick a default" and coerced to a fixed
|
||||||
expire_timeout as u32
|
// 5s, so a sender explicitly asking for a persistent notification
|
||||||
};
|
// (e.g. a progress/error dialog) got auto-dismissed anyway.
|
||||||
|
// Critical-urgency notifications conventionally persist too, even
|
||||||
|
// when the sender left expire_timeout at the server-default (-1).
|
||||||
|
let urgency_critical = hints
|
||||||
|
.get("urgency")
|
||||||
|
.and_then(|v| u8::try_from(v).ok())
|
||||||
|
.is_some_and(|u| u == 2);
|
||||||
|
let expire = compute_expire(expire_timeout, urgency_critical);
|
||||||
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.tx
|
.tx
|
||||||
.send(NotifEvent::Show {
|
.send(NotifEvent::Show {
|
||||||
|
|
@ -52,7 +93,7 @@ impl NotifServer {
|
||||||
app_name: app_name.to_string(),
|
app_name: app_name.to_string(),
|
||||||
summary: summary.to_string(),
|
summary: summary.to_string(),
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
timeout_ms,
|
expire,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
id
|
id
|
||||||
|
|
@ -78,6 +119,7 @@ impl NotifServer {
|
||||||
|
|
||||||
pub fn spawn() {
|
pub fn spawn() {
|
||||||
let (tx, rx) = mpsc::channel(32);
|
let (tx, rx) = mpsc::channel(32);
|
||||||
|
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
relm4::spawn(async move {
|
relm4::spawn(async move {
|
||||||
let server = NotifServer {
|
let server = NotifServer {
|
||||||
|
|
@ -85,7 +127,7 @@ pub fn spawn() {
|
||||||
next_id: AtomicU32::new(1),
|
next_id: AtomicU32::new(1),
|
||||||
};
|
};
|
||||||
// Builder failures here would only occur with invalid static strings — safe to unwrap.
|
// Builder failures here would only occur with invalid static strings — safe to unwrap.
|
||||||
let _conn = zbus::connection::Builder::session()
|
let conn = zbus::connection::Builder::session()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.name("org.freedesktop.Notifications")
|
.name("org.freedesktop.Notifications")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -94,8 +136,55 @@ pub fn spawn() {
|
||||||
.build()
|
.build()
|
||||||
.await
|
.await
|
||||||
.expect("failed to claim org.freedesktop.Notifications on D-Bus session bus");
|
.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
|
std::future::pending::<()>().await
|
||||||
});
|
});
|
||||||
|
|
||||||
relm4::spawn_local(popup::run(rx));
|
relm4::spawn_local(async move {
|
||||||
|
if let Ok(conn) = conn_rx.await {
|
||||||
|
popup::run(rx, conn).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_timeout_never_expires_regardless_of_urgency() {
|
||||||
|
assert!(matches!(compute_expire(0, false), Expire::Never));
|
||||||
|
assert!(matches!(compute_expire(0, true), Expire::Never));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_timeout_defaults_to_five_seconds_for_normal_urgency() {
|
||||||
|
match compute_expire(-1, false) {
|
||||||
|
Expire::After(d) => assert_eq!(d, Duration::from_millis(5000)),
|
||||||
|
Expire::Never => panic!("expected a 5s default, got Never"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_timeout_persists_for_critical_urgency() {
|
||||||
|
assert!(matches!(compute_expire(-1, true), Expire::Never));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positive_timeout_is_taken_literally() {
|
||||||
|
match compute_expire(1500, false) {
|
||||||
|
Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)),
|
||||||
|
Expire::Never => panic!("expected 1500ms, got Never"),
|
||||||
|
}
|
||||||
|
// Even for critical urgency, an explicit positive timeout is honored
|
||||||
|
// rather than overridden to Never — "critical persists" is only the
|
||||||
|
// *default* when the sender didn't specify one.
|
||||||
|
match compute_expire(1500, true) {
|
||||||
|
Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)),
|
||||||
|
Expire::Never => panic!("expected 1500ms, got Never"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration};
|
use std::{cell::RefCell, collections::HashMap, rc::Rc};
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
|
|
||||||
use super::NotifEvent;
|
use super::{Expire, NotifEvent};
|
||||||
|
|
||||||
type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>;
|
type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>;
|
||||||
|
// Bumped every time an id gets a (re)placed card — an auto-dismiss timer
|
||||||
|
// scheduled for an earlier Show captures the generation it was scheduled
|
||||||
|
// under, and checks it's still current before dismissing. Without this, a
|
||||||
|
// notification that replaces an existing id (replaces_id) doesn't cancel
|
||||||
|
// the original's timer, so the *replacement* card gets dismissed on the
|
||||||
|
// *original*'s deadline instead of its own.
|
||||||
|
type Generations = Rc<RefCell<HashMap<u32, u64>>>;
|
||||||
|
|
||||||
pub async fn run(mut rx: Receiver<NotifEvent>) {
|
/// NotificationClosed reason codes per the freedesktop spec.
|
||||||
|
mod close_reason {
|
||||||
|
pub const EXPIRED: u32 = 1;
|
||||||
|
#[allow(dead_code)] // no in-app dismiss button exists yet (see make_card)
|
||||||
|
pub const DISMISSED_BY_USER: u32 = 2;
|
||||||
|
pub const CLOSE_NOTIFICATION_CALL: u32 = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(mut rx: Receiver<NotifEvent>, conn: zbus::Connection) {
|
||||||
let window = create_window();
|
let window = create_window();
|
||||||
let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
cards_box.set_margin_top(8);
|
cards_box.set_margin_top(8);
|
||||||
|
|
@ -18,6 +33,7 @@ pub async fn run(mut rx: Receiver<NotifEvent>) {
|
||||||
window.set_child(Some(&cards_box));
|
window.set_child(Some(&cards_box));
|
||||||
|
|
||||||
let cards: Cards = Rc::new(RefCell::new(HashMap::new()));
|
let cards: Cards = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
let generations: Generations = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
match event {
|
match event {
|
||||||
|
|
@ -26,7 +42,7 @@ pub async fn run(mut rx: Receiver<NotifEvent>) {
|
||||||
app_name,
|
app_name,
|
||||||
summary,
|
summary,
|
||||||
body,
|
body,
|
||||||
timeout_ms,
|
expire,
|
||||||
} => {
|
} => {
|
||||||
// Replace existing card with same id (replaces_id case)
|
// Replace existing card with same id (replaces_id case)
|
||||||
if let Some(old) = cards.borrow_mut().remove(&id) {
|
if let Some(old) = cards.borrow_mut().remove(&id) {
|
||||||
|
|
@ -37,29 +53,75 @@ pub async fn run(mut rx: Receiver<NotifEvent>) {
|
||||||
cards.borrow_mut().insert(id, card.clone());
|
cards.borrow_mut().insert(id, card.clone());
|
||||||
window.set_visible(true);
|
window.set_visible(true);
|
||||||
|
|
||||||
// Auto-dismiss via GLib-native timer (safe inside spawn_local)
|
let my_generation = {
|
||||||
let cards_clone = cards.clone();
|
let mut gens = generations.borrow_mut();
|
||||||
let cards_box_clone = cards_box.clone();
|
let g = gens.entry(id).or_insert(0);
|
||||||
let win_clone = window.clone();
|
*g += 1;
|
||||||
relm4::spawn_local(async move {
|
*g
|
||||||
gtk4::glib::timeout_future(Duration::from_millis(timeout_ms as u64)).await;
|
};
|
||||||
dismiss(&cards_box_clone, &win_clone, &cards_clone, id);
|
|
||||||
});
|
// `Expire::Never` (expire_timeout=0, or a critical-urgency
|
||||||
|
// notification with no explicit timeout) schedules no timer
|
||||||
|
// at all — it persists until an explicit CloseNotification.
|
||||||
|
if let Expire::After(duration) = expire {
|
||||||
|
let cards_clone = cards.clone();
|
||||||
|
let cards_box_clone = cards_box.clone();
|
||||||
|
let win_clone = window.clone();
|
||||||
|
let generations_clone = generations.clone();
|
||||||
|
let conn_clone = conn.clone();
|
||||||
|
relm4::spawn_local(async move {
|
||||||
|
gtk4::glib::timeout_future(duration).await;
|
||||||
|
let still_current =
|
||||||
|
generations_clone.borrow().get(&id) == Some(&my_generation);
|
||||||
|
if still_current
|
||||||
|
&& dismiss(&cards_box_clone, &win_clone, &cards_clone, id)
|
||||||
|
{
|
||||||
|
emit_closed(&conn_clone, id, close_reason::EXPIRED).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
NotifEvent::Close(id) => {
|
NotifEvent::Close(id) => {
|
||||||
dismiss(&cards_box, &window, &cards, id);
|
if dismiss(&cards_box, &window, &cards, id) {
|
||||||
|
emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) {
|
/// Removes `id`'s card if present. Returns whether a card was actually
|
||||||
if let Some(card) = cards.borrow_mut().remove(&id) {
|
/// removed, so callers only emit `NotificationClosed` for a real dismissal
|
||||||
cards_box.remove(&card);
|
/// (not a no-op on an id that's already gone or was never shown).
|
||||||
}
|
fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) -> bool {
|
||||||
|
let removed = cards.borrow_mut().remove(&id);
|
||||||
|
let Some(card) = removed else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
cards_box.remove(&card);
|
||||||
if cards.borrow().is_empty() {
|
if cards.borrow().is_empty() {
|
||||||
window.set_visible(false);
|
window.set_visible(false);
|
||||||
}
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
let result = conn
|
||||||
|
.emit_signal(
|
||||||
|
None::<&str>,
|
||||||
|
"/org/freedesktop/Notifications",
|
||||||
|
"org.freedesktop.Notifications",
|
||||||
|
"NotificationClosed",
|
||||||
|
&(id, reason),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("breadbar: failed to emit NotificationClosed for {id}: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_window() -> gtk4::Window {
|
fn create_window() -> gtk4::Window {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue