Add notification history; pin ecosystem crates to v0.7.2
All checks were successful
dev release / build (push) Successful in 1m25s

Keep a bounded in-memory history (last 50) and a layer-shell window
listing app, summary, truncated body, and time. Toggle with
`breadbar --history` or D-Bus `dev.breadway.Bar.ToggleHistory`.

Pin bread-theme, bread-utils, and bread-screenshots to tag v0.7.2.
bread-shared stays on bread v0.8.0-rc.1.
This commit is contained in:
Breadway 2026-08-15 22:54:04 +08:00
parent 614dca71af
commit 465088dc55
10 changed files with 394 additions and 21 deletions

View file

@ -0,0 +1,274 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use super::Urgency;
pub const LIMIT: usize = 50;
const BODY_MAX_CHARS: usize = 96;
pub type Store = Arc<Mutex<VecDeque<Entry>>>;
#[derive(Debug, Clone)]
pub struct Entry {
pub id: u32,
pub app_name: String,
pub summary: String,
pub body: String,
pub urgency: Urgency,
pub received: SystemTime,
}
pub struct Ui {
pub window: gtk4::Window,
pub list: gtk4::Box,
pub store: Store,
}
pub fn new_store() -> Store {
Arc::new(Mutex::new(VecDeque::new()))
}
/// Insert or replace by `id`, newest first. Drops anything past [`LIMIT`].
pub fn record(store: &Store, entry: Entry) {
let mut hist = store.lock().unwrap();
if let Some(pos) = hist.iter().position(|e| e.id == entry.id) {
hist.remove(pos);
}
hist.push_front(entry);
while hist.len() > LIMIT {
hist.pop_back();
}
}
pub fn build_window(store: Store) -> Ui {
let window = gtk4::Window::new();
window.add_css_class("breadbar-history");
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_anchor(Edge::Top, true);
window.set_anchor(Edge::Right, true);
window.set_margin(Edge::Top, 48);
window.set_margin(Edge::Right, 20);
window.set_default_width(360);
window.set_keyboard_mode(KeyboardMode::OnDemand);
let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
outer.set_margin_top(10);
outer.set_margin_bottom(10);
outer.set_margin_start(10);
outer.set_margin_end(10);
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let title = gtk4::Label::new(Some("Notifications"));
title.add_css_class("history-title");
title.set_xalign(0.0);
title.set_hexpand(true);
header.append(&title);
let close_btn = gtk4::Button::with_label("Close");
close_btn.add_css_class("flat");
close_btn.add_css_class("history-close");
let win_close = window.clone();
close_btn.connect_clicked(move |_| {
win_close.set_visible(false);
});
header.append(&close_btn);
outer.append(&header);
let list = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
let scroll = gtk4::ScrolledWindow::new();
scroll.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Automatic);
scroll.set_propagate_natural_height(true);
scroll.set_max_content_height(480);
scroll.set_min_content_width(320);
scroll.set_child(Some(&list));
outer.append(&scroll);
window.set_child(Some(&outer));
let win_esc = window.clone();
let keys = gtk4::EventControllerKey::new();
keys.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Escape {
win_esc.set_visible(false);
gtk4::glib::Propagation::Stop
} else {
gtk4::glib::Propagation::Proceed
}
});
window.add_controller(keys);
window.connect_close_request(|w| {
w.set_visible(false);
gtk4::glib::Propagation::Stop
});
Ui {
window,
list,
store,
}
}
pub fn toggle(ui: &Ui) {
if ui.window.is_visible() {
ui.window.set_visible(false);
} else {
rebuild(&ui.list, &ui.store);
ui.window.set_visible(true);
}
}
pub fn refresh_if_visible(ui: &Ui) {
if ui.window.is_visible() {
rebuild(&ui.list, &ui.store);
}
}
pub fn rebuild(list: &gtk4::Box, store: &Store) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let entries: Vec<Entry> = store.lock().unwrap().iter().cloned().collect();
if entries.is_empty() {
let empty = gtk4::Label::new(Some("No notifications yet"));
empty.add_css_class("history-empty");
empty.set_xalign(0.0);
list.append(&empty);
return;
}
for entry in entries {
list.append(&make_row(&entry));
}
}
fn make_row(entry: &Entry) -> gtk4::Box {
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
card.add_css_class("notification-card");
card.add_css_class("history-card");
if let Some(class) = entry.urgency.css_class() {
card.add_css_class(class);
}
let top = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let show_app = !entry.app_name.is_empty()
&& !entry.app_name.eq_ignore_ascii_case(&entry.summary);
if show_app {
let app = gtk4::Label::new(Some(&entry.app_name));
app.add_css_class("notification-app");
app.set_xalign(0.0);
app.set_hexpand(true);
app.set_ellipsize(gtk4::pango::EllipsizeMode::End);
top.append(&app);
} else {
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
top.append(&spacer);
}
let time = gtk4::Label::new(Some(&format_time(entry.received)));
time.add_css_class("history-time");
time.set_xalign(1.0);
top.append(&time);
card.append(&top);
if !entry.summary.is_empty() {
let summary = gtk4::Label::new(Some(&entry.summary));
summary.add_css_class("notification-summary");
summary.set_xalign(0.0);
summary.set_wrap(true);
summary.set_wrap_mode(gtk4::pango::WrapMode::WordChar);
card.append(&summary);
}
let body = collapse_ws(&entry.body);
if !body.is_empty() {
let body_lbl = gtk4::Label::new(Some(&truncate(&body, BODY_MAX_CHARS)));
body_lbl.add_css_class("notification-body");
body_lbl.add_css_class("history-body");
body_lbl.set_xalign(0.0);
body_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::End);
body_lbl.set_max_width_chars(48);
card.append(&body_lbl);
}
card
}
fn format_time(received: SystemTime) -> String {
let Ok(dur) = received.duration_since(SystemTime::UNIX_EPOCH) else {
return "--:--".into();
};
let Ok(dt) = gtk4::glib::DateTime::from_unix_local(dur.as_secs() as i64) else {
return "--:--".into();
};
dt.format("%H:%M")
.map(|s| s.to_string())
.unwrap_or_else(|_| "--:--".into())
}
fn collapse_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate(s: &str, max_chars: usize) -> String {
let mut chars = s.chars();
let taken: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{taken}")
} else {
taken
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: u32, summary: &str) -> Entry {
Entry {
id,
app_name: "app".into(),
summary: summary.into(),
body: String::new(),
urgency: Urgency::Normal,
received: SystemTime::UNIX_EPOCH,
}
}
#[test]
fn record_is_newest_first_and_bounded() {
let store = new_store();
for i in 0..(LIMIT as u32 + 5) {
record(&store, entry(i, &format!("n{i}")));
}
let hist = store.lock().unwrap();
assert_eq!(hist.len(), LIMIT);
assert_eq!(hist.front().unwrap().id, LIMIT as u32 + 4);
assert_eq!(hist.back().unwrap().id, 5);
}
#[test]
fn record_replaces_same_id_and_moves_to_front() {
let store = new_store();
record(&store, entry(1, "old"));
record(&store, entry(2, "other"));
record(&store, entry(1, "new"));
let hist = store.lock().unwrap();
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].id, 1);
assert_eq!(hist[0].summary, "new");
assert_eq!(hist[1].id, 2);
}
#[test]
fn truncate_adds_ellipsis_past_limit() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 5), "hello…");
}
}

