tui: add tiers, traits, dates, contacts and relations

2803012 · Gabriel Morell · 2026-10-15 20:59

8 files +815 -24
Message
{commit_body(@commit)}

Files changed

modified README.md
+16 −3
@@ -62,6 +62,9 @@ Press `e` to edit the selected person; the `👤` row at the top edits their nam
62 62 (the nickname is what the list, calendar and motd show). From the CLI: `ppl edit Anjali --nick Anji`,
63 63 `--name` to rename, `--nick ""` to clear.
64 64
65 +Press `a` to add to the selected person: `i` tier, `r` trait, `d` date, `c` contact, `l` relation.
66 +While editing a person, `Delete` (or backspace) twice removes the selected row.
67 +
65 68 ![default.png](screenshots/default.png)
66 69
67 70
@@ -73,13 +76,14 @@ Upcoming Anniversaries
73 76
74 77 #### Tier View
75 78
76 −Add and Modify Tiers and Defaults
79 +Add and Modify Tiers and Defaults. `a` creates a new tier.
77 80
78 81 ![tiers.png](screenshots/tiers.png)
79 82
80 83 #### Trait View
81 84
82 −Add and Modify Traits and Defaults
85 +Add and Modify Traits and Defaults. `a` creates a new trait; its kind is `trait`, `date`
86 +(shows up in the motd and calendar) or `contact`.
83 87
84 88 ![img.png](screenshots/traits.png)
85 89
@@ -89,4 +93,13 @@ Add and Modify Traits and Defaults
89 93
90 94 ### Tiers & Traits
91 95
92 −`ppl tiers` and `ppl traits` list all the tiers and traits in the database
96 +`ppl tiers` and `ppl traits` list all the tiers and traits in the database.
97 +
98 +```
99 +ppl tiers add Neighbors --symbol 🏡 --color teal --days 7 # new tier
100 +ppl tiers add Neighbors -p Anji # put someone in a tier
101 +ppl traits add pronouns --symbol 🏳️ --color violet # new trait
102 +ppl traits add namesday --kind date # new kind of date
103 +```
104 +
105 +Colors: red, gold, amber, green, teal, cyan, blue, violet, pink, slate.
modified src/data.rs
+129 −1
@@ -1,5 +1,5 @@
1 1 use crate::color::colo;
2 −use crate::do_tui::Editable;
2 +use crate::do_tui::{Editable, Editablez};
3 3 use crate::entities::ppl::Model;
4 4 use crate::entities::prelude::Tier;
5 5 use crate::entities::prelude::Traits;
@@ -714,6 +714,67 @@ impl FromStr for MotdRemindEnum {
714 714 }
715 715 }
716 716
717 +/// Color names the TUI knows how to draw (see `do_init::lcolor`)
718 +pub const COLORS: &[&str] = &[
719 + "red", "gold", "amber", "green", "teal", "cyan", "blue", "violet", "pink", "slate",
720 +];
721 +
722 +fn invalid(msg: String) -> PplError {
723 + io::Error::new(io::ErrorKind::InvalidInput, msg).into()
724 +}
725 +
726 +/// Empty is fine (no color); otherwise it has to be one of COLORS
727 +fn check_color(color: &str) -> Result<String, PplError> {
728 + let color = color.trim().to_lowercase();
729 + if color.is_empty() || COLORS.contains(&color.as_str()) {
730 + Ok(color)
731 + } else {
732 + Err(invalid(format!(
733 + "unknown color '{}', expected one of: {}",
734 + color,
735 + COLORS.join(", ")
736 + )))
737 + }
738 +}
739 +
740 +/// What a trait default describes: a plain trait, a significant date, or a way to contact someone
741 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
742 +pub enum TraitKind {
743 + #[default]
744 + Trait,
745 + Date,
746 + Contact,
747 +}
748 +
749 +impl FromStr for TraitKind {
750 + type Err = String;
751 +
752 + fn from_str(s: &str) -> Result<Self, Self::Err> {
753 + match s.trim().to_lowercase().as_str() {
754 + "" | "trait" => Ok(TraitKind::Trait),
755 + "date" => Ok(TraitKind::Date),
756 + "contact" => Ok(TraitKind::Contact),
757 + other => Err(format!(
758 + "unknown kind '{}', expected one of: trait, date, contact",
759 + other
760 + )),
761 + }
762 + }
763 +}
764 +
765 +/// Remove the row behind a TUI detail line. People themselves aren't deleted this way.
766 +pub async fn delete_editable(db: &DatabaseConnection, editable: &Editable) -> Result<(), PplError> {
767 + match editable.tgt {
768 + Editablez::Ppl => return Err(invalid("people can't be removed from here".to_string())),
769 + Editablez::Trait => Traits::delete_by_id(editable.id).exec(db).await?,
770 + Editablez::Tier => Tier::delete_by_id(editable.id).exec(db).await?,
771 + Editablez::Contact => Contact::delete_by_id(editable.id).exec(db).await?,
772 + Editablez::Relation => Relation::delete_by_id(editable.id).exec(db).await?,
773 + Editablez::SigDate => SigDate::delete_by_id(editable.id).exec(db).await?,
774 + };
775 + Ok(())
776 +}
777 +
717 778 pub struct TierDefaultOps {}
718 779
719 780 impl TierDefaultOps {
@@ -755,6 +816,39 @@ impl TierDefaultOps {
755 816 TierDefaults::find().all(db).await
756 817 }
757 818
819 + /// Add a user-defined tier, checking the key is new and the color is known
820 + pub async fn add(
821 + db: &DatabaseConnection,
822 + key: &str,
823 + symbol: &str,
824 + color: &str,
825 + sig_date_delta: Option<u32>,
826 + ) -> Result<String, PplError> {
827 + let key = key.trim();
828 + if key.is_empty() {
829 + return Err(invalid("tier needs a name".to_string()));
830 + }
831 + if let Some(t) = Self::list(db)
832 + .await?
833 + .iter()
834 + .find(|t| t.key.eq_ignore_ascii_case(key))
835 + {
836 + return Err(invalid(format!("tier '{}' already exists", t.key)));
837 + }
838 + let color = check_color(color)?;
839 + Self::create(
840 + db,
841 + key.to_string(),
842 + false,
843 + true,
844 + color,
845 + symbol.trim().to_string(),
846 + sig_date_delta,
847 + )
848 + .await?;
849 + Ok(key.to_string())
850 + }
851 +
758 852 /// Set how many days ahead the motd starts reminding for this tier's dates; None clears it.
759 853 pub async fn set_sig_date_delta(
760 854 db: &DatabaseConnection,
@@ -836,4 +930,38 @@ impl TraitDefaultOps {
836 930 pub async fn list(db: &DatabaseConnection) -> Result<Vec<trait_defaults::Model>, DbErr> {
837 931 TraitDefaults::find().all(db).await
838 932 }
933 +
934 + /// Add a user-defined trait, checking the key is new and the color is known
935 + pub async fn add(
936 + db: &DatabaseConnection,
937 + key: &str,
938 + symbol: &str,
939 + color: &str,
940 + kind: TraitKind,
941 + ) -> Result<String, PplError> {
942 + let key = key.trim();
943 + if key.is_empty() {
944 + return Err(invalid("trait needs a name".to_string()));
945 + }
946 + if let Some(t) = Self::list(db)
947 + .await?
948 + .iter()
949 + .find(|t| t.key.eq_ignore_ascii_case(key))
950 + {
951 + return Err(invalid(format!("trait '{}' already exists", t.key)));
952 + }
953 + let color = check_color(color)?;
954 + Self::create(
955 + db,
956 + key.to_string(),
957 + color,
958 + symbol.trim().to_string(),
959 + false,
960 + true,
961 + kind == TraitKind::Date,
962 + kind == TraitKind::Contact,
963 + )
964 + .await?;
965 + Ok(key.to_string())
966 + }
839 967 }
modified src/do_tiers.rs
+78 −3
@@ -24,6 +24,18 @@ pub enum TierAction {
24 24 Remind { tier: String, mode: MotdRemindEnum },
25 25 /// Remove the reminder window and remind mode for this tier
26 26 Clear { tier: String },
27 + /// Create a new tier, or with --person put that person in an existing one
28 + Add {
29 + tier: String,
30 + #[arg(long)]
31 + symbol: Option<String>,
32 + /// One of: red, gold, amber, green, teal, cyan, blue, violet, pink, slate
33 + #[arg(long)]
34 + color: Option<String>,
35 + /// Remind this many days ahead of dates for ppl in this tier
36 + #[arg(long)]
37 + days: Option<u32>,
38 + },
27 39 }
28 40
29 41 /// Which row gets the change: a tier default, or one person's tier
@@ -110,13 +122,31 @@ pub async fn do_tiers(args: &TierArgs, db: &DatabaseConnection) -> Result<(), Pp
110 122 list(&defaults, &person_tiers, person.as_ref());
111 123 return Ok(());
112 124 }
125 + Some(TierAction::Add {
126 + tier,
127 + symbol,
128 + color,
129 + days,
130 + }) => {
131 + return add(
132 + db,
133 + &defaults,
134 + &person_tiers,
135 + person.as_ref(),
136 + tier,
137 + symbol,
138 + color,
139 + *days,
140 + )
141 + .await;
142 + }
113 143 Some(a) => a,
114 144 };
115 145 let tier_name = match action {
116 146 TierAction::Set { tier, .. }
117 147 | TierAction::Remind { tier, .. }
118 148 | TierAction::Clear { tier } => tier,
119 − TierAction::List => unreachable!(),
149 + TierAction::List | TierAction::Add { .. } => unreachable!(),
120 150 };
121 151
122 152 // Who we're changing, for the confirmation message
@@ -164,8 +194,50 @@ pub async fn do_tiers(args: &TierArgs, db: &DatabaseConnection) -> Result<(), Pp
164 194 None => println!("{}: reminder settings cleared", label),
165 195 }
166 196 }
167 − TierAction::List => unreachable!(),
197 + TierAction::List | TierAction::Add { .. } => unreachable!(),
198 + }
199 + Ok(())
200 +}
201 +
202 +async fn add(
203 + db: &DatabaseConnection,
204 + defaults: &[tier_defaults::Model],
205 + person_tiers: &[tier::Model],
206 + person: Option<&ppl::Model>,
207 + tier: &str,
208 + symbol: &Option<String>,
209 + color: &Option<String>,
210 + days: Option<u32>,
211 +) -> Result<(), PplError> {
212 + let Some(p) = person else {
213 + let key = TierDefaultOps::add(
214 + db,
215 + tier,
216 + symbol.as_deref().unwrap_or(""),
217 + color.as_deref().unwrap_or(""),
218 + days,
219 + )
220 + .await?;
221 + println!("added tier {}", key);
222 + return Ok(());
223 + };
224 + if symbol.is_some() || color.is_some() || days.is_some() {
225 + return Err(io::Error::new(
226 + io::ErrorKind::InvalidInput,
227 + "--symbol, --color and --days describe a new tier; leave out --person to create one",
228 + )
229 + .into());
168 230 }
231 + let d = find_default(defaults, tier)?;
232 + if person_tiers
233 + .iter()
234 + .any(|t| t.ppl_id == p.id && t.name == d.key)
235 + {
236 + println!("{} is already in {}", p.name.trim(), d.key);
237 + return Ok(());
238 + }
239 + TierOps::create(db, p.id, d.key.clone(), None).await?;
240 + println!("{} added to {}", p.name.trim(), d.key);
169 241 Ok(())
170 242 }
171 243
@@ -209,7 +281,10 @@ fn list(
209 281 let window = format!("{}{}", window, mark(t.sig_date_delta.is_some()));
210 282 println!(
211 283 "{} {:<16} {:<20} {}{}",
212 − t.symbol.as_deref().unwrap_or(" "),
284 + t.symbol
285 + .as_deref()
286 + .or(d.and_then(|d| d.symbol.as_deref()))
287 + .unwrap_or(" "),
213 288 t.name,
214 289 window,
215 290 mode,
added src/do_traits.rs
+66 −0
@@ -0,0 +1,66 @@
1 +use crate::data::{TraitDefaultOps, TraitKind};
2 +use crate::PplError;
3 +use clap::{Args, Subcommand};
4 +use sea_orm::DatabaseConnection;
5 +
6 +#[derive(Args, Debug)]
7 +pub struct TraitArgs {
8 + #[command(subcommand)]
9 + pub action: Option<TraitAction>,
10 +}
11 +
12 +#[derive(Subcommand, Debug)]
13 +pub enum TraitAction {
14 + /// List the traits ppl can have
15 + List,
16 + /// Create a new trait
17 + Add {
18 + name: String,
19 + #[arg(long)]
20 + symbol: Option<String>,
21 + /// One of: red, gold, amber, green, teal, cyan, blue, violet, pink, slate
22 + #[arg(long)]
23 + color: Option<String>,
24 + /// trait (default), date (shows in motd and the calendar) or contact
25 + #[arg(long, default_value = "trait")]
26 + kind: TraitKind,
27 + },
28 +}
29 +
30 +pub async fn do_traits(args: &TraitArgs, db: &DatabaseConnection) -> Result<(), PplError> {
31 + match &args.action {
32 + None | Some(TraitAction::List) => {
33 + for t in TraitDefaultOps::list(db).await? {
34 + let kind = match (t.is_date, t.is_contact) {
35 + (true, _) => "date",
36 + (_, true) => "contact",
37 + _ => "trait",
38 + };
39 + println!(
40 + "{} {:<16} {:<8} {}",
41 + t.symbol,
42 + t.key,
43 + kind,
44 + if t.enabled { "" } else { "(disabled)" }
45 + );
46 + }
47 + }
48 + Some(TraitAction::Add {
49 + name,
50 + symbol,
51 + color,
52 + kind,
53 + }) => {
54 + let key = TraitDefaultOps::add(
55 + db,
56 + name,
57 + symbol.as_deref().unwrap_or(""),
58 + color.as_deref().unwrap_or(""),
59 + *kind,
60 + )
61 + .await?;
62 + println!("added trait {}", key);
63 + }
64 + }
65 + Ok(())
66 +}
modified src/do_tui.rs
+140 −10
@@ -1,12 +1,13 @@
1 1 use crate::data::{
2 − anniversary_in, ContactOps, MotdRemindEnum, PplOps, RelationOps, SigDateOps, TierDefaultOps,
3 − TierOps, TraitDefaultOps, TraitOps,
2 + anniversary_in, delete_editable, ContactOps, MotdRemindEnum, PplOps, RelationOps, SigDateOps,
3 + TierDefaultOps, TierOps, TraitDefaultOps, TraitOps,
4 4 };
5 5 use crate::do_init::lcolor;
6 6 use crate::entities::ppl::Model;
7 7 use crate::entities::{
8 8 contact, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
9 9 };
10 +use crate::tui_add::{render_person_menu, AddContext, AddForm, AddKind, FormAction};
10 11 use crate::PplError;
11 12 use chrono::{Datelike, Local, NaiveDate};
12 13 use crossterm::event;
@@ -57,6 +58,11 @@ struct Tui {
57 58 def_trait_state: ListState,
58 59 /// Shown in place of the welcome line until the next key press, e.g. a failed save
59 60 status: Option<String>,
61 + /// The "add what?" menu for the selected person is open
62 + add_menu: bool,
63 + add_form: Option<AddForm>,
64 + /// The detail row Delete was pressed on once; a second press removes it
65 + pending_delete: Option<(Editablez, i32)>,
60 66 }
61 67
62 68 #[derive(Debug, PartialEq, Sequence, Clone)]
@@ -67,7 +73,7 @@ enum Tabs {
67 73 TraitSettings,
68 74 }
69 75
70 −#[derive(Clone, Debug)]
76 +#[derive(Clone, Debug, PartialEq)]
71 77 pub enum Editablez {
72 78 Ppl,
73 79 Trait,
@@ -79,7 +85,7 @@ pub enum Editablez {
79 85
80 86 #[derive(Clone, Debug)]
81 87 pub struct Editable {
82 − tgt: Editablez,
88 + pub tgt: Editablez,
83 89 pub id: i32,
84 90 pub first: String,
85 91 pub second: Option<String>,
@@ -105,6 +111,21 @@ fn get_key_in_tier(key: String, tiers: &[tier_defaults::Model]) -> bool {
105 111 }
106 112
107 113 impl Tui {
114 + fn add_ctx(&self) -> AddContext<'_> {
115 + AddContext {
116 + ppl: &self.ppl_list,
117 + tiers: &self.tier_list,
118 + tier_defaults: &self.def_tier_list,
119 + trait_defaults: &self.def_trait_list,
120 + }
121 + }
122 +
123 + fn selected_person(&self) -> Option<&ppl::Model> {
124 + self.ppl_state
125 + .selected()
126 + .and_then(|idx| self.ppl_list.get(idx))
127 + }
128 +
108 129 async fn reload(&mut self, db: &DatabaseConnection) -> Result<(), PplError> {
109 130 self.ppl_list = PplOps::list(db).await?;
110 131 self.def_trait_list = TraitDefaultOps::list(db).await?;
@@ -151,6 +172,9 @@ impl Default for Tui {
151 172 def_trait_list: vec![],
152 173 def_trait_state: ListState::default(),
153 174 status: None,
175 + add_menu: false,
176 + add_form: None,
177 + pending_delete: None,
154 178 }
155 179 }
156 180 }
@@ -183,10 +207,44 @@ pub async fn run_tui(
183 207 loop {
184 208 let _ = terminal.draw(|f| render(f, &mut app));
185 209 let ev = event::read()?;
210 + let mut confirm_delete = None;
186 211 if matches!(ev, Event::Key(k) if k.kind == KeyEventKind::Press) {
187 212 app.status = None;
213 + confirm_delete = app.pending_delete.take();
188 214 }
189 215 match ev {
216 + Event::Key(key_event) if key_event.kind == KeyEventKind::Press && app.add_menu => {
217 + app.add_menu = false;
218 + let kind = AddKind::PERSON_MENU
219 + .iter()
220 + .find(|(c, _, _)| key_event.code == KeyCode::Char(*c))
221 + .map(|(_, kind, _)| *kind);
222 + if let (Some(kind), Some(p)) = (kind, app.selected_person()) {
223 + app.add_form = Some(AddForm::new(kind, Some(p.id), &app.add_ctx()));
224 + }
225 + }
226 + Event::Key(key_event)
227 + if key_event.kind == KeyEventKind::Press && app.add_form.is_some() =>
228 + {
229 + let Some(mut form) = app.add_form.take() else {
230 + continue;
231 + };
232 + match form.handle_key(&key_event) {
233 + FormAction::Continue => app.add_form = Some(form),
234 + FormAction::Cancel => {}
235 + FormAction::Submit => match form.submit(&db, &app.add_ctx()).await {
236 + Ok(msg) => {
237 + app.status = Some(format!("✓ {}", msg));
238 + app.reload(&db).await?;
239 + }
240 + // Keep the form open so the input can be fixed
241 + Err(err) => {
242 + app.status = Some(format!("⚠ {}", err));
243 + app.add_form = Some(form);
244 + }
245 + },
246 + }
247 + }
190 248 Event::Key(key_event)
191 249 if key_event.kind == KeyEventKind::Press
192 250 && app.current_tab == Tabs::TierSettings
@@ -345,7 +403,7 @@ pub async fn run_tui(
345 403 };
346 404 // Keep the fields open so the input can be fixed
347 405 if let Err(err) = saved {
348 − app.status = Some(format!("couldn't save: {}", err));
406 + app.status = Some(format!("⚠ couldn't save: {}", err));
349 407 continue;
350 408 }
351 409 app.reload(&db).await?;
@@ -353,6 +411,50 @@ pub async fn run_tui(
353 411 app.ppl_field_editing = !app.ppl_field_editing;
354 412 }
355 413 }
414 + KeyCode::Char('a') if !app.ppl_field_editing => match app.current_tab {
415 + Tabs::Ppl if app.selected_person().is_some() => app.add_menu = true,
416 + Tabs::TierSettings => {
417 + app.add_form =
418 + Some(AddForm::new(AddKind::DefaultTier, None, &app.add_ctx()))
419 + }
420 + Tabs::TraitSettings => {
421 + app.add_form =
422 + Some(AddForm::new(AddKind::DefaultTrait, None, &app.add_ctx()))
423 + }
424 + _ => {}
425 + },
426 + KeyCode::Delete | KeyCode::Backspace
427 + if app.current_tab == Tabs::Ppl
428 + && app.ppl_editing
429 + && !app.ppl_field_editing =>
430 + {
431 + let Some(e) = app
432 + .ppl_detail_state
433 + .selected()
434 + .and_then(|idx| app.ppl_editables.get(idx))
435 + .cloned()
436 + else {
437 + continue;
438 + };
439 + if confirm_delete == Some((e.tgt.clone(), e.id)) {
440 + match delete_editable(&db, &e).await {
441 + Ok(()) => {
442 + app.status = Some(format!("✓ removed {}", e.first));
443 + app.reload(&db).await?;
444 + }
445 + Err(err) => app.status = Some(format!("⚠ {}", err)),
446 + }
447 + } else if e.tgt == Editablez::Ppl {
448 + app.status = Some("⚠ people can't be removed from here".to_string());
449 + } else {
450 + app.status = Some(format!(
451 + "⚠ press Delete again to remove {} {}",
452 + e.first,
453 + e.second.clone().unwrap_or_default()
454 + ));
455 + app.pending_delete = Some((e.tgt.clone(), e.id));
456 + }
457 + }
356 458 KeyCode::Char(c @ ('m' | '+' | '-' | '[' | ']' | 'x'))
357 459 if !app.ppl_field_editing =>
358 460 {
@@ -591,7 +693,10 @@ fn render(f: &mut Frame, app: &mut Tui) {
591 693 Span::raw(" :)"),
592 694 ];
593 695 let textw = match &app.status {
594 − Some(status) => Text::from(Line::styled(format!("⚠ {}", status), ORANGE.c400)),
696 + Some(status) if status.starts_with('✓') => {
697 + Text::from(Line::styled(status.clone(), GREEN.c400))
698 + }
699 + Some(status) => Text::from(Line::styled(status.clone(), ORANGE.c400)),
595 700 None => Text::from(Line::from(msgw)).style(Style::default()),
596 701 };
597 702 let welcome = Paragraph::new(textw);
@@ -607,11 +712,13 @@ fn render(f: &mut Frame, app: &mut Tui) {
607 712 Style::default().add_modifier(Modifier::BOLD).fg(WHITE),
608 713 ),
609 714 Span::raw(" to modify a ppl entry. "),
715 + Span::styled("a", Style::default().add_modifier(Modifier::BOLD).fg(WHITE)),
716 + Span::raw(" to add one. "),
610 717 Span::styled(
611 − "Shift+Enter",
718 + "Delete",
612 719 Style::default().add_modifier(Modifier::BOLD).fg(WHITE),
613 720 ),
614 − Span::raw(" to add a ppl entry"),
721 + Span::raw(" twice to remove one"),
615 722 ];
616 723 if reminder_target(app).is_some() {
617 724 msgw.push(Span::styled(
@@ -703,7 +810,7 @@ fn render(f: &mut Frame, app: &mut Tui) {
703 810 }
704 811
705 812 msgw.push(Span::raw(
706 − " to toggle Contacts/Dates/tRaits/tIers/reLations",
813 + " to toggle Contacts/Dates/tRaits/tIers/reLations · e edit · a add",
707 814 ));
708 815
709 816 let textw = Text::from(Line::from(msgw)).style(Style::default());
@@ -1354,7 +1461,7 @@ fn render(f: &mut Frame, app: &mut Tui) {
1354 1461 ),
1355 1462 ]),
1356 1463 false => Line::styled(
1357 − "enter/+/-: days · m: mode · [/]: random% · x: clear · tab: next tab · esc: quit",
1464 + "a: new tier · enter/+/-: days · m: mode · [/]: random% · x: clear · tab: next tab · esc: quit",
1358 1465 SLATE.c500,
1359 1466 ),
1360 1467 };
@@ -1370,8 +1477,31 @@ fn render(f: &mut Frame, app: &mut Tui) {
1370 1477 .direction(ListDirection::TopToBottom);
1371 1478
1372 1479 f.render_stateful_widget(ppl, chunks[1], &mut app.def_trait_state);
1480 + f.render_widget(
1481 + Paragraph::new(Line::styled(
1482 + "a: new trait · tab: next tab · esc: quit",
1483 + SLATE.c500,
1484 + )),
1485 + chunks[2],
1486 + );
1373 1487 }
1374 1488 }
1489 +
1490 + // Popups go on top of whichever tab is showing
1491 + let person = app
1492 + .selected_person()
1493 + .map(|p| format!(" {}", p.nick.as_deref().unwrap_or(p.name.trim())))
1494 + .unwrap_or_default();
1495 + if app.add_menu {
1496 + render_person_menu(f, &person);
1497 + }
1498 + if let Some(form) = &app.add_form {
1499 + let subtitle = match form.ppl_id {
1500 + Some(_) => format!(" ·{}", person),
1501 + None => String::new(),
1502 + };
1503 + form.render(f, &subtitle);
1504 + }
1375 1505 }
1376 1506
1377 1507 struct PplAndProps {
modified src/main.rs
+7 −7
@@ -15,15 +15,18 @@ mod do_edit;
15 15 mod do_init;
16 16 mod do_shell;
17 17 mod do_tiers;
18 +mod do_traits;
18 19 mod do_tui;
19 20 mod entities;
20 21 mod migrator;
22 +mod tui_add;
21 23
22 24 use crate::data::{anniversary_in, MotdRemindEnum};
23 25 use crate::do_add::{do_add, AddArgs};
24 26 use crate::do_edit::{do_edit, EditArgs};
25 27 use crate::do_shell::{do_shell, ShellArgs};
26 28 use crate::do_tiers::{do_tiers, TierArgs};
29 +use crate::do_traits::{do_traits, TraitArgs};
27 30 use crate::entities::ppl::Column::Me;
28 31 use entities::prelude::*;
29 32
@@ -107,8 +110,8 @@ enum Commands {
107 110 Show,
108 111 /// Tiers and their motd reminder windows
109 112 Tiers(TierArgs),
110 − /// Traits
111 − Traits,
113 + /// Traits ppl can have (dates and contacts too)
114 + Traits(TraitArgs),
112 115 /// Stats
113 116 Stats,
114 117 }
@@ -190,12 +193,9 @@ async fn main() -> Result<(), PplError> {
190 193 info!("pls run init");
191 194 }
192 195 },
193 − Some(Commands::Traits) => match is_init {
196 + Some(Commands::Traits(args)) => match is_init {
194 197 true => {
195 − let traits = TraitDefaults::find().all(&db).await?;
196 − for t in traits {
197 − println!("{:?}", t)
198 − }
198 + do_traits(args, &db).await?;
199 199 }
200 200 false => {
201 201 info!("pls run init");
added src/tui_add.rs
+379 −0
@@ -0,0 +1,379 @@
1 +//! The TUI's "add" popups: a person's tiers, traits, dates, contacts and relations,
2 +//! and new tier and trait defaults.
3 +
4 +use crate::data::{
5 + find_person, ContactOps, RelationOps, SigDateOps, TierDefaultOps, TierOps, TraitDefaultOps,
6 + TraitKind, TraitOps, COLORS,
7 +};
8 +use crate::entities::{ppl, tier, tier_defaults, trait_defaults};
9 +use crate::PplError;
10 +use chrono::NaiveDate;
11 +use crossterm::event::{Event, KeyCode, KeyEvent};
12 +use ratatui::layout::{Constraint, Flex, Layout, Rect};
13 +use ratatui::prelude::{Line, Span, Style, Stylize};
14 +use ratatui::style::palette::tailwind::{AMBER, ORANGE, SLATE, WHITE};
15 +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
16 +use ratatui::Frame;
17 +use sea_orm::DatabaseConnection;
18 +use std::io;
19 +use tui_input::backend::crossterm::EventHandler;
20 +use tui_input::Input;
21 +
22 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23 +pub enum AddKind {
24 + PersonTier,
25 + PersonTrait,
26 + PersonDate,
27 + PersonContact,
28 + PersonRelation,
29 + DefaultTier,
30 + DefaultTrait,
31 +}
32 +
33 +impl AddKind {
34 + /// The menu shown after pressing `a` on a person; keys match the detail view's toggles
35 + pub const PERSON_MENU: [(char, AddKind, &'static str); 5] = [
36 + ('i', AddKind::PersonTier, "tier"),
37 + ('r', AddKind::PersonTrait, "trait"),
38 + ('d', AddKind::PersonDate, "date"),
39 + ('c', AddKind::PersonContact, "contact"),
40 + ('l', AddKind::PersonRelation, "relation"),
41 + ];
42 +
43 + fn title(&self) -> &'static str {
44 + match self {
45 + AddKind::PersonTier => "add tier",
46 + AddKind::PersonTrait => "add trait",
47 + AddKind::PersonDate => "add date",
48 + AddKind::PersonContact => "add contact",
49 + AddKind::PersonRelation => "add relation",
50 + AddKind::DefaultTier => "new tier",
51 + AddKind::DefaultTrait => "new trait",
52 + }
53 + }
54 +
55 + /// (label, prefilled value)
56 + fn fields(&self) -> Vec<(&'static str, &'static str)> {
57 + match self {
58 + AddKind::PersonTier => vec![("tier", "")],
59 + AddKind::PersonTrait => vec![("key", ""), ("value", "")],
60 + AddKind::PersonDate => vec![
61 + ("event", ""),
62 + ("date (YYYY-MM-DD)", ""),
63 + ("remind (y/n)", "y"),
64 + ],
65 + AddKind::PersonContact => vec![("type", ""), ("value", ""), ("label (optional)", "")],
66 + AddKind::PersonRelation => vec![("person (name, nick or id)", ""), ("relation", "")],
67 + AddKind::DefaultTier => vec![
68 + ("name", ""),
69 + ("symbol", ""),
70 + ("color", ""),
71 + ("remind days ahead (optional)", ""),
72 + ],
73 + AddKind::DefaultTrait => vec![
74 + ("name", ""),
75 + ("symbol", ""),
76 + ("color", ""),
77 + ("kind (trait/date/contact)", "trait"),
78 + ],
79 + }
80 + }
81 +}
82 +
83 +/// Everything the forms check input against
84 +pub struct AddContext<'a> {
85 + pub ppl: &'a [ppl::Model],
86 + pub tiers: &'a [tier::Model],
87 + pub tier_defaults: &'a [tier_defaults::Model],
88 + pub trait_defaults: &'a [trait_defaults::Model],
89 +}
90 +
91 +impl AddContext<'_> {
92 + fn trait_keys(&self, is_date: bool, is_contact: bool) -> Vec<&str> {
93 + self.trait_defaults
94 + .iter()
95 + .filter(|t| t.enabled && t.is_date == is_date && t.is_contact == is_contact)
96 + .map(|t| t.key.as_str())
97 + .collect()
98 + }
99 +
100 + /// Use the existing spelling when the key matches a default
101 + fn canonical_trait_key(&self, key: &str) -> String {
102 + self.trait_defaults
103 + .iter()
104 + .find(|t| t.key.eq_ignore_ascii_case(key))
105 + .map(|t| t.key.clone())
106 + .unwrap_or_else(|| key.to_string())
107 + }
108 +}
109 +
110 +pub struct AddForm {
111 + pub kind: AddKind,
112 + /// The person being added to; None for tier and trait defaults
113 + pub ppl_id: Option<i32>,
114 + fields: Vec<(&'static str, Input)>,
115 + focus: usize,
116 + hint: String,
117 +}
118 +
119 +pub enum FormAction {
120 + Continue,
121 + Cancel,
122 + Submit,
123 +}
124 +
125 +fn invalid(msg: String) -> PplError {
126 + io::Error::new(io::ErrorKind::InvalidInput, msg).into()
127 +}
128 +
129 +impl AddForm {
130 + pub fn new(kind: AddKind, ppl_id: Option<i32>, ctx: &AddContext) -> Self {
131 + let hint = match kind {
132 + AddKind::PersonTier => {
133 + let have = ctx
134 + .tiers
135 + .iter()
136 + .filter(|t| Some(t.ppl_id) == ppl_id)
137 + .map(|t| t.name.as_str())
138 + .collect::<Vec<_>>();
139 + let options = ctx
140 + .tier_defaults
141 + .iter()
142 + .filter(|t| t.enabled && !have.contains(&t.key.as_str()))
143 + .map(|t| t.key.as_str())
144 + .collect::<Vec<_>>();
145 + format!("tiers: {}", options.join(", "))
146 + }
147 + AddKind::PersonTrait => format!("known: {}", ctx.trait_keys(false, false).join(", ")),
148 + AddKind::PersonDate => format!("known: {}", ctx.trait_keys(true, false).join(", ")),
149 + AddKind::PersonContact => format!("known: {}", ctx.trait_keys(false, true).join(", ")),
150 + AddKind::PersonRelation => "e.g. sibling, partner, parent, friend".to_string(),
151 + AddKind::DefaultTier | AddKind::DefaultTrait => {
152 + format!("colors: {}", COLORS.join(", "))
153 + }
154 + };
155 + AddForm {
156 + kind,
157 + ppl_id,
158 + fields: kind
159 + .fields()
160 + .into_iter()
161 + .map(|(label, value)| (label, Input::new(value.to_string())))
162 + .collect(),
163 + focus: 0,
164 + hint,
165 + }
166 + }
167 +
168 + pub fn handle_key(&mut self, key: &KeyEvent) -> FormAction {
169 + match key.code {
170 + KeyCode::Esc => FormAction::Cancel,
171 + KeyCode::Enter => FormAction::Submit,
172 + KeyCode::Tab | KeyCode::Down => {
173 + self.focus = (self.focus + 1) % self.fields.len();
174 + FormAction::Continue
175 + }
176 + KeyCode::BackTab | KeyCode::Up => {
177 + self.focus = (self.focus + self.fields.len() - 1) % self.fields.len();
178 + FormAction::Continue
179 + }
180 + _ => {
181 + if let Some((_, input)) = self.fields.get_mut(self.focus) {
182 + input.handle_event(&Event::Key(*key));
183 + }
184 + FormAction::Continue
185 + }
186 + }
187 + }
188 +
189 + fn value(&self, idx: usize) -> String {
190 + self.fields
191 + .get(idx)
192 + .map(|(_, i)| i.value().trim().to_string())
193 + .unwrap_or_default()
194 + }
195 +
196 + fn required(&self, idx: usize) -> Result<String, PplError> {
197 + let v = self.value(idx);
198 + match v.is_empty() {
199 + true => Err(invalid(format!(
200 + "{} is required",
201 + self.fields.get(idx).map(|(l, _)| *l).unwrap_or("field")
202 + ))),
203 + false => Ok(v),
204 + }
205 + }
206 +
207 + /// Validate and write; returns what to tell the user
208 + pub async fn submit(
209 + &self,
210 + db: &DatabaseConnection,
211 + ctx: &AddContext<'_>,
212 + ) -> Result<String, PplError> {
213 + let person = || {
214 + self.ppl_id
215 + .ok_or_else(|| invalid("no person selected".to_string()))
216 + };
217 + match self.kind {
218 + AddKind::PersonTier => {
219 + let ppl_id = person()?;
220 + let name = self.required(0)?;
221 + let Some(td) = ctx
222 + .tier_defaults
223 + .iter()
224 + .find(|t| t.key.eq_ignore_ascii_case(&name))
225 + else {
226 + return Err(invalid(format!("no tier '{}'; {}", name, self.hint)));
227 + };
228 + if ctx
229 + .tiers
230 + .iter()
231 + .any(|t| t.ppl_id == ppl_id && t.name == td.key)
232 + {
233 + return Err(invalid(format!("already in {}", td.key)));
234 + }
235 + TierOps::create(db, ppl_id, td.key.clone(), None).await?;
236 + Ok(format!("added to {}", td.key))
237 + }
238 + AddKind::PersonTrait => {
239 + let ppl_id = person()?;
240 + let key = ctx.canonical_trait_key(&self.required(0)?);
241 + if let Some(td) = ctx.trait_defaults.iter().find(|t| t.key == key) {
242 + if td.is_date {
243 + return Err(invalid(format!("{} is a date, add it with d", key)));
244 + }
245 + if td.is_contact {
246 + return Err(invalid(format!("{} is a contact, add it with c", key)));
247 + }
248 + }
249 + TraitOps::create(db, ppl_id, key.clone(), self.required(1)?, false).await?;
250 + Ok(format!("added {}", key))
251 + }
252 + AddKind::PersonDate => {
253 + let ppl_id = person()?;
254 + let event = ctx.canonical_trait_key(&self.required(0)?);
255 + let date = NaiveDate::parse_from_str(&self.required(1)?, "%Y-%m-%d")
256 + .map_err(|_| invalid("date must look like YYYY-MM-DD".to_string()))?;
257 + let remind = match self.value(2).to_lowercase().as_str() {
258 + "" | "y" | "yes" | "t" | "true" => true,
259 + "n" | "no" | "f" | "false" => false,
260 + other => {
261 + return Err(invalid(format!("remind should be y or n, got '{}'", other)))
262 + }
263 + };
264 + SigDateOps::create(db, ppl_id, date, event.clone(), remind).await?;
265 + Ok(format!("added {} {}", event, date))
266 + }
267 + AddKind::PersonContact => {
268 + let ppl_id = person()?;
269 + let typ = ctx.canonical_trait_key(&self.required(0)?);
270 + ContactOps::create(db, ppl_id, typ.clone(), self.value(2), self.required(1)?)
271 + .await?;
272 + Ok(format!("added {}", typ))
273 + }
274 + AddKind::PersonRelation => {
275 + let ppl_id = person()?;
276 + let other = find_person(ctx.ppl, &self.required(0)?)?;
277 + if other.id == ppl_id {
278 + return Err(invalid("pick someone else".to_string()));
279 + }
280 + let typ = self.required(1)?;
281 + RelationOps::create(db, ppl_id, other.id, typ.clone(), false, None, None).await?;
282 + Ok(format!(
283 + "added {} {}",
284 + typ,
285 + other.nick.as_deref().unwrap_or(other.name.trim())
286 + ))
287 + }
288 + AddKind::DefaultTier => {
289 + let days = match self.value(3) {
290 + d if d.is_empty() => None,
291 + d => Some(d.parse::<u32>().map_err(|_| {
292 + invalid(format!("remind days should be a number, got '{}'", d))
293 + })?),
294 + };
295 + let key =
296 + TierDefaultOps::add(db, &self.value(0), &self.value(1), &self.value(2), days)
297 + .await?;
298 + Ok(format!("added tier {}", key))
299 + }
300 + AddKind::DefaultTrait => {
301 + let kind = self.value(3).parse::<TraitKind>().map_err(invalid)?;
302 + let key =
303 + TraitDefaultOps::add(db, &self.value(0), &self.value(1), &self.value(2), kind)
304 + .await?;
305 + Ok(format!("added trait {}", key))
306 + }
307 + }
308 + }
309 +
310 + pub fn render(&self, f: &mut Frame, subtitle: &str) {
311 + let height = self.fields.len() as u16 * 3 + 5;
312 + let area = popup_area(f.area(), 64, height);
313 + f.render_widget(Clear, area);
314 + let block = Block::bordered()
315 + .title(format!(" {}{} ", self.kind.title(), subtitle))
316 + .title_bottom(" tab next · enter save · esc cancel ")
317 + .style(Style::default().fg(ORANGE.c500));
318 + let inner = block.inner(area);
319 + f.render_widget(block, area);
320 +
321 + let mut rows = vec![Constraint::Length(3); self.fields.len()];
322 + rows.push(Constraint::Min(2));
323 + let rows = Layout::vertical(rows).split(inner);
324 + for (idx, (label, input)) in self.fields.iter().enumerate() {
325 + let focused = idx == self.focus;
326 + let style = match focused {
327 + true => Style::default().fg(WHITE),
328 + false => Style::default().fg(SLATE.c500),
329 + };
330 + let field = Paragraph::new(input.value())
331 + .style(style)
332 + .block(Block::default().borders(Borders::ALL).title(*label));
333 + f.render_widget(field, rows[idx]);
334 + if focused {
335 + // Put the terminal cursor at the end of what's been typed
336 + let x = rows[idx].x + 1 + input.visual_cursor() as u16;
337 + f.set_cursor_position((
338 + x.min(rows[idx].right().saturating_sub(2)),
339 + rows[idx].y + 1,
340 + ));
341 + }
342 + }
343 + let hint =
344 + Paragraph::new(Line::styled(self.hint.clone(), SLATE.c400)).wrap(Wrap { trim: true });
345 + f.render_widget(hint, rows[self.fields.len()]);
346 + }
347 +}
348 +
349 +/// The "add what?" menu for a person
350 +pub fn render_person_menu(f: &mut Frame, subtitle: &str) {
351 + let area = popup_area(f.area(), 40, AddKind::PERSON_MENU.len() as u16 + 4);
352 + f.render_widget(Clear, area);
353 + let mut lines = AddKind::PERSON_MENU
354 + .iter()
355 + .map(|(key, _, label)| {
356 + Line::from(vec![
357 + Span::styled(format!(" {} ", key), Style::default().fg(WHITE).bold()),
358 + Span::raw(*label),
359 + ])
360 + })
361 + .collect::<Vec<_>>();
362 + lines.push(Line::styled(" esc cancel", SLATE.c500));
363 + let menu = Paragraph::new(lines).block(
364 + Block::bordered()
365 + .title(format!(" add to{} ", subtitle))
366 + .style(Style::default().fg(AMBER.c500)),
367 + );
368 + f.render_widget(menu, area);
369 +}
370 +
371 +fn popup_area(area: Rect, width: u16, height: u16) -> Rect {
372 + let [area] = Layout::vertical([Constraint::Length(height.min(area.height))])
373 + .flex(Flex::Center)
374 + .areas(area);
375 + let [area] = Layout::horizontal([Constraint::Length(width.min(area.width))])
376 + .flex(Flex::Center)
377 + .areas(area);
378 + area
379 +}

Parents: 1aa586c