//! 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);
}
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
}
⭐
Log in to mark this repository.
14.0 KiB · text
History
2803012