14.0 KiB · text History d9460a8
//! The TUI's "add" popups: a person's tiers, traits, dates, contacts and relations,
//! and new tier and trait defaults.

use crate::data::{
    find_person, ContactOps, RelationOps, SigDateOps, TierDefaultOps, TierOps, TraitDefaultOps,
    TraitKind, TraitOps, COLORS,
};
use crate::entities::{ppl, tier, tier_defaults, trait_defaults};
use crate::PplError;
use chrono::NaiveDate;
use crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::prelude::{Line, Span, Style, Stylize};
use ratatui::style::palette::tailwind::{AMBER, ORANGE, SLATE, WHITE};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::Frame;
use sea_orm::DatabaseConnection;
use std::io;
use tui_input::backend::crossterm::EventHandler;
use tui_input::Input;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddKind {
    PersonTier,
    PersonTrait,
    PersonDate,
    PersonContact,
    PersonRelation,
    DefaultTier,
    DefaultTrait,
}

impl AddKind {
    /// The menu shown after pressing `a` on a person; keys match the detail view's toggles
    pub const PERSON_MENU: [(char, AddKind, &'static str); 5] = [
        ('i', AddKind::PersonTier, "tier"),
        ('r', AddKind::PersonTrait, "trait"),
        ('d', AddKind::PersonDate, "date"),
        ('c', AddKind::PersonContact, "contact"),
        ('l', AddKind::PersonRelation, "relation"),
    ];

    fn title(&self) -> &'static str {
        match self {
            AddKind::PersonTier => "add tier",
            AddKind::PersonTrait => "add trait",
            AddKind::PersonDate => "add date",
            AddKind::PersonContact => "add contact",
            AddKind::PersonRelation => "add relation",
            AddKind::DefaultTier => "new tier",
            AddKind::DefaultTrait => "new trait",
        }
    }

    /// (label, prefilled value)
    fn fields(&self) -> Vec<(&'static str, &'static str)> {
        match self {
            AddKind::PersonTier => vec![("tier", "")],
            AddKind::PersonTrait => vec![("key", ""), ("value", "")],
            AddKind::PersonDate => vec![
                ("event", ""),
                ("date (YYYY-MM-DD)", ""),
                ("remind (y/n)", "y"),
            ],
            AddKind::PersonContact => vec![("type", ""), ("value", ""), ("label (optional)", "")],
            AddKind::PersonRelation => vec![("person (name, nick or id)", ""), ("relation", "")],
            AddKind::DefaultTier => vec![
                ("name", ""),
                ("symbol", ""),
                ("color", ""),
                ("remind days ahead (optional)", ""),
            ],
            AddKind::DefaultTrait => vec![
                ("name", ""),
                ("symbol", ""),
                ("color", ""),
                ("kind (trait/date/contact)", "trait"),
            ],
        }
    }
}

/// Everything the forms check input against
pub struct AddContext<'a> {
    pub ppl: &'a [ppl::Model],
    pub tiers: &'a [tier::Model],
    pub tier_defaults: &'a [tier_defaults::Model],
    pub trait_defaults: &'a [trait_defaults::Model],
}

impl AddContext<'_> {
    fn trait_keys(&self, is_date: bool, is_contact: bool) -> Vec<&str> {
        self.trait_defaults
            .iter()
            .filter(|t| t.enabled && t.is_date == is_date && t.is_contact == is_contact)
            .map(|t| t.key.as_str())
            .collect()
    }

    /// Use the existing spelling when the key matches a default
    fn canonical_trait_key(&self, key: &str) -> String {
        self.trait_defaults
            .iter()
            .find(|t| t.key.eq_ignore_ascii_case(key))
            .map(|t| t.key.clone())
            .unwrap_or_else(|| key.to_string())
    }
}

