//! Templates screen. Left pane lists exercises, right pane shows details or //! an editor form when creating/updating. Tag toggles live inside the form. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ripgrow_core::{Db, Exercise, LoadUnit, ResistanceType, Tag}; pub struct TemplatesScreen { pub exercises: Vec, pub tags: Vec, list_state: ListState, mode: Mode, pub status: String, } enum Mode { Browse, Edit(EditForm), } /// What the outer app should do after this tick. pub enum TemplatesAction { None, Quit, Refresh, } impl TemplatesScreen { pub fn load(db: &Db) -> Result { let exercises = db.list_exercises()?; let tags = db.list_tags()?; let mut list_state = ListState::default(); if !exercises.is_empty() { list_state.select(Some(0)); } Ok(Self { exercises, tags, list_state, mode: Mode::Browse, status: String::new(), }) } pub fn on_key(&mut self, db: &Db, key: KeyEvent) -> TemplatesAction { // Take the mode out so we can pattern-match on it and mutate self. let mode = std::mem::replace(&mut self.mode, Mode::Browse); let (next_mode, action) = match mode { Mode::Browse => { let a = self.on_browse_key(db, key); (std::mem::replace(&mut self.mode, Mode::Browse), a) } Mode::Edit(form) => self.on_edit_key(db, form, key), }; self.mode = next_mode; action } fn on_browse_key(&mut self, db: &Db, key: KeyEvent) -> TemplatesAction { match (key.code, key.modifiers) { (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { TemplatesAction::Quit } (KeyCode::Down | KeyCode::Char('j'), _) => { self.move_selection(1); TemplatesAction::None } (KeyCode::Up | KeyCode::Char('k'), _) => { self.move_selection(-1); TemplatesAction::None } (KeyCode::Char('n'), _) => { self.mode = Mode::Edit(EditForm::new_blank(self.default_unit(db))); TemplatesAction::None } (KeyCode::Char('e'), _) => { if let Some(ex) = self.selected() { self.mode = Mode::Edit(EditForm::from_exercise(ex)); } TemplatesAction::None } (KeyCode::Char('d'), _) => { if let Some(ex) = self.selected() { let id = ex.id; match db.delete_exercise(id) { Ok(()) => { self.status = format!("deleted"); return TemplatesAction::Refresh; } Err(e) => { self.status = format!("delete failed: {e}"); } } } TemplatesAction::None } _ => TemplatesAction::None, } } fn on_edit_key( &mut self, db: &Db, mut form: EditForm, key: KeyEvent, ) -> (Mode, TemplatesAction) { match (key.code, key.modifiers) { (KeyCode::Esc, _) => (Mode::Browse, TemplatesAction::None), (KeyCode::Char('s'), KeyModifiers::CONTROL) => match form.save(db) { Ok(()) => { self.status = format!("saved"); (Mode::Browse, TemplatesAction::Refresh) } Err(e) => { self.status = format!("save failed: {e}"); (Mode::Edit(form), TemplatesAction::None) } }, (KeyCode::Tab, _) => { form.cycle_focus(1); (Mode::Edit(form), TemplatesAction::None) } (KeyCode::BackTab, _) => { form.cycle_focus(-1); (Mode::Edit(form), TemplatesAction::None) } // `+` opens the tag-add prompt from any field. When the prompt // is already open it falls through so the buffer can accept the // literal character (unlikely for tag names but not forbidden). (KeyCode::Char('+'), _) if form.new_tag_buffer.is_none() => { form.focus = Focus::Tags; form.new_tag_buffer = Some(String::new()); (Mode::Edit(form), TemplatesAction::None) } _ => { if let Some(refresh) = form.on_field_key(db, key) { match refresh { FormRefresh::Tags => { if let Ok(tags) = db.list_tags() { self.tags = tags.clone(); form.all_tags = tags; } } } } (Mode::Edit(form), TemplatesAction::None) } } } fn move_selection(&mut self, delta: isize) { if self.exercises.is_empty() { return; } let len = self.exercises.len() as isize; let cur = self.list_state.selected().unwrap_or(0) as isize; let next = (cur + delta).rem_euclid(len) as usize; self.list_state.select(Some(next)); } fn selected(&self) -> Option<&Exercise> { self.list_state.selected().and_then(|i| self.exercises.get(i)) } fn default_unit(&self, db: &Db) -> String { db.unit() .ok() .flatten() .map(|u| u.as_str().to_string()) .unwrap_or_else(|| "kg".to_string()) } pub fn render(&mut self, frame: &mut Frame, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(1)]) .split(area); let panes = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(chunks[0]); // Left: exercise list. let items: Vec = if self.exercises.is_empty() { vec![ListItem::new("no templates yet — press n to add one")] } else { self.exercises .iter() .map(|e| ListItem::new(e.name.as_str())) .collect() }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(" templates ")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("> "); frame.render_stateful_widget(list, panes[0], &mut self.list_state); // Right: details or edit form. match &self.mode { Mode::Browse => self.render_details(frame, panes[1]), Mode::Edit(form) => form.render(frame, panes[1], &self.tags), } // Footer. let hint = match &self.mode { Mode::Browse => "j/k move n new e edit d delete q quit", Mode::Edit(_) => "tab/shift-tab move + new tag (any field) space toggle ctrl-s save esc cancel", }; frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw(hint), Span::raw(" "), Span::styled( self.status.as_str(), Style::default().add_modifier(Modifier::DIM), ), ])), chunks[1], ); } fn render_details(&self, frame: &mut Frame, area: Rect) { let block = Block::default().borders(Borders::ALL).title(" details "); if let Some(ex) = self.selected() { let tag_names: Vec = ex .tag_ids .iter() .filter_map(|tid| self.tags.iter().find(|t| t.id == *tid)) .map(|t| t.name.clone()) .collect(); let body = format!( "name: {}\ntype: {}\nload unit: {}\nincrement: {}\ntags: {}", ex.name, ex.resistance_type.as_str(), ex.load_unit, ex.increment, if tag_names.is_empty() { "-".to_string() } else { tag_names.join(", ") }, ); frame.render_widget(Paragraph::new(body).block(block), area); } else { frame.render_widget( Paragraph::new("no template selected").block(block), area, ); } } } // ---- edit form ------------------------------------------------------------- /// Focus positions the cursor can occupy. `Middle(_)` carries type-specific /// subfocus so each resistance kind can own its own middle section without /// polluting a shared Field enum. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Focus { Name, Type, Middle(MiddleFocus), Tags, } /// Subfocus for the type-specific middle section. Each variant belongs to /// exactly one `TypeMiddle` shape; adding a new resistance type means /// adding a new variant here plus a new `TypeMiddle` variant + arms in /// `focus_order` / `render_middle` / `save_middle` / `on_middle_key`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MiddleFocus { WeightedLoadUnit, WeightedIncrement, // Timed and Distance middles are empty today. Phase 4 will add // TimedTargetDuration, DistanceUnit, etc. as their tables land. } /// Type-specific middle-section state. Held on the form regardless of /// the current resistance type so switching Freeweight -> Bodyweight -> /// Freeweight doesn't wipe the load_unit / increment the user typed. #[derive(Debug, Clone)] struct WeightedFields { load_unit: String, increment: String, } impl WeightedFields { fn new(load_unit: String) -> Self { Self { load_unit, increment: "2.5".to_string(), } } } enum FormRefresh { Tags, } /// Middle-section focus order for a given resistance type. Empty when /// the type has no middle prompts (bodyweight, cardio). Header (Name, /// Type) and footer (Tags) are always present; this only names what /// slots between them. fn middle_focus_order(rt: ResistanceType) -> &'static [MiddleFocus] { match rt { ResistanceType::Freeweight | ResistanceType::Machine => { &[MiddleFocus::WeightedLoadUnit, MiddleFocus::WeightedIncrement] } ResistanceType::Bodyweight | ResistanceType::CardioTime | ResistanceType::CardioDistance => &[], } } /// One-line hint shown under the Type row explaining which prompts have /// been elided for the current type. `None` when every field applies. fn type_hint(rt: ResistanceType) -> Option<&'static str> { match rt { ResistanceType::Freeweight | ResistanceType::Machine => None, ResistanceType::Bodyweight => { Some("bodyweight — no load or increment prompted (weighted variants come later)") } ResistanceType::CardioTime => { Some("cardio (time) — duration lives on the effort kind (phase 4 will split this out)") } ResistanceType::CardioDistance => { Some("cardio (distance) — distance lives on the effort kind (phase 4 will split this out)") } } } struct EditForm { editing_id: Option, name: String, resistance_type: ResistanceType, /// Held for every type so Freeweight -> Bodyweight -> Freeweight /// doesn't wipe the user's load-unit / increment buffers. Only read /// on save when the current type actually uses weighted middle /// fields; otherwise the values sleep here. weighted: WeightedFields, selected_tag_ids: Vec, all_tags: Vec, focus: Focus, tag_cursor: usize, new_tag_buffer: Option, } impl EditForm { fn new_blank(load_unit: String) -> Self { Self { editing_id: None, name: String::new(), resistance_type: ResistanceType::Freeweight, weighted: WeightedFields::new(load_unit), selected_tag_ids: Vec::new(), all_tags: Vec::new(), focus: Focus::Name, tag_cursor: 0, new_tag_buffer: None, } } fn from_exercise(ex: &Exercise) -> Self { Self { editing_id: Some(ex.id), name: ex.name.clone(), resistance_type: ex.resistance_type, weighted: WeightedFields { load_unit: ex.load_unit.as_str().to_string(), increment: format!("{}", ex.increment), }, selected_tag_ids: ex.tag_ids.clone(), all_tags: Vec::new(), focus: Focus::Name, tag_cursor: 0, new_tag_buffer: None, } } /// Full tab order for the current resistance type: header, middle /// (per type), footer. fn focus_order(&self) -> Vec { let mut order = vec![Focus::Name, Focus::Type]; for m in middle_focus_order(self.resistance_type) { order.push(Focus::Middle(*m)); } order.push(Focus::Tags); order } fn cycle_focus(&mut self, delta: i32) { let order = self.focus_order(); let idx = order.iter().position(|f| *f == self.focus).unwrap_or(0) as i32; let next = (idx + delta).rem_euclid(order.len() as i32) as usize; self.focus = order[next]; self.new_tag_buffer = None; } /// Snap focus onto the nearest visible field. Called after a Type /// change that hid the currently-focused middle field so the cursor /// doesn't end up in a section that no longer renders. fn snap_focus_into_visible(&mut self) { let order = self.focus_order(); if !order.contains(&self.focus) { self.focus = Focus::Name; } } fn on_field_key(&mut self, db: &Db, key: KeyEvent) -> Option { // Tag-add sub-prompt intercepts input first. if self.focus == Focus::Tags && self.new_tag_buffer.is_some() { return self.on_tag_add_key(db, key); } match self.focus { Focus::Name => text_edit(&mut self.name, key), Focus::Middle(m) => self.on_middle_key(m, key), Focus::Type => { let all = ResistanceType::ALL; let cur = all .iter() .position(|t| *t == self.resistance_type) .unwrap_or(0) as i32; let delta = match key.code { KeyCode::Left | KeyCode::Char('h') => -1, KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => 1, _ => 0, }; if delta != 0 { let next = (cur + delta).rem_euclid(all.len() as i32) as usize; self.resistance_type = all[next]; self.snap_focus_into_visible(); } } Focus::Tags => match key.code { KeyCode::Up | KeyCode::Char('k') => { if !self.all_tags.is_empty() { self.tag_cursor = (self.tag_cursor + self.all_tags.len() - 1) % self.all_tags.len(); } } KeyCode::Down | KeyCode::Char('j') => { if !self.all_tags.is_empty() { self.tag_cursor = (self.tag_cursor + 1) % self.all_tags.len(); } } KeyCode::Char(' ') => { if let Some(tag) = self.all_tags.get(self.tag_cursor) { if let Some(pos) = self.selected_tag_ids.iter().position(|id| *id == tag.id) { self.selected_tag_ids.remove(pos); } else { self.selected_tag_ids.push(tag.id); self.selected_tag_ids.sort(); } } } KeyCode::Char('+') => { self.new_tag_buffer = Some(String::new()); } _ => {} }, } None } /// Route a keypress to the current middle-field subfocus. Adding a /// new resistance kind that has editable middle prompts means adding /// a match arm here plus one in `render_middle` / `save_middle`. fn on_middle_key(&mut self, m: MiddleFocus, key: KeyEvent) { match m { MiddleFocus::WeightedLoadUnit => text_edit(&mut self.weighted.load_unit, key), MiddleFocus::WeightedIncrement => text_edit(&mut self.weighted.increment, key), } } fn on_tag_add_key(&mut self, db: &Db, key: KeyEvent) -> Option { let buf = self.new_tag_buffer.as_mut()?; match key.code { KeyCode::Esc => { self.new_tag_buffer = None; None } KeyCode::Enter => { let name = std::mem::take(buf); self.new_tag_buffer = None; if name.trim().is_empty() { return None; } match db.upsert_tag(&name) { Ok(tag) => { if !self.selected_tag_ids.contains(&tag.id) { self.selected_tag_ids.push(tag.id); self.selected_tag_ids.sort(); } Some(FormRefresh::Tags) } Err(_) => None, } } KeyCode::Backspace => { buf.pop(); None } KeyCode::Char(c) => { buf.push(c); None } _ => None, } } fn save(&self, db: &Db) -> Result<(), ripgrow_core::Error> { let (load_unit, increment) = self.save_middle()?; match self.editing_id { Some(id) => db.update_exercise( id, &self.name, self.resistance_type, load_unit, increment, &self.selected_tag_ids, ), None => db .create_exercise( &self.name, self.resistance_type, load_unit, increment, &self.selected_tag_ids, ) .map(|_| ()), } } /// Per-type: turn the current middle-section buffers into the /// (LoadUnit, increment) pair the schema wants. Types without a /// middle section fall back to the buffered LoadUnit (defaulting to /// Kg if unparseable) and a zero increment. Adding a new type with /// its own middle is one match arm here. fn save_middle(&self) -> Result<(LoadUnit, f64), ripgrow_core::Error> { match self.resistance_type { ResistanceType::Freeweight | ResistanceType::Machine => { let increment: f64 = self .weighted .increment .trim() .parse() .map_err(|_| ripgrow_core::Error::InvalidExerciseName)?; let load_unit = LoadUnit::parse(self.weighted.load_unit.trim())?; Ok((load_unit, increment)) } ResistanceType::Bodyweight | ResistanceType::CardioTime | ResistanceType::CardioDistance => { let load_unit = LoadUnit::parse(self.weighted.load_unit.trim()).unwrap_or(LoadUnit::Kg); Ok((load_unit, 0.0)) } } } fn render(&self, frame: &mut Frame, area: Rect, all_tags: &[Tag]) { let block = Block::default() .borders(Borders::ALL) .title(if self.editing_id.is_some() { " edit template " } else { " new template " }); let inner = block.inner(area); frame.render_widget(block, area); let mut lines: Vec = Vec::new(); // Header: name + type (+ optional hint). lines.push(field_line("name", &self.name, self.focus == Focus::Name)); lines.push(field_line( "type", self.resistance_type.as_str(), self.focus == Focus::Type, )); if let Some(hint) = type_hint(self.resistance_type) { lines.push(Line::from(Span::styled( format!(" {hint}"), Style::default().add_modifier(Modifier::DIM), ))); } // Middle: type-specific. self.render_middle(&mut lines); // Footer: tag toggle grid. lines.push(Line::from("")); lines.push(Line::from(if self.focus == Focus::Tags { Span::styled("tags:", Style::default().add_modifier(Modifier::REVERSED)) } else { Span::raw("tags:") })); for tag_line in tag_lines(all_tags, &self.selected_tag_ids, self.focus, self.tag_cursor) { lines.push(tag_line); } if let Some(buf) = self.new_tag_buffer.as_ref() { lines.push(Line::from("")); lines.push(Line::from(format!("new tag: {buf}_ (enter save, esc cancel)"))); } frame.render_widget(Paragraph::new(lines), inner); } /// Per-type middle-section renderer. Weighted types show load_unit /// and increment; bodyweight/cardio render nothing here. Adding a /// new type with its own middle is one arm here + one in /// `on_middle_key` + one in `save_middle`. fn render_middle<'a>(&'a self, lines: &mut Vec>) { match self.resistance_type { ResistanceType::Freeweight | ResistanceType::Machine => { lines.push(field_line( "load unit", &self.weighted.load_unit, self.focus == Focus::Middle(MiddleFocus::WeightedLoadUnit), )); lines.push(field_line( "increment", &self.weighted.increment, self.focus == Focus::Middle(MiddleFocus::WeightedIncrement), )); } ResistanceType::Bodyweight | ResistanceType::CardioTime | ResistanceType::CardioDistance => { // No middle prompts today. Phase 4 will add per-kind fields // (weight-belt toggle, target duration, distance unit) here. } } } } fn tag_lines<'a>( all_tags: &'a [Tag], selected: &[i64], focus: Focus, tag_cursor: usize, ) -> Vec> { if all_tags.is_empty() { return vec![Line::from(" (no tags yet — press + to add one)")]; } all_tags .iter() .enumerate() .map(|(i, t)| { let checked = selected.contains(&t.id); let cursor = focus == Focus::Tags && tag_cursor == i; let marker = if checked { "[x]" } else { "[ ]" }; let prefix = if cursor { "> " } else { " " }; Line::from(format!("{prefix}{marker} {}", t.name)) }) .collect() } fn field_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> { let content = format!("{label:<12}{value}"); if focused { Line::from(Span::styled( content, Style::default().add_modifier(Modifier::REVERSED), )) } else { Line::from(content) } } fn text_edit(target: &mut String, key: KeyEvent) { match key.code { KeyCode::Backspace => { target.pop(); } KeyCode::Char(c) => { target.push(c); } _ => {} } } #[cfg(test)] mod tests { use super::*; use crossterm::event::KeyEvent; use ripgrow_core::LoadUnit; fn key(c: KeyCode) -> KeyEvent { KeyEvent::new(c, KeyModifiers::empty()) } fn ctrl(c: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) } fn fresh_db() -> Db { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); db } #[test] fn n_opens_edit_form_and_ctrl_s_creates_exercise() { let db = fresh_db(); let mut screen = TemplatesScreen::load(&db).unwrap(); screen.on_key(&db, key(KeyCode::Char('n'))); for c in "bench".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } // Ctrl-S should save. let action = screen.on_key(&db, ctrl('s')); assert!(matches!(action, TemplatesAction::Refresh)); let list = db.list_exercises().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].name, "bench"); assert_eq!(list[0].resistance_type, ResistanceType::Freeweight); assert_eq!(list[0].increment, 2.5); } #[test] fn esc_from_edit_cancels_without_saving() { let db = fresh_db(); let mut screen = TemplatesScreen::load(&db).unwrap(); screen.on_key(&db, key(KeyCode::Char('n'))); for c in "abc".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Esc)); assert!(db.list_exercises().unwrap().is_empty()); } #[test] fn tab_cycles_focus_and_type_field_cycles() { let mut form = EditForm::new_blank("kg".into()); assert_eq!(form.focus, Focus::Name); form.cycle_focus(1); assert_eq!(form.focus, Focus::Type); // Right on type field cycles to the next ResistanceType. form.on_field_key( &fresh_db(), KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), ); assert_eq!(form.resistance_type, ResistanceType::CardioTime); } #[test] fn plus_in_tag_field_opens_prompt_and_enter_creates_and_selects_tag() { let db = fresh_db(); let mut screen = TemplatesScreen::load(&db).unwrap(); screen.on_key(&db, key(KeyCode::Char('n'))); // Focus -> Tags (Name, Type, LoadUnit, Increment, Tags). for _ in 0..4 { screen.on_key(&db, key(KeyCode::Tab)); } screen.on_key(&db, key(KeyCode::Char('+'))); for c in "chest".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); assert_eq!(db.list_tags().unwrap().len(), 1); // Give the exercise a name and save. screen.on_key(&db, key(KeyCode::Tab)); // wrap to Name for c in "bench".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, ctrl('s')); let list = db.list_exercises().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].tag_ids.len(), 1, "new tag should be pre-selected"); } #[test] fn plus_from_any_field_opens_tag_prompt_and_jumps_focus() { let db = fresh_db(); let mut screen = TemplatesScreen::load(&db).unwrap(); screen.on_key(&db, key(KeyCode::Char('n'))); // Focus is Name. Press + directly. screen.on_key(&db, key(KeyCode::Char('+'))); // Prompt should be open and focus should have jumped to Tags. let Mode::Edit(form) = &screen.mode else { panic!("expected edit mode"); }; assert!(form.new_tag_buffer.is_some()); assert_eq!(form.focus, Focus::Tags); for c in "pull".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); assert_eq!(db.list_tags().unwrap()[0].name, "pull"); } #[test] fn bodyweight_middle_is_empty() { assert!(middle_focus_order(ResistanceType::Bodyweight).is_empty()); assert!(middle_focus_order(ResistanceType::CardioTime).is_empty()); assert!(middle_focus_order(ResistanceType::CardioDistance).is_empty()); } #[test] fn weighted_middle_has_load_unit_and_increment() { let order = middle_focus_order(ResistanceType::Freeweight); assert_eq!( order, &[MiddleFocus::WeightedLoadUnit, MiddleFocus::WeightedIncrement] ); } #[test] fn tab_skips_absent_middle_on_bodyweight() { let mut form = EditForm::new_blank("kg".into()); form.resistance_type = ResistanceType::Bodyweight; form.focus = Focus::Name; form.cycle_focus(1); assert_eq!(form.focus, Focus::Type); form.cycle_focus(1); assert_eq!(form.focus, Focus::Tags, "no middle to visit"); form.cycle_focus(1); assert_eq!(form.focus, Focus::Name, "wraps back to Name"); } #[test] fn changing_type_snaps_focus_out_of_absent_middle() { let mut form = EditForm::new_blank("kg".into()); // Start focused on the weighted middle's Increment (visible for // the default Freeweight). form.focus = Focus::Middle(MiddleFocus::WeightedIncrement); form.resistance_type = ResistanceType::Bodyweight; form.snap_focus_into_visible(); assert_eq!(form.focus, Focus::Name, "snaps to a focusable field"); } #[test] fn weighted_buffers_survive_type_toggle_round_trip() { let mut form = EditForm::new_blank("kg".into()); form.weighted.load_unit = "lb".to_string(); form.weighted.increment = "5.0".to_string(); // Toggle to bodyweight and back — the buffers must not be cleared. form.resistance_type = ResistanceType::Bodyweight; form.resistance_type = ResistanceType::Freeweight; assert_eq!(form.weighted.load_unit, "lb"); assert_eq!(form.weighted.increment, "5.0"); } #[test] fn saving_bodyweight_uses_defaults_for_hidden_fields() { let db = fresh_db(); let mut screen = TemplatesScreen::load(&db).unwrap(); screen.on_key(&db, key(KeyCode::Char('n'))); // Type name, cycle to Type, switch to Bodyweight. for c in "pullup".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Tab)); // -> Type // Right twice: Freeweight is ALL[2], Bodyweight is ALL[0]. // Start on Freeweight, right -> Machine (ALL[1]), right -> Bodyweight? No: // ALL = [Bodyweight, Machine, Freeweight, CardioTime, CardioDistance]. // From Freeweight, right = CardioTime, right = CardioDistance, // right = Bodyweight. for _ in 0..3 { screen.on_key(&db, key(KeyCode::Right)); } let Mode::Edit(form) = &screen.mode else { panic!("expected edit mode"); }; assert_eq!(form.resistance_type, ResistanceType::Bodyweight); screen.on_key(&db, ctrl('s')); let list = db.list_exercises().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].resistance_type, ResistanceType::Bodyweight); assert_eq!(list[0].increment, 0.0); // load_unit falls back to the profile's default (Kg here). assert_eq!(list[0].load_unit, LoadUnit::Kg); } #[test] fn delete_removes_selected_exercise() { let db = fresh_db(); db.create_exercise("a", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[]) .unwrap(); let mut screen = TemplatesScreen::load(&db).unwrap(); let action = screen.on_key(&db, key(KeyCode::Char('d'))); assert!(matches!(action, TemplatesAction::Refresh)); assert!(db.list_exercises().unwrap().is_empty()); } }