basalt-tui 0.12.6

Basalt TUI application for Obsidian notes.
Documentation
use std::time::{Duration, Instant};

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style, Stylize},
    text::{Line, Span},
    widgets::{Block, BorderType, Clear, Paragraph, Widget},
};

use crate::{app::Message as AppMessage, config::Symbols};

pub const TOAST_WIDTH: u16 = 40;

#[derive(Clone, PartialEq, Debug)]
pub struct Toast {
    level: Option<ToastLevel>,
    pub(super) message: String,
    pub icon: String,
    created_at: Instant,
    duration: Duration,
    width: usize,
    pub border_type: BorderType,
}

impl Toast {
    pub fn new(message: &str, duration: Duration) -> Self {
        Self {
            message: message.to_string(),
            duration,
            ..Default::default()
        }
    }

    pub fn info(message: &str, duration: Duration) -> Self {
        Self {
            level: Some(ToastLevel::Info),
            ..Toast::new(message, duration)
        }
    }

    pub fn warn(message: &str, duration: Duration) -> Self {
        Self {
            level: Some(ToastLevel::Warning),
            ..Toast::new(message, duration)
        }
    }

    pub fn error(message: &str, duration: Duration) -> Self {
        Self {
            level: Some(ToastLevel::Error),
            ..Toast::new(message, duration)
        }
    }

    pub fn success(message: &str, duration: Duration) -> Self {
        Self {
            level: Some(ToastLevel::Success),
            ..Toast::new(message, duration)
        }
    }

    pub fn level_icon(&self, symbols: &Symbols) -> String {
        match &self.level {
            Some(ToastLevel::Success) => symbols.toast_success.clone(),
            Some(ToastLevel::Info) => symbols.toast_info.clone(),
            Some(ToastLevel::Error) => symbols.toast_error.clone(),
            Some(ToastLevel::Warning) => symbols.toast_warning.clone(),
            None => String::default(),
        }
    }

    pub fn is_expired(&self) -> bool {
        self.created_at.elapsed() >= self.duration
    }

    pub fn height(&self) -> u16 {
        let content_width = TOAST_WIDTH.saturating_sub(6) as usize;
        let wrapped = textwrap::wrap(&self.message, content_width);
        wrapped.len().max(1) as u16 + 2
    }
}

impl Widget for Toast {
    fn render(self, area: Rect, buf: &mut Buffer)
    where
        Self: Sized,
    {
        let height = self.height();
        let color = self.level.as_ref().map(|l| l.color()).unwrap_or_default();

        let block = Block::bordered()
            .border_type(self.border_type)
            .border_style(Style::new().fg(color));

        let toast_area = Rect {
            x: area.x,
            y: area.y,
            width: TOAST_WIDTH.min(area.width),
            height: height.min(area.height),
        };

        Clear.render(toast_area, buf);

        let content_width = TOAST_WIDTH.saturating_sub(6) as usize;
        let wrapped = textwrap::wrap(&self.message, content_width);

        let lines: Vec<Line> = wrapped
            .iter()
            .enumerate()
            .map(|(i, line)| {
                if i == 0 {
                    Line::from(vec![
                        Span::from(" "),
                        Span::from(self.icon.clone()).fg(color),
                        Span::from(" "),
                        Span::from(line.to_string()),
                    ])
                } else {
                    Line::from(format!("   {line}"))
                }
            })
            .collect();

        Paragraph::new(lines).block(block).render(toast_area, buf);
    }
}

impl Default for Toast {
    fn default() -> Self {
        Self {
            level: Option::default(),
            message: String::default(),
            icon: String::default(),
            created_at: Instant::now(),
            duration: Duration::default(),
            border_type: BorderType::default(),
            width: 30,
        }
    }
}

#[derive(Clone, PartialEq, Debug)]
pub enum ToastLevel {
    Success,
    Info,
    Warning,
    Error,
}

impl ToastLevel {
    pub fn icon(&self) -> &'static str {
        match self {
            ToastLevel::Success => "",
            ToastLevel::Info => "",
            ToastLevel::Error => "",
            ToastLevel::Warning => "",
        }
    }

    pub fn color(&self) -> Color {
        match self {
            ToastLevel::Success => Color::Green,
            ToastLevel::Info => Color::Blue,
            ToastLevel::Error => Color::Red,
            ToastLevel::Warning => Color::Yellow,
        }
    }
}

#[derive(Clone, PartialEq, Debug)]
pub enum Message {
    Create(Toast),
    Tick,
}

pub fn update<'a>(message: Message, state: &mut Vec<Toast>) -> Option<AppMessage<'a>> {
    match message {
        Message::Create(toast) => {
            state.push(toast);
        }
        Message::Tick => {
            state.retain(|toast| !toast.is_expired());
        }
    };
    None
}

#[cfg(test)]
mod tests {
    use std::{thread::sleep, time::Duration};

    use super::*;
    use insta::assert_snapshot;
    use ratatui::{backend::TestBackend, Terminal};

    use crate::toast::{update, Message, Toast};

    #[test]
    fn test_toast_update_expired() {
        let mut state = vec![];
        update(Message::Create(Toast::default()), &mut state);
        assert_eq!(state.len(), 1);
        sleep(Duration::from_millis(1));
        update(Message::Tick, &mut state);
        assert_eq!(state.len(), 0);
    }

    #[test]
    fn test_toast_update_not_expired() {
        let mut state = vec![];
        update(
            Message::Create(Toast::new("Toast B", Duration::from_secs(10))),
            &mut state,
        );
        update(Message::Tick, &mut state);
        assert_eq!(state.len(), 1);
    }

    #[test]
    fn test_toast_render() {
        let width = 50;
        let height = 3;
        let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();

        let tests: Vec<(&str, Toast)> = vec![
            ("info", Toast::info("File saved", Duration::from_secs(5))),
            (
                "error",
                Toast::error("Failed to save file", Duration::from_secs(5)),
            ),
            (
                "warning",
                Toast::warn("Unsaved changes", Duration::from_secs(5)),
            ),
            (
                "success",
                Toast::success("Operation complete", Duration::from_secs(5)),
            ),
            (
                "long_message",
                Toast::info(
                    "This is a really long message that should be truncated",
                    Duration::from_secs(5),
                ),
            ),
            (
                "no_level",
                Toast::new("Plain toast", Duration::from_secs(5)),
            ),
        ];

        tests.into_iter().for_each(|(name, mut toast)| {
            _ = terminal.clear();
            terminal
                .draw(|frame| {
                    toast.icon = toast.level_icon(&Symbols::unicode());
                    toast.render(frame.area(), frame.buffer_mut());
                })
                .unwrap();
            assert_snapshot!(name, terminal.backend());
        });
    }
}