View file

@ -1,9 +1,10 @@
pub mod history;
pub mod popup;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use std::time::{Duration, SystemTime};
use tokio::sync::mpsc;
use zbus::zvariant::OwnedValue;
@ -37,6 +38,7 @@ pub enum NotifEvent {
expire: Expire,
},
Close(u32),
ToggleHistory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -93,6 +95,34 @@ struct NotifServer {
/// (app_name, synchronous-hint tag) -> id, for senders relying on
/// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`.
sync_tags: Mutex<HashMap<(String, String), u32>>,
history: history::Store,
}
/// Private breadbar control surface on the same connection as
/// `org.freedesktop.Notifications`. `breadbar --history` is a one-shot
/// client of `ToggleHistory` — there is no other IPC.
struct BarService {
tx: mpsc::Sender<NotifEvent>,
}
#[zbus::interface(name = "dev.breadway.Bar")]
impl BarService {
async fn toggle_history(&self) {
let _ = self.tx.send(NotifEvent::ToggleHistory).await;
}
}
const BAR_DEST: &str = "org.freedesktop.Notifications";
const BAR_PATH: &str = "/dev/breadway/Bar";
const BAR_IFACE: &str = "dev.breadway.Bar";
/// Ask a running breadbar to toggle the history window. Used by
/// `breadbar --history`; does not start a second bar.
pub async fn toggle_history_remote() -> zbus::Result<()> {
let conn = zbus::Connection::session().await?;
conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &())
.await?;
Ok(())
}
#[zbus::interface(name = "org.freedesktop.Notifications")]
@ -140,6 +170,18 @@ impl NotifServer {
let urgency = Urgency::from_hint(hints.get("urgency"));
let expire = compute_expire(expire_timeout, urgency == Urgency::Critical);
history::record(
&self.history,
history::Entry {
id,
app_name: app_name.to_string(),
summary: summary.to_string(),
body: body.to_string(),
urgency,
received: SystemTime::now(),
},
);
let _ = self
.tx
.send(NotifEvent::Show {
@ -217,18 +259,22 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
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;
popup::run(window_for_loop, cards_box, rx, None, None).await;
});
}
None => {
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
let store = history::new_store();
let history_ui = history::build_window(store.clone());
relm4::spawn(async move {
let server = NotifServer {
tx,
tx: tx.clone(),
next_id: AtomicU32::new(1),
sync_tags: Mutex::new(HashMap::new()),
history: store,
};
let bar = BarService { tx };
// Builder failures here would only occur with invalid static strings — safe to unwrap.
let conn = zbus::connection::Builder::session()
.unwrap()
@ -236,6 +282,8 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
.unwrap()
.serve_at("/org/freedesktop/Notifications", server)
.unwrap()
.serve_at(BAR_PATH, bar)
.unwrap()
.build()
.await
.expect("failed to claim org.freedesktop.Notifications on D-Bus session bus");
@ -249,7 +297,8 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
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;
popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui))
.await;
}
});
}
@ -303,6 +352,7 @@ mod tests {
tx,
next_id: AtomicU32::new(1),
sync_tags: Mutex::new(HashMap::new()),
history: history::new_store(),
},
rx,
)
@ -394,4 +444,21 @@ mod tests {
.await;
assert_ne!(first, second);
}
#[tokio::test]
async fn notify_records_history_newest_first() {
let (server, _rx) = test_server();
server
.notify("app-a", 0, "", "first", "body-a", vec![], HashMap::new(), -1)
.await;
server
.notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1)
.await;
let hist = server.history.lock().unwrap();
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].summary, "second");
assert_eq!(hist[0].app_name, "app-b");
assert_eq!(hist[0].body, "body-b");
assert_eq!(hist[1].summary, "first");
}
}

View file

@ -4,7 +4,7 @@ use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell};
use tokio::sync::mpsc::Receiver;
use super::{Expire, NotifEvent, Urgency};
use super::{history, Expire, NotifEvent, Urgency};
type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>;
// Bumped every time an id gets a (re)placed card — an auto-dismiss timer
@ -49,6 +49,7 @@ pub async fn run(
cards_box: gtk4::Box,
mut rx: Receiver<NotifEvent>,
conn: Option<zbus::Connection>,
history_ui: Option<history::Ui>,
) {
let cards: Cards = Rc::new(RefCell::new(HashMap::new()));
let generations: Generations = Rc::new(RefCell::new(HashMap::new()));
@ -71,6 +72,9 @@ pub async fn run(
cards_box.prepend(&card);
cards.borrow_mut().insert(id, card.clone());
window.set_visible(true);
if let Some(ui) = &history_ui {
history::refresh_if_visible(ui);
}
let my_generation = {
let mut gens = generations.borrow_mut();
@ -105,6 +109,11 @@ pub async fn run(
emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await;
}
}
NotifEvent::ToggleHistory => {
if let Some(ui) = &history_ui {
history::toggle(ui);
}
}
}
}
}