Skip to main content

max / ripgrow

templates: progressive disclosure in the edit form visible_fields(resistance_type) returns the fields that make sense for the chosen type. Freeweight and Machine show everything; Bodyweight and both cardio kinds hide load_unit and increment, since those questions don't apply. Tab now skips hidden fields; changing type snaps focus back into the visible set; save fills defaults for whatever was hidden (increment = 0, load_unit = profile default). Hidden values persist in the form buffers so toggling to Bodyweight and back to Freeweight doesn't wipe the increment you already typed. A dim one-line hint under the Type field explains what has been elided ("bodyweight — no load or increment prompted") so the missing prompts don't look like a bug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:42 UTC
Commit: 11987b2acbd4d17f753f82f8cfd975993fb89bcf
Parent: 2f9849e
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, &[])