Skip to main content

max / ripgrow

templates: restructure the edit form around type-scoped middle sections The form now has a hierarchical Focus: Name and Type at the top, Middle(MiddleFocus) between, Tags at the bottom. Each resistance type owns its own middle-section shape via three call sites — middle_focus_order (tab order), render_middle (drawing), and save_middle (save-time conversion). Adding a new type is one arm in each; header and footer never move. Weighted state (load_unit, increment) lives in a WeightedFields substruct that persists across type toggles, so switching Freeweight -> Bodyweight -> Freeweight preserves the buffers. No new user-facing fields. The point of phase 3 is that phase 4 can slot per-kind data (weight-belt toggle for bodyweight, target duration for timed, distance unit for distance) into render_middle / on_middle_key / save_middle without touching anything else. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:48 UTC
Commit: dee68bfd963cde3504c8096020334a3ef8498880
Parent: 11987b2
1 file changed, +218 insertions, -143 deletions
@@ -132,7 +132,7 @@ impl TemplatesScreen {
132 132 // is already open it falls through so the buffer can accept the
133 133 // literal character (unlikely for tag names but not forbidden).
134 134 (KeyCode::Char('+'), _) if form.new_tag_buffer.is_none() => {
135 - form.focus = Field::Tags;
135 + form.focus = Focus::Tags;
136 136 form.new_tag_buffer = Some(String::new());
137 137 (Mode::Edit(form), TemplatesAction::None)
138 138 }
@@ -257,36 +257,63 @@ impl TemplatesScreen {
257 257
258 258 // ---- edit form -------------------------------------------------------------
259 259
260 + /// Focus positions the cursor can occupy. `Middle(_)` carries type-specific
261 + /// subfocus so each resistance kind can own its own middle section without
262 + /// polluting a shared Field enum.
260 263 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
261 - enum Field {
264 + enum Focus {
262 265 Name,
263 266 Type,
264 - LoadUnit,
265 - Increment,
267 + Middle(MiddleFocus),
266 268 Tags,
267 269 }
268 270
271 + /// Subfocus for the type-specific middle section. Each variant belongs to
272 + /// exactly one `TypeMiddle` shape; adding a new resistance type means
273 + /// adding a new variant here plus a new `TypeMiddle` variant + arms in
274 + /// `focus_order` / `render_middle` / `save_middle` / `on_middle_key`.
275 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
276 + enum MiddleFocus {
277 + WeightedLoadUnit,
278 + WeightedIncrement,
279 + // Timed and Distance middles are empty today. Phase 4 will add
280 + // TimedTargetDuration, DistanceUnit, etc. as their tables land.
281 + }
282 +
283 + /// Type-specific middle-section state. Held on the form regardless of
284 + /// the current resistance type so switching Freeweight -> Bodyweight ->
285 + /// Freeweight doesn't wipe the load_unit / increment the user typed.
286 + #[derive(Debug, Clone)]
287 + struct WeightedFields {
288 + load_unit: String,
289 + increment: String,
290 + }
291 +
292 + impl WeightedFields {
293 + fn new(load_unit: String) -> Self {
294 + Self {
295 + load_unit,
296 + increment: "2.5".to_string(),
297 + }
298 + }
299 + }
300 +
269 301 enum FormRefresh {
270 302 Tags,
271 303 }
272 304
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] {
305 + /// Middle-section focus order for a given resistance type. Empty when
306 + /// the type has no middle prompts (bodyweight, cardio). Header (Name,
307 + /// Type) and footer (Tags) are always present; this only names what
308 + /// slots between them.
309 + fn middle_focus_order(rt: ResistanceType) -> &'static [MiddleFocus] {
279 310 match rt {
280 - ResistanceType::Freeweight | ResistanceType::Machine => &[
281 - Field::Name,
282 - Field::Type,
283 - Field::LoadUnit,
284 - Field::Increment,
285 - Field::Tags,
286 - ],
311 + ResistanceType::Freeweight | ResistanceType::Machine => {
312 + &[MiddleFocus::WeightedLoadUnit, MiddleFocus::WeightedIncrement]
313 + }
287 314 ResistanceType::Bodyweight
288 315 | ResistanceType::CardioTime
289 - | ResistanceType::CardioDistance => &[Field::Name, Field::Type, Field::Tags],
316 + | ResistanceType::CardioDistance => &[],
290 317 }
291 318 }
292 319
@@ -311,11 +338,14 @@ struct EditForm {
311 338 editing_id: Option<i64>,
312 339 name: String,
313 340 resistance_type: ResistanceType,
314 - load_unit: String,
315 - increment: String,
341 + /// Held for every type so Freeweight -> Bodyweight -> Freeweight
342 + /// doesn't wipe the user's load-unit / increment buffers. Only read
343 + /// on save when the current type actually uses weighted middle
344 + /// fields; otherwise the values sleep here.
345 + weighted: WeightedFields,
316 346 selected_tag_ids: Vec<i64>,
317 347 all_tags: Vec<Tag>,
318 - focus: Field,
348 + focus: Focus,
319 349 tag_cursor: usize,
320 350 new_tag_buffer: Option<String>,
321 351 }
@@ -326,11 +356,10 @@ impl EditForm {
326 356 editing_id: None,
327 357 name: String::new(),
328 358 resistance_type: ResistanceType::Freeweight,
329 - load_unit,
330 - increment: "2.5".to_string(),
359 + weighted: WeightedFields::new(load_unit),
331 360 selected_tag_ids: Vec::new(),
332 361 all_tags: Vec::new(),
333 - focus: Field::Name,
362 + focus: Focus::Name,
334 363 tag_cursor: 0,
335 364 new_tag_buffer: None,
336 365 }
@@ -341,18 +370,31 @@ impl EditForm {
341 370 editing_id: Some(ex.id),
342 371 name: ex.name.clone(),
343 372 resistance_type: ex.resistance_type,
344 - load_unit: ex.load_unit.as_str().to_string(),
345 - increment: format!("{}", ex.increment),
373 + weighted: WeightedFields {
374 + load_unit: ex.load_unit.as_str().to_string(),
375 + increment: format!("{}", ex.increment),
376 + },
346 377 selected_tag_ids: ex.tag_ids.clone(),
347 378 all_tags: Vec::new(),
348 - focus: Field::Name,
379 + focus: Focus::Name,
349 380 tag_cursor: 0,
350 381 new_tag_buffer: None,
351 382 }
352 383 }
353 384
385 + /// Full tab order for the current resistance type: header, middle
386 + /// (per type), footer.
387 + fn focus_order(&self) -> Vec<Focus> {
388 + let mut order = vec![Focus::Name, Focus::Type];
389 + for m in middle_focus_order(self.resistance_type) {
390 + order.push(Focus::Middle(*m));
391 + }
392 + order.push(Focus::Tags);
393 + order
394 + }
395 +
354 396 fn cycle_focus(&mut self, delta: i32) {
355 - let order = visible_fields(self.resistance_type);
397 + let order = self.focus_order();
356 398 let idx = order.iter().position(|f| *f == self.focus).unwrap_or(0) as i32;
357 399 let next = (idx + delta).rem_euclid(order.len() as i32) as usize;
358 400 self.focus = order[next];
@@ -360,25 +402,24 @@ impl EditForm {
360 402 }
361 403
362 404 /// 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.
405 + /// change that hid the currently-focused middle field so the cursor
406 + /// doesn't end up in a section that no longer renders.
365 407 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];
408 + let order = self.focus_order();
409 + if !order.contains(&self.focus) {
410 + self.focus = Focus::Name;
369 411 }
370 412 }
371 413
372 414 fn on_field_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> {
373 415 // Tag-add sub-prompt intercepts input first.
374 - if self.focus == Field::Tags && self.new_tag_buffer.is_some() {
416 + if self.focus == Focus::Tags && self.new_tag_buffer.is_some() {
375 417 return self.on_tag_add_key(db, key);
376 418 }
377 419 match self.focus {
378 - Field::Name => text_edit(&mut self.name, key),
379 - Field::LoadUnit => text_edit(&mut self.load_unit, key),
380 - Field::Increment => text_edit(&mut self.increment, key),
381 - Field::Type => {
420 + Focus::Name => text_edit(&mut self.name, key),
421 + Focus::Middle(m) => self.on_middle_key(m, key),
422 + Focus::Type => {
382 423 let all = ResistanceType::ALL;
383 424 let cur = all
384 425 .iter()
@@ -392,14 +433,10 @@ impl EditForm {
392 433 if delta != 0 {
393 434 let next = (cur + delta).rem_euclid(all.len() as i32) as usize;
394 435 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 436 self.snap_focus_into_visible();
400 437 }
401 438 }
402 - Field::Tags => match key.code {
439 + Focus::Tags => match key.code {
403 440 KeyCode::Up | KeyCode::Char('k') => {
404 441 if !self.all_tags.is_empty() {
405 442 self.tag_cursor = (self.tag_cursor + self.all_tags.len() - 1)
@@ -432,6 +469,16 @@ impl EditForm {
432 469 None
433 470 }
434 471
472 + /// Route a keypress to the current middle-field subfocus. Adding a
473 + /// new resistance kind that has editable middle prompts means adding
474 + /// a match arm here plus one in `render_middle` / `save_middle`.
475 + fn on_middle_key(&mut self, m: MiddleFocus, key: KeyEvent) {
476 + match m {
477 + MiddleFocus::WeightedLoadUnit => text_edit(&mut self.weighted.load_unit, key),
478 + MiddleFocus::WeightedIncrement => text_edit(&mut self.weighted.increment, key),
479 + }
480 + }
481 +
435 482 fn on_tag_add_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> {
436 483 let buf = self.new_tag_buffer.as_mut()?;
437 484 match key.code {
@@ -469,27 +516,7 @@ impl EditForm {
469 516 }
470 517
471 518 fn save(&self, db: &Db) -> Result<(), ripgrow_core::Error> {
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 - };
519 + let (load_unit, increment) = self.save_middle()?;
493 520 match self.editing_id {
494 521 Some(id) => db.update_exercise(
495 522 id,
@@ -511,6 +538,33 @@ impl EditForm {
511 538 }
512 539 }
513 540
541 + /// Per-type: turn the current middle-section buffers into the
542 + /// (LoadUnit, increment) pair the schema wants. Types without a
543 + /// middle section fall back to the buffered LoadUnit (defaulting to
544 + /// Kg if unparseable) and a zero increment. Adding a new type with
545 + /// its own middle is one match arm here.
546 + fn save_middle(&self) -> Result<(LoadUnit, f64), ripgrow_core::Error> {
547 + match self.resistance_type {
548 + ResistanceType::Freeweight | ResistanceType::Machine => {
549 + let increment: f64 = self
550 + .weighted
551 + .increment
552 + .trim()
553 + .parse()
554 + .map_err(|_| ripgrow_core::Error::InvalidExerciseName)?;
555 + let load_unit = LoadUnit::parse(self.weighted.load_unit.trim())?;
556 + Ok((load_unit, increment))
557 + }
558 + ResistanceType::Bodyweight
559 + | ResistanceType::CardioTime
560 + | ResistanceType::CardioDistance => {
561 + let load_unit =
562 + LoadUnit::parse(self.weighted.load_unit.trim()).unwrap_or(LoadUnit::Kg);
563 + Ok((load_unit, 0.0))
564 + }
565 + }
566 + }
567 +
514 568 fn render(&self, frame: &mut Frame, area: Rect, all_tags: &[Tag]) {
515 569 let block = Block::default()
516 570 .borders(Borders::ALL)
@@ -522,68 +576,36 @@ impl EditForm {
522 576 let inner = block.inner(area);
523 577 frame.render_widget(block, area);
524 578
525 - let tag_lines: Vec<Line> = if all_tags.is_empty() {
526 - vec![Line::from(" (no tags yet — press + to add one)")]
527 - } else {
528 - all_tags
529 - .iter()
530 - .enumerate()
531 - .map(|(i, t)| {
532 - let checked = self.selected_tag_ids.contains(&t.id);
533 - let cursor = self.focus == Field::Tags && self.tag_cursor == i;
534 - let marker = if checked { "[x]" } else { "[ ]" };
535 - let prefix = if cursor { "> " } else { " " };
536 - Line::from(format!("{prefix}{marker} {}", t.name))
537 - })
538 - .collect()
539 - };
540 -
541 - let visible = visible_fields(self.resistance_type);
542 579 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 - }
580 +
581 + // Header: name + type (+ optional hint).
582 + lines.push(field_line("name", &self.name, self.focus == Focus::Name));
583 + lines.push(field_line(
584 + "type",
585 + self.resistance_type.as_str(),
586 + self.focus == Focus::Type,
587 + ));
588 + if let Some(hint) = type_hint(self.resistance_type) {
589 + lines.push(Line::from(Span::styled(
590 + format!(" {hint}"),
591 + Style::default().add_modifier(Modifier::DIM),
592 + )));
586 593 }
594 +
595 + // Middle: type-specific.
596 + self.render_middle(&mut lines);
597 +
598 + // Footer: tag toggle grid.
599 + lines.push(Line::from(""));
600 + lines.push(Line::from(if self.focus == Focus::Tags {
601 + Span::styled("tags:", Style::default().add_modifier(Modifier::REVERSED))
602 + } else {
603 + Span::raw("tags:")
604 + }));
605 + for tag_line in tag_lines(all_tags, &self.selected_tag_ids, self.focus, self.tag_cursor) {
606 + lines.push(tag_line);
607 + }
608 +
587 609 if let Some(buf) = self.new_tag_buffer.as_ref() {
588 610 lines.push(Line::from(""));
589 611 lines.push(Line::from(format!("new tag: {buf}_ (enter save, esc cancel)")));
@@ -591,6 +613,55 @@ impl EditForm {
591 613
592 614 frame.render_widget(Paragraph::new(lines), inner);
593 615 }
616 +
617 + /// Per-type middle-section renderer. Weighted types show load_unit
618 + /// and increment; bodyweight/cardio render nothing here. Adding a
619 + /// new type with its own middle is one arm here + one in
620 + /// `on_middle_key` + one in `save_middle`.
621 + fn render_middle<'a>(&'a self, lines: &mut Vec<Line<'a>>) {
622 + match self.resistance_type {
623 + ResistanceType::Freeweight | ResistanceType::Machine => {
624 + lines.push(field_line(
625 + "load unit",
626 + &self.weighted.load_unit,
627 + self.focus == Focus::Middle(MiddleFocus::WeightedLoadUnit),
628 + ));
629 + lines.push(field_line(
630 + "increment",
631 + &self.weighted.increment,
632 + self.focus == Focus::Middle(MiddleFocus::WeightedIncrement),
633 + ));
634 + }
635 + ResistanceType::Bodyweight
636 + | ResistanceType::CardioTime
637 + | ResistanceType::CardioDistance => {
638 + // No middle prompts today. Phase 4 will add per-kind fields
639 + // (weight-belt toggle, target duration, distance unit) here.
640 + }
641 + }
642 + }
643 + }
644 +
645 + fn tag_lines<'a>(
646 + all_tags: &'a [Tag],
647 + selected: &[i64],
648 + focus: Focus,
649 + tag_cursor: usize,
650 + ) -> Vec<Line<'a>> {
651 + if all_tags.is_empty() {
652 + return vec![Line::from(" (no tags yet — press + to add one)")];
653 + }
654 + all_tags
655 + .iter()
656 + .enumerate()
657 + .map(|(i, t)| {
658 + let checked = selected.contains(&t.id);
659 + let cursor = focus == Focus::Tags && tag_cursor == i;
660 + let marker = if checked { "[x]" } else { "[ ]" };
661 + let prefix = if cursor { "> " } else { " " };
662 + Line::from(format!("{prefix}{marker} {}", t.name))
663 + })
664 + .collect()
594 665 }
595 666
596 667 fn field_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {
@@ -670,10 +741,10 @@ mod tests {
670 741 #[test]
671 742 fn tab_cycles_focus_and_type_field_cycles() {
672 743 let mut form = EditForm::new_blank("kg".into());
673 - assert_eq!(form.focus, Field::Name);
744 + assert_eq!(form.focus, Focus::Name);
674 745 form.cycle_focus(1);
675 - assert_eq!(form.focus, Field::Type);
676 - // Right on type field cycles to Machine (Freeweight is index 2 in ALL).
746 + assert_eq!(form.focus, Focus::Type);
747 + // Right on type field cycles to the next ResistanceType.
677 748 form.on_field_key(
678 749 &fresh_db(),
679 750 KeyEvent::new(KeyCode::Right, KeyModifiers::empty()),
@@ -719,7 +790,7 @@ mod tests {
719 790 panic!("expected edit mode");
720 791 };
721 792 assert!(form.new_tag_buffer.is_some());
722 - assert_eq!(form.focus, Field::Tags);
793 + assert_eq!(form.focus, Focus::Tags);
723 794 for c in "pull".chars() {
724 795 screen.on_key(&db, key(KeyCode::Char(c)));
725 796 }
@@ -728,40 +799,55 @@ mod tests {
728 799 }
729 800
730 801 #[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));
802 + fn bodyweight_middle_is_empty() {
803 + assert!(middle_focus_order(ResistanceType::Bodyweight).is_empty());
804 + assert!(middle_focus_order(ResistanceType::CardioTime).is_empty());
805 + assert!(middle_focus_order(ResistanceType::CardioDistance).is_empty());
740 806 }
741 807
742 808 #[test]
743 - fn tab_skips_hidden_fields_on_bodyweight() {
809 + fn weighted_middle_has_load_unit_and_increment() {
810 + let order = middle_focus_order(ResistanceType::Freeweight);
811 + assert_eq!(
812 + order,
813 + &[MiddleFocus::WeightedLoadUnit, MiddleFocus::WeightedIncrement]
814 + );
815 + }
816 +
817 + #[test]
818 + fn tab_skips_absent_middle_on_bodyweight() {
744 819 let mut form = EditForm::new_blank("kg".into());
745 820 form.resistance_type = ResistanceType::Bodyweight;
746 - form.focus = Field::Name;
821 + form.focus = Focus::Name;
747 822 form.cycle_focus(1);
748 - assert_eq!(form.focus, Field::Type);
823 + assert_eq!(form.focus, Focus::Type);
749 824 form.cycle_focus(1);
750 - assert_eq!(form.focus, Field::Tags, "should skip LoadUnit and Increment");
825 + assert_eq!(form.focus, Focus::Tags, "no middle to visit");
751 826 form.cycle_focus(1);
752 - assert_eq!(form.focus, Field::Name, "wraps back to Name");
827 + assert_eq!(form.focus, Focus::Name, "wraps back to Name");
753 828 }
754 829
755 830 #[test]
756 - fn changing_type_snaps_focus_out_of_hidden_field() {
831 + fn changing_type_snaps_focus_out_of_absent_middle() {
757 832 let mut form = EditForm::new_blank("kg".into());
Lines truncated