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 <noreply@anthropic.com>
This commit is contained in:
Austin Schaefer 2026-07-29 11:50:35 +02:00
commit fe2039c05e
8 changed files with 668 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

353
Cargo.lock generated Normal file
View file

@ -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"

9
Cargo.toml Normal file
View file

@ -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"

38
Makefile.toml Normal file
View file

@ -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"

60
src/format.rs Normal file
View file

@ -0,0 +1,60 @@
/// Escape text for Pango markup (wofi is run with `--allow-markup`).
pub fn escape_markup(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// 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: `<b>App</b>: 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!("<b>{app}</b>: {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>"), "&lt;Tom &amp; Jerry&gt;");
}
#[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 <world>");
assert_eq!(text, "<b>Firefox</b>: New message — Hello &lt;world&gt;");
}
#[test]
fn builds_entry_text_without_body() {
let text = entry_text("Firefox", "New message", "");
assert_eq!(text, "<b>Firefox</b>: New message");
}
}

83
src/icon.rs Normal file
View file

@ -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<PathBuf> {
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<PathBuf> {
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<String> {
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<PathBuf> {
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<String> {
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
}

75
src/main.rs Normal file
View file

@ -0,0 +1,75 @@
mod format;
mod icon;
mod notification;
use std::io::Write;
use std::process::{Command, Stdio};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let notifications = notification::history()?;
if notifications.is_empty() {
return Ok(());
}
let entries: Vec<String> = 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<Option<usize>, Box<dyn std::error::Error>> {
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::<usize>().ok())
}

49
src/notification.rs Normal file
View file

@ -0,0 +1,49 @@
use serde::Deserialize;
use std::process::Command;
#[derive(Debug, Deserialize)]
pub struct Notification {
pub id: u32,
pub app_name: Option<String>,
pub app_icon: Option<String>,
pub desktop_entry: Option<String>,
pub summary: Option<String>,
pub body: Option<String>,
}
#[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<Vec<Notification>, 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(())
}