pub struct AddForm {
    pub kind: AddKind,
    /// The person being added to; None for tier and trait defaults
    pub ppl_id: Option<i32>,
    fields: Vec<(&'static str, Input)>,
    focus: usize,
    hint: String,
}

pub enum FormAction {
    Continue,
    Cancel,
    Submit,
}

fn invalid(msg: String) -> PplError {
    io::Error::new(io::ErrorKind::InvalidInput, msg).into()
}

impl AddForm {
    pub fn new(kind: AddKind, ppl_id: Option<i32>, ctx: &AddContext) -> Self {
        let hint = match kind {
            AddKind::PersonTier => {
                let have = ctx
                    .tiers
                    .iter()
                    .filter(|t| Some(t.ppl_id) == ppl_id)
                    .map(|t| t.name.as_str())
                    .collect::<Vec<_>>();
                let options = ctx
                    .tier_defaults
                    .iter()
                    .filter(|t| t.enabled && !have.contains(&t.key.as_str()))
                    .map(|t| t.key.as_str())
                    .collect::<Vec<_>>();
                format!("tiers: {}", options.join(", "))
            }
            AddKind::PersonTrait => format!("known: {}", ctx.trait_keys(false, false).join(", ")),
            AddKind::PersonDate => format!("known: {}", ctx.trait_keys(true, false).join(", ")),
            AddKind::PersonContact => format!("known: {}", ctx.trait_keys(false, true).join(", ")),
            AddKind::PersonRelation => "e.g. sibling, partner, parent, friend".to_string(),
            AddKind::DefaultTier | AddKind::DefaultTrait => {
                format!("colors: {}", COLORS.join(", "))
            }
        };
        AddForm {
            kind,
            ppl_id,
            fields: kind
                .fields()
                .into_iter()
                .map(|(label, value)| (label, Input::new(value.to_string())))
                .collect(),
            focus: 0,
            hint,
        }
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> FormAction {
        match key.code {
            KeyCode::Esc => FormAction::Cancel,
            KeyCode::Enter => FormAction::Submit,
            KeyCode::Tab | KeyCode::Down => {
                self.focus = (self.focus + 1) % self.fields.len();
                FormAction::Continue
            }
            KeyCode::BackTab | KeyCode::Up => {
                self.focus = (self.focus + self.fields.len() - 1) % self.fields.len();
                FormAction::Continue
            }
            _ => {
                if let Some((_, input)) = self.fields.get_mut(self.focus) {
                    input.handle_event(&Event::Key(*key));
                }
                FormAction::Continue
            }
        }
    }

    fn value(&self, idx: usize) -> String {
        self.fields
            .get(idx)
            .map(|(_, i)| i.value().trim().to_string())
            .unwrap_or_default()
    }

    fn required(&self, idx: usize) -> Result<String, PplError> {
        let v = self.value(idx);
        match v.is_empty() {
            true => Err(invalid(format!(
                "{} is required",
                self.fields.get(idx).map(|(l, _)| *l).unwrap_or("field")
            ))),
            false => Ok(v),
        }
    }

    /// Validate and write; returns what to tell the user
    pub async fn submit(
        &self,
        db: &DatabaseConnection,
        ctx: &AddContext<'_>,
    ) -> Result<String, PplError> {
        let person = || {
            self.ppl_id
                .ok_or_else(|| invalid("no person selected".to_string()))
        };
        match self.kind {
            AddKind::PersonTier => {
                let ppl_id = person()?;
                let name = self.required(0)?;
                let Some(td) = ctx
                    .tier_defaults
                    .iter()
                    .find(|t| t.key.eq_ignore_ascii_case(&name))
                else {
                    return Err(invalid(format!("no tier '{}'; {}", name, self.hint)));
                };
                if ctx
                    .tiers
                    .iter()
                    .any(|t| t.ppl_id == ppl_id && t.name == td.key)
                {
                    return Err(invalid(format!("already in {}", td.key)));
                }
                TierOps::create(db, ppl_id, td.key.clone(), None).await?;
                Ok(format!("added to {}", td.key))
            }
            AddKind::PersonTrait => {
                let ppl_id = person()?;
                let key = ctx.canonical_trait_key(&self.required(0)?);
                if let Some(td) = ctx.trait_defaults.iter().find(|t| t.key == key) {
                    if td.is_date {
                        return Err(invalid(format!("{} is a date, add it with d", key)));
                    }
                    if td.is_contact {
                        return Err(invalid(format!("{} is a contact, add it with c", key)));
                    }
                }
                TraitOps::create(db, ppl_id, key.clone(), self.required(1)?, false).await?;
                Ok(format!("added {}", key))
            }
            AddKind::PersonDate => {
                let ppl_id = person()?;
                let event = ctx.canonical_trait_key(&self.required(0)?);
                let date = NaiveDate::parse_from_str(&self.required(1)?, "%Y-%m-%d")
                    .map_err(|_| invalid("date must look like YYYY-MM-DD".to_string()))?;
                let remind = match self.value(2).to_lowercase().as_str() {
                    "" | "y" | "yes" | "t" | "true" => true,
                    "n" | "no" | "f" | "false" => false,
                    other => {
                        return Err(invalid(format!("remind should be y or n, got '{}'", other)))
                    }
                };
                SigDateOps::create(db, ppl_id, date, event.clone(), remind).await?;
                Ok(format!("added {} {}", event, date))
            }
            AddKind::PersonContact => {
                let ppl_id = person()?;
                let typ = ctx.canonical_trait_key(&self.required(0)?);
                ContactOps::create(db, ppl_id, typ.clone(), self.value(2), self.required(1)?)
                    .await?;
                Ok(format!("added {}", typ))
            }
            AddKind::PersonRelation => {
                let ppl_id = person()?;
                let other = find_person(ctx.ppl, &self.required(0)?)?;
                if other.id == ppl_id {
                    return Err(invalid("pick someone else".to_string()));
                }
                let typ = self.required(1)?;
                RelationOps::create(db, ppl_id, other.id, typ.clone(), false, None, None).await?;
                Ok(format!(
                    "added {} {}",
                    typ,
                    other.nick.as_deref().unwrap_or(other.name.trim())
                ))
            }
            AddKind::DefaultTier => {
                let days = match self.value(3) {
                    d if d.is_empty() => None,
                    d => Some(d.parse::<u32>().map_err(|_| {
                        invalid(format!("remind days should be a number, got '{}'", d))
                    })?),
                };
                let key =
                    TierDefaultOps::add(db, &self.value(0), &self.value(1), &self.value(2), days)
                        .await?;
                Ok(format!("added tier {}", key))
            }
            AddKind::DefaultTrait => {
                let kind = self.value(3).parse::<TraitKind>().map_err(invalid)?;
                let key =
                    TraitDefaultOps::add(db, &self.value(0), &self.value(1), &self.value(2), kind)
                        .await?;
                Ok(format!("added trait {}", key))
            }
        }
    }

    pub fn render(&self, f: &mut Frame, subtitle: &str) {
        let height = self.fields.len() as u16 * 3 + 5;
        let area = popup_area(f.area(), 64, height);
        f.render_widget(Clear, area);
        let block = Block::bordered()
            .title(format!(" {}{} ", self.kind.title(), subtitle))
            .title_bottom(" tab next · enter save · esc cancel ")
            .style(Style::default().fg(ORANGE.c500));
        let inner = block.inner(area);
        f.render_widget(block, area);

        let mut rows = vec![Constraint::Length(3); self.fields.len()];
        rows.push(Constraint::Min(2));
        let rows = Layout::vertical(rows).split(inner);
        for (idx, (label, input)) in self.fields.iter().enumerate() {
            let focused = idx == self.focus;
            let style = match focused {
                true => Style::default().fg(WHITE),
                false => Style::default().fg(SLATE.c500),
            };
            let field = Paragraph::new(input.value())
                .style(style)
                .block(Block::default().borders(Borders::ALL).title(*label));
            f.render_widget(field, rows[idx]);
            if focused {
                // Put the terminal cursor at the end of what's been typed
                let x = rows[idx].x + 1 + input.visual_cursor() as u16;
                f.set_cursor_position((
                    x.min(rows[idx].right().saturating_sub(2)),
                    rows[idx].y + 1,
                ));
            }
        }
        let hint =
            Paragraph::new(Line::styled(self.hint.clone(), SLATE.c400)).wrap(Wrap { trim: true });
        f.render_widget(hint, rows[self.fields.len()]);
    }
}

/// The "add what?" menu for a person
pub fn render_person_menu(f: &mut Frame, subtitle: &str) {
    let area = popup_area(f.area(), 40, AddKind::PERSON_MENU.len() as u16 + 4);
    f.render_widget(Clear, area);
    let mut lines = AddKind::PERSON_MENU
        .iter()
        .map(|(key, _, label)| {
            Line::from(vec![
                Span::styled(format!(" {} ", key), Style::default().fg(WHITE).bold()),
                Span::raw(*label),
            ])
        })
        .collect::<Vec<_>>();
    lines.push(Line::styled(" esc cancel", SLATE.c500));
    let menu = Paragraph::new(lines).block(
        Block::bordered()
            .title(format!(" add to{} ", subtitle))
            .style(Style::default().fg(AMBER.c500)),
    );
    f.render_widget(menu, area);
}

pub fn popup_area(area: Rect, width: u16, height: u16) -> Rect {
    let [area] = Layout::vertical([Constraint::Length(height.min(area.height))])
        .flex(Flex::Center)
        .areas(area);
    let [area] = Layout::horizontal([Constraint::Length(width.min(area.width))])
        .flex(Flex::Center)
        .areas(area);
    area
}