Skip to main content

max / ripgrow

9.6 KB · 301 lines History Blame Raw
1 //! Profile picker: the first screen the user sees.
2 //!
3 //! Lists every `.db` under the profiles directory. Enter opens the highlighted
4 //! profile; `n` opens the new-profile modal (requires a display name and a
5 //! kg/lb choice — no silent default, per the design open question).
6
7 use std::path::PathBuf;
8
9 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10 use ratatui::Frame;
11 use ratatui::layout::{Constraint, Direction, Layout, Rect};
12 use ratatui::style::{Modifier, Style};
13 use ratatui::text::Line;
14 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
15 use ripgrow_core::{Profile, LoadUnit};
16
17 pub struct ProfilePicker {
18 pub profiles: Vec<Profile>,
19 list_state: ListState,
20 pub modal: Option<NewProfileModal>,
21 }
22
23 /// What the picker asks the outer loop to do this tick.
24 pub enum PickerAction {
25 None,
26 Quit,
27 Open(PathBuf),
28 Create { display_name: String, unit: LoadUnit },
29 }
30
31 impl ProfilePicker {
32 pub fn new(profiles: Vec<Profile>) -> Self {
33 let mut list_state = ListState::default();
34 if !profiles.is_empty() {
35 list_state.select(Some(0));
36 }
37 Self {
38 profiles,
39 list_state,
40 modal: None,
41 }
42 }
43
44 pub fn on_key(&mut self, key: KeyEvent) -> PickerAction {
45 if let Some(modal) = self.modal.as_mut() {
46 match modal.on_key(key) {
47 ModalAction::None => PickerAction::None,
48 ModalAction::Cancel => {
49 self.modal = None;
50 PickerAction::None
51 }
52 ModalAction::Submit { display_name, unit } => {
53 self.modal = None;
54 PickerAction::Create { display_name, unit }
55 }
56 }
57 } else {
58 self.on_list_key(key)
59 }
60 }
61
62 fn on_list_key(&mut self, key: KeyEvent) -> PickerAction {
63 match (key.code, key.modifiers) {
64 (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
65 PickerAction::Quit
66 }
67 (KeyCode::Char('n'), _) => {
68 self.modal = Some(NewProfileModal::default());
69 PickerAction::None
70 }
71 (KeyCode::Down | KeyCode::Char('j'), _) => {
72 self.move_selection(1);
73 PickerAction::None
74 }
75 (KeyCode::Up | KeyCode::Char('k'), _) => {
76 self.move_selection(-1);
77 PickerAction::None
78 }
79 (KeyCode::Enter, _) => {
80 if let Some(idx) = self.list_state.selected()
81 && let Some(p) = self.profiles.get(idx)
82 {
83 return PickerAction::Open(p.path.clone());
84 }
85 PickerAction::None
86 }
87 _ => PickerAction::None,
88 }
89 }
90
91 fn move_selection(&mut self, delta: isize) {
92 if self.profiles.is_empty() {
93 return;
94 }
95 let len = self.profiles.len() as isize;
96 let cur = self.list_state.selected().unwrap_or(0) as isize;
97 let next = (cur + delta).rem_euclid(len) as usize;
98 self.list_state.select(Some(next));
99 }
100
101 pub fn render(&mut self, frame: &mut Frame, area: Rect) {
102 let chunks = Layout::default()
103 .direction(Direction::Vertical)
104 .constraints([Constraint::Min(3), Constraint::Length(1)])
105 .split(area);
106
107 let items: Vec<ListItem> = if self.profiles.is_empty() {
108 vec![ListItem::new("no profiles yet — press n to create one")]
109 } else {
110 self.profiles
111 .iter()
112 .map(|p| ListItem::new(p.slug.as_str()))
113 .collect()
114 };
115
116 let list = List::new(items)
117 .block(Block::default().borders(Borders::ALL).title(" profiles "))
118 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
119 .highlight_symbol("> ");
120 frame.render_stateful_widget(list, chunks[0], &mut self.list_state);
121
122 let hint = Paragraph::new(Line::from(
123 "enter open n new j/k move q quit",
124 ));
125 frame.render_widget(hint, chunks[1]);
126
127 if let Some(modal) = self.modal.as_ref() {
128 modal.render(frame, area);
129 }
130 }
131 }
132
133 /// Modal state for creating a new profile.
134 #[derive(Default)]
135 pub struct NewProfileModal {
136 pub name: String,
137 pub unit: Option<LoadUnit>,
138 }
139
140 enum ModalAction {
141 None,
142 Cancel,
143 Submit { display_name: String, unit: LoadUnit },
144 }
145
146 impl NewProfileModal {
147 fn on_key(&mut self, key: KeyEvent) -> ModalAction {
148 match key.code {
149 KeyCode::Esc => ModalAction::Cancel,
150 KeyCode::Enter => {
151 if !self.name.trim().is_empty()
152 && let Some(u) = self.unit
153 {
154 return ModalAction::Submit {
155 display_name: self.name.trim().to_string(),
156 unit: u,
157 };
158 }
159 ModalAction::None
160 }
161 KeyCode::Backspace => {
162 self.name.pop();
163 ModalAction::None
164 }
165 KeyCode::Tab => {
166 self.unit = Some(match self.unit {
167 None => LoadUnit::Kg,
168 Some(LoadUnit::Kg) => LoadUnit::Lb,
169 Some(LoadUnit::Lb) => LoadUnit::Kg,
170 });
171 ModalAction::None
172 }
173 KeyCode::Char(c) => {
174 self.name.push(c);
175 ModalAction::None
176 }
177 _ => ModalAction::None,
178 }
179 }
180
181 fn render(&self, frame: &mut Frame, area: Rect) {
182 let modal = centered_rect(60, 30, area);
183 let unit_str = match self.unit {
184 None => "(tab to choose kg or lb)",
185 Some(LoadUnit::Kg) => "kg",
186 Some(LoadUnit::Lb) => "lb",
187 };
188 let text = format!(
189 "name: {}_\nunit: {}\n\nenter save tab toggle unit esc cancel",
190 self.name, unit_str
191 );
192 let block = Block::default().borders(Borders::ALL).title(" new profile ");
193 let para = Paragraph::new(text).block(block);
194 frame.render_widget(ratatui::widgets::Clear, modal);
195 frame.render_widget(para, modal);
196 }
197 }
198
199 fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
200 let popup_layout = Layout::default()
201 .direction(Direction::Vertical)
202 .constraints([
203 Constraint::Percentage((100 - percent_y) / 2),
204 Constraint::Percentage(percent_y),
205 Constraint::Percentage((100 - percent_y) / 2),
206 ])
207 .split(r);
208 Layout::default()
209 .direction(Direction::Horizontal)
210 .constraints([
211 Constraint::Percentage((100 - percent_x) / 2),
212 Constraint::Percentage(percent_x),
213 Constraint::Percentage((100 - percent_x) / 2),
214 ])
215 .split(popup_layout[1])[1]
216 }
217
218 #[cfg(test)]
219 mod tests {
220 use super::*;
221 use crossterm::event::{KeyCode, KeyEvent};
222
223 fn key(c: KeyCode) -> KeyEvent {
224 KeyEvent::new(c, KeyModifiers::empty())
225 }
226
227 #[test]
228 fn q_quits() {
229 let mut p = ProfilePicker::new(vec![]);
230 assert!(matches!(p.on_key(key(KeyCode::Char('q'))), PickerAction::Quit));
231 }
232
233 #[test]
234 fn enter_on_empty_list_does_nothing() {
235 let mut p = ProfilePicker::new(vec![]);
236 assert!(matches!(p.on_key(key(KeyCode::Enter)), PickerAction::None));
237 }
238
239 #[test]
240 fn enter_opens_selected_profile() {
241 let profiles = vec![
242 Profile { slug: "a".into(), path: "/tmp/a.db".into() },
243 Profile { slug: "b".into(), path: "/tmp/b.db".into() },
244 ];
245 let mut p = ProfilePicker::new(profiles);
246 p.on_key(key(KeyCode::Down));
247 match p.on_key(key(KeyCode::Enter)) {
248 PickerAction::Open(path) => assert_eq!(path, PathBuf::from("/tmp/b.db")),
249 _ => panic!("expected Open action"),
250 }
251 }
252
253 #[test]
254 fn n_opens_modal_and_esc_closes_it() {
255 let mut p = ProfilePicker::new(vec![]);
256 p.on_key(key(KeyCode::Char('n')));
257 assert!(p.modal.is_some());
258 p.on_key(key(KeyCode::Esc));
259 assert!(p.modal.is_none());
260 }
261
262 #[test]
263 fn modal_requires_name_and_unit_before_submit() {
264 let mut p = ProfilePicker::new(vec![]);
265 p.on_key(key(KeyCode::Char('n')));
266 // Enter with empty name + no unit: no-op.
267 assert!(matches!(p.on_key(key(KeyCode::Enter)), PickerAction::None));
268 assert!(p.modal.is_some(), "modal should stay open");
269
270 // Type name but leave unit unset: still a no-op.
271 for c in "jane".chars() {
272 p.on_key(key(KeyCode::Char(c)));
273 }
274 assert!(matches!(p.on_key(key(KeyCode::Enter)), PickerAction::None));
275 assert!(p.modal.is_some());
276
277 // Choose kg, then submit.
278 p.on_key(key(KeyCode::Tab));
279 match p.on_key(key(KeyCode::Enter)) {
280 PickerAction::Create { display_name, unit } => {
281 assert_eq!(display_name, "jane");
282 assert_eq!(unit, LoadUnit::Kg);
283 }
284 _ => panic!("expected Create action"),
285 }
286 assert!(p.modal.is_none(), "modal should close on submit");
287 }
288
289 #[test]
290 fn modal_tab_cycles_units() {
291 let mut m = NewProfileModal::default();
292 assert_eq!(m.unit, None);
293 m.on_key(key(KeyCode::Tab));
294 assert_eq!(m.unit, Some(LoadUnit::Kg));
295 m.on_key(key(KeyCode::Tab));
296 assert_eq!(m.unit, Some(LoadUnit::Lb));
297 m.on_key(key(KeyCode::Tab));
298 assert_eq!(m.unit, Some(LoadUnit::Kg));
299 }
300 }
301