max / ripgrow
7 files changed,
+917 insertions,
-37 deletions
| @@ -55,8 +55,8 @@ impl Db { | |||
| 55 | 55 | Ok(db) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | /// In-memory database for tests. | |
| 59 | - | #[cfg(test)] | |
| 58 | + | /// In-memory database for tests. Kept public so downstream crates can | |
| 59 | + | /// exercise their TUI logic without touching the filesystem. | |
| 60 | 60 | pub fn open_in_memory() -> Result<Self, Error> { | |
| 61 | 61 | let conn = Connection::open_in_memory()?; | |
| 62 | 62 | conn.pragma_update(None, "foreign_keys", "ON")?; |
| @@ -10,4 +10,16 @@ pub enum Error { | |||
| 10 | 10 | ||
| 11 | 11 | #[error("invalid unit: {0}")] | |
| 12 | 12 | InvalidUnit(String), | |
| 13 | + | ||
| 14 | + | #[error("invalid resistance type: {0}")] | |
| 15 | + | InvalidResistanceType(String), | |
| 16 | + | ||
| 17 | + | #[error("tag name cannot be empty")] | |
| 18 | + | InvalidTagName, | |
| 19 | + | ||
| 20 | + | #[error("exercise name cannot be empty")] | |
| 21 | + | InvalidExerciseName, | |
| 22 | + | ||
| 23 | + | #[error("not found: {0}")] | |
| 24 | + | NotFound(String), | |
| 13 | 25 | } |
| @@ -6,7 +6,9 @@ | |||
| 6 | 6 | pub mod db; | |
| 7 | 7 | pub mod error; | |
| 8 | 8 | pub mod profiles; | |
| 9 | + | pub mod templates; | |
| 9 | 10 | ||
| 10 | 11 | pub use db::{Db, Unit}; | |
| 11 | 12 | pub use error::Error; | |
| 12 | 13 | pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify}; | |
| 14 | + | pub use templates::{Exercise, ResistanceType, Tag}; |
| @@ -0,0 +1,344 @@ | |||
| 1 | + | //! Exercise templates and their tag associations. | |
| 2 | + | //! | |
| 3 | + | //! Exercises carry a name, resistance type, load unit, per-exercise | |
| 4 | + | //! increment, and a set of muscle-agnostic tag ids. No target sets or reps; | |
| 5 | + | //! prescription is computed at generation time from history. | |
| 6 | + | ||
| 7 | + | use rusqlite::{OptionalExtension, params}; | |
| 8 | + | ||
| 9 | + | use crate::db::Db; | |
| 10 | + | use crate::error::Error; | |
| 11 | + | ||
| 12 | + | /// Resistance type is a UI hint. It determines how the log screen renders | |
| 13 | + | /// input, not how the schema stores rows. | |
| 14 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 15 | + | pub enum ResistanceType { | |
| 16 | + | Bodyweight, | |
| 17 | + | Machine, | |
| 18 | + | Freeweight, | |
| 19 | + | CardioTime, | |
| 20 | + | CardioDistance, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl ResistanceType { | |
| 24 | + | pub const ALL: [ResistanceType; 5] = [ | |
| 25 | + | ResistanceType::Bodyweight, | |
| 26 | + | ResistanceType::Machine, | |
| 27 | + | ResistanceType::Freeweight, | |
| 28 | + | ResistanceType::CardioTime, | |
| 29 | + | ResistanceType::CardioDistance, | |
| 30 | + | ]; | |
| 31 | + | ||
| 32 | + | pub fn as_str(&self) -> &'static str { | |
| 33 | + | match self { | |
| 34 | + | ResistanceType::Bodyweight => "bodyweight", | |
| 35 | + | ResistanceType::Machine => "machine", | |
| 36 | + | ResistanceType::Freeweight => "freeweight", | |
| 37 | + | ResistanceType::CardioTime => "cardio_time", | |
| 38 | + | ResistanceType::CardioDistance => "cardio_distance", | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | pub fn parse(s: &str) -> Result<Self, Error> { | |
| 43 | + | match s { | |
| 44 | + | "bodyweight" => Ok(ResistanceType::Bodyweight), | |
| 45 | + | "machine" => Ok(ResistanceType::Machine), | |
| 46 | + | "freeweight" => Ok(ResistanceType::Freeweight), | |
| 47 | + | "cardio_time" => Ok(ResistanceType::CardioTime), | |
| 48 | + | "cardio_distance" => Ok(ResistanceType::CardioDistance), | |
| 49 | + | other => Err(Error::InvalidResistanceType(other.to_string())), | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | #[derive(Debug, Clone, PartialEq)] | |
| 55 | + | pub struct Tag { | |
| 56 | + | pub id: i64, | |
| 57 | + | pub name: String, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | #[derive(Debug, Clone, PartialEq)] | |
| 61 | + | pub struct Exercise { | |
| 62 | + | pub id: i64, | |
| 63 | + | pub name: String, | |
| 64 | + | pub resistance_type: ResistanceType, | |
| 65 | + | pub load_unit: String, | |
| 66 | + | pub increment: f64, | |
| 67 | + | pub notes: String, | |
| 68 | + | pub tag_ids: Vec<i64>, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | impl Db { | |
| 72 | + | // ---- tags ----------------------------------------------------------- | |
| 73 | + | ||
| 74 | + | pub fn list_tags(&self) -> Result<Vec<Tag>, Error> { | |
| 75 | + | let mut stmt = self | |
| 76 | + | .conn() | |
| 77 | + | .prepare("SELECT id, name FROM tags ORDER BY name")?; | |
| 78 | + | let rows = stmt.query_map([], |row| { | |
| 79 | + | Ok(Tag { | |
| 80 | + | id: row.get(0)?, | |
| 81 | + | name: row.get(1)?, | |
| 82 | + | }) | |
| 83 | + | })?; | |
| 84 | + | Ok(rows.collect::<Result<Vec<_>, _>>()?) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Insert a tag, or return the existing row if the name already exists. | |
| 88 | + | pub fn upsert_tag(&self, name: &str) -> Result<Tag, Error> { | |
| 89 | + | let name = name.trim(); | |
| 90 | + | if name.is_empty() { | |
| 91 | + | return Err(Error::InvalidTagName); | |
| 92 | + | } | |
| 93 | + | if let Some(existing) = self | |
| 94 | + | .conn() | |
| 95 | + | .query_row( | |
| 96 | + | "SELECT id, name FROM tags WHERE name = ?1", | |
| 97 | + | params![name], | |
| 98 | + | |row| { | |
| 99 | + | Ok(Tag { | |
| 100 | + | id: row.get(0)?, | |
| 101 | + | name: row.get(1)?, | |
| 102 | + | }) | |
| 103 | + | }, | |
| 104 | + | ) | |
| 105 | + | .optional()? | |
| 106 | + | { | |
| 107 | + | return Ok(existing); | |
| 108 | + | } | |
| 109 | + | self.conn() | |
| 110 | + | .execute("INSERT INTO tags (name) VALUES (?1)", params![name])?; | |
| 111 | + | let id = self.conn().last_insert_rowid(); | |
| 112 | + | Ok(Tag { | |
| 113 | + | id, | |
| 114 | + | name: name.to_string(), | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // ---- exercises ------------------------------------------------------ | |
| 119 | + | ||
| 120 | + | pub fn list_exercises(&self) -> Result<Vec<Exercise>, Error> { | |
| 121 | + | let mut stmt = self.conn().prepare( | |
| 122 | + | "SELECT id, name, resistance_type, load_unit, increment, notes \ | |
| 123 | + | FROM exercises ORDER BY name", | |
| 124 | + | )?; | |
| 125 | + | let rows = stmt.query_map([], |row| { | |
| 126 | + | let rt: String = row.get(2)?; | |
| 127 | + | Ok(( | |
| 128 | + | row.get::<_, i64>(0)?, | |
| 129 | + | row.get::<_, String>(1)?, | |
| 130 | + | rt, | |
| 131 | + | row.get::<_, String>(3)?, | |
| 132 | + | row.get::<_, f64>(4)?, | |
| 133 | + | row.get::<_, String>(5)?, | |
| 134 | + | )) | |
| 135 | + | })?; | |
| 136 | + | ||
| 137 | + | let mut out = Vec::new(); | |
| 138 | + | for row in rows { | |
| 139 | + | let (id, name, rt, load_unit, increment, notes) = row?; | |
| 140 | + | out.push(Exercise { | |
| 141 | + | id, | |
| 142 | + | name, | |
| 143 | + | resistance_type: ResistanceType::parse(&rt)?, | |
| 144 | + | load_unit, | |
| 145 | + | increment, | |
| 146 | + | notes, | |
| 147 | + | tag_ids: self.tag_ids_for(id)?, | |
| 148 | + | }); | |
| 149 | + | } | |
| 150 | + | Ok(out) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | fn tag_ids_for(&self, exercise_id: i64) -> Result<Vec<i64>, Error> { | |
| 154 | + | let mut stmt = self.conn().prepare( | |
| 155 | + | "SELECT tag_id FROM exercise_tags WHERE exercise_id = ?1 ORDER BY tag_id", | |
| 156 | + | )?; | |
| 157 | + | let rows = stmt.query_map(params![exercise_id], |row| row.get(0))?; | |
| 158 | + | Ok(rows.collect::<Result<Vec<_>, _>>()?) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | pub fn create_exercise( | |
| 162 | + | &self, | |
| 163 | + | name: &str, | |
| 164 | + | resistance_type: ResistanceType, | |
| 165 | + | load_unit: &str, | |
| 166 | + | increment: f64, | |
| 167 | + | tag_ids: &[i64], | |
| 168 | + | ) -> Result<i64, Error> { | |
| 169 | + | let name = name.trim(); | |
| 170 | + | if name.is_empty() { | |
| 171 | + | return Err(Error::InvalidExerciseName); | |
| 172 | + | } | |
| 173 | + | self.conn().execute( | |
| 174 | + | "INSERT INTO exercises (name, resistance_type, load_unit, increment) \ | |
| 175 | + | VALUES (?1, ?2, ?3, ?4)", | |
| 176 | + | params![name, resistance_type.as_str(), load_unit, increment], | |
| 177 | + | )?; | |
| 178 | + | let id = self.conn().last_insert_rowid(); | |
| 179 | + | self.write_tag_ids(id, tag_ids)?; | |
| 180 | + | Ok(id) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | pub fn update_exercise( | |
| 184 | + | &self, | |
| 185 | + | id: i64, | |
| 186 | + | name: &str, | |
| 187 | + | resistance_type: ResistanceType, | |
| 188 | + | load_unit: &str, | |
| 189 | + | increment: f64, | |
| 190 | + | tag_ids: &[i64], | |
| 191 | + | ) -> Result<(), Error> { | |
| 192 | + | let name = name.trim(); | |
| 193 | + | if name.is_empty() { | |
| 194 | + | return Err(Error::InvalidExerciseName); | |
| 195 | + | } | |
| 196 | + | let n = self.conn().execute( | |
| 197 | + | "UPDATE exercises SET name = ?1, resistance_type = ?2, load_unit = ?3, \ | |
| 198 | + | increment = ?4 WHERE id = ?5", | |
| 199 | + | params![name, resistance_type.as_str(), load_unit, increment, id], | |
| 200 | + | )?; | |
| 201 | + | if n == 0 { | |
| 202 | + | return Err(Error::NotFound(format!("exercise {id}"))); | |
| 203 | + | } | |
| 204 | + | self.write_tag_ids(id, tag_ids)?; | |
| 205 | + | Ok(()) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// Delete an exercise. Errors if any set references it (FK RESTRICT on | |
| 209 | + | /// `sets.exercise_id`); the caller should surface that as "log | |
| 210 | + | /// history exists; delete blocked". | |
| 211 | + | pub fn delete_exercise(&self, id: i64) -> Result<(), Error> { | |
| 212 | + | let n = self | |
| 213 | + | .conn() | |
| 214 | + | .execute("DELETE FROM exercises WHERE id = ?1", params![id])?; | |
| 215 | + | if n == 0 { | |
| 216 | + | return Err(Error::NotFound(format!("exercise {id}"))); | |
| 217 | + | } | |
| 218 | + | Ok(()) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | fn write_tag_ids(&self, exercise_id: i64, tag_ids: &[i64]) -> Result<(), Error> { | |
| 222 | + | self.conn().execute( | |
| 223 | + | "DELETE FROM exercise_tags WHERE exercise_id = ?1", | |
| 224 | + | params![exercise_id], | |
| 225 | + | )?; | |
| 226 | + | for tid in tag_ids { | |
| 227 | + | self.conn().execute( | |
| 228 | + | "INSERT INTO exercise_tags (exercise_id, tag_id) VALUES (?1, ?2)", | |
| 229 | + | params![exercise_id, tid], | |
| 230 | + | )?; | |
| 231 | + | } | |
| 232 | + | Ok(()) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[cfg(test)] | |
| 237 | + | mod tests { | |
| 238 | + | use super::*; | |
| 239 | + | use crate::db::Unit; | |
| 240 | + | ||
| 241 | + | fn setup() -> Db { | |
| 242 | + | let db = Db::open_in_memory().unwrap(); | |
| 243 | + | db.init_profile("self", Unit::Kg).unwrap(); | |
| 244 | + | db | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[test] | |
| 248 | + | fn tag_upsert_idempotent_and_trims() { | |
| 249 | + | let db = setup(); | |
| 250 | + | let a = db.upsert_tag("chest").unwrap(); | |
| 251 | + | let b = db.upsert_tag(" chest ").unwrap(); | |
| 252 | + | assert_eq!(a.id, b.id); | |
| 253 | + | assert_eq!(a.name, "chest"); | |
| 254 | + | assert_eq!(db.list_tags().unwrap().len(), 1); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[test] | |
| 258 | + | fn tag_upsert_rejects_empty() { | |
| 259 | + | let db = setup(); | |
| 260 | + | assert!(db.upsert_tag(" ").is_err()); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[test] | |
| 264 | + | fn exercise_round_trip_with_tags() { | |
| 265 | + | let db = setup(); | |
| 266 | + | let t1 = db.upsert_tag("chest").unwrap(); | |
| 267 | + | let t2 = db.upsert_tag("triceps").unwrap(); | |
| 268 | + | let id = db | |
| 269 | + | .create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[t1.id, t2.id]) | |
| 270 | + | .unwrap(); | |
| 271 | + | let list = db.list_exercises().unwrap(); | |
| 272 | + | assert_eq!(list.len(), 1); | |
| 273 | + | let ex = &list[0]; | |
| 274 | + | assert_eq!(ex.id, id); | |
| 275 | + | assert_eq!(ex.name, "bench"); | |
| 276 | + | assert_eq!(ex.resistance_type, ResistanceType::Freeweight); | |
| 277 | + | assert_eq!(ex.increment, 2.5); | |
| 278 | + | assert_eq!(ex.tag_ids, vec![t1.id, t2.id]); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | #[test] | |
| 282 | + | fn update_replaces_tag_set() { | |
| 283 | + | let db = setup(); | |
| 284 | + | let t1 = db.upsert_tag("a").unwrap(); | |
| 285 | + | let t2 = db.upsert_tag("b").unwrap(); | |
| 286 | + | let t3 = db.upsert_tag("c").unwrap(); | |
| 287 | + | let id = db | |
| 288 | + | .create_exercise("x", ResistanceType::Machine, "kg", 5.0, &[t1.id, t2.id]) | |
| 289 | + | .unwrap(); | |
| 290 | + | db.update_exercise(id, "x", ResistanceType::Machine, "kg", 5.0, &[t3.id]) | |
| 291 | + | .unwrap(); | |
| 292 | + | let list = db.list_exercises().unwrap(); | |
| 293 | + | assert_eq!(list[0].tag_ids, vec![t3.id]); | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | #[test] | |
| 297 | + | fn delete_removes_exercise_and_tag_links() { | |
| 298 | + | let db = setup(); | |
| 299 | + | let t = db.upsert_tag("t").unwrap(); | |
| 300 | + | let id = db | |
| 301 | + | .create_exercise("y", ResistanceType::Bodyweight, "reps", 0.0, &[t.id]) | |
| 302 | + | .unwrap(); | |
| 303 | + | db.delete_exercise(id).unwrap(); | |
| 304 | + | assert!(db.list_exercises().unwrap().is_empty()); | |
| 305 | + | // exercise_tags row should have cascaded away. | |
| 306 | + | let n: i64 = db | |
| 307 | + | .conn() | |
| 308 | + | .query_row("SELECT COUNT(*) FROM exercise_tags", [], |row| row.get(0)) | |
| 309 | + | .unwrap(); | |
| 310 | + | assert_eq!(n, 0); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | #[test] | |
| 314 | + | fn delete_blocked_when_sets_exist() { | |
| 315 | + | let db = setup(); | |
| 316 | + | let id = db | |
| 317 | + | .create_exercise("z", ResistanceType::Freeweight, "kg", 2.5, &[]) | |
| 318 | + | .unwrap(); | |
| 319 | + | db.conn() | |
| 320 | + | .execute( | |
| 321 | + | "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \ | |
| 322 | + | VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)", | |
| 323 | + | params![id], | |
| 324 | + | ) | |
| 325 | + | .unwrap(); | |
| 326 | + | assert!(db.delete_exercise(id).is_err(), "FK RESTRICT should block"); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[test] | |
| 330 | + | fn create_rejects_empty_name_and_duplicate() { | |
| 331 | + | let db = setup(); | |
| 332 | + | assert!( | |
| 333 | + | db.create_exercise(" ", ResistanceType::Machine, "kg", 5.0, &[]) | |
| 334 | + | .is_err() | |
| 335 | + | ); | |
| 336 | + | db.create_exercise("dup", ResistanceType::Machine, "kg", 5.0, &[]) | |
| 337 | + | .unwrap(); | |
| 338 | + | assert!( | |
| 339 | + | db.create_exercise("dup", ResistanceType::Machine, "kg", 5.0, &[]) | |
| 340 | + | .is_err(), | |
| 341 | + | "UNIQUE(name) should block" | |
| 342 | + | ); | |
| 343 | + | } | |
| 344 | + | } |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Top-level app state and the render/event dispatch. | |
| 2 | 2 | //! | |
| 3 | - | //! v1 has one screen (the profile picker). Once a profile is opened, the app | |
| 4 | - | //! records the resulting `Db` and (later) hands control to the tab set. | |
| 3 | + | //! Two states so far: the profile picker (until a profile is opened) and the | |
| 4 | + | //! templates screen (after). More screens land on tabs later. | |
| 5 | 5 | ||
| 6 | 6 | use std::path::PathBuf; | |
| 7 | 7 | ||
| @@ -10,17 +10,19 @@ use ratatui::Frame; | |||
| 10 | 10 | use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir}; | |
| 11 | 11 | ||
| 12 | 12 | use crate::screens::profile_picker::{PickerAction, ProfilePicker}; | |
| 13 | + | use crate::screens::templates::{TemplatesAction, TemplatesScreen}; | |
| 13 | 14 | ||
| 14 | 15 | pub struct App { | |
| 15 | - | pub picker: ProfilePicker, | |
| 16 | - | pub opened: Option<OpenedProfile>, | |
| 17 | - | pub status: String, | |
| 16 | + | picker: ProfilePicker, | |
| 17 | + | opened: Option<Opened>, | |
| 18 | + | status: String, | |
| 18 | 19 | pub should_quit: bool, | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | - | pub struct OpenedProfile { | |
| 22 | - | pub path: PathBuf, | |
| 23 | - | pub db: Db, | |
| 22 | + | struct Opened { | |
| 23 | + | path: PathBuf, | |
| 24 | + | db: Db, | |
| 25 | + | templates: TemplatesScreen, | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | impl App { | |
| @@ -40,32 +42,35 @@ impl App { | |||
| 40 | 42 | let area = frame.area(); | |
| 41 | 43 | let chunks = Layout::default() | |
| 42 | 44 | .direction(Direction::Vertical) | |
| 43 | - | .constraints([Constraint::Min(1), Constraint::Length(1)]) | |
| 45 | + | .constraints([Constraint::Length(1), Constraint::Min(1)]) | |
| 44 | 46 | .split(area); | |
| 45 | 47 | ||
| 46 | - | if let Some(op) = &self.opened { | |
| 47 | - | let title = format!( | |
| 48 | - | "opened: {} (screens coming soon) q to quit", | |
| 48 | + | let header = match &self.opened { | |
| 49 | + | Some(op) => format!( | |
| 50 | + | "ripgrow | {} | {}", | |
| 51 | + | op.db.profile_name().ok().flatten().unwrap_or_default(), | |
| 49 | 52 | op.path.display() | |
| 50 | - | ); | |
| 51 | - | frame.render_widget(ratatui::widgets::Paragraph::new(title), chunks[0]); | |
| 52 | - | } else { | |
| 53 | - | self.picker.render(frame, chunks[0]); | |
| 54 | - | } | |
| 53 | + | ), | |
| 54 | + | None => "ripgrow".to_string(), | |
| 55 | + | }; | |
| 56 | + | frame.render_widget(ratatui::widgets::Paragraph::new(header), chunks[0]); | |
| 55 | 57 | ||
| 56 | - | let status = ratatui::widgets::Paragraph::new(self.status.as_str()) | |
| 57 | - | .style(ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::DIM)); | |
| 58 | - | frame.render_widget(status, chunks[1]); | |
| 58 | + | match &mut self.opened { | |
| 59 | + | Some(op) => op.templates.render(frame, chunks[1]), | |
| 60 | + | None => self.picker.render(frame, chunks[1]), | |
| 61 | + | } | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | pub fn on_key(&mut self, key: KeyEvent) { | |
| 62 | - | if self.opened.is_some() { | |
| 63 | - | use crossterm::event::{KeyCode, KeyModifiers}; | |
| 64 | - | if matches!( | |
| 65 | - | (key.code, key.modifiers), | |
| 66 | - | (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) | |
| 67 | - | ) { | |
| 68 | - | self.should_quit = true; | |
| 65 | + | if let Some(op) = self.opened.as_mut() { | |
| 66 | + | let action = op.templates.on_key(&op.db, key); | |
| 67 | + | match action { | |
| 68 | + | TemplatesAction::None => {} | |
| 69 | + | TemplatesAction::Quit => self.should_quit = true, | |
| 70 | + | TemplatesAction::Refresh => match TemplatesScreen::load(&op.db) { | |
| 71 | + | Ok(next) => op.templates = next, | |
| 72 | + | Err(e) => self.status = format!("reload failed: {e}"), | |
| 73 | + | }, | |
| 69 | 74 | } | |
| 70 | 75 | return; | |
| 71 | 76 | } | |
| @@ -80,13 +85,22 @@ impl App { | |||
| 80 | 85 | } | |
| 81 | 86 | ||
| 82 | 87 | fn open_profile(&mut self, path: PathBuf) { | |
| 83 | - | match Db::open(&path) { | |
| 84 | - | Ok(db) => { | |
| 85 | - | self.status = format!("opened {}", path.display()); | |
| 86 | - | self.opened = Some(OpenedProfile { path, db }); | |
| 88 | + | let db = match Db::open(&path) { | |
| 89 | + | Ok(d) => d, | |
| 90 | + | Err(e) => { | |
| 91 | + | self.status = format!("open failed: {e}"); | |
| 92 | + | return; | |
| 87 | 93 | } | |
| 88 | - | Err(e) => self.status = format!("open failed: {e}"), | |
| 89 | - | } | |
| 94 | + | }; | |
| 95 | + | let templates = match TemplatesScreen::load(&db) { | |
| 96 | + | Ok(s) => s, | |
| 97 | + | Err(e) => { | |
| 98 | + | self.status = format!("load templates failed: {e}"); | |
| 99 | + | return; | |
| 100 | + | } | |
| 101 | + | }; | |
| 102 | + | self.status = format!("opened {}", path.display()); | |
| 103 | + | self.opened = Some(Opened { path, db, templates }); | |
| 90 | 104 | } | |
| 91 | 105 | ||
| 92 | 106 | fn create_profile(&mut self, display_name: &str, unit: Unit) { | |
| @@ -99,15 +113,22 @@ impl App { | |||
| 99 | 113 | }; | |
| 100 | 114 | match create_profile_in(&dir, display_name, unit) { | |
| 101 | 115 | Ok((profile, db)) => { | |
| 116 | + | let templates = match TemplatesScreen::load(&db) { | |
| 117 | + | Ok(s) => s, | |
| 118 | + | Err(e) => { | |
| 119 | + | self.status = format!("load templates failed: {e}"); | |
| 120 | + | return; | |
| 121 | + | } | |
| 122 | + | }; | |
| 102 | 123 | self.status = format!("created {}", profile.slug); | |
| 103 | - | self.opened = Some(OpenedProfile { | |
| 124 | + | self.opened = Some(Opened { | |
| 104 | 125 | path: profile.path, | |
| 105 | 126 | db, | |
| 127 | + | templates, | |
| 106 | 128 | }); | |
| 107 | 129 | } | |
| 108 | 130 | Err(e) => self.status = format!("create failed: {e}"), | |
| 109 | 131 | } | |
| 110 | - | // Refresh the list so a re-open shows the new profile. | |
| 111 | 132 | if let Ok(list) = list_profiles() { | |
| 112 | 133 | self.picker.profiles = list; | |
| 113 | 134 | } |
| @@ -1 +1,2 @@ | |||
| 1 | 1 | pub mod profile_picker; | |
| 2 | + | pub mod templates; |
| @@ -0,0 +1,621 @@ | |||
| 1 | + | //! Templates screen. Left pane lists exercises, right pane shows details or | |
| 2 | + | //! an editor form when creating/updating. Tag toggles live inside the form. | |
| 3 | + | ||
| 4 | + | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 5 | + | use ratatui::Frame; | |
| 6 | + | use ratatui::layout::{Constraint, Direction, Layout, Rect}; | |
| 7 | + | use ratatui::style::{Modifier, Style}; | |
| 8 | + | use ratatui::text::{Line, Span}; | |
| 9 | + | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; | |
| 10 | + | use ripgrow_core::{Db, Exercise, ResistanceType, Tag}; | |
| 11 | + | ||
| 12 | + | pub struct TemplatesScreen { | |
| 13 | + | pub exercises: Vec<Exercise>, | |
| 14 | + | pub tags: Vec<Tag>, | |
| 15 | + | list_state: ListState, | |
| 16 | + | mode: Mode, | |
| 17 | + | pub status: String, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | enum Mode { | |
| 21 | + | Browse, | |
| 22 | + | Edit(EditForm), | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// What the outer app should do after this tick. | |
| 26 | + | pub enum TemplatesAction { | |
| 27 | + | None, | |
| 28 | + | Quit, | |
| 29 | + | Refresh, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl TemplatesScreen { | |
| 33 | + | pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> { | |
| 34 | + | let exercises = db.list_exercises()?; | |
| 35 | + | let tags = db.list_tags()?; | |
| 36 | + | let mut list_state = ListState::default(); | |
| 37 | + | if !exercises.is_empty() { | |
| 38 | + | list_state.select(Some(0)); | |
| 39 | + | } | |
| 40 | + | Ok(Self { | |
| 41 | + | exercises, | |
| 42 | + | tags, | |
| 43 | + | list_state, | |
| 44 | + | mode: Mode::Browse, | |
| 45 | + | status: String::new(), | |
| 46 | + | }) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | pub fn on_key(&mut self, db: &Db, key: KeyEvent) -> TemplatesAction { | |
| 50 | + | // Take the mode out so we can pattern-match on it and mutate self. | |
| 51 | + | let mode = std::mem::replace(&mut self.mode, Mode::Browse); | |
| 52 | + | let (next_mode, action) = match mode { | |
| 53 | + | Mode::Browse => { | |
| 54 | + | let a = self.on_browse_key(db, key); | |
| 55 | + | (std::mem::replace(&mut self.mode, Mode::Browse), a) | |
| 56 | + | } | |
| 57 | + | Mode::Edit(form) => self.on_edit_key(db, form, key), | |
| 58 | + | }; | |
| 59 | + | self.mode = next_mode; | |
| 60 | + | action | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | fn on_browse_key(&mut self, db: &Db, key: KeyEvent) -> TemplatesAction { | |
| 64 | + | match (key.code, key.modifiers) { | |
| 65 | + | (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { | |
| 66 | + | TemplatesAction::Quit | |
| 67 | + | } | |
| 68 | + | (KeyCode::Down | KeyCode::Char('j'), _) => { | |
| 69 | + | self.move_selection(1); | |
| 70 | + | TemplatesAction::None | |
| 71 | + | } | |
| 72 | + | (KeyCode::Up | KeyCode::Char('k'), _) => { | |
| 73 | + | self.move_selection(-1); | |
| 74 | + | TemplatesAction::None | |
| 75 | + | } | |
| 76 | + | (KeyCode::Char('n'), _) => { | |
| 77 | + | self.mode = Mode::Edit(EditForm::new_blank(self.default_unit(db))); | |
| 78 | + | TemplatesAction::None | |
| 79 | + | } | |
| 80 | + | (KeyCode::Char('e'), _) => { | |
| 81 | + | if let Some(ex) = self.selected() { | |
| 82 | + | self.mode = Mode::Edit(EditForm::from_exercise(ex)); | |
| 83 | + | } | |
| 84 | + | TemplatesAction::None | |
| 85 | + | } | |
| 86 | + | (KeyCode::Char('d'), _) => { | |
| 87 | + | if let Some(ex) = self.selected() { | |
| 88 | + | let id = ex.id; | |
| 89 | + | match db.delete_exercise(id) { | |
| 90 | + | Ok(()) => { | |
| 91 | + | self.status = format!("deleted"); | |
| 92 | + | return TemplatesAction::Refresh; | |
| 93 | + | } | |
| 94 | + | Err(e) => { | |
| 95 | + | self.status = format!("delete failed: {e}"); | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | TemplatesAction::None | |
| 100 | + | } | |
| 101 | + | _ => TemplatesAction::None, | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn on_edit_key( | |
| 106 | + | &mut self, | |
| 107 | + | db: &Db, | |
| 108 | + | mut form: EditForm, | |
| 109 | + | key: KeyEvent, | |
| 110 | + | ) -> (Mode, TemplatesAction) { | |
| 111 | + | match (key.code, key.modifiers) { | |
| 112 | + | (KeyCode::Esc, _) => (Mode::Browse, TemplatesAction::None), | |
| 113 | + | (KeyCode::Char('s'), KeyModifiers::CONTROL) => match form.save(db) { | |
| 114 | + | Ok(()) => { | |
| 115 | + | self.status = format!("saved"); | |
| 116 | + | (Mode::Browse, TemplatesAction::Refresh) | |
| 117 | + | } | |
| 118 | + | Err(e) => { | |
| 119 | + | self.status = format!("save failed: {e}"); | |
| 120 | + | (Mode::Edit(form), TemplatesAction::None) | |
| 121 | + | } | |
| 122 | + | }, | |
| 123 | + | (KeyCode::Tab, _) => { | |
| 124 | + | form.cycle_focus(1); | |
| 125 | + | (Mode::Edit(form), TemplatesAction::None) | |
| 126 | + | } | |
| 127 | + | (KeyCode::BackTab, _) => { | |
| 128 | + | form.cycle_focus(-1); | |
| 129 | + | (Mode::Edit(form), TemplatesAction::None) | |
| 130 | + | } | |
| 131 | + | _ => { | |
| 132 | + | if let Some(refresh) = form.on_field_key(db, key) { | |
| 133 | + | match refresh { | |
| 134 | + | FormRefresh::Tags => { | |
| 135 | + | if let Ok(tags) = db.list_tags() { | |
| 136 | + | self.tags = tags.clone(); | |
| 137 | + | form.all_tags = tags; | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | (Mode::Edit(form), TemplatesAction::None) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | fn move_selection(&mut self, delta: isize) { | |
| 148 | + | if self.exercises.is_empty() { | |
| 149 | + | return; | |
| 150 | + | } | |
| 151 | + | let len = self.exercises.len() as isize; | |
| 152 | + | let cur = self.list_state.selected().unwrap_or(0) as isize; | |
| 153 | + | let next = (cur + delta).rem_euclid(len) as usize; | |
| 154 | + | self.list_state.select(Some(next)); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | fn selected(&self) -> Option<&Exercise> { | |
| 158 | + | self.list_state.selected().and_then(|i| self.exercises.get(i)) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | fn default_unit(&self, db: &Db) -> String { | |
| 162 | + | db.unit() | |
| 163 | + | .ok() | |
| 164 | + | .flatten() | |
| 165 | + | .map(|u| u.as_str().to_string()) | |
| 166 | + | .unwrap_or_else(|| "kg".to_string()) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | pub fn render(&mut self, frame: &mut Frame, area: Rect) { | |
| 170 | + | let chunks = Layout::default() | |
| 171 | + | .direction(Direction::Vertical) | |
| 172 | + | .constraints([Constraint::Min(3), Constraint::Length(1)]) | |
| 173 | + | .split(area); | |
| 174 | + | ||
| 175 | + | let panes = Layout::default() | |
| 176 | + | .direction(Direction::Horizontal) | |
| 177 | + | .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) | |
| 178 | + | .split(chunks[0]); | |
| 179 | + | ||
| 180 | + | // Left: exercise list. | |
| 181 | + | let items: Vec<ListItem> = if self.exercises.is_empty() { | |
| 182 | + | vec![ListItem::new("no templates yet — press n to add one")] | |
| 183 | + | } else { | |
| 184 | + | self.exercises | |
| 185 | + | .iter() | |
| 186 | + | .map(|e| ListItem::new(e.name.as_str())) | |
| 187 | + | .collect() | |
| 188 | + | }; | |
| 189 | + | let list = List::new(items) | |
| 190 | + | .block(Block::default().borders(Borders::ALL).title(" templates ")) | |
| 191 | + | .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) | |
| 192 | + | .highlight_symbol("> "); | |
| 193 | + | frame.render_stateful_widget(list, panes[0], &mut self.list_state); | |
| 194 | + | ||
| 195 | + | // Right: details or edit form. | |
| 196 | + | match &self.mode { | |
| 197 | + | Mode::Browse => self.render_details(frame, panes[1]), | |
| 198 | + | Mode::Edit(form) => form.render(frame, panes[1], &self.tags), | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // Footer. | |
| 202 | + | let hint = match &self.mode { | |
| 203 | + | Mode::Browse => "j/k move n new e edit d delete q quit", | |
| 204 | + | Mode::Edit(_) => "tab/shift-tab move space toggle + add tag ctrl-s save esc cancel", | |
| 205 | + | }; | |
| 206 | + | frame.render_widget( | |
| 207 | + | Paragraph::new(Line::from(vec![ | |
| 208 | + | Span::raw(hint), | |
| 209 | + | Span::raw(" "), | |
| 210 | + | Span::styled( | |
| 211 | + | self.status.as_str(), | |
| 212 | + | Style::default().add_modifier(Modifier::DIM), | |
| 213 | + | ), | |
| 214 | + | ])), | |
| 215 | + | chunks[1], | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | fn render_details(&self, frame: &mut Frame, area: Rect) { | |
| 220 | + | let block = Block::default().borders(Borders::ALL).title(" details "); | |
| 221 | + | if let Some(ex) = self.selected() { | |
| 222 | + | let tag_names: Vec<String> = ex | |
| 223 | + | .tag_ids | |
| 224 | + | .iter() | |
| 225 | + | .filter_map(|tid| self.tags.iter().find(|t| t.id == *tid)) | |
| 226 | + | .map(|t| t.name.clone()) | |
| 227 | + | .collect(); | |
| 228 | + | let body = format!( | |
| 229 | + | "name: {}\ntype: {}\nload unit: {}\nincrement: {}\ntags: {}", | |
| 230 | + | ex.name, | |
| 231 | + | ex.resistance_type.as_str(), | |
| 232 | + | ex.load_unit, | |
| 233 | + | ex.increment, | |
| 234 | + | if tag_names.is_empty() { | |
| 235 | + | "-".to_string() | |
| 236 | + | } else { | |
| 237 | + | tag_names.join(", ") | |
| 238 | + | }, | |
| 239 | + | ); | |
| 240 | + | frame.render_widget(Paragraph::new(body).block(block), area); | |
| 241 | + | } else { | |
| 242 | + | frame.render_widget( | |
| 243 | + | Paragraph::new("no template selected").block(block), | |
| 244 | + | area, | |
| 245 | + | ); | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // ---- edit form ------------------------------------------------------------- | |
| 251 | + | ||
| 252 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 253 | + | enum Field { | |
| 254 | + | Name, | |
| 255 | + | Type, | |
| 256 | + | LoadUnit, | |
| 257 | + | Increment, | |
| 258 | + | Tags, | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | enum FormRefresh { | |
| 262 | + | Tags, | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | struct EditForm { | |
| 266 | + | editing_id: Option<i64>, | |
| 267 | + | name: String, | |
| 268 | + | resistance_type: ResistanceType, | |
| 269 | + | load_unit: String, | |
| 270 | + | increment: String, | |
| 271 | + | selected_tag_ids: Vec<i64>, | |
| 272 | + | all_tags: Vec<Tag>, | |
| 273 | + | focus: Field, | |
| 274 | + | tag_cursor: usize, | |
| 275 | + | new_tag_buffer: Option<String>, | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | impl EditForm { | |
| 279 | + | fn new_blank(load_unit: String) -> Self { | |
| 280 | + | Self { | |
| 281 | + | editing_id: None, | |
| 282 | + | name: String::new(), | |
| 283 | + | resistance_type: ResistanceType::Freeweight, | |
| 284 | + | load_unit, | |
| 285 | + | increment: "2.5".to_string(), | |
| 286 | + | selected_tag_ids: Vec::new(), | |
| 287 | + | all_tags: Vec::new(), | |
| 288 | + | focus: Field::Name, | |
| 289 | + | tag_cursor: 0, | |
| 290 | + | new_tag_buffer: None, | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | fn from_exercise(ex: &Exercise) -> Self { | |
| 295 | + | Self { | |
| 296 | + | editing_id: Some(ex.id), | |
| 297 | + | name: ex.name.clone(), | |
| 298 | + | resistance_type: ex.resistance_type, | |
| 299 | + | load_unit: ex.load_unit.clone(), | |
| 300 | + | increment: format!("{}", ex.increment), | |
| 301 | + | selected_tag_ids: ex.tag_ids.clone(), | |
| 302 | + | all_tags: Vec::new(), | |
| 303 | + | focus: Field::Name, | |
| 304 | + | tag_cursor: 0, | |
| 305 | + | new_tag_buffer: None, | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | fn cycle_focus(&mut self, delta: i32) { | |
| 310 | + | let order = [ | |
| 311 | + | Field::Name, | |
| 312 | + | Field::Type, | |
| 313 | + | Field::LoadUnit, | |
| 314 | + | Field::Increment, | |
| 315 | + | Field::Tags, | |
| 316 | + | ]; | |
| 317 | + | let idx = order.iter().position(|f| *f == self.focus).unwrap_or(0) as i32; | |
| 318 | + | let next = (idx + delta).rem_euclid(order.len() as i32) as usize; | |
| 319 | + | self.focus = order[next]; | |
| 320 | + | self.new_tag_buffer = None; | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | fn on_field_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> { | |
| 324 | + | // Tag-add sub-prompt intercepts input first. | |
| 325 | + | if self.focus == Field::Tags && self.new_tag_buffer.is_some() { | |
| 326 | + | return self.on_tag_add_key(db, key); | |
| 327 | + | } | |
| 328 | + | match self.focus { | |
| 329 | + | Field::Name => text_edit(&mut self.name, key), | |
| 330 | + | Field::LoadUnit => text_edit(&mut self.load_unit, key), | |
| 331 | + | Field::Increment => text_edit(&mut self.increment, key), | |
| 332 | + | Field::Type => { | |
| 333 | + | let all = ResistanceType::ALL; | |
| 334 | + | let cur = all | |
| 335 | + | .iter() | |
| 336 | + | .position(|t| *t == self.resistance_type) | |
| 337 | + | .unwrap_or(0) as i32; | |
| 338 | + | let delta = match key.code { | |
| 339 | + | KeyCode::Left | KeyCode::Char('h') => -1, | |
| 340 | + | KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => 1, | |
| 341 | + | _ => 0, | |
| 342 | + | }; | |
| 343 | + | if delta != 0 { | |
| 344 | + | let next = (cur + delta).rem_euclid(all.len() as i32) as usize; | |
| 345 | + | self.resistance_type = all[next]; | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | Field::Tags => match key.code { | |
| 349 | + | KeyCode::Up | KeyCode::Char('k') => { | |
| 350 | + | if !self.all_tags.is_empty() { | |
| 351 | + | self.tag_cursor = (self.tag_cursor + self.all_tags.len() - 1) | |
| 352 | + | % self.all_tags.len(); | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | KeyCode::Down | KeyCode::Char('j') => { | |
| 356 | + | if !self.all_tags.is_empty() { | |
| 357 | + | self.tag_cursor = (self.tag_cursor + 1) % self.all_tags.len(); | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | KeyCode::Char(' ') => { | |
| 361 | + | if let Some(tag) = self.all_tags.get(self.tag_cursor) { | |
| 362 | + | if let Some(pos) = | |
| 363 | + | self.selected_tag_ids.iter().position(|id| *id == tag.id) | |
| 364 | + | { | |
| 365 | + | self.selected_tag_ids.remove(pos); | |
| 366 | + | } else { | |
| 367 | + | self.selected_tag_ids.push(tag.id); | |
| 368 | + | self.selected_tag_ids.sort(); | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | KeyCode::Char('+') => { | |
| 373 | + | self.new_tag_buffer = Some(String::new()); | |
| 374 | + | } | |
| 375 | + | _ => {} | |
| 376 | + | }, | |
| 377 | + | } | |
| 378 | + | None | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | fn on_tag_add_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> { | |
| 382 | + | let buf = self.new_tag_buffer.as_mut()?; | |
| 383 | + | match key.code { | |
| 384 | + | KeyCode::Esc => { | |
| 385 | + | self.new_tag_buffer = None; | |
| 386 | + | None | |
| 387 | + | } | |
| 388 | + | KeyCode::Enter => { | |
| 389 | + | let name = std::mem::take(buf); | |
| 390 | + | self.new_tag_buffer = None; | |
| 391 | + | if name.trim().is_empty() { | |
| 392 | + | return None; | |
| 393 | + | } | |
| 394 | + | match db.upsert_tag(&name) { | |
| 395 | + | Ok(tag) => { | |
| 396 | + | if !self.selected_tag_ids.contains(&tag.id) { | |
| 397 | + | self.selected_tag_ids.push(tag.id); | |
| 398 | + | self.selected_tag_ids.sort(); | |
| 399 | + | } | |
| 400 | + | Some(FormRefresh::Tags) | |
| 401 | + | } | |
| 402 | + | Err(_) => None, | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | KeyCode::Backspace => { | |
| 406 | + | buf.pop(); | |
| 407 | + | None | |
| 408 | + | } | |
| 409 | + | KeyCode::Char(c) => { | |
| 410 | + | buf.push(c); | |
| 411 | + | None | |
| 412 | + | } | |
| 413 | + | _ => None, | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | fn save(&self, db: &Db) -> Result<(), ripgrow_core::Error> { | |
| 418 | + | let increment: f64 = self | |
| 419 | + | .increment | |
| 420 | + | .trim() | |
| 421 | + | .parse() | |
| 422 | + | .map_err(|_| ripgrow_core::Error::InvalidExerciseName)?; | |
| 423 | + | match self.editing_id { | |
| 424 | + | Some(id) => db.update_exercise( | |
| 425 | + | id, | |
| 426 | + | &self.name, | |
| 427 | + | self.resistance_type, | |
| 428 | + | &self.load_unit, | |
| 429 | + | increment, | |
| 430 | + | &self.selected_tag_ids, | |
| 431 | + | ), | |
| 432 | + | None => db | |
| 433 | + | .create_exercise( | |
| 434 | + | &self.name, | |
| 435 | + | self.resistance_type, | |
| 436 | + | &self.load_unit, | |
| 437 | + | increment, | |
| 438 | + | &self.selected_tag_ids, | |
| 439 | + | ) | |
| 440 | + | .map(|_| ()), | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | fn render(&self, frame: &mut Frame, area: Rect, all_tags: &[Tag]) { | |
| 445 | + | let block = Block::default() | |
| 446 | + | .borders(Borders::ALL) | |
| 447 | + | .title(if self.editing_id.is_some() { | |
| 448 | + | " edit template " | |
| 449 | + | } else { | |
| 450 | + | " new template " | |
| 451 | + | }); | |
| 452 | + | let inner = block.inner(area); | |
| 453 | + | frame.render_widget(block, area); | |
| 454 | + | ||
| 455 | + | let tag_lines: Vec<Line> = if all_tags.is_empty() { | |
| 456 | + | vec![Line::from(" (no tags yet — press + to add one)")] | |
| 457 | + | } else { | |
| 458 | + | all_tags | |
| 459 | + | .iter() | |
| 460 | + | .enumerate() | |
| 461 | + | .map(|(i, t)| { | |
| 462 | + | let checked = self.selected_tag_ids.contains(&t.id); | |
| 463 | + | let cursor = self.focus == Field::Tags && self.tag_cursor == i; | |
| 464 | + | let marker = if checked { "[x]" } else { "[ ]" }; | |
| 465 | + | let prefix = if cursor { "> " } else { " " }; | |
| 466 | + | Line::from(format!("{prefix}{marker} {}", t.name)) | |
| 467 | + | }) | |
| 468 | + | .collect() | |
| 469 | + | }; | |
| 470 | + | ||
| 471 | + | let mut lines: Vec<Line> = vec![ | |
| 472 | + | field_line("name", &self.name, self.focus == Field::Name), | |
| 473 | + | field_line( | |
| 474 | + | "type", | |
| 475 | + | self.resistance_type.as_str(), | |
| 476 | + | self.focus == Field::Type, | |
| 477 | + | ), | |
| 478 | + | field_line("load unit", &self.load_unit, self.focus == Field::LoadUnit), | |
| 479 | + | field_line("increment", &self.increment, self.focus == Field::Increment), | |
| 480 | + | Line::from(""), | |
| 481 | + | Line::from(if self.focus == Field::Tags { | |
| 482 | + | Span::styled("tags:", Style::default().add_modifier(Modifier::REVERSED)) | |
| 483 | + | } else { | |
| 484 | + | Span::raw("tags:") | |
| 485 | + | }), | |
| 486 | + | ]; | |
| 487 | + | lines.extend(tag_lines); | |
| 488 | + | if let Some(buf) = self.new_tag_buffer.as_ref() { | |
| 489 | + | lines.push(Line::from("")); | |
| 490 | + | lines.push(Line::from(format!("new tag: {buf}_ (enter save, esc cancel)"))); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | frame.render_widget(Paragraph::new(lines), inner); | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | fn field_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> { | |
| 498 | + | let content = format!("{label:<12}{value}"); | |
| 499 | + | if focused { | |
| 500 | + | Line::from(Span::styled( |
Lines truncated