max / ripgrow
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