max / ripgrow
1 file changed,
+188 insertions,
-30 deletions
| @@ -270,6 +270,43 @@ enum FormRefresh { | |||
| 270 | 270 | Tags, | |
| 271 | 271 | } | |
| 272 | 272 | ||
| 273 | + | /// Progressive disclosure: only prompt for fields that make sense for the | |
| 274 | + | /// chosen resistance type. Bodyweight and cardio exercises inherit the | |
| 275 | + | /// profile's default load unit and get a zero increment; the form hides | |
| 276 | + | /// those fields entirely so the operator isn't asked to answer questions | |
| 277 | + | /// that don't apply. Adding a new type is one line here. | |
| 278 | + | fn visible_fields(rt: ResistanceType) -> &'static [Field] { | |
| 279 | + | match rt { | |
| 280 | + | ResistanceType::Freeweight | ResistanceType::Machine => &[ | |
| 281 | + | Field::Name, | |
| 282 | + | Field::Type, | |
| 283 | + | Field::LoadUnit, | |
| 284 | + | Field::Increment, | |
| 285 | + | Field::Tags, | |
| 286 | + | ], | |
| 287 | + | ResistanceType::Bodyweight | |
| 288 | + | | ResistanceType::CardioTime | |
| 289 | + | | ResistanceType::CardioDistance => &[Field::Name, Field::Type, Field::Tags], | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// One-line hint shown under the Type row explaining which prompts have | |
| 294 | + | /// been elided for the current type. `None` when every field applies. | |
| 295 | + | fn type_hint(rt: ResistanceType) -> Option<&'static str> { | |
| 296 | + | match rt { | |
| 297 | + | ResistanceType::Freeweight | ResistanceType::Machine => None, | |
| 298 | + | ResistanceType::Bodyweight => { | |
| 299 | + | Some("bodyweight — no load or increment prompted (weighted variants come later)") | |
| 300 | + | } | |
| 301 | + | ResistanceType::CardioTime => { | |
| 302 | + | Some("cardio (time) — duration lives on the effort kind (phase 4 will split this out)") | |
| 303 | + | } | |
| 304 | + | ResistanceType::CardioDistance => { | |
| 305 | + | Some("cardio (distance) — distance lives on the effort kind (phase 4 will split this out)") | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 273 | 310 | struct EditForm { | |
| 274 | 311 | editing_id: Option<i64>, | |
| 275 | 312 | name: String, | |
| @@ -315,19 +352,23 @@ impl EditForm { | |||
| 315 | 352 | } | |
| 316 | 353 | ||
| 317 | 354 | fn cycle_focus(&mut self, delta: i32) { | |
| 318 | - | let order = [ | |
| 319 | - | Field::Name, | |
| 320 | - | Field::Type, | |
| 321 | - | Field::LoadUnit, | |
| 322 | - | Field::Increment, | |
| 323 | - | Field::Tags, | |
| 324 | - | ]; | |
| 355 | + | let order = visible_fields(self.resistance_type); | |
| 325 | 356 | let idx = order.iter().position(|f| *f == self.focus).unwrap_or(0) as i32; | |
| 326 | 357 | let next = (idx + delta).rem_euclid(order.len() as i32) as usize; | |
| 327 | 358 | self.focus = order[next]; | |
| 328 | 359 | self.new_tag_buffer = None; | |
| 329 | 360 | } | |
| 330 | 361 | ||
| 362 | + | /// Snap focus onto the nearest visible field. Called after a Type | |
| 363 | + | /// change that hid the currently-focused field so the cursor doesn't | |
| 364 | + | /// end up in nowhere. | |
| 365 | + | fn snap_focus_into_visible(&mut self) { | |
| 366 | + | let visible = visible_fields(self.resistance_type); | |
| 367 | + | if !visible.contains(&self.focus) { | |
| 368 | + | self.focus = visible[0]; | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 331 | 372 | fn on_field_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> { | |
| 332 | 373 | // Tag-add sub-prompt intercepts input first. | |
| 333 | 374 | if self.focus == Field::Tags && self.new_tag_buffer.is_some() { | |
| @@ -351,6 +392,11 @@ impl EditForm { | |||
| 351 | 392 | if delta != 0 { | |
| 352 | 393 | let next = (cur + delta).rem_euclid(all.len() as i32) as usize; | |
| 353 | 394 | self.resistance_type = all[next]; | |
| 395 | + | // Hidden LoadUnit / Increment values persist in the | |
| 396 | + | // form buffers so switching back doesn't wipe them, | |
| 397 | + | // but the focus needs to move if it was pointing at | |
| 398 | + | // a field that's no longer visible. | |
| 399 | + | self.snap_focus_into_visible(); | |
| 354 | 400 | } | |
| 355 | 401 | } | |
| 356 | 402 | Field::Tags => match key.code { | |
| @@ -423,12 +469,27 @@ impl EditForm { | |||
| 423 | 469 | } | |
| 424 | 470 | ||
| 425 | 471 | fn save(&self, db: &Db) -> Result<(), ripgrow_core::Error> { | |
| 426 | - | let increment: f64 = self | |
| 427 | - | .increment | |
| 428 | - | .trim() | |
| 429 | - | .parse() | |
| 430 | - | .map_err(|_| ripgrow_core::Error::InvalidExerciseName)?; | |
| 431 | - | let load_unit = LoadUnit::parse(self.load_unit.trim())?; | |
| 472 | + | // Fill defaults for fields that were hidden by progressive | |
| 473 | + | // disclosure. Bodyweight/cardio don't prompt for load_unit or | |
| 474 | + | // increment, but the schema still stores them, so a bodyweight | |
| 475 | + | // exercise ends up with the profile's default unit and a zero | |
| 476 | + | // increment. When the user switches back to a weighted type | |
| 477 | + | // their prior LoadUnit / Increment inputs are still in the | |
| 478 | + | // buffers and get used again. | |
| 479 | + | let visible = visible_fields(self.resistance_type); | |
| 480 | + | let increment: f64 = if visible.contains(&Field::Increment) { | |
| 481 | + | self.increment | |
| 482 | + | .trim() | |
| 483 | + | .parse() | |
| 484 | + | .map_err(|_| ripgrow_core::Error::InvalidExerciseName)? | |
| 485 | + | } else { | |
| 486 | + | 0.0 | |
| 487 | + | }; | |
| 488 | + | let load_unit = if visible.contains(&Field::LoadUnit) { | |
| 489 | + | LoadUnit::parse(self.load_unit.trim())? | |
| 490 | + | } else { | |
| 491 | + | LoadUnit::parse(self.load_unit.trim()).unwrap_or(LoadUnit::Kg) | |
| 492 | + | }; | |
| 432 | 493 | match self.editing_id { | |
| 433 | 494 | Some(id) => db.update_exercise( | |
| 434 | 495 | id, | |
| @@ -477,23 +538,52 @@ impl EditForm { | |||
| 477 | 538 | .collect() | |
| 478 | 539 | }; | |
| 479 | 540 | ||
| 480 | - | let mut lines: Vec<Line> = vec![ | |
| 481 | - | field_line("name", &self.name, self.focus == Field::Name), | |
| 482 | - | field_line( | |
| 483 | - | "type", | |
| 484 | - | self.resistance_type.as_str(), | |
| 485 | - | self.focus == Field::Type, | |
| 486 | - | ), | |
| 487 | - | field_line("load unit", &self.load_unit, self.focus == Field::LoadUnit), | |
| 488 | - | field_line("increment", &self.increment, self.focus == Field::Increment), | |
| 489 | - | Line::from(""), | |
| 490 | - | Line::from(if self.focus == Field::Tags { | |
| 491 | - | Span::styled("tags:", Style::default().add_modifier(Modifier::REVERSED)) | |
| 492 | - | } else { | |
| 493 | - | Span::raw("tags:") | |
| 494 | - | }), | |
| 495 | - | ]; | |
| 496 | - | lines.extend(tag_lines); | |
| 541 | + | let visible = visible_fields(self.resistance_type); | |
| 542 | + | let mut lines: Vec<Line> = Vec::new(); | |
| 543 | + | for field in visible { | |
| 544 | + | match field { | |
| 545 | + | Field::Name => lines.push(field_line( | |
| 546 | + | "name", | |
| 547 | + | &self.name, | |
| 548 | + | self.focus == Field::Name, | |
| 549 | + | )), | |
| 550 | + | Field::Type => { | |
| 551 | + | lines.push(field_line( | |
| 552 | + | "type", | |
| 553 | + | self.resistance_type.as_str(), | |
| 554 | + | self.focus == Field::Type, | |
| 555 | + | )); | |
| 556 | + | if let Some(hint) = type_hint(self.resistance_type) { | |
| 557 | + | lines.push(Line::from(Span::styled( | |
| 558 | + | format!(" {hint}"), | |
| 559 | + | Style::default().add_modifier(Modifier::DIM), | |
| 560 | + | ))); | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | Field::LoadUnit => lines.push(field_line( | |
| 564 | + | "load unit", | |
| 565 | + | &self.load_unit, | |
| 566 | + | self.focus == Field::LoadUnit, | |
| 567 | + | )), | |
| 568 | + | Field::Increment => lines.push(field_line( | |
| 569 | + | "increment", | |
| 570 | + | &self.increment, | |
| 571 | + | self.focus == Field::Increment, | |
| 572 | + | )), | |
| 573 | + | Field::Tags => { | |
| 574 | + | lines.push(Line::from("")); | |
| 575 | + | lines.push(Line::from(if self.focus == Field::Tags { | |
| 576 | + | Span::styled( | |
| 577 | + | "tags:", | |
| 578 | + | Style::default().add_modifier(Modifier::REVERSED), | |
| 579 | + | ) | |
| 580 | + | } else { | |
| 581 | + | Span::raw("tags:") | |
| 582 | + | })); | |
| 583 | + | lines.extend(tag_lines.iter().cloned()); | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | } | |
| 497 | 587 | if let Some(buf) = self.new_tag_buffer.as_ref() { | |
| 498 | 588 | lines.push(Line::from("")); | |
| 499 | 589 | lines.push(Line::from(format!("new tag: {buf}_ (enter save, esc cancel)"))); | |
| @@ -638,6 +728,74 @@ mod tests { | |||
| 638 | 728 | } | |
| 639 | 729 | ||
| 640 | 730 | #[test] | |
| 731 | + | fn bodyweight_form_hides_load_unit_and_increment() { | |
| 732 | + | let mut form = EditForm::new_blank("kg".into()); | |
| 733 | + | form.resistance_type = ResistanceType::Bodyweight; | |
| 734 | + | let visible = visible_fields(form.resistance_type); | |
| 735 | + | assert!(!visible.contains(&Field::LoadUnit)); | |
| 736 | + | assert!(!visible.contains(&Field::Increment)); | |
| 737 | + | assert!(visible.contains(&Field::Name)); | |
| 738 | + | assert!(visible.contains(&Field::Type)); | |
| 739 | + | assert!(visible.contains(&Field::Tags)); | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | #[test] | |
| 743 | + | fn tab_skips_hidden_fields_on_bodyweight() { | |
| 744 | + | let mut form = EditForm::new_blank("kg".into()); | |
| 745 | + | form.resistance_type = ResistanceType::Bodyweight; | |
| 746 | + | form.focus = Field::Name; | |
| 747 | + | form.cycle_focus(1); | |
| 748 | + | assert_eq!(form.focus, Field::Type); | |
| 749 | + | form.cycle_focus(1); | |
| 750 | + | assert_eq!(form.focus, Field::Tags, "should skip LoadUnit and Increment"); | |
| 751 | + | form.cycle_focus(1); | |
| 752 | + | assert_eq!(form.focus, Field::Name, "wraps back to Name"); | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | #[test] | |
| 756 | + | fn changing_type_snaps_focus_out_of_hidden_field() { | |
| 757 | + | let mut form = EditForm::new_blank("kg".into()); | |
| 758 | + | // Start on Increment (visible for the default Freeweight). | |
| 759 | + | form.focus = Field::Increment; | |
| 760 | + | form.resistance_type = ResistanceType::Freeweight; | |
| 761 | + | // Simulate a Type change to Bodyweight by walking the same code path. | |
| 762 | + | form.resistance_type = ResistanceType::Bodyweight; | |
| 763 | + | form.snap_focus_into_visible(); | |
| 764 | + | assert_eq!(form.focus, Field::Name, "snaps to first visible field"); | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | #[test] | |
| 768 | + | fn saving_bodyweight_uses_defaults_for_hidden_fields() { | |
| 769 | + | let db = fresh_db(); | |
| 770 | + | let mut screen = TemplatesScreen::load(&db).unwrap(); | |
| 771 | + | screen.on_key(&db, key(KeyCode::Char('n'))); | |
| 772 | + | // Type name, cycle to Type, switch to Bodyweight. | |
| 773 | + | for c in "pullup".chars() { | |
| 774 | + | screen.on_key(&db, key(KeyCode::Char(c))); | |
| 775 | + | } | |
| 776 | + | screen.on_key(&db, key(KeyCode::Tab)); // -> Type | |
| 777 | + | // Right twice: Freeweight is ALL[2], Bodyweight is ALL[0]. | |
| 778 | + | // Start on Freeweight, right -> Machine (ALL[1]), right -> Bodyweight? No: | |
| 779 | + | // ALL = [Bodyweight, Machine, Freeweight, CardioTime, CardioDistance]. | |
| 780 | + | // From Freeweight, right = CardioTime, right = CardioDistance, | |
| 781 | + | // right = Bodyweight. | |
| 782 | + | for _ in 0..3 { | |
| 783 | + | screen.on_key(&db, key(KeyCode::Right)); | |
| 784 | + | } | |
| 785 | + | let Mode::Edit(form) = &screen.mode else { | |
| 786 | + | panic!("expected edit mode"); | |
| 787 | + | }; | |
| 788 | + | assert_eq!(form.resistance_type, ResistanceType::Bodyweight); | |
| 789 | + | screen.on_key(&db, ctrl('s')); | |
| 790 | + | let list = db.list_exercises().unwrap(); | |
| 791 | + | assert_eq!(list.len(), 1); | |
| 792 | + | assert_eq!(list[0].resistance_type, ResistanceType::Bodyweight); | |
| 793 | + | assert_eq!(list[0].increment, 0.0); | |
| 794 | + | // load_unit falls back to the profile's default (Kg here). | |
| 795 | + | assert_eq!(list[0].load_unit, LoadUnit::Kg); | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | #[test] | |
| 641 | 799 | fn delete_removes_selected_exercise() { | |
| 642 | 800 | let db = fresh_db(); | |
| 643 | 801 | db.create_exercise("a", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[]) |