⭐
Log in to mark this repository.
tui: add tiers, traits, dates, contacts and relations
2803012 · Gabriel Morell · 2026-10-15 20:59
✓
Rust CI and Docker Build / Create Release (push)
✓
Rust CI and Docker Build / Release (7z a, .exe, ppl-x86_64-windows.zip, windows-latest, windows-latest, 1.92) (push)
✓
Rust CI and Docker Build / Release (tar -czf, , ppl-aarch64-linux-gnu.tar.gz, ubuntu-24.04-arm, ubuntu-24.04-arm, 1.92) (push)
✓
Rust CI and Docker Build / Release (tar -czf, , ppl-aarch64-macos.tar.gz, macos-latest, macos-latest, 1.92) (push)
✓
Rust CI and Docker Build / Release (tar -czf, , ppl-x86_64-linux-gnu.tar.gz, ubuntu-latest, arc-runner-set, 1.92) (push)
✗
Rust CI and Docker Build / build (aarch64, ubuntu-24.04-arm) (push)
⏳
Rust CI and Docker Build
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 |  |
| 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 |  |
| 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 |  |
| 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