From 465088dc55ec5e58ec5561b4fb236b17939372a4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:54:04 +0800 Subject: [PATCH] Add notification history; pin ecosystem crates to v0.7.2 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. --- AGENTS.md | 2 +- Cargo.lock | 12 +- Cargo.toml | 13 +- README.md | 4 +- src/main.rs | 11 ++ src/notifications/history.rs | 274 +++++++++++++++++++++++++++++++++++ src/notifications/mod.rs | 75 +++++++++- src/notifications/popup.rs | 11 +- src/screenshot.rs | 5 + src/theme.rs | 8 + 10 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 src/notifications/history.rs diff --git a/AGENTS.md b/AGENTS.md index 5acf2c9..1207e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ One GTK4/`relm4` binary, four surfaces: | Area | Path | Role | |---|---|---| | Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray | -| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups | +| Notifications | `src/notifications/` | `org.freedesktop.Notifications` daemon + stacked popups + in-memory history (`breadbar --history`) | | OSD | `src/osd.rs` | Volume/brightness overlay | | Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` | diff --git a/Cargo.lock b/Cargo.lock index de799e0..7c03af7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bread-screenshots" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "anyhow", "bread-utils", @@ -185,8 +185,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs 5.0.1", "gtk4", @@ -196,8 +196,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared 0.7.0", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index 1d06a30..ffa6d25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,20 +10,17 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"] categories = ["gui"] [dependencies] -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } # Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) # for talking to breadd's IPC socket, and bread-shared purely for the # WidgetSpec/WidgetNode wire types so we deserialize into real structs # instead of hand-parsing serde_json::Value. See src/widgets/. -# v0.7.1's BreadClient has no `request`; crate added after that tag too. -# Pin origin/main rather than inventing a tag or floating on branch = "main". -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d", features = ["bread-client"] } -# v0.7.0 has no `bread_shared::widget`; v0.8.0 was never cut. Latest -# existing tag that carries the wire types breadbar deserializes. +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } +# v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if +# ecosystem crates move independently. bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" } # Capture primitives for `--screenshot` mode — see src/screenshot.rs. -# Same rev as bread-utils: v0.7.1 predates this crate (added 2026-07-23). -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" } +bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } diff --git a/README.md b/README.md index 474eafa..0047613 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Implements `org.freedesktop.Notifications` (D-Bus) — works with any standard sender (`notify-send`, etc.) - Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s) - Supports `CloseNotification` and `replaces_id` +- In-memory history of the last 50 notifications (app, summary, truncated body, time). Toggle with `breadbar --history` (Hyprland: `bind = SUPER, N, exec, breadbar --history`) or D-Bus `dev.breadway.Bar.ToggleHistory` on `org.freedesktop.Notifications` at `/dev/breadway/Bar`. Not persisted. **Volume/brightness OSD**: @@ -138,8 +139,9 @@ Example — change the font size: | `src/bar/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration | | `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) | | `src/bar/tray.rs` | `org.kde.StatusNotifierWatcher` D-Bus service, SNI item rendering | -| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service | +| `src/notifications/mod.rs` | `org.freedesktop.Notifications` zbus service + `dev.breadway.Bar` history IPC | | `src/notifications/popup.rs` | Layer-shell popup window and card stack | +| `src/notifications/history.rs` | Bounded in-memory history and layer-shell history window | | `src/osd.rs` | Volume/brightness on-screen display | | `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) | | `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection | diff --git a/src/main.rs b/src/main.rs index 58a70c9..0ce99a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1481,6 +1481,17 @@ fn stat_label() -> gtk4::Label { fn main() { use clap::Parser; let cli = screenshot::Cli::parse(); + if cli.history { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + if let Err(e) = rt.block_on(notifications::toggle_history_remote()) { + eprintln!("breadbar: could not toggle history (is breadbar running?): {e}"); + std::process::exit(1); + } + return; + } let screenshot_req = cli.screenshot_request(); relm4::spawn(async { diff --git a/src/notifications/history.rs b/src/notifications/history.rs new file mode 100644 index 0000000..711f229 --- /dev/null +++ b/src/notifications/history.rs @@ -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>>; + +#[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: >k4::Box, store: &Store) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + let entries: Vec = 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::>().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…"); + } +} diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 0c25c0d..ccc8125 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -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>, + 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, +} + +#[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) -> 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) -> 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) -> 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"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 91adec0..9ec29af 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -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>>; // 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, conn: Option, + history_ui: Option, ) { 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); + } + } } } } diff --git a/src/screenshot.rs b/src/screenshot.rs index 8ccdd87..ecf75b3 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -74,6 +74,11 @@ pub struct Cli { /// Capture canvas height — see `width`. #[arg(long, default_value_t = 1080)] pub height: u32, + + /// Toggle the in-memory notification history on a running breadbar, then + /// exit. Keybind-friendly; does not start a second instance. + #[arg(long)] + pub history: bool, } pub struct ScreenshotRequest { diff --git a/src/theme.rs b/src/theme.rs index 10c2415..0e7186f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -36,12 +36,20 @@ fn load_css() -> String { .bt-icon {{ margin-right: 14px; }}\ separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\ window.breadbar-notification {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; }}\ + window.breadbar-history {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg};\ + border-radius: {radius}; }}\ .notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\ padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\ .notification-card.urgency-critical {{ border-left-color: {critical}; }}\ .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-summary {{ font-weight: bold; }}\ .notification-app {{ opacity: 0.6; }}\ + .history-title {{ font-weight: bold; font-size: 13px; }}\ + .history-close {{ padding: 2px 8px; }}\ + .history-empty {{ opacity: 0.5; padding: 8px 0; }}\ + .history-time {{ opacity: 0.5; font-size: 11px; }}\ + .history-body {{ opacity: 0.75; }}\ + .history-card {{ margin-bottom: 6px; }}\ window.breadbar-osd {{ background-color: alpha({bg_plain}, 0.95); color: {on_bg}; border-radius: {radius_pill}; }}\ .osd-icon {{ opacity: 0.85; margin-right: 8px; }}\ .osd-icon-muted {{ opacity: 0.35; }}\