From fe2039c05ea32bfef57652811b42a7338137bfb1 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Wed, 29 Jul 2026 11:50:35 +0200 Subject: [PATCH] Initial commit: mako notification picker for wofi Reads notification history via makoctl, formats entries as pango markup with resolved app icons, and pipes them to wofi in dmenu mode for selection and invocation. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + Cargo.lock | 353 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 9 ++ Makefile.toml | 38 +++++ src/format.rs | 60 ++++++++ src/icon.rs | 83 +++++++++++ src/main.rs | 75 ++++++++++ src/notification.rs | 49 ++++++ 8 files changed, 668 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Makefile.toml create mode 100644 src/format.rs create mode 100644 src/icon.rs create mode 100644 src/main.rs create mode 100644 src/notification.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ff6f822 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,353 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "freedesktop-icons" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f87364ea709292a3b3f74014ce3ee78412c89807eea75a358c8e029b000994" +dependencies = [ + "dirs", + "ini_core", + "once_cell", + "thiserror", + "tracing", + "xdg", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ini_core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a467a31a9f439b5262fa99c17084537bff57f24703d5a09a2b5c9657ec73a61" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "notif-picker" +version = "0.1.0" +dependencies = [ + "freedesktop-icons", + "serde", + "serde_json", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..82cb1ff --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "notif-picker" +version = "0.1.0" +edition = "2024" + +[dependencies] +freedesktop-icons = "0.4.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" diff --git a/Makefile.toml b/Makefile.toml new file mode 100644 index 0000000..b013d11 --- /dev/null +++ b/Makefile.toml @@ -0,0 +1,38 @@ +[config] +default_to_workspace = false + +[tasks.format] +command = "cargo" +args = ["fmt"] + +[tasks.format-check] +command = "cargo" +args = ["fmt", "--check"] + +[tasks.lint] +command = "cargo" +args = ["clippy", "--all-targets", "--", "-D", "warnings"] + +[tasks.test] +command = "cargo" +args = ["test"] + +[tasks.audit] +command = "cargo" +args = ["audit"] + +[tasks.build] +command = "cargo" +args = ["build", "--release"] + +[tasks.install] +dependencies = ["build"] +script = ''' +install -Dm755 target/release/notif-picker "${HOME}/.local/bin/notif-picker" +''' + +[tasks.ci] +dependencies = ["format-check", "lint", "test", "audit"] + +[tasks.default] +alias = "ci" diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..fb19aa4 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,60 @@ +/// Escape text for Pango markup (wofi is run with `--allow-markup`). +pub fn escape_markup(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// Collapse embedded newlines/tabs to spaces so a notification's summary or +/// body renders as a single logical line (wofi wraps long lines itself via +/// `line_wrap` in its config). +fn normalize_whitespace(text: &str) -> String { + text.chars() + .map(|c| if c == '\n' || c == '\t' { ' ' } else { c }) + .collect() +} + +/// Build the display text for one notification row: `App: summary — body`. +pub fn entry_text(app_name: &str, summary: &str, body: &str) -> String { + let app = escape_markup(app_name); + let summary = escape_markup(&normalize_whitespace(summary)); + let mut text = format!("{app}: {summary}"); + + if !body.is_empty() { + let body = escape_markup(&normalize_whitespace(body)); + text.push_str(" — "); + text.push_str(&body); + } + + text +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escapes_markup_special_characters() { + assert_eq!(escape_markup(""), "<Tom & Jerry>"); + } + + #[test] + fn normalizes_newlines_and_tabs_to_spaces() { + assert_eq!( + normalize_whitespace("line one\nline\ttwo"), + "line one line two" + ); + } + + #[test] + fn builds_entry_text_with_body() { + let text = entry_text("Firefox", "New message", "Hello "); + assert_eq!(text, "Firefox: New message — Hello <world>"); + } + + #[test] + fn builds_entry_text_without_body() { + let text = entry_text("Firefox", "New message", ""); + assert_eq!(text, "Firefox: New message"); + } +} diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..02fd7d9 --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,83 @@ +use freedesktop_icons::lookup; +use std::path::PathBuf; + +const ICON_SIZE: u16 = 32; + +/// Resolve the best icon path for a notification, preferring the app's own +/// identity (desktop entry, then app name) over `app_icon`, which is often +/// just a hint for the specific notification's content (e.g. blueman sends +/// "battery" for a battery-level alert, not its own icon). +pub fn resolve( + app_icon: Option<&str>, + desktop_entry: Option<&str>, + app_name: Option<&str>, +) -> Option { + let theme = freedesktop_icons::default_theme_gtk(); + + let candidates = [ + desktop_entry.and_then(desktop_file_icon_name), + app_name.map(str::to_lowercase), + app_icon.map(str::to_owned), + ]; + + candidates + .into_iter() + .flatten() + .find_map(|name| resolve_name(&name, theme.as_deref())) +} + +fn resolve_name(name: &str, theme: Option<&str>) -> Option { + if name.starts_with('/') { + let path = PathBuf::from(name); + return path.is_file().then_some(path); + } + + let mut query = lookup(name).with_size(ICON_SIZE); + if let Some(theme) = theme { + query = query.with_theme(theme); + } + query.find() +} + +/// Look up the `Icon=` value from a `.desktop` file identified by its +/// desktop-entry id, searching XDG_DATA_DIRS/applications the same way a +/// desktop environment would. +fn desktop_file_icon_name(desktop_entry: &str) -> Option { + for dir in data_dirs() { + let path = dir + .join("applications") + .join(format!("{desktop_entry}.desktop")); + if let Ok(contents) = std::fs::read_to_string(&path) + && let Some(icon) = parse_icon_key(&contents) + { + return Some(icon); + } + } + None +} + +fn data_dirs() -> Vec { + let mut dirs = vec![]; + if let Some(home) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(home).join(".local/share")); + } + let xdg_data_dirs = std::env::var("XDG_DATA_DIRS") + .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); + dirs.extend(xdg_data_dirs.split(':').map(PathBuf::from)); + dirs +} + +fn parse_icon_key(desktop_file_contents: &str) -> Option { + let mut in_desktop_entry_section = false; + for line in desktop_file_contents.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_desktop_entry_section = line == "[Desktop Entry]"; + continue; + } + if in_desktop_entry_section && let Some(value) = line.strip_prefix("Icon=") { + return Some(value.trim().to_string()); + } + } + None +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f9cb776 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,75 @@ +mod format; +mod icon; +mod notification; + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn main() -> Result<(), Box> { + let notifications = notification::history()?; + if notifications.is_empty() { + return Ok(()); + } + + let entries: Vec = notifications + .iter() + .map(|n| { + let app_name = n.app_name.as_deref().unwrap_or("unknown"); + let summary = n.summary.as_deref().unwrap_or(""); + let body = n.body.as_deref().unwrap_or(""); + let text = format::entry_text(app_name, summary, body); + + match icon::resolve( + n.app_icon.as_deref(), + n.desktop_entry.as_deref(), + n.app_name.as_deref(), + ) { + Some(path) => format!("img:{}:text:{text}", path.display()), + None => text, + } + }) + .collect(); + + let Some(index) = prompt_selection(&entries)? else { + return Ok(()); + }; + + if let Some(notification) = notifications.get(index) { + notification::invoke(notification.id)?; + } + + Ok(()) +} + +/// Run wofi in dmenu mode with the given entries and return the selected +/// index, or `None` if the user cancelled (Escape / clicked outside). +fn prompt_selection(entries: &[String]) -> Result, Box> { + let mut child = Command::new("wofi") + .args([ + "--dmenu", + "-p", + "Notifications", + "-D", + "dmenu-print_line_num=true", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + let mut stdin = child.stdin.take().expect("stdin was piped"); + stdin.write_all(entries.join("\n").as_bytes())?; + drop(stdin); + + let output = child.wait_with_output()?; + if !output.status.success() { + return Ok(None); + } + + let selection = String::from_utf8(output.stdout)?; + let selection = selection.trim(); + if selection.is_empty() { + return Ok(None); + } + + Ok(selection.parse::().ok()) +} diff --git a/src/notification.rs b/src/notification.rs new file mode 100644 index 0000000..c126e78 --- /dev/null +++ b/src/notification.rs @@ -0,0 +1,49 @@ +use serde::Deserialize; +use std::process::Command; + +#[derive(Debug, Deserialize)] +pub struct Notification { + pub id: u32, + pub app_name: Option, + pub app_icon: Option, + pub desktop_entry: Option, + pub summary: Option, + pub body: Option, +} + +#[derive(Debug)] +pub enum MakoError { + Spawn(std::io::Error), + NonUtf8Output, + Parse(serde_json::Error), +} + +impl std::fmt::Display for MakoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MakoError::Spawn(e) => write!(f, "failed to run makoctl: {e}"), + MakoError::NonUtf8Output => write!(f, "makoctl produced non-UTF8 output"), + MakoError::Parse(e) => write!(f, "failed to parse makoctl history JSON: {e}"), + } + } +} + +impl std::error::Error for MakoError {} + +pub fn history() -> Result, MakoError> { + let output = Command::new("makoctl") + .args(["history", "-j"]) + .output() + .map_err(MakoError::Spawn)?; + + let stdout = String::from_utf8(output.stdout).map_err(|_| MakoError::NonUtf8Output)?; + serde_json::from_str(&stdout).map_err(MakoError::Parse) +} + +pub fn invoke(id: u32) -> Result<(), MakoError> { + Command::new("makoctl") + .args(["invoke", "-n", &id.to_string()]) + .status() + .map_err(MakoError::Spawn)?; + Ok(()) +}