//! Log screen: the fast keyboard-only path for entering a session's sets. //! //! Two sub-modes. **Pick** fuzzy-filters exercise templates by substring //! subsequence match; Enter selects the highlighted one. **Grid** shows //! the sets already logged for (date, exercise) plus a pending row being //! typed. Enter on the last column commits the pending row and opens a //! fresh one. //! //! Grid shape depends on the exercise's effort kind: //! //! - **Reps** `load [tab] reps [tab] rpe [enter]` → row saved. //! - **Timed** `duration [tab] rpe [enter]`. Duration accepts `N` //! (seconds) or `M:SS` (minutes:seconds). //! - **Distance** `distance [tab] duration [tab] rpe [enter]`. Distance //! is meters. //! //! Session date defaults to today; `[`/`]` step by a day. use chrono::{Duration as ChronoDuration, Local, NaiveDate}; 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, Distance, DistanceKind, DistancePayload, DistanceSet, Duration, EffortKind, Exercise, Kind, Load, Reps, RepsKind, RepsPayload, RepsSet, Rpe, TimedKind, TimedPayload, TimedSet, }; pub struct LogScreen { date: NaiveDate, exercises: Vec, picker: PickState, grid: Option, pub status: String, } struct PickState { query: String, list_state: ListState, } struct GridState { exercise: Exercise, kind: KindGrid, } enum KindGrid { Reps(RepsGrid), Timed(TimedGrid), Distance(DistanceGrid), } struct RepsGrid { committed: Vec, pending: RepsPending, focus: RepsField, } #[derive(Default)] struct RepsPending { load: String, reps: String, rpe: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RepsField { Load, Reps, Rpe, } struct TimedGrid { committed: Vec, pending: TimedPending, focus: TimedField, } #[derive(Default)] struct TimedPending { duration: String, rpe: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TimedField { Duration, Rpe, } struct DistanceGrid { committed: Vec, pending: DistancePending, focus: DistanceField, } #[derive(Default)] struct DistancePending { distance: String, duration: String, rpe: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DistanceField { Distance, Duration, Rpe, } impl LogScreen { pub fn load(db: &Db) -> Result { let exercises = db.list_exercises()?; let mut list_state = ListState::default(); if !exercises.is_empty() { list_state.select(Some(0)); } Ok(Self { date: Local::now().date_naive(), exercises, picker: PickState { query: String::new(), list_state, }, grid: None, status: String::new(), }) } pub fn on_key(&mut self, db: &Db, key: KeyEvent) { if self.grid.is_some() { self.on_grid_key(db, key); } else { self.on_pick_key(key); } } // ---- picker sub-mode ------------------------------------------------ fn filtered_indices(&self) -> Vec { let q = self.picker.query.trim().to_lowercase(); if q.is_empty() { (0..self.exercises.len()).collect() } else { self.exercises .iter() .enumerate() .filter(|(_, e)| subsequence_match(&e.name.to_lowercase(), &q)) .map(|(i, _)| i) .collect() } } fn on_pick_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { self.picker.query.clear(); } KeyCode::Enter => { let filt = self.filtered_indices(); if let Some(row) = self.picker.list_state.selected() && let Some(ex_idx) = filt.get(row) && let Some(ex) = self.exercises.get(*ex_idx) { self.enter_grid(ex.clone()); } } KeyCode::Down => self.move_pick(1), KeyCode::Up => self.move_pick(-1), KeyCode::Char('[') if key.modifiers == KeyModifiers::NONE => { self.date -= ChronoDuration::days(1); } KeyCode::Char(']') if key.modifiers == KeyModifiers::NONE => { self.date += ChronoDuration::days(1); } KeyCode::Backspace => { self.picker.query.pop(); self.picker.list_state.select(Some(0)); } KeyCode::Char(c) => { self.picker.query.push(c); self.picker.list_state.select(Some(0)); } _ => {} } } fn move_pick(&mut self, delta: isize) { let n = self.filtered_indices().len(); if n == 0 { return; } let cur = self.picker.list_state.selected().unwrap_or(0) as isize; let next = (cur + delta).rem_euclid(n as isize) as usize; self.picker.list_state.select(Some(next)); } fn enter_grid(&mut self, exercise: Exercise) { let kind = match exercise.effort_kind { EffortKind::Reps => KindGrid::Reps(RepsGrid { committed: Vec::new(), pending: RepsPending::default(), focus: RepsField::Load, }), EffortKind::Timed => KindGrid::Timed(TimedGrid { committed: Vec::new(), pending: TimedPending::default(), focus: TimedField::Duration, }), EffortKind::Distance => KindGrid::Distance(DistanceGrid { committed: Vec::new(), pending: DistancePending::default(), focus: DistanceField::Distance, }), }; self.grid = Some(GridState { exercise, kind }); } fn refresh_grid(&mut self, db: &Db) { let Some(g) = self.grid.as_mut() else { return }; match &mut g.kind { KindGrid::Reps(r) => { if let Ok(list) = RepsKind::list_sets_for_session(db, g.exercise.id, self.date) { r.committed = list; } } KindGrid::Timed(t) => { if let Ok(list) = TimedKind::list_sets_for_session(db, g.exercise.id, self.date) { t.committed = list; } } KindGrid::Distance(d) => { if let Ok(list) = DistanceKind::list_sets_for_session(db, g.exercise.id, self.date) { d.committed = list; } } } } // ---- grid sub-mode -------------------------------------------------- fn on_grid_key(&mut self, db: &Db, key: KeyEvent) { let Some(g) = self.grid.as_mut() else { return }; match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.grid = None; return; } (KeyCode::Tab, _) => { advance_focus(&mut g.kind); return; } (KeyCode::BackTab, _) => { retreat_focus(&mut g.kind); return; } (KeyCode::Enter, _) => { if let Err(e) = commit_pending(db, g, self.date) { self.status = format!("save failed: {e}"); } else { self.status = "set saved".to_string(); } self.refresh_grid(db); return; } (KeyCode::Backspace, KeyModifiers::CONTROL) => { match delete_last(db, g) { Ok(true) => { self.status = "last set deleted".to_string(); self.refresh_grid(db); } Ok(false) => {} Err(e) => self.status = format!("delete failed: {e}"), } return; } _ => {} } let target = focused_field_mut(&mut g.kind); match key.code { KeyCode::Backspace => { target.pop(); } KeyCode::Char(c) => target.push(c), _ => {} } } // ---- rendering ------------------------------------------------------ pub fn render(&mut self, frame: &mut Frame, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ]) .split(area); let date_hint = format!( "session: {} ([/] to change date)", self.date.format("%Y-%m-%d") ); frame.render_widget(Paragraph::new(date_hint), chunks[0]); match &mut self.grid { Some(g) => render_grid(frame, chunks[1], g), None => render_picker(frame, chunks[1], &mut self.picker, &self.exercises), } let hint = match &self.grid { None => "type to filter enter pick [/] date esc clear", Some(_) => "tab move enter save ctrl-backspace remove last esc back to picker", }; 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[2], ); } } fn advance_focus(kind: &mut KindGrid) { match kind { KindGrid::Reps(r) => { r.focus = match r.focus { RepsField::Load => RepsField::Reps, RepsField::Reps => RepsField::Rpe, RepsField::Rpe => RepsField::Load, } } KindGrid::Timed(t) => { t.focus = match t.focus { TimedField::Duration => TimedField::Rpe, TimedField::Rpe => TimedField::Duration, } } KindGrid::Distance(d) => { d.focus = match d.focus { DistanceField::Distance => DistanceField::Duration, DistanceField::Duration => DistanceField::Rpe, DistanceField::Rpe => DistanceField::Distance, } } } } fn retreat_focus(kind: &mut KindGrid) { match kind { KindGrid::Reps(r) => { r.focus = match r.focus { RepsField::Load => RepsField::Rpe, RepsField::Reps => RepsField::Load, RepsField::Rpe => RepsField::Reps, } } KindGrid::Timed(t) => { t.focus = match t.focus { TimedField::Duration => TimedField::Rpe, TimedField::Rpe => TimedField::Duration, } } KindGrid::Distance(d) => { d.focus = match d.focus { DistanceField::Distance => DistanceField::Rpe, DistanceField::Duration => DistanceField::Distance, DistanceField::Rpe => DistanceField::Duration, } } } } fn focused_field_mut(kind: &mut KindGrid) -> &mut String { match kind { KindGrid::Reps(r) => match r.focus { RepsField::Load => &mut r.pending.load, RepsField::Reps => &mut r.pending.reps, RepsField::Rpe => &mut r.pending.rpe, }, KindGrid::Timed(t) => match t.focus { TimedField::Duration => &mut t.pending.duration, TimedField::Rpe => &mut t.pending.rpe, }, KindGrid::Distance(d) => match d.focus { DistanceField::Distance => &mut d.pending.distance, DistanceField::Duration => &mut d.pending.duration, DistanceField::Rpe => &mut d.pending.rpe, }, } } fn commit_pending( db: &Db, g: &mut GridState, date: NaiveDate, ) -> Result<(), ripgrow_core::Error> { match &mut g.kind { KindGrid::Reps(r) => { let load = Load::new(parse_field("load", &r.pending.load)?)?; let reps = Reps::new(parse_field("reps", &r.pending.reps)?)?; let rpe = Rpe::new(parse_field("rpe", &r.pending.rpe)?)?; RepsKind::append( db, g.exercise.id, date, RepsPayload::new(load, reps), rpe, false, )?; r.pending = RepsPending::default(); r.focus = RepsField::Load; } KindGrid::Timed(t) => { let duration = Duration::from_seconds(parse_duration(&t.pending.duration)?)?; let rpe = Rpe::new(parse_field("rpe", &t.pending.rpe)?)?; TimedKind::append( db, g.exercise.id, date, TimedPayload::new(duration), rpe, false, )?; t.pending = TimedPending::default(); t.focus = TimedField::Duration; } KindGrid::Distance(d) => { let distance = Distance::from_meters(parse_field("distance", &d.pending.distance)?)?; let duration = Duration::from_seconds(parse_duration(&d.pending.duration)?)?; let rpe = Rpe::new(parse_field("rpe", &d.pending.rpe)?)?; DistanceKind::append( db, g.exercise.id, date, DistancePayload::new(distance, duration), rpe, false, )?; d.pending = DistancePending::default(); d.focus = DistanceField::Distance; } } Ok(()) } /// Delete the most recently committed set for the current grid. Returns /// `Ok(true)` when a row was actually removed, `Ok(false)` when there /// was nothing to delete. fn delete_last(db: &Db, g: &GridState) -> Result { match &g.kind { KindGrid::Reps(r) => { let Some(last) = r.committed.last() else { return Ok(false) }; RepsKind::delete(db, last.id)?; Ok(true) } KindGrid::Timed(t) => { let Some(last) = t.committed.last() else { return Ok(false) }; TimedKind::delete(db, last.id)?; Ok(true) } KindGrid::Distance(d) => { let Some(last) = d.committed.last() else { return Ok(false) }; DistanceKind::delete(db, last.id)?; Ok(true) } } } fn parse_field( field: &'static str, raw: &str, ) -> Result { raw.trim() .parse() .map_err(|_| ripgrow_core::Error::ParseField { field, value: raw.to_string(), }) } /// Parse a duration string into seconds. Accepts `N` (seconds) or `M:SS` /// (minutes:seconds). `1:5` is 65s, `1:05` is 65s — the seconds field is /// numeric, not lexical. fn parse_duration(raw: &str) -> Result { let s = raw.trim(); let err = || ripgrow_core::Error::ParseField { field: "duration", value: raw.to_string(), }; if let Some((m, sec)) = s.split_once(':') { let m: i32 = m.parse().map_err(|_| err())?; let sec: i32 = sec.parse().map_err(|_| err())?; if m < 0 || sec < 0 { return Err(err()); } Ok(m * 60 + sec) } else { let n: i32 = s.parse().map_err(|_| err())?; if n < 0 { return Err(err()); } Ok(n) } } fn render_picker( frame: &mut Frame, area: Rect, picker: &mut PickState, exercises: &[Exercise], ) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(area); let query_block = Block::default().borders(Borders::ALL).title(" pick exercise "); let query = Paragraph::new(format!("> {}_", picker.query)).block(query_block); frame.render_widget(query, chunks[0]); let q = picker.query.trim().to_lowercase(); let items: Vec = exercises .iter() .filter(|e| q.is_empty() || subsequence_match(&e.name.to_lowercase(), &q)) .map(|e| ListItem::new(e.name.as_str())) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(" matches ")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("> "); frame.render_stateful_widget(list, chunks[1], &mut picker.list_state); } fn render_grid(frame: &mut Frame, area: Rect, g: &GridState) { let block = Block::default() .borders(Borders::ALL) .title(format!(" {} ", g.exercise.name)); let inner = block.inner(area); frame.render_widget(block, area); let lines = match &g.kind { KindGrid::Reps(r) => render_reps_lines(&g.exercise, r), KindGrid::Timed(t) => render_timed_lines(t), KindGrid::Distance(d) => render_distance_lines(d), }; frame.render_widget(Paragraph::new(lines), inner); } fn render_reps_lines<'a>(exercise: &'a Exercise, r: &'a RepsGrid) -> Vec> { let header = format!( " # {:>8} {:>4} {:>3}", format!("load ({})", exercise.load_unit), "reps", "rpe" ); let mut lines: Vec = vec![Line::from(Span::styled( header, Style::default().add_modifier(Modifier::BOLD), ))]; for s in &r.committed { lines.push(Line::from(format!( " {:<3} {:>8} {:>4} {:>3}", s.set_index, s.load, s.reps, s.rpe ))); } let load = focused_span(&r.pending.load, r.focus == RepsField::Load, 8); let reps = focused_span(&r.pending.reps, r.focus == RepsField::Reps, 4); let rpe = focused_span(&r.pending.rpe, r.focus == RepsField::Rpe, 3); let next_idx = r.committed.last().map(|s| s.set_index + 1).unwrap_or(1); lines.push(Line::from(vec![ Span::raw(format!(" {:<3} ", next_idx)), load, Span::raw(" "), reps, Span::raw(" "), rpe, ])); lines } fn render_timed_lines(t: &TimedGrid) -> Vec> { let header = format!(" # {:>10} {:>3}", "duration", "rpe"); let mut lines: Vec = vec![Line::from(Span::styled( header, Style::default().add_modifier(Modifier::BOLD), ))]; for s in &t.committed { lines.push(Line::from(format!( " {:<3} {:>10} {:>3}", s.set_index, s.duration, s.rpe ))); } let dur = focused_span(&t.pending.duration, t.focus == TimedField::Duration, 10); let rpe = focused_span(&t.pending.rpe, t.focus == TimedField::Rpe, 3); let next_idx = t.committed.last().map(|s| s.set_index + 1).unwrap_or(1); lines.push(Line::from(vec![ Span::raw(format!(" {:<3} ", next_idx)), dur, Span::raw(" "), rpe, ])); lines } fn render_distance_lines(d: &DistanceGrid) -> Vec> { let header = format!( " # {:>10} {:>10} {:>3}", "distance", "duration", "rpe" ); let mut lines: Vec = vec![Line::from(Span::styled( header, Style::default().add_modifier(Modifier::BOLD), ))]; for s in &d.committed { lines.push(Line::from(format!( " {:<3} {:>10} {:>10} {:>3}", s.set_index, s.distance, s.duration, s.rpe ))); } let dist = focused_span(&d.pending.distance, d.focus == DistanceField::Distance, 10); let dur = focused_span(&d.pending.duration, d.focus == DistanceField::Duration, 10); let rpe = focused_span(&d.pending.rpe, d.focus == DistanceField::Rpe, 3); let next_idx = d.committed.last().map(|s| s.set_index + 1).unwrap_or(1); lines.push(Line::from(vec![ Span::raw(format!(" {:<3} ", next_idx)), dist, Span::raw(" "), dur, Span::raw(" "), rpe, ])); lines } fn focused_span(value: &str, focused: bool, width: usize) -> Span<'_> { let content = format!("{:>width$}", value, width = width); if focused { Span::styled(content, Style::default().add_modifier(Modifier::REVERSED)) } else { Span::raw(content) } } /// Case-insensitive subsequence match. `"sq"` matches `"squat"` and /// `"back squat"` but not `"pushup"`. fn subsequence_match(haystack: &str, needle: &str) -> bool { let mut chars = haystack.chars(); needle.chars().all(|nc| chars.any(|hc| hc == nc)) } #[cfg(test)] mod tests { use super::*; use crossterm::event::KeyEvent; use ripgrow_core::{LoadUnit, ResistanceType}; fn key(c: KeyCode) -> KeyEvent { KeyEvent::new(c, KeyModifiers::empty()) } fn setup() -> (Db, i64) { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let sq = db .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); db.create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); db.create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); (db, sq) } fn type_str(screen: &mut LogScreen, db: &Db, text: &str) { for c in text.chars() { screen.on_key(db, key(KeyCode::Char(c))); } } #[test] fn subsequence_match_examples() { assert!(subsequence_match("squat", "sq")); assert!(subsequence_match("back squat", "bsq")); assert!(!subsequence_match("pushup", "sq")); } #[test] fn parse_duration_accepts_seconds_and_mm_ss() { assert_eq!(parse_duration("60").unwrap(), 60); assert_eq!(parse_duration("1:30").unwrap(), 90); assert_eq!(parse_duration("1:05").unwrap(), 65); assert_eq!(parse_duration("0:45").unwrap(), 45); assert!(parse_duration("abc").is_err()); assert!(parse_duration("-1").is_err()); } #[test] fn typing_filters_and_enter_selects() { let (db, sq) = setup(); let mut screen = LogScreen::load(&db).unwrap(); type_str(&mut screen, &db, "sq"); assert_eq!(screen.filtered_indices().len(), 1); screen.on_key(&db, key(KeyCode::Enter)); assert!(screen.grid.is_some()); assert_eq!(screen.grid.as_ref().unwrap().exercise.id, sq); } #[test] fn enter_on_last_column_commits_reps_set() { let (db, sq) = setup(); let mut screen = LogScreen::load(&db).unwrap(); type_str(&mut screen, &db, "squat"); screen.on_key(&db, key(KeyCode::Enter)); // 100 [tab] 5 [tab] 3 [enter] type_str(&mut screen, &db, "100"); screen.on_key(&db, key(KeyCode::Tab)); type_str(&mut screen, &db, "5"); screen.on_key(&db, key(KeyCode::Tab)); type_str(&mut screen, &db, "3"); screen.on_key(&db, key(KeyCode::Enter)); let today = chrono::Local::now().date_naive(); let list = RepsKind::list_sets_for_session(&db, sq, today).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].load.get(), 100.0); assert_eq!(list[0].reps.get(), 5); assert_eq!(list[0].rpe.get(), 3); // Pending row cleared, focus back on Load. let KindGrid::Reps(r) = &screen.grid.as_ref().unwrap().kind else { panic!("expected reps grid"); }; assert!(r.pending.load.is_empty()); assert_eq!(r.focus, RepsField::Load); } #[test] fn commits_timed_set_to_timed_sets_table() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let plank = db .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[]) .unwrap(); let mut screen = LogScreen::load(&db).unwrap(); type_str(&mut screen, &db, "plank"); screen.on_key(&db, key(KeyCode::Enter)); // 1:30 [tab] 3 [enter] type_str(&mut screen, &db, "1:30"); screen.on_key(&db, key(KeyCode::Tab)); type_str(&mut screen, &db, "3"); screen.on_key(&db, key(KeyCode::Enter)); let today = chrono::Local::now().date_naive(); let list = TimedKind::list_sets_for_session(&db, plank, today).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].duration.seconds(), 90); assert_eq!(list[0].rpe.get(), 3); // Reps table should NOT have received this row. assert!(RepsKind::list_sets_for_session(&db, plank, today) .unwrap() .is_empty()); } #[test] fn commits_distance_set_to_distance_sets_table() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let run = db .create_exercise( "5k run", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[], ) .unwrap(); let mut screen = LogScreen::load(&db).unwrap(); type_str(&mut screen, &db, "5k"); screen.on_key(&db, key(KeyCode::Enter)); // 5000 [tab] 25:00 [tab] 3 [enter] type_str(&mut screen, &db, "5000"); screen.on_key(&db, key(KeyCode::Tab)); type_str(&mut screen, &db, "25:00"); screen.on_key(&db, key(KeyCode::Tab)); type_str(&mut screen, &db, "3"); screen.on_key(&db, key(KeyCode::Enter)); let today = chrono::Local::now().date_naive(); let list = DistanceKind::list_sets_for_session(&db, run, today).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].distance.meters(), 5000.0); assert_eq!(list[0].duration.seconds(), 1500); assert_eq!(list[0].rpe.get(), 3); } #[test] fn ctrl_backspace_removes_last_reps_set() { let (db, sq) = setup(); let today = chrono::Local::now().date_naive(); let payload = RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap()); RepsKind::append(&db, sq, today, payload, Rpe::new(3).unwrap(), false).unwrap(); RepsKind::append(&db, sq, today, payload, Rpe::new(3).unwrap(), false).unwrap(); let mut screen = LogScreen::load(&db).unwrap(); type_str(&mut screen, &db, "squat"); screen.on_key(&db, key(KeyCode::Enter)); screen.refresh_grid(&db); let KindGrid::Reps(r) = &screen.grid.as_ref().unwrap().kind else { panic!("expected reps grid"); }; assert_eq!(r.committed.len(), 2); screen.on_key( &db, KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), ); assert_eq!( RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 1 ); } #[test] fn ctrl_backspace_removes_last_timed_set() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let plank = db .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[]) .unwrap(); let today = chrono::Local::now().date_naive(); TimedKind::append( &db, plank, today, TimedPayload::new(Duration::from_seconds(60).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let mut screen = LogScreen::load(&db).unwrap(); type_str(&mut screen, &db, "plank"); screen.on_key(&db, key(KeyCode::Enter)); screen.refresh_grid(&db); screen.on_key( &db, KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), ); assert!( TimedKind::list_sets_for_session(&db, plank, today) .unwrap() .is_empty() ); } #[test] fn bracket_keys_change_date() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let mut screen = LogScreen::load(&db).unwrap(); let start = screen.date; screen.on_key(&db, key(KeyCode::Char('['))); assert_eq!(screen.date, start - ChronoDuration::days(1)); screen.on_key(&db, key(KeyCode::Char(']'))); screen.on_key(&db, key(KeyCode::Char(']'))); assert_eq!(screen.date, start + ChronoDuration::days(1)); } #[test] fn esc_from_grid_returns_to_picker() { let (db, _sq) = setup(); let mut screen = LogScreen::load(&db).unwrap(); type_str(&mut screen, &db, "squat"); screen.on_key(&db, key(KeyCode::Enter)); assert!(screen.grid.is_some()); screen.on_key(&db, key(KeyCode::Esc)); assert!(screen.grid.is_none()); } }