use std::io::Write;
use std::time::Duration;
use rich::{Console, ConsoleOptions, Panel, Renderable, Segment, Style, Text};
use crate::a11y::{Status, SymbolSet};
use crate::live::{LiveCoordinator, LiveError, RegionId};
use crate::target::RenderTarget;
use crate::transfer::{effective_symbols, finish_line, join_lines, keyed_style};
pub const STYLES: &[(&str, &str)] = &[
("notify.title", "bold"),
("notify.message", "none"),
("notify.more", "dim"),
("notify.ok", "green"),
("notify.warning", "yellow"),
("notify.error", "bold red"),
("notify.info", "cyan"),
("notify.pending", "magenta"),
("notify.skipped", "dim"),
];
fn style(console: &Console, key: &str) -> Style {
keyed_style(console, STYLES, key)
}
fn level_style(console: &Console, status: Status) -> Style {
style(console, &format!("notify.{}", status.word()))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToastStyle {
#[default]
Line,
Panel,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notification {
status: Status,
title: Option<String>,
message: String,
ttl: Option<Duration>,
symbols: SymbolSet,
toast: ToastStyle,
}
impl Notification {
pub fn new(status: Status, message: impl Into<String>) -> Self {
Notification {
status,
title: None,
message: message.into(),
ttl: None,
symbols: SymbolSet::Unicode,
toast: ToastStyle::Line,
}
}
pub fn ok(message: impl Into<String>) -> Self {
Notification::new(Status::Ok, message)
}
pub fn info(message: impl Into<String>) -> Self {
Notification::new(Status::Info, message)
}
pub fn warning(message: impl Into<String>) -> Self {
Notification::new(Status::Warning, message)
}
pub fn error(message: impl Into<String>) -> Self {
Notification::new(Status::Error, message)
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
pub fn symbols(mut self, set: SymbolSet) -> Self {
self.symbols = set;
self
}
pub fn toast_style(mut self, toast: ToastStyle) -> Self {
self.toast = toast;
self
}
pub fn status(&self) -> Status {
self.status
}
pub fn get_title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn message(&self) -> &str {
&self.message
}
pub fn get_ttl(&self) -> Option<Duration> {
self.ttl
}
fn render_as(
&self,
console: &Console,
options: &ConsoleOptions,
set: SymbolSet,
toast: ToastStyle,
) -> Vec<Segment> {
let set = effective_symbols(console, set);
let level = level_style(console, self.status);
match toast {
ToastStyle::Line => {
let mut line = vec![
Segment::new(self.status.symbol(set), Some(level)),
Segment::new(" ", None),
];
if let Some(title) = &self.title {
line.push(Segment::new(
title.clone(),
Some(style(console, "notify.title")),
));
line.push(Segment::new(": ", None));
}
line.push(Segment::new(
self.message.clone(),
Some(style(console, "notify.message")),
));
finish_line(line, options.max_width)
}
ToastStyle::Panel => {
let heading = match &self.title {
Some(title) => format!("{} {title}", self.status.symbol(set)),
None => self.status.symbol(set).to_string(),
};
let body = Text::styled(self.message.clone(), style(console, "notify.message"));
Panel::fit(Box::new(body))
.title(rich::markup::escape(&heading))
.title_align(rich::HorizontalAlign::Left)
.border_style(level)
.rich_render(console, options)
}
}
}
}
impl Renderable for Notification {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
self.render_as(console, options, self.symbols, self.toast)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct NotificationId(u64);
#[derive(Clone, Debug)]
struct Entry {
id: NotificationId,
notification: Notification,
expires: Option<Duration>,
}
#[derive(Clone, Debug)]
pub struct Notifications {
entries: Vec<Entry>,
log: Vec<Notification>,
next: u64,
default_ttl: Option<Duration>,
max_visible: usize,
transient: bool,
symbols: Option<SymbolSet>,
toast: Option<ToastStyle>,
}
impl Default for Notifications {
fn default() -> Self {
Notifications {
entries: Vec::new(),
log: Vec::new(),
next: 0,
default_ttl: Some(Duration::from_secs(5)),
max_visible: 3,
transient: true,
symbols: None,
toast: None,
}
}
}
impl Notifications {
pub fn new() -> Self {
Notifications::default()
}
pub fn for_target(target: &RenderTarget) -> Self {
use rich::protocol::RenderEnvironment;
Notifications::new().transient(target.capabilities().interactive)
}
pub fn default_ttl(mut self, ttl: Option<Duration>) -> Self {
self.default_ttl = ttl;
self
}
pub fn max_visible(mut self, count: usize) -> Self {
self.max_visible = count.max(1);
self
}
pub fn transient(mut self, transient: bool) -> Self {
self.transient = transient;
self
}
pub fn symbols(mut self, set: SymbolSet) -> Self {
self.symbols = Some(set);
self
}
pub fn toast_style(mut self, toast: ToastStyle) -> Self {
self.toast = Some(toast);
self
}
pub fn push(&mut self, notification: Notification, now: Duration) -> NotificationId {
let id = NotificationId(self.next);
self.next += 1;
if !self.transient {
self.log.push(notification);
return id;
}
let expires = notification
.ttl
.or(self.default_ttl)
.map(|ttl| now.saturating_add(ttl));
self.entries.push(Entry {
id,
notification,
expires,
});
id
}
pub fn dismiss(&mut self, id: NotificationId) -> bool {
let before = self.entries.len();
self.entries.retain(|entry| entry.id != id);
self.entries.len() != before
}
pub fn expire(&mut self, now: Duration) -> usize {
let before = self.entries.len();
self.entries
.retain(|entry| entry.expires.is_none_or(|at| at > now));
before - self.entries.len()
}
pub fn next_expiry(&self) -> Option<Duration> {
self.entries.iter().filter_map(|entry| entry.expires).min()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Notification> {
self.entries.iter().map(|entry| &entry.notification)
}
pub fn take_log(&mut self) -> Vec<Notification> {
std::mem::take(&mut self.log)
}
pub fn present<W: Write>(
&mut self,
live: &mut LiveCoordinator<W>,
target: &RenderTarget,
region: &mut Option<RegionId>,
now: Duration,
) -> Result<(), LiveError> {
for notification in self.take_log() {
live.print(&target.segments(&self.styled(¬ification)))?;
}
self.expire(now);
match (region.take(), self.is_empty()) {
(Some(id), true) => live.remove(id)?,
(None, true) => {}
(Some(id), false) => {
live.update(id.clone(), target.segments(self))?;
*region = Some(id);
}
(None, false) => *region = Some(live.add(target.segments(self))?),
}
live.refresh()
}
fn styled(&self, notification: &Notification) -> Notification {
let mut out = notification.clone();
if let Some(set) = self.symbols {
out.symbols = set;
}
if let Some(toast) = self.toast {
out.toast = toast;
}
out
}
}
impl Renderable for Notifications {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let hidden = self.entries.len().saturating_sub(self.max_visible);
let mut lines = Vec::new();
if hidden > 0 {
lines.push(finish_line(
vec![Segment::new(
format!("+{hidden} more"),
Some(style(console, "notify.more")),
)],
options.max_width,
));
}
for entry in &self.entries[hidden..] {
lines.push(
self.styled(&entry.notification)
.rich_render(console, options),
);
}
join_lines(lines)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dismiss_and_next_expiry() {
let secs = Duration::from_secs;
let mut stack = Notifications::new().default_ttl(None);
let a = stack.push(Notification::info("a").ttl(secs(3)), secs(1));
stack.push(Notification::info("b"), secs(2));
assert_eq!(stack.next_expiry(), Some(secs(4)));
assert!(stack.dismiss(a));
assert!(!stack.dismiss(a));
assert_eq!(stack.next_expiry(), None);
assert_eq!(stack.expire(secs(1000)), 0, "no TTL, kept");
}
}