//! Profile picker: the first screen the user sees. //! //! Lists every `.db` under the profiles directory. Enter opens the highlighted //! profile; `n` opens the new-profile modal (requires a display name and a //! kg/lb choice — no silent default, per the design open question). use std::path::PathBuf; 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; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ripgrow_core::{Profile, LoadUnit}; pub struct ProfilePicker { pub profiles: Vec, list_state: ListState, pub modal: Option, } /// What the picker asks the outer loop to do this tick. pub enum PickerAction { None, Quit, Open(PathBuf), Create { display_name: String, unit: LoadUnit }, } impl ProfilePicker { pub fn new(profiles: Vec) -> Self { let mut list_state = ListState::default(); if !profiles.is_empty() { list_state.select(Some(0)); } Self { profiles, list_state, modal: None, } } pub fn on_key(&mut self, key: KeyEvent) -> PickerAction { if let Some(modal) = self.modal.as_mut() { match modal.on_key(key) { ModalAction::None => PickerAction::None, ModalAction::Cancel => { self.modal = None; PickerAction::None } ModalAction::Submit { display_name, unit } => { self.modal = None; PickerAction::Create { display_name, unit } } } } else { self.on_list_key(key) } } fn on_list_key(&mut self, key: KeyEvent) -> PickerAction { match (key.code, key.modifiers) { (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { PickerAction::Quit } (KeyCode::Char('n'), _) => { self.modal = Some(NewProfileModal::default()); PickerAction::None } (KeyCode::Down | KeyCode::Char('j'), _) => { self.move_selection(1); PickerAction::None } (KeyCode::Up | KeyCode::Char('k'), _) => { self.move_selection(-1); PickerAction::None } (KeyCode::Enter, _) => { if let Some(idx) = self.list_state.selected() && let Some(p) = self.profiles.get(idx) { return PickerAction::Open(p.path.clone()); } PickerAction::None } _ => PickerAction::None, } } fn move_selection(&mut self, delta: isize) { if self.profiles.is_empty() { return; } let len = self.profiles.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)); } 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 items: Vec = if self.profiles.is_empty() { vec![ListItem::new("no profiles yet — press n to create one")] } else { self.profiles .iter() .map(|p| ListItem::new(p.slug.as_str())) .collect() }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(" profiles ")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("> "); frame.render_stateful_widget(list, chunks[0], &mut self.list_state); let hint = Paragraph::new(Line::from( "enter open n new j/k move q quit", )); frame.render_widget(hint, chunks[1]); if let Some(modal) = self.modal.as_ref() { modal.render(frame, area); } } } /// Modal state for creating a new profile. #[derive(Default)] pub struct NewProfileModal { pub name: String, pub unit: Option, } enum ModalAction { None, Cancel, Submit { display_name: String, unit: LoadUnit }, } impl NewProfileModal { fn on_key(&mut self, key: KeyEvent) -> ModalAction { match key.code { KeyCode::Esc => ModalAction::Cancel, KeyCode::Enter => { if !self.name.trim().is_empty() && let Some(u) = self.unit { return ModalAction::Submit { display_name: self.name.trim().to_string(), unit: u, }; } ModalAction::None } KeyCode::Backspace => { self.name.pop(); ModalAction::None } KeyCode::Tab => { self.unit = Some(match self.unit { None => LoadUnit::Kg, Some(LoadUnit::Kg) => LoadUnit::Lb, Some(LoadUnit::Lb) => LoadUnit::Kg, }); ModalAction::None } KeyCode::Char(c) => { self.name.push(c); ModalAction::None } _ => ModalAction::None, } } fn render(&self, frame: &mut Frame, area: Rect) { let modal = centered_rect(60, 30, area); let unit_str = match self.unit { None => "(tab to choose kg or lb)", Some(LoadUnit::Kg) => "kg", Some(LoadUnit::Lb) => "lb", }; let text = format!( "name: {}_\nunit: {}\n\nenter save tab toggle unit esc cancel", self.name, unit_str ); let block = Block::default().borders(Borders::ALL).title(" new profile "); let para = Paragraph::new(text).block(block); frame.render_widget(ratatui::widgets::Clear, modal); frame.render_widget(para, modal); } } fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]) .split(r); Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ]) .split(popup_layout[1])[1] } #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyCode, KeyEvent}; fn key(c: KeyCode) -> KeyEvent { KeyEvent::new(c, KeyModifiers::empty()) } #[test] fn q_quits() { let mut p = ProfilePicker::new(vec![]); assert!(matches!(p.on_key(key(KeyCode::Char('q'))), PickerAction::Quit)); } #[test] fn enter_on_empty_list_does_nothing() { let mut p = ProfilePicker::new(vec![]); assert!(matches!(p.on_key(key(KeyCode::Enter)), PickerAction::None)); } #[test] fn enter_opens_selected_profile() { let profiles = vec![ Profile { slug: "a".into(), path: "/tmp/a.db".into() }, Profile { slug: "b".into(), path: "/tmp/b.db".into() }, ]; let mut p = ProfilePicker::new(profiles); p.on_key(key(KeyCode::Down)); match p.on_key(key(KeyCode::Enter)) { PickerAction::Open(path) => assert_eq!(path, PathBuf::from("/tmp/b.db")), _ => panic!("expected Open action"), } } #[test] fn n_opens_modal_and_esc_closes_it() { let mut p = ProfilePicker::new(vec![]); p.on_key(key(KeyCode::Char('n'))); assert!(p.modal.is_some()); p.on_key(key(KeyCode::Esc)); assert!(p.modal.is_none()); } #[test] fn modal_requires_name_and_unit_before_submit() { let mut p = ProfilePicker::new(vec![]); p.on_key(key(KeyCode::Char('n'))); // Enter with empty name + no unit: no-op. assert!(matches!(p.on_key(key(KeyCode::Enter)), PickerAction::None)); assert!(p.modal.is_some(), "modal should stay open"); // Type name but leave unit unset: still a no-op. for c in "jane".chars() { p.on_key(key(KeyCode::Char(c))); } assert!(matches!(p.on_key(key(KeyCode::Enter)), PickerAction::None)); assert!(p.modal.is_some()); // Choose kg, then submit. p.on_key(key(KeyCode::Tab)); match p.on_key(key(KeyCode::Enter)) { PickerAction::Create { display_name, unit } => { assert_eq!(display_name, "jane"); assert_eq!(unit, LoadUnit::Kg); } _ => panic!("expected Create action"), } assert!(p.modal.is_none(), "modal should close on submit"); } #[test] fn modal_tab_cycles_units() { let mut m = NewProfileModal::default(); assert_eq!(m.unit, None); m.on_key(key(KeyCode::Tab)); assert_eq!(m.unit, Some(LoadUnit::Kg)); m.on_key(key(KeyCode::Tab)); assert_eq!(m.unit, Some(LoadUnit::Lb)); m.on_key(key(KeyCode::Tab)); assert_eq!(m.unit, Some(LoadUnit::Kg)); } }