8.5 KiB · text History 2c2327e
//! The calendar tab's popup for switching built-in holiday calendars on and off,
//! and setting how the motd reminds of them

use crate::data::ReminderTarget;
use crate::do_tui::reminder_spans;
use crate::entities::calendar;
use crate::holidays::{self, about, CalendarDef, Group, Holiday, Kind, GROUPS};
use crate::tui_add::popup_area;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Layout};
use ratatui::prelude::{Line, Span, Style, Stylize};
use ratatui::style::palette::tailwind::{AMBER, CYAN, GREEN, SLATE, VIOLET, WHITE};
use ratatui::style::Color;
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame;
use std::io;
use std::process::{Command, Stdio};

enum Row {
    /// A group heading, which holds the group's motd defaults
    Group(&'static Group),
    Cal(&'static CalendarDef),
}

impl Row {
    fn target(&self) -> ReminderTarget {
        match self {
            Row::Group(g) => ReminderTarget::Group(g),
            Row::Cal(c) => ReminderTarget::Calendar(c),
        }
    }
}

/// A country's states only show while the country is on, to keep the list short
fn rows(enabled: &[String]) -> Vec<Row> {
    let on = |c: &CalendarDef| enabled.iter().any(|k| k == c.key);
    let mut rows = vec![];
    for g in &GROUPS {
        rows.push(Row::Group(g));
        for c in holidays::in_group(g) {
            rows.push(Row::Cal(c));
            if on(c) {
                rows.extend(holidays::regions(c.key).map(Row::Cal));
            }
        }
    }
    rows
}

/// The colour a calendar's holidays get in the month grid and the upcoming list
pub fn holiday_color(h: &Holiday) -> Color {
    match h.calendar.kind {
        Kind::Religious => VIOLET.c400,
        Kind::Other => AMBER.c300,
        Kind::Subscribed => GREEN.c400,
        _ => CYAN.c400,
    }
}

pub enum MenuAction {
    Continue,
    Close,
    Toggle(&'static CalendarDef),
    /// One of the reminder keys, on a calendar or a group
    Reminder(ReminderTarget, char),
}

#[derive(Default)]
pub struct CalendarMenu {
    state: ListState,
}

impl CalendarMenu {
    pub fn new() -> Self {
        let mut menu = CalendarMenu::default();
        // Row 0 is the countries heading; start on the first country
        menu.state.select(Some(1));
        menu
    }

    fn selected<'a>(&self, rows: &'a [Row]) -> Option<&'a Row> {
        rows.get(self.state.selected()?)
    }

    pub fn handle_key(&mut self, key: &KeyEvent, enabled: &[String]) -> MenuAction {
        let rows = rows(enabled);
        match key.code {
            KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Char('q') => {
                MenuAction::Close
            }
            KeyCode::Up | KeyCode::Char('k') => {
                self.state.scroll_up_by(1);
                MenuAction::Continue
            }
            KeyCode::Down | KeyCode::Char('j') => {
                self.state.scroll_down_by(1);
                MenuAction::Continue
            }
            KeyCode::Enter | KeyCode::Char(' ') => match self.selected(&rows) {
                Some(Row::Cal(c)) => MenuAction::Toggle(c),
                _ => MenuAction::Continue,
            },
            KeyCode::Char(k @ ('m' | '+' | '-' | '[' | ']' | 'x')) => match self.selected(&rows) {
                Some(r) => MenuAction::Reminder(r.target(), k),
                None => MenuAction::Continue,
            },
            _ => MenuAction::Continue,
        }
    }

    pub fn render(&mut self, f: &mut Frame, enabled: &[String], settings: &[calendar::Model]) {
        let rows = rows(enabled);
        // Keep the cursor in range when a country's states disappear
        if self.state.selected().is_none_or(|i| i >= rows.len()) {
            self.state.select(Some(rows.len().saturating_sub(1)));
        }
        let note = match self.selected(&rows) {
            Some(Row::Cal(c)) => c.note,
            Some(Row::Group(_)) => Some("motd defaults for the calendars below"),
            None => None,
        };
        let area = popup_area(f.area(), 84, rows.len() as u16 + 6);
        f.render_widget(Clear, area);
        let block = Block::bordered()
            .title(" built-in calendars ")
            .style(Style::default().fg(AMBER.c500));
        let inner = block.inner(area);
        f.render_widget(block, area);
        let [list_area, help_area] =
            Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).areas(inner);

        // "⏰ 7d · daily", dimmed where it comes from a default
        let spans = |t: ReminderTarget| {
            let r = t.reminder(settings);
            reminder_spans(r.delta, r.remind, r.default_delta, r.default_remind)
        };
        let items = rows
            .iter()
            .map(|r| match r {
                Row::Group(g) => {
                    let mut line =
                        Line::from(Span::styled(format!(" {}", g.name), SLATE.c400).bold());
                    for s in spans(r.target()) {
                        line.push_span(s);
                    }
                    ListItem::new(line)
                }
                Row::Cal(c) => {
                    let on = enabled.iter().any(|k| k == c.key);
                    let indent = if c.kind == Kind::Region { "    " } else { "" };
                    let (mark, color) = match (on, c.kind) {
                        (false, _) => ("☐", SLATE.c500),
                        (true, Kind::Religious) => ("✓", VIOLET.c400),
                        (true, Kind::Other) => ("✓", AMBER.c300),
                        (true, Kind::Subscribed) => ("✓", GREEN.c400),
                        (true, _) => ("✓", CYAN.c400),
                    };
                    let mut line = Line::from(vec![
                        Span::styled(format!("  {}{} ", indent, mark), color),
                        Span::styled(c.name, if on { WHITE } else { SLATE.c400 }),
                        Span::styled(format!("  {}", c.key), SLATE.c600),
                    ]);
                    if on {
                        for s in spans(r.target()) {
                            line.push_span(s);
                        }
                    }
                    ListItem::new(line)
                }
            })
            .collect::<Vec<_>>();
        let list = List::new(items).highlight_symbol(">>");
        f.render_stateful_widget(list, list_area, &mut self.state);

        let mut help = vec![];
        if let Some(n) = note {
            help.push(Line::styled(format!(" {}", n), SLATE.c400).italic());
        }
        help.push(Line::styled(
            " space: on/off · motd: +/- days ahead · m: mode · [/]: random% · x: use default · esc: close",
            SLATE.c500,
        ));
        f.render_widget(Paragraph::new(help).wrap(Wrap { trim: false }), help_area);
    }
}

/// The `i` popup: what a holiday is, and a link to read more
pub fn render_holiday_info(f: &mut Frame, h: &Holiday) {
    let info = about(h);
    let area = popup_area(f.area(), 72, 18);
    f.render_widget(Clear, area);
    let color = holiday_color(h);
    let mut lines = vec![
        Line::from(vec![
            Span::styled(h.date.format("%A %-d %B %Y").to_string(), WHITE),
            Span::styled(format!("  ·  {}", h.calendar.name), color),
        ]),
        Line::raw(""),
        Line::styled(info.blurb, WHITE),
        Line::raw(""),
    ];
    // A subscription's note is where it comes from, not a caveat
    if let Some(note) = h
        .calendar
        .note
        .filter(|_| h.calendar.kind != Kind::Subscribed)
    {
        lines.push(Line::styled(note, SLATE.c400).italic());
        lines.push(Line::raw(""));
    }
    match info.url.is_empty() {
        true => lines.push(Line::styled("any key: close", SLATE.c500)),
        false => {
            lines.push(Line::styled(info.url, CYAN.c300).underlined());
            lines.push(Line::styled(
                "o: open in browser · any other key: close",
                SLATE.c500,
            ));
        }
    }
    let popup = Paragraph::new(lines).wrap(Wrap { trim: true }).block(
        Block::bordered()
            .title(format!(" {} ", h.name))
            .style(Style::default().fg(color)),
    );
    f.render_widget(popup, area);
}

/// Open a link in the desktop's browser, without waiting for it
pub fn open_url(url: &str) -> io::Result<()> {
    let opener = match std::env::consts::OS {
        "macos" => "open",
        _ => "xdg-open",
    };
    Command::new(opener)
        .arg(url)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .map(|_| ())
}