use std::io::Write;
use std::time::Duration;
use base64::Engine;
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use crate::text;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Notifications {
#[default]
Auto,
Off,
Bell,
Osc9,
Osc777,
Osc99,
}
impl Notifications {
pub fn resolve(self, env: impl Fn(&str) -> Option<String>) -> Notifications {
match self {
Notifications::Auto => detect(env),
other => other,
}
}
}
pub fn detect(env: impl Fn(&str) -> Option<String>) -> Notifications {
let is = |name: &str, value: &str| env(name).is_some_and(|v| v == value);
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
let term = env("TERM").unwrap_or_default();
if set("KITTY_WINDOW_ID")
|| term == "xterm-kitty"
|| term.starts_with("foot")
|| set("KONSOLE_VERSION")
{
Notifications::Osc99
} else if is("TERM_PROGRAM", "ghostty")
|| set("GHOSTTY_RESOURCES_DIR")
|| is("TERM_PROGRAM", "WezTerm")
|| set("WEZTERM_PANE")
|| set("WT_SESSION")
{
Notifications::Osc777
} else if is("TERM_PROGRAM", "iTerm.app") || is("LC_TERMINAL", "iTerm2") {
Notifications::Osc9
} else {
Notifications::Bell
}
}
#[derive(Debug, PartialEq)]
pub struct Alert {
pub title: String,
pub body: String,
}
pub fn in_tmux() -> bool {
std::env::var_os("TMUX").is_some_and(|v| !v.is_empty())
}
pub fn escape(method: Notifications, alert: &Alert, id: u64, tmux: bool) -> Option<String> {
let title = plain(&alert.title).replace(';', ",");
let body = plain(&alert.body).replace(';', ",");
let code = match method {
Notifications::Auto | Notifications::Off => return None,
Notifications::Bell => return Some("\x07".into()),
Notifications::Osc9 => format!("\x1b]9;{title}: {body}\x07"),
Notifications::Osc777 => format!("\x1b]777;notify;{title};{body}\x07"),
Notifications::Osc99 => {
let base64 = |text: &str| base64::engine::general_purpose::STANDARD.encode(text);
format!(
"\x1b]99;i={id}:d=0:o=unfocused:e=1;{}\x1b\\\
\x1b]99;i={id}:d=1:p=body:e=1;{}\x1b\\",
base64(&title),
base64(&body)
)
}
};
Some(if tmux {
format!("\x1bPtmux;{}\x1b\\", code.replace('\x1b', "\x1b\x1b"))
} else {
code
})
}
pub fn title(unread: u32) -> String {
if unread > 0 {
format!("\x1b]2;({unread}) tuimail\x07")
} else {
"\x1b]2;tuimail\x07".into()
}
}
pub fn send(code: &str) {
let mut out = std::io::stdout();
let _ = out.write_all(code.as_bytes());
let _ = out.flush();
}
pub const SAVE_TITLE: &str = "\x1b[22;0t";
pub const RESTORE_TITLE: &str = "\x1b]2;\x07\x1b[23;0t";
fn plain(text: &str) -> String {
text::clean(text)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn cut(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let mut out: String = text.chars().take(max - 1).collect();
out.push('โฆ');
out
}
const GAP: Duration = Duration::from_secs(3);
const MAX_TITLE: usize = 60;
const MAX_BODY: usize = 150;
#[derive(Clone, Debug)]
pub struct Note {
pub account: String,
pub from: String,
pub subject: String,
}
#[derive(Default)]
pub struct Notifier {
pending: Vec<Note>,
last_sent: Option<Instant>,
}
impl Notifier {
pub fn add(&mut self, note: Note) {
self.pending.push(note);
}
pub fn due(&mut self, now: Instant) -> Option<Alert> {
if self.pending.is_empty() || self.last_sent.is_some_and(|sent| now < sent + GAP) {
return None;
}
let notes = std::mem::take(&mut self.pending);
self.last_sent = Some(now);
Some(alert(¬es))
}
}
fn alert(notes: &[Note]) -> Alert {
let (title, body) = match notes {
[note] => {
let title = if note.account.is_empty() {
note.from.clone()
} else {
format!("{} ยท {}", note.from, note.account)
};
(title, note.subject.clone())
}
_ => {
let mut senders: Vec<&str> = Vec::new();
for note in notes {
if !senders.contains(¬e.from.as_str()) {
senders.push(¬e.from);
}
}
(format!("{} new messages", notes.len()), senders.join(", "))
}
};
Alert {
title: cut(&plain(&title), MAX_TITLE),
body: cut(&plain(&body), MAX_BODY),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn note(from: &str, subject: &str) -> Note {
Note {
account: String::new(),
from: from.into(),
subject: subject.into(),
}
}
fn alert(title: &str, body: &str) -> Alert {
Alert {
title: title.into(),
body: body.into(),
}
}
#[test]
fn the_terminal_is_recognized_from_its_environment() {
let detect_with = |vars: &[(&str, &str)]| {
let vars: HashMap<String, String> = vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
detect(|name| vars.get(name).cloned())
};
assert_eq!(
detect_with(&[("TERM_PROGRAM", "ghostty")]),
Notifications::Osc777
);
assert_eq!(
detect_with(&[("TERM_PROGRAM", "tmux"), ("GHOSTTY_RESOURCES_DIR", "/x")]),
Notifications::Osc777,
"inside tmux"
);
assert_eq!(
detect_with(&[("TERM", "xterm-kitty")]),
Notifications::Osc99
);
assert_eq!(
detect_with(&[("LC_TERMINAL", "iTerm2")]),
Notifications::Osc9
);
assert_eq!(
detect_with(&[("TERM_PROGRAM", "Apple_Terminal")]),
Notifications::Bell
);
assert_eq!(
Notifications::Off.resolve(|_| Some("ghostty".into())),
Notifications::Off,
"a setting wins"
);
}
#[test]
fn each_code_carries_the_title_and_text() {
let hi = alert("Alice", "Lunch?");
assert_eq!(
escape(Notifications::Osc777, &hi, 1, false).unwrap(),
"\x1b]777;notify;Alice;Lunch?\x07"
);
assert_eq!(
escape(Notifications::Osc9, &hi, 1, false).unwrap(),
"\x1b]9;Alice: Lunch?\x07"
);
assert_eq!(escape(Notifications::Bell, &hi, 1, false).unwrap(), "\x07");
assert_eq!(escape(Notifications::Off, &hi, 1, false), None);
assert!(
escape(Notifications::Osc99, &hi, 7, false)
.unwrap()
.starts_with("\x1b]99;i=7:d=0")
);
}
#[test]
fn text_from_senders_cannot_end_the_code_early() {
let sneaky = alert("Eve;x", "a\x07b\x1b]0;pwned\x1b\\c\nd;e");
assert_eq!(
escape(Notifications::Osc777, &sneaky, 1, false).unwrap(),
"\x1b]777;notify;Eve,x;ab]0,pwned\\c d,e\x07"
);
}
#[test]
fn inside_tmux_codes_are_passed_through() {
let hi = alert("Alice", "hi");
assert_eq!(
escape(Notifications::Osc777, &hi, 1, true).unwrap(),
"\x1bPtmux;\x1b\x1b]777;notify;Alice;hi\x07\x1b\\"
);
}
#[test]
fn mail_arriving_together_makes_one_notification_spaced_out() {
let mut notifier = Notifier::default();
let now = Instant::now();
notifier.add(note("Alice", "Lunch?"));
assert_eq!(notifier.due(now).unwrap(), alert("Alice", "Lunch?"));
assert_eq!(notifier.due(now), None, "nothing left");
notifier.add(note("Bob", "one"));
notifier.add(note("Carol", "two"));
notifier.add(note("Bob", "three"));
assert_eq!(notifier.due(now + Duration::from_secs(1)), None, "too soon");
assert_eq!(
notifier.due(now + GAP).unwrap(),
alert("3 new messages", "Bob, Carol")
);
let work = Note {
account: "Work".into(),
..note("Dana", "Q3")
};
notifier.add(work);
assert_eq!(
notifier.due(now + GAP + GAP).unwrap(),
alert("Dana ยท Work", "Q3")
);
}
}