aven 0.1.6

Local-first task manager CLI and sync server
Documentation
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap};

use super::truncate::truncate_chars;
use crate::tui::overlay::dialog_area;
use crate::tui::theme::{ACCENT, BG_ALT, FG, FG_MUTED};

pub(super) struct Dialog<'a> {
    title: &'a str,
    width: u16,
    height: u16,
    wrap: bool,
    right_title: Option<Line<'a>>,
}

impl<'a> Dialog<'a> {
    pub(super) fn new(title: &'a str, width: u16, height: u16) -> Self {
        Self {
            title,
            width,
            height,
            wrap: false,
            right_title: None,
        }
    }

    pub(super) fn wrap(mut self) -> Self {
        self.wrap = true;
        self
    }

    pub(super) fn right_title(mut self, title: Line<'a>) -> Self {
        self.right_title = Some(title);
        self
    }

    pub(super) fn area(&self, frame: &Frame) -> Rect {
        dialog_area(frame.area(), self.width, self.height)
    }

    pub(super) fn render_block(self, frame: &mut Frame) -> Rect {
        let area = self.area(frame);
        self.render_block_at(frame, area)
    }

    pub(super) fn render_block_at(self, frame: &mut Frame, area: Rect) -> Rect {
        frame.render_widget(Clear, area);
        let block = overlay_block(self.title, area.width)
            .title_top(right_edge_title(self.right_title).right_aligned());
        let inner = block.inner(area);
        frame.render_widget(block, area);
        inner
    }

    pub(super) fn render_text<'text>(self, frame: &mut Frame, text: impl Into<Text<'text>>) {
        let wrap = self.wrap;
        let inner = self.render_block(frame);
        let mut paragraph = Paragraph::new(text).style(Style::new().fg(FG).bg(BG_ALT));
        if wrap {
            paragraph = paragraph.wrap(Wrap { trim: false });
        }
        frame.render_widget(paragraph, inner);
    }
}

fn overlay_block(title: &str, width: u16) -> Block<'_> {
    Block::new()
        .title(edge_title(title, width))
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::new().fg(ACCENT))
        .padding(Padding::horizontal(1))
        .style(Style::new().bg(BG_ALT))
}

fn edge_title(title: &str, width: u16) -> Line<'_> {
    let title = truncate_chars(title, width.saturating_sub(5) as usize);
    Line::from(vec![
        Span::styled("", Style::new().fg(ACCENT)),
        Span::raw(title),
        Span::styled(" ", Style::new().fg(ACCENT)),
    ])
}

fn right_edge_title(title: Option<Line<'_>>) -> Line<'_> {
    let Some(mut title) = title else {
        return Line::from(Span::styled("", Style::new().fg(ACCENT)));
    };
    title
        .spans
        .push(Span::styled("", Style::new().fg(ACCENT)));
    title
}

pub(super) fn dialog_hint_line(items: &[(&str, &str)]) -> Line<'static> {
    let mut spans = Vec::new();
    for (index, (key, label)) in items.iter().enumerate() {
        if index > 0 {
            spans.push(Span::styled("  ", Style::new().fg(FG_MUTED)));
        }
        spans.push(Span::styled(
            key.to_string(),
            Style::new().fg(FG).add_modifier(Modifier::BOLD),
        ));
        spans.push(Span::styled(format!(" {label}"), Style::new().fg(FG_MUTED)));
    }
    Line::from(spans)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::theme::FG;

    #[test]
    fn dialog_hint_line_styles_keys() {
        let keys = styled_key_contents(dialog_hint_line(&[("Enter", "submit"), ("Esc", "cancel")]));
        assert_eq!(keys, vec!["Enter", "Esc"]);
    }

    #[test]
    fn dialog_truncates_title_to_border_width() {
        let backend = ratatui::backend::TestBackend::new(20, 5);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| Dialog::new("abcdef", 7, 3).render_text(frame, ""))
            .unwrap();
        let rendered = (0..terminal.backend().buffer().area.width)
            .map(|column| terminal.backend().buffer()[(column, 1)].symbol())
            .collect::<String>();

        assert!(rendered.contains("─ a"));
        assert!(!rendered.contains("abcdef"));
    }

    fn styled_key_contents(line: Line<'static>) -> Vec<String> {
        line.spans
            .iter()
            .filter(|span| span.style.fg == Some(FG))
            .map(|span| span.content.to_string())
            .collect()
    }
}