Skip to main content

max / ripgrow

31.7 KB · 895 lines History Blame Raw
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, LoadUnit, 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 // `+` opens the tag-add prompt from any field. When the prompt
132 // is already open it falls through so the buffer can accept the
133 // literal character (unlikely for tag names but not forbidden).
134 (KeyCode::Char('+'), _) if form.new_tag_buffer.is_none() => {
135 form.focus = Focus::Tags;
136 form.new_tag_buffer = Some(String::new());
137 (Mode::Edit(form), TemplatesAction::None)
138 }
139 _ => {
140 if let Some(refresh) = form.on_field_key(db, key) {
141 match refresh {
142 FormRefresh::Tags => {
143 if let Ok(tags) = db.list_tags() {
144 self.tags = tags.clone();
145 form.all_tags = tags;
146 }
147 }
148 }
149 }
150 (Mode::Edit(form), TemplatesAction::None)
151 }
152 }
153 }
154
155 fn move_selection(&mut self, delta: isize) {
156 if self.exercises.is_empty() {
157 return;
158 }
159 let len = self.exercises.len() as isize;
160 let cur = self.list_state.selected().unwrap_or(0) as isize;
161 let next = (cur + delta).rem_euclid(len) as usize;
162 self.list_state.select(Some(next));
163 }
164
165 fn selected(&self) -> Option<&Exercise> {
166 self.list_state.selected().and_then(|i| self.exercises.get(i))
167 }
168
169 fn default_unit(&self, db: &Db) -> String {
170 db.unit()
171 .ok()
172 .flatten()
173 .map(|u| u.as_str().to_string())
174 .unwrap_or_else(|| "kg".to_string())
175 }
176
177 pub fn render(&mut self, frame: &mut Frame, area: Rect) {
178 let chunks = Layout::default()
179 .direction(Direction::Vertical)
180 .constraints([Constraint::Min(3), Constraint::Length(1)])
181 .split(area);
182
183 let panes = Layout::default()
184 .direction(Direction::Horizontal)
185 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
186 .split(chunks[0]);
187
188 // Left: exercise list.
189 let items: Vec<ListItem> = if self.exercises.is_empty() {
190 vec![ListItem::new("no templates yet — press n to add one")]
191 } else {
192 self.exercises
193 .iter()
194 .map(|e| ListItem::new(e.name.as_str()))
195 .collect()
196 };
197 let list = List::new(items)
198 .block(Block::default().borders(Borders::ALL).title(" templates "))
199 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
200 .highlight_symbol("> ");
201 frame.render_stateful_widget(list, panes[0], &mut self.list_state);
202
203 // Right: details or edit form.
204 match &self.mode {
205 Mode::Browse => self.render_details(frame, panes[1]),
206 Mode::Edit(form) => form.render(frame, panes[1], &self.tags),
207 }
208
209 // Footer.
210 let hint = match &self.mode {
211 Mode::Browse => "j/k move n new e edit d delete q quit",
212 Mode::Edit(_) => "tab/shift-tab move + new tag (any field) space toggle ctrl-s save esc cancel",
213 };
214 frame.render_widget(
215 Paragraph::new(Line::from(vec![
216 Span::raw(hint),
217 Span::raw(" "),
218 Span::styled(
219 self.status.as_str(),
220 Style::default().add_modifier(Modifier::DIM),
221 ),
222 ])),
223 chunks[1],
224 );
225 }
226
227 fn render_details(&self, frame: &mut Frame, area: Rect) {
228 let block = Block::default().borders(Borders::ALL).title(" details ");
229 if let Some(ex) = self.selected() {
230 let tag_names: Vec<String> = ex
231 .tag_ids
232 .iter()
233 .filter_map(|tid| self.tags.iter().find(|t| t.id == *tid))
234 .map(|t| t.name.clone())
235 .collect();
236 let body = format!(
237 "name: {}\ntype: {}\nload unit: {}\nincrement: {}\ntags: {}",
238 ex.name,
239 ex.resistance_type.as_str(),
240 ex.load_unit,
241 ex.increment,
242 if tag_names.is_empty() {
243 "-".to_string()
244 } else {
245 tag_names.join(", ")
246 },
247 );
248 frame.render_widget(Paragraph::new(body).block(block), area);
249 } else {
250 frame.render_widget(
251 Paragraph::new("no template selected").block(block),
252 area,
253 );
254 }
255 }
256 }
257
258 // ---- edit form -------------------------------------------------------------
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.
263 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
264 enum Focus {
265 Name,
266 Type,
267 Middle(MiddleFocus),
268 Tags,
269 }
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
301 enum FormRefresh {
302 Tags,
303 }
304
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] {
310 match rt {
311 ResistanceType::Freeweight | ResistanceType::Machine => {
312 &[MiddleFocus::WeightedLoadUnit, MiddleFocus::WeightedIncrement]
313 }
314 ResistanceType::Bodyweight
315 | ResistanceType::CardioTime
316 | ResistanceType::CardioDistance => &[],
317 }
318 }
319
320 /// One-line hint shown under the Type row explaining which prompts have
321 /// been elided for the current type. `None` when every field applies.
322 fn type_hint(rt: ResistanceType) -> Option<&'static str> {
323 match rt {
324 ResistanceType::Freeweight | ResistanceType::Machine => None,
325 ResistanceType::Bodyweight => {
326 Some("bodyweight — no load or increment prompted (weighted variants come later)")
327 }
328 ResistanceType::CardioTime => {
329 Some("cardio (time) — duration lives on the effort kind (phase 4 will split this out)")
330 }
331 ResistanceType::CardioDistance => {
332 Some("cardio (distance) — distance lives on the effort kind (phase 4 will split this out)")
333 }
334 }
335 }
336
337 struct EditForm {
338 editing_id: Option<i64>,
339 name: String,
340 resistance_type: ResistanceType,
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,
346 selected_tag_ids: Vec<i64>,
347 all_tags: Vec<Tag>,
348 focus: Focus,
349 tag_cursor: usize,
350 new_tag_buffer: Option<String>,
351 }
352
353 impl EditForm {
354 fn new_blank(load_unit: String) -> Self {
355 Self {
356 editing_id: None,
357 name: String::new(),
358 resistance_type: ResistanceType::Freeweight,
359 weighted: WeightedFields::new(load_unit),
360 selected_tag_ids: Vec::new(),
361 all_tags: Vec::new(),
362 focus: Focus::Name,
363 tag_cursor: 0,
364 new_tag_buffer: None,
365 }
366 }
367
368 fn from_exercise(ex: &Exercise) -> Self {
369 Self {
370 editing_id: Some(ex.id),
371 name: ex.name.clone(),
372 resistance_type: ex.resistance_type,
373 weighted: WeightedFields {
374 load_unit: ex.load_unit.as_str().to_string(),
375 increment: format!("{}", ex.increment),
376 },
377 selected_tag_ids: ex.tag_ids.clone(),
378 all_tags: Vec::new(),
379 focus: Focus::Name,
380 tag_cursor: 0,
381 new_tag_buffer: None,
382 }
383 }
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
396 fn cycle_focus(&mut self, delta: i32) {
397 let order = self.focus_order();
398 let idx = order.iter().position(|f| *f == self.focus).unwrap_or(0) as i32;
399 let next = (idx + delta).rem_euclid(order.len() as i32) as usize;
400 self.focus = order[next];
401 self.new_tag_buffer = None;
402 }
403
404 /// Snap focus onto the nearest visible field. Called after a Type
405 /// change that hid the currently-focused middle field so the cursor
406 /// doesn't end up in a section that no longer renders.
407 fn snap_focus_into_visible(&mut self) {
408 let order = self.focus_order();
409 if !order.contains(&self.focus) {
410 self.focus = Focus::Name;
411 }
412 }
413
414 fn on_field_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> {
415 // Tag-add sub-prompt intercepts input first.
416 if self.focus == Focus::Tags && self.new_tag_buffer.is_some() {
417 return self.on_tag_add_key(db, key);
418 }
419 match self.focus {
420 Focus::Name => text_edit(&mut self.name, key),
421 Focus::Middle(m) => self.on_middle_key(m, key),
422 Focus::Type => {
423 let all = ResistanceType::ALL;
424 let cur = all
425 .iter()
426 .position(|t| *t == self.resistance_type)
427 .unwrap_or(0) as i32;
428 let delta = match key.code {
429 KeyCode::Left | KeyCode::Char('h') => -1,
430 KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => 1,
431 _ => 0,
432 };
433 if delta != 0 {
434 let next = (cur + delta).rem_euclid(all.len() as i32) as usize;
435 self.resistance_type = all[next];
436 self.snap_focus_into_visible();
437 }
438 }
439 Focus::Tags => match key.code {
440 KeyCode::Up | KeyCode::Char('k') => {
441 if !self.all_tags.is_empty() {
442 self.tag_cursor = (self.tag_cursor + self.all_tags.len() - 1)
443 % self.all_tags.len();
444 }
445 }
446 KeyCode::Down | KeyCode::Char('j') => {
447 if !self.all_tags.is_empty() {
448 self.tag_cursor = (self.tag_cursor + 1) % self.all_tags.len();
449 }
450 }
451 KeyCode::Char(' ') => {
452 if let Some(tag) = self.all_tags.get(self.tag_cursor) {
453 if let Some(pos) =
454 self.selected_tag_ids.iter().position(|id| *id == tag.id)
455 {
456 self.selected_tag_ids.remove(pos);
457 } else {
458 self.selected_tag_ids.push(tag.id);
459 self.selected_tag_ids.sort();
460 }
461 }
462 }
463 KeyCode::Char('+') => {
464 self.new_tag_buffer = Some(String::new());
465 }
466 _ => {}
467 },
468 }
469 None
470 }
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
482 fn on_tag_add_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> {
483 let buf = self.new_tag_buffer.as_mut()?;
484 match key.code {
485 KeyCode::Esc => {
486 self.new_tag_buffer = None;
487 None
488 }
489 KeyCode::Enter => {
490 let name = std::mem::take(buf);
491 self.new_tag_buffer = None;
492 if name.trim().is_empty() {
493 return None;
494 }
495 match db.upsert_tag(&name) {
496 Ok(tag) => {
497 if !self.selected_tag_ids.contains(&tag.id) {
498 self.selected_tag_ids.push(tag.id);
499 self.selected_tag_ids.sort();
500 }
501 Some(FormRefresh::Tags)
502 }
503 Err(_) => None,
504 }
505 }
506 KeyCode::Backspace => {
507 buf.pop();
508 None
509 }
510 KeyCode::Char(c) => {
511 buf.push(c);
512 None
513 }
514 _ => None,
515 }
516 }
517
518 fn save(&self, db: &Db) -> Result<(), ripgrow_core::Error> {
519 let (load_unit, increment) = self.save_middle()?;
520 match self.editing_id {
521 Some(id) => db.update_exercise(
522 id,
523 &self.name,
524 self.resistance_type,
525 load_unit,
526 increment,
527 &self.selected_tag_ids,
528 ),
529 None => db
530 .create_exercise(
531 &self.name,
532 self.resistance_type,
533 load_unit,
534 increment,
535 &self.selected_tag_ids,
536 )
537 .map(|_| ()),
538 }
539 }
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
568 fn render(&self, frame: &mut Frame, area: Rect, all_tags: &[Tag]) {
569 let block = Block::default()
570 .borders(Borders::ALL)
571 .title(if self.editing_id.is_some() {
572 " edit template "
573 } else {
574 " new template "
575 });
576 let inner = block.inner(area);
577 frame.render_widget(block, area);
578
579 let mut lines: Vec<Line> = Vec::new();
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 )));
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
609 if let Some(buf) = self.new_tag_buffer.as_ref() {
610 lines.push(Line::from(""));
611 lines.push(Line::from(format!("new tag: {buf}_ (enter save, esc cancel)")));
612 }
613
614 frame.render_widget(Paragraph::new(lines), inner);
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()
665 }
666
667 fn field_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {
668 let content = format!("{label:<12}{value}");
669 if focused {
670 Line::from(Span::styled(
671 content,
672 Style::default().add_modifier(Modifier::REVERSED),
673 ))
674 } else {
675 Line::from(content)
676 }
677 }
678
679 fn text_edit(target: &mut String, key: KeyEvent) {
680 match key.code {
681 KeyCode::Backspace => {
682 target.pop();
683 }
684 KeyCode::Char(c) => {
685 target.push(c);
686 }
687 _ => {}
688 }
689 }
690
691 #[cfg(test)]
692 mod tests {
693 use super::*;
694 use crossterm::event::KeyEvent;
695 use ripgrow_core::LoadUnit;
696
697 fn key(c: KeyCode) -> KeyEvent {
698 KeyEvent::new(c, KeyModifiers::empty())
699 }
700
701 fn ctrl(c: char) -> KeyEvent {
702 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
703 }
704
705 fn fresh_db() -> Db {
706 let db = Db::open_in_memory().unwrap();
707 db.init_profile("self", LoadUnit::Kg).unwrap();
708 db
709 }
710
711 #[test]
712 fn n_opens_edit_form_and_ctrl_s_creates_exercise() {
713 let db = fresh_db();
714 let mut screen = TemplatesScreen::load(&db).unwrap();
715 screen.on_key(&db, key(KeyCode::Char('n')));
716 for c in "bench".chars() {
717 screen.on_key(&db, key(KeyCode::Char(c)));
718 }
719 // Ctrl-S should save.
720 let action = screen.on_key(&db, ctrl('s'));
721 assert!(matches!(action, TemplatesAction::Refresh));
722 let list = db.list_exercises().unwrap();
723 assert_eq!(list.len(), 1);
724 assert_eq!(list[0].name, "bench");
725 assert_eq!(list[0].resistance_type, ResistanceType::Freeweight);
726 assert_eq!(list[0].increment, 2.5);
727 }
728
729 #[test]
730 fn esc_from_edit_cancels_without_saving() {
731 let db = fresh_db();
732 let mut screen = TemplatesScreen::load(&db).unwrap();
733 screen.on_key(&db, key(KeyCode::Char('n')));
734 for c in "abc".chars() {
735 screen.on_key(&db, key(KeyCode::Char(c)));
736 }
737 screen.on_key(&db, key(KeyCode::Esc));
738 assert!(db.list_exercises().unwrap().is_empty());
739 }
740
741 #[test]
742 fn tab_cycles_focus_and_type_field_cycles() {
743 let mut form = EditForm::new_blank("kg".into());
744 assert_eq!(form.focus, Focus::Name);
745 form.cycle_focus(1);
746 assert_eq!(form.focus, Focus::Type);
747 // Right on type field cycles to the next ResistanceType.
748 form.on_field_key(
749 &fresh_db(),
750 KeyEvent::new(KeyCode::Right, KeyModifiers::empty()),
751 );
752 assert_eq!(form.resistance_type, ResistanceType::CardioTime);
753 }
754
755 #[test]
756 fn plus_in_tag_field_opens_prompt_and_enter_creates_and_selects_tag() {
757 let db = fresh_db();
758 let mut screen = TemplatesScreen::load(&db).unwrap();
759 screen.on_key(&db, key(KeyCode::Char('n')));
760 // Focus -> Tags (Name, Type, LoadUnit, Increment, Tags).
761 for _ in 0..4 {
762 screen.on_key(&db, key(KeyCode::Tab));
763 }
764 screen.on_key(&db, key(KeyCode::Char('+')));
765 for c in "chest".chars() {
766 screen.on_key(&db, key(KeyCode::Char(c)));
767 }
768 screen.on_key(&db, key(KeyCode::Enter));
769 assert_eq!(db.list_tags().unwrap().len(), 1);
770 // Give the exercise a name and save.
771 screen.on_key(&db, key(KeyCode::Tab)); // wrap to Name
772 for c in "bench".chars() {
773 screen.on_key(&db, key(KeyCode::Char(c)));
774 }
775 screen.on_key(&db, ctrl('s'));
776 let list = db.list_exercises().unwrap();
777 assert_eq!(list.len(), 1);
778 assert_eq!(list[0].tag_ids.len(), 1, "new tag should be pre-selected");
779 }
780
781 #[test]
782 fn plus_from_any_field_opens_tag_prompt_and_jumps_focus() {
783 let db = fresh_db();
784 let mut screen = TemplatesScreen::load(&db).unwrap();
785 screen.on_key(&db, key(KeyCode::Char('n')));
786 // Focus is Name. Press + directly.
787 screen.on_key(&db, key(KeyCode::Char('+')));
788 // Prompt should be open and focus should have jumped to Tags.
789 let Mode::Edit(form) = &screen.mode else {
790 panic!("expected edit mode");
791 };
792 assert!(form.new_tag_buffer.is_some());
793 assert_eq!(form.focus, Focus::Tags);
794 for c in "pull".chars() {
795 screen.on_key(&db, key(KeyCode::Char(c)));
796 }
797 screen.on_key(&db, key(KeyCode::Enter));
798 assert_eq!(db.list_tags().unwrap()[0].name, "pull");
799 }
800
801 #[test]
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());
806 }
807
808 #[test]
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() {
819 let mut form = EditForm::new_blank("kg".into());
820 form.resistance_type = ResistanceType::Bodyweight;
821 form.focus = Focus::Name;
822 form.cycle_focus(1);
823 assert_eq!(form.focus, Focus::Type);
824 form.cycle_focus(1);
825 assert_eq!(form.focus, Focus::Tags, "no middle to visit");
826 form.cycle_focus(1);
827 assert_eq!(form.focus, Focus::Name, "wraps back to Name");
828 }
829
830 #[test]
831 fn changing_type_snaps_focus_out_of_absent_middle() {
832 let mut form = EditForm::new_blank("kg".into());
833 // Start focused on the weighted middle's Increment (visible for
834 // the default Freeweight).
835 form.focus = Focus::Middle(MiddleFocus::WeightedIncrement);
836 form.resistance_type = ResistanceType::Bodyweight;
837 form.snap_focus_into_visible();
838 assert_eq!(form.focus, Focus::Name, "snaps to a focusable field");
839 }
840
841 #[test]
842 fn weighted_buffers_survive_type_toggle_round_trip() {
843 let mut form = EditForm::new_blank("kg".into());
844 form.weighted.load_unit = "lb".to_string();
845 form.weighted.increment = "5.0".to_string();
846 // Toggle to bodyweight and back — the buffers must not be cleared.
847 form.resistance_type = ResistanceType::Bodyweight;
848 form.resistance_type = ResistanceType::Freeweight;
849 assert_eq!(form.weighted.load_unit, "lb");
850 assert_eq!(form.weighted.increment, "5.0");
851 }
852
853 #[test]
854 fn saving_bodyweight_uses_defaults_for_hidden_fields() {
855 let db = fresh_db();
856 let mut screen = TemplatesScreen::load(&db).unwrap();
857 screen.on_key(&db, key(KeyCode::Char('n')));
858 // Type name, cycle to Type, switch to Bodyweight.
859 for c in "pullup".chars() {
860 screen.on_key(&db, key(KeyCode::Char(c)));
861 }
862 screen.on_key(&db, key(KeyCode::Tab)); // -> Type
863 // Right twice: Freeweight is ALL[2], Bodyweight is ALL[0].
864 // Start on Freeweight, right -> Machine (ALL[1]), right -> Bodyweight? No:
865 // ALL = [Bodyweight, Machine, Freeweight, CardioTime, CardioDistance].
866 // From Freeweight, right = CardioTime, right = CardioDistance,
867 // right = Bodyweight.
868 for _ in 0..3 {
869 screen.on_key(&db, key(KeyCode::Right));
870 }
871 let Mode::Edit(form) = &screen.mode else {
872 panic!("expected edit mode");
873 };
874 assert_eq!(form.resistance_type, ResistanceType::Bodyweight);
875 screen.on_key(&db, ctrl('s'));
876 let list = db.list_exercises().unwrap();
877 assert_eq!(list.len(), 1);
878 assert_eq!(list[0].resistance_type, ResistanceType::Bodyweight);
879 assert_eq!(list[0].increment, 0.0);
880 // load_unit falls back to the profile's default (Kg here).
881 assert_eq!(list[0].load_unit, LoadUnit::Kg);
882 }
883
884 #[test]
885 fn delete_removes_selected_exercise() {
886 let db = fresh_db();
887 db.create_exercise("a", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
888 .unwrap();
889 let mut screen = TemplatesScreen::load(&db).unwrap();
890 let action = screen.on_key(&db, key(KeyCode::Char('d')));
891 assert!(matches!(action, TemplatesAction::Refresh));
892 assert!(db.list_exercises().unwrap().is_empty());
893 }
894 }
895