Add notification history; pin ecosystem crates to v0.7.2
All checks were successful
dev release / build (push) Successful in 1m25s
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:
parent
614dca71af
commit
465088dc55
10 changed files with 394 additions and 21 deletions
|
|
@ -36,7 +36,7 @@ One GTK4/`relm4` binary, four surfaces:
|
||||||
| Area | Path | Role |
|
| Area | Path | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Bar | `src/bar/` | Layer-shell top bar: workspaces, clock, media, stats, wifi, bluetooth, control panel + SNI tray |
|
| 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 |
|
| OSD | `src/osd.rs` | Volume/brightness overlay |
|
||||||
| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` |
|
| Widgets | `src/widgets/` | Live Lua widgets from breadd via `BreadClient` / `WidgetSpec` |
|
||||||
|
|
||||||
|
|
|
||||||
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -152,8 +152,8 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-screenshots"
|
name = "bread-screenshots"
|
||||||
version = "0.3.1"
|
version = "0.7.2"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-utils",
|
"bread-utils",
|
||||||
|
|
@ -185,8 +185,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-theme"
|
name = "bread-theme"
|
||||||
version = "0.3.1"
|
version = "0.7.2"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
|
@ -196,8 +196,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-utils"
|
name = "bread-utils"
|
||||||
version = "0.3.1"
|
version = "0.7.2"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?rev=69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d#69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-shared 0.7.0",
|
"bread-shared 0.7.0",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|
|
||||||
13
Cargo.toml
13
Cargo.toml
|
|
@ -10,20 +10,17 @@ keywords = ["wayland", "hyprland", "bar", "status-bar", "gtk4"]
|
||||||
categories = ["gui"]
|
categories = ["gui"]
|
||||||
|
|
||||||
[dependencies]
|
[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)
|
# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe)
|
||||||
# for talking to breadd's IPC socket, and bread-shared purely for the
|
# for talking to breadd's IPC socket, and bread-shared purely for the
|
||||||
# WidgetSpec/WidgetNode wire types so we deserialize into real structs
|
# WidgetSpec/WidgetNode wire types so we deserialize into real structs
|
||||||
# instead of hand-parsing serde_json::Value. See src/widgets/.
|
# instead of hand-parsing serde_json::Value. See src/widgets/.
|
||||||
# v0.7.1's BreadClient has no `request`; crate added after that tag too.
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||||
# Pin origin/main rather than inventing a tag or floating on branch = "main".
|
# v0.8.0-rc.1 carries bread_shared::widget; keep this bread tag even if
|
||||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d", features = ["bread-client"] }
|
# ecosystem crates move independently.
|
||||||
# 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-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" }
|
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0-rc.1" }
|
||||||
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
# 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", tag = "v0.7.2" }
|
||||||
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", rev = "69ce2d67a8de8a09c064aa9d7ff99b46656f0b1d" }
|
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
relm4 = { version = "0.11", features = ["macros"] }
|
relm4 = { version = "0.11", features = ["macros"] }
|
||||||
|
|
|
||||||
|
|
@ -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.)
|
- 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)
|
- Popups appear top-right, stack vertically, auto-dismiss after the sender-specified timeout (default 5 s)
|
||||||
- Supports `CloseNotification` and `replaces_id`
|
- 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**:
|
**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/wifi.rs` | WiFi details popover, `breadcrumbs` profile/scan integration |
|
||||||
| `src/bar/control.rs` | Control panel data: volume (`wpctl`), brightness (`brightnessctl`), sinks (`pactl`) |
|
| `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/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/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/osd.rs` | Volume/brightness on-screen display |
|
||||||
| `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) |
|
| `src/widgets/` | Live Lua widgets from breadd (`BreadClient` + `WidgetSpec`) |
|
||||||
| `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection |
|
| `src/theme.rs` | `bread-theme` palette loading, GTK CSS provider injection |
|
||||||
|
|
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -1481,6 +1481,17 @@ fn stat_label() -> gtk4::Label {
|
||||||
fn main() {
|
fn main() {
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
let cli = screenshot::Cli::parse();
|
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();
|
let screenshot_req = cli.screenshot_request();
|
||||||
|
|
||||||
relm4::spawn(async {
|
relm4::spawn(async {
|
||||||
|
|
|
||||||
274
src/notifications/history.rs
Normal file
274
src/notifications/history.rs
Normal 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: >k4::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…");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
pub mod history;
|
||||||
pub mod popup;
|
pub mod popup;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use zbus::zvariant::OwnedValue;
|
use zbus::zvariant::OwnedValue;
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ pub enum NotifEvent {
|
||||||
expire: Expire,
|
expire: Expire,
|
||||||
},
|
},
|
||||||
Close(u32),
|
Close(u32),
|
||||||
|
ToggleHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -93,6 +95,34 @@ struct NotifServer {
|
||||||
/// (app_name, synchronous-hint tag) -> id, for senders relying on
|
/// (app_name, synchronous-hint tag) -> id, for senders relying on
|
||||||
/// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`.
|
/// `SYNCHRONOUS_HINT` instead of an explicit `replaces_id`.
|
||||||
sync_tags: Mutex<HashMap<(String, String), u32>>,
|
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")]
|
#[zbus::interface(name = "org.freedesktop.Notifications")]
|
||||||
|
|
@ -140,6 +170,18 @@ impl NotifServer {
|
||||||
let urgency = Urgency::from_hint(hints.get("urgency"));
|
let urgency = Urgency::from_hint(hints.get("urgency"));
|
||||||
let expire = compute_expire(expire_timeout, urgency == Urgency::Critical);
|
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
|
let _ = self
|
||||||
.tx
|
.tx
|
||||||
.send(NotifEvent::Show {
|
.send(NotifEvent::Show {
|
||||||
|
|
@ -217,18 +259,22 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
|
||||||
let _ = tx.try_send(kind.sample_event());
|
let _ = tx.try_send(kind.sample_event());
|
||||||
let window_for_loop = window.clone();
|
let window_for_loop = window.clone();
|
||||||
relm4::spawn_local(async move {
|
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 => {
|
None => {
|
||||||
let (conn_tx, conn_rx) = tokio::sync::oneshot::channel();
|
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 {
|
relm4::spawn(async move {
|
||||||
let server = NotifServer {
|
let server = NotifServer {
|
||||||
tx,
|
tx: tx.clone(),
|
||||||
next_id: AtomicU32::new(1),
|
next_id: AtomicU32::new(1),
|
||||||
sync_tags: Mutex::new(HashMap::new()),
|
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.
|
// 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()
|
||||||
|
|
@ -236,6 +282,8 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.serve_at("/org/freedesktop/Notifications", server)
|
.serve_at("/org/freedesktop/Notifications", server)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.serve_at(BAR_PATH, bar)
|
||||||
|
.unwrap()
|
||||||
.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");
|
||||||
|
|
@ -249,7 +297,8 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
|
||||||
let window_for_loop = window.clone();
|
let window_for_loop = window.clone();
|
||||||
relm4::spawn_local(async move {
|
relm4::spawn_local(async move {
|
||||||
if let Ok(conn) = conn_rx.await {
|
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,
|
tx,
|
||||||
next_id: AtomicU32::new(1),
|
next_id: AtomicU32::new(1),
|
||||||
sync_tags: Mutex::new(HashMap::new()),
|
sync_tags: Mutex::new(HashMap::new()),
|
||||||
|
history: history::new_store(),
|
||||||
},
|
},
|
||||||
rx,
|
rx,
|
||||||
)
|
)
|
||||||
|
|
@ -394,4 +444,21 @@ mod tests {
|
||||||
.await;
|
.await;
|
||||||
assert_ne!(first, second);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ 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::{Expire, NotifEvent, Urgency};
|
use super::{history, Expire, NotifEvent, Urgency};
|
||||||
|
|
||||||
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
|
// 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,
|
cards_box: gtk4::Box,
|
||||||
mut rx: Receiver<NotifEvent>,
|
mut rx: Receiver<NotifEvent>,
|
||||||
conn: Option<zbus::Connection>,
|
conn: Option<zbus::Connection>,
|
||||||
|
history_ui: Option<history::Ui>,
|
||||||
) {
|
) {
|
||||||
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()));
|
let generations: Generations = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
|
@ -71,6 +72,9 @@ pub async fn run(
|
||||||
cards_box.prepend(&card);
|
cards_box.prepend(&card);
|
||||||
cards.borrow_mut().insert(id, card.clone());
|
cards.borrow_mut().insert(id, card.clone());
|
||||||
window.set_visible(true);
|
window.set_visible(true);
|
||||||
|
if let Some(ui) = &history_ui {
|
||||||
|
history::refresh_if_visible(ui);
|
||||||
|
}
|
||||||
|
|
||||||
let my_generation = {
|
let my_generation = {
|
||||||
let mut gens = generations.borrow_mut();
|
let mut gens = generations.borrow_mut();
|
||||||
|
|
@ -105,6 +109,11 @@ pub async fn run(
|
||||||
emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await;
|
emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
NotifEvent::ToggleHistory => {
|
||||||
|
if let Some(ui) = &history_ui {
|
||||||
|
history::toggle(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,11 @@ pub struct Cli {
|
||||||
/// Capture canvas height — see `width`.
|
/// Capture canvas height — see `width`.
|
||||||
#[arg(long, default_value_t = 1080)]
|
#[arg(long, default_value_t = 1080)]
|
||||||
pub height: u32,
|
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 {
|
pub struct ScreenshotRequest {
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,20 @@ fn load_css() -> String {
|
||||||
.bt-icon {{ margin-right: 14px; }}\
|
.bt-icon {{ margin-right: 14px; }}\
|
||||||
separator.bar-sep {{ min-height: 14px; margin: 0 8px 0 0; background: alpha({on_bg}, 0.14); }}\
|
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-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};\
|
.notification-card {{ background: {surface}; color: {on_surface}; border-radius: {radius};\
|
||||||
padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\
|
padding: {pad}; margin-bottom: 8px; border-left: 3px solid transparent; }}\
|
||||||
.notification-card.urgency-critical {{ border-left-color: {critical}; }}\
|
.notification-card.urgency-critical {{ border-left-color: {critical}; }}\
|
||||||
.notification-card.urgency-normal {{ border-left-color: {accent}; }}\
|
.notification-card.urgency-normal {{ border-left-color: {accent}; }}\
|
||||||
.notification-summary {{ font-weight: bold; }}\
|
.notification-summary {{ font-weight: bold; }}\
|
||||||
.notification-app {{ opacity: 0.6; }}\
|
.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}; }}\
|
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 {{ opacity: 0.85; margin-right: 8px; }}\
|
||||||
.osd-icon-muted {{ opacity: 0.35; }}\
|
.osd-icon-muted {{ opacity: 0.35; }}\
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue