Skip to main content

max / ripgrow

profile picker: list, open, create Adds ripgrow-core::profiles (dir resolution, slugify, list, create) and the ratatui shell with a profile picker as the first screen. New-profile modal requires both a display name and an explicit kg/lb choice — no silent unit default, since it is hard to change safely later. 17 tests total, all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:17 UTC
Commit: 2443cf1db28d07b128636e3ee8de8494711dc291
Parent: 5eecf01
9 files changed, +683 insertions, -3 deletions
M Cargo.lock +1
@@ -627,6 +627,7 @@ name = "ripgrow-core"
627 627 version = "0.1.0"
628 628 dependencies = [
629 629 "chrono",
630 + "dirs",
630 631 "rusqlite",
631 632 "thiserror",
632 633 ]
@@ -10,3 +10,4 @@ description = "Core data model and progression logic for ripgrow, a resistance-t
10 10 rusqlite = { workspace = true }
11 11 thiserror = { workspace = true }
12 12 chrono = { workspace = true }
13 + dirs = { workspace = true }
@@ -5,6 +5,8 @@
5 5
6 6 pub mod db;
7 7 pub mod error;
8 + pub mod profiles;
8 9
9 10 pub use db::{Db, Unit};
10 11 pub use error::Error;
12 + pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
@@ -0,0 +1,209 @@
1 + //! Profile file discovery on disk.
2 + //!
3 + //! One SQLite file per profile at
4 + //! `~/Library/Application Support/ripgrow/profiles/<slug>.db`. This module
5 + //! turns display names into filesystem-safe slugs, resolves the profile
6 + //! directory, lists existing profiles, and opens (creating on demand) a
7 + //! profile by name.
8 +
9 + use std::fs;
10 + use std::path::{Path, PathBuf};
11 +
12 + use crate::db::{Db, Unit};
13 + use crate::error::Error;
14 +
15 + /// Return the profiles directory, creating it if missing.
16 + ///
17 + /// macOS: `~/Library/Application Support/ripgrow/profiles/`. Other platforms
18 + /// fall back to the OS "data dir" per the `dirs` crate. The whole tree is
19 + /// created if any component is missing.
20 + pub fn profiles_dir() -> Result<PathBuf, Error> {
21 + let base = dirs::data_dir().ok_or_else(|| {
22 + Error::Io(std::io::Error::other("could not resolve OS data dir"))
23 + })?;
24 + let dir = base.join("ripgrow").join("profiles");
25 + fs::create_dir_all(&dir)?;
26 + Ok(dir)
27 + }
28 +
29 + /// Turn a display name ("Jane Doe", "self") into a filesystem-safe slug.
30 + ///
31 + /// Rules: lowercase, ASCII letters/digits/hyphen only, whitespace and other
32 + /// characters collapse into single hyphens, no leading/trailing hyphens.
33 + /// Empty input becomes `"profile"`.
34 + pub fn slugify(name: &str) -> String {
35 + let mut out = String::with_capacity(name.len());
36 + let mut prev_hyphen = true;
37 + for c in name.chars() {
38 + let ch = c.to_ascii_lowercase();
39 + if ch.is_ascii_alphanumeric() {
40 + out.push(ch);
41 + prev_hyphen = false;
42 + } else if !prev_hyphen {
43 + out.push('-');
44 + prev_hyphen = true;
45 + }
46 + }
47 + while out.ends_with('-') {
48 + out.pop();
49 + }
50 + if out.is_empty() {
51 + "profile".to_string()
52 + } else {
53 + out
54 + }
55 + }
56 +
57 + /// A profile file on disk.
58 + #[derive(Debug, Clone, PartialEq, Eq)]
59 + pub struct Profile {
60 + /// The slug (filename stem). Not necessarily the display name.
61 + pub slug: String,
62 + pub path: PathBuf,
63 + }
64 +
65 + /// List every `.db` file in the profiles directory, sorted by slug.
66 + pub fn list_profiles() -> Result<Vec<Profile>, Error> {
67 + list_profiles_in(&profiles_dir()?)
68 + }
69 +
70 + /// Same as [`list_profiles`] but rooted at an explicit directory (for tests).
71 + pub fn list_profiles_in(dir: &Path) -> Result<Vec<Profile>, Error> {
72 + let mut out = Vec::new();
73 + if !dir.exists() {
74 + return Ok(out);
75 + }
76 + for entry in fs::read_dir(dir)? {
77 + let entry = entry?;
78 + let path = entry.path();
79 + if path.extension().and_then(|e| e.to_str()) == Some("db")
80 + && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
81 + {
82 + out.push(Profile {
83 + slug: stem.to_string(),
84 + path,
85 + });
86 + }
87 + }
88 + out.sort_by(|a, b| a.slug.cmp(&b.slug));
89 + Ok(out)
90 + }
91 +
92 + /// Create a new profile file at `<dir>/<slug>.db` and initialize its `meta`
93 + /// row. Errors if the file already exists.
94 + pub fn create_profile_in(
95 + dir: &Path,
96 + display_name: &str,
97 + unit: Unit,
98 + ) -> Result<(Profile, Db), Error> {
99 + let slug = slugify(display_name);
100 + let path = dir.join(format!("{slug}.db"));
101 + if path.exists() {
102 + return Err(Error::Io(std::io::Error::new(
103 + std::io::ErrorKind::AlreadyExists,
104 + format!("profile file already exists: {}", path.display()),
105 + )));
106 + }
107 + fs::create_dir_all(dir)?;
108 + let db = Db::open(&path)?;
109 + db.init_profile(display_name, unit)?;
110 + Ok((Profile { slug, path }, db))
111 + }
112 +
113 + #[cfg(test)]
114 + mod tests {
115 + use super::*;
116 +
117 + fn tmp() -> tempdir::Handle {
118 + tempdir::mkdtemp()
119 + }
120 +
121 + #[test]
122 + fn slugify_examples() {
123 + assert_eq!(slugify("self"), "self");
124 + assert_eq!(slugify("Jane Doe"), "jane-doe");
125 + assert_eq!(slugify(" Weird Name!! "), "weird-name");
126 + assert_eq!(slugify("---"), "profile");
127 + assert_eq!(slugify(""), "profile");
128 + assert_eq!(slugify("a b c d"), "a-b-c-d");
129 + }
130 +
131 + #[test]
132 + fn list_profiles_empty_dir_ok() {
133 + let t = tmp();
134 + assert_eq!(list_profiles_in(t.path()).unwrap(), vec![]);
135 + }
136 +
137 + #[test]
138 + fn create_and_list_round_trip() {
139 + let t = tmp();
140 + let (p, _db) = create_profile_in(t.path(), "self", Unit::Kg).unwrap();
141 + assert_eq!(p.slug, "self");
142 + let listed = list_profiles_in(t.path()).unwrap();
143 + assert_eq!(listed.len(), 1);
144 + assert_eq!(listed[0].slug, "self");
145 + }
146 +
147 + #[test]
148 + fn create_profile_writes_meta_row() {
149 + let t = tmp();
150 + let (_, db) = create_profile_in(t.path(), "Jane Doe", Unit::Lb).unwrap();
151 + assert_eq!(db.profile_name().unwrap().as_deref(), Some("Jane Doe"));
152 + assert_eq!(db.unit().unwrap(), Some(Unit::Lb));
153 + }
154 +
155 + #[test]
156 + fn create_profile_rejects_collision() {
157 + let t = tmp();
158 + create_profile_in(t.path(), "self", Unit::Kg).unwrap();
159 + let err = create_profile_in(t.path(), "SELF", Unit::Kg);
160 + assert!(err.is_err(), "second create with same slug must error");
161 + }
162 +
163 + #[test]
164 + fn list_ignores_non_db_files() {
165 + let t = tmp();
166 + std::fs::write(t.path().join("notes.txt"), b"x").unwrap();
167 + std::fs::write(t.path().join("readme"), b"x").unwrap();
168 + create_profile_in(t.path(), "self", Unit::Kg).unwrap();
169 + let listed = list_profiles_in(t.path()).unwrap();
170 + assert_eq!(listed.len(), 1);
171 + assert_eq!(listed[0].slug, "self");
172 + }
173 + }
174 +
175 + /// Tiny tempdir helper for tests; keeps the dep tree small.
176 + #[cfg(test)]
177 + mod tempdir {
178 + use std::path::{Path, PathBuf};
179 + use std::sync::atomic::{AtomicU64, Ordering};
180 + use std::time::{SystemTime, UNIX_EPOCH};
181 +
182 + static COUNTER: AtomicU64 = AtomicU64::new(0);
183 +
184 + pub struct Handle(PathBuf);
185 +
186 + impl Handle {
187 + pub fn path(&self) -> &Path {
188 + &self.0
189 + }
190 + }
191 +
192 + impl Drop for Handle {
193 + fn drop(&mut self) {
194 + let _ = std::fs::remove_dir_all(&self.0);
195 + }
196 + }
197 +
198 + pub fn mkdtemp() -> Handle {
199 + let nanos = SystemTime::now()
200 + .duration_since(UNIX_EPOCH)
201 + .unwrap()
202 + .as_nanos();
203 + let n = COUNTER.fetch_add(1, Ordering::Relaxed);
204 + let dir =
205 + std::env::temp_dir().join(format!("ripgrow-test-{nanos}-{n}-{}", std::process::id()));
206 + std::fs::create_dir_all(&dir).unwrap();
207 + Handle(dir)
208 + }
209 + }
@@ -0,0 +1,115 @@
1 + //! Top-level app state and the render/event dispatch.
2 + //!
3 + //! v1 has one screen (the profile picker). Once a profile is opened, the app
4 + //! records the resulting `Db` and (later) hands control to the tab set.
5 +
6 + use std::path::PathBuf;
7 +
8 + use crossterm::event::KeyEvent;
9 + use ratatui::Frame;
10 + use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir};
11 +
12 + use crate::screens::profile_picker::{PickerAction, ProfilePicker};
13 +
14 + pub struct App {
15 + pub picker: ProfilePicker,
16 + pub opened: Option<OpenedProfile>,
17 + pub status: String,
18 + pub should_quit: bool,
19 + }
20 +
21 + pub struct OpenedProfile {
22 + pub path: PathBuf,
23 + pub db: Db,
24 + }
25 +
26 + impl App {
27 + pub fn new() -> Result<Self, ripgrow_core::Error> {
28 + let profiles = list_profiles()?;
29 + Ok(Self {
30 + picker: ProfilePicker::new(profiles),
31 + opened: None,
32 + status: String::new(),
33 + should_quit: false,
34 + })
35 + }
36 +
37 + pub fn render(&mut self, frame: &mut Frame) {
38 + use ratatui::layout::{Constraint, Direction, Layout};
39 +
40 + let area = frame.area();
41 + let chunks = Layout::default()
42 + .direction(Direction::Vertical)
43 + .constraints([Constraint::Min(1), Constraint::Length(1)])
44 + .split(area);
45 +
46 + if let Some(op) = &self.opened {
47 + let title = format!(
48 + "opened: {} (screens coming soon) q to quit",
49 + op.path.display()
50 + );
51 + frame.render_widget(ratatui::widgets::Paragraph::new(title), chunks[0]);
52 + } else {
53 + self.picker.render(frame, chunks[0]);
54 + }
55 +
56 + let status = ratatui::widgets::Paragraph::new(self.status.as_str())
57 + .style(ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::DIM));
58 + frame.render_widget(status, chunks[1]);
59 + }
60 +
61 + pub fn on_key(&mut self, key: KeyEvent) {
62 + if self.opened.is_some() {
63 + use crossterm::event::{KeyCode, KeyModifiers};
64 + if matches!(
65 + (key.code, key.modifiers),
66 + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL)
67 + ) {
68 + self.should_quit = true;
69 + }
70 + return;
71 + }
72 + match self.picker.on_key(key) {
73 + PickerAction::None => {}
74 + PickerAction::Quit => self.should_quit = true,
75 + PickerAction::Open(path) => self.open_profile(path),
76 + PickerAction::Create { display_name, unit } => {
77 + self.create_profile(&display_name, unit);
78 + }
79 + }
80 + }
81 +
82 + fn open_profile(&mut self, path: PathBuf) {
83 + match Db::open(&path) {
84 + Ok(db) => {
85 + self.status = format!("opened {}", path.display());
86 + self.opened = Some(OpenedProfile { path, db });
87 + }
88 + Err(e) => self.status = format!("open failed: {e}"),
89 + }
90 + }
91 +
92 + fn create_profile(&mut self, display_name: &str, unit: Unit) {
93 + let dir = match profiles_dir() {
94 + Ok(d) => d,
95 + Err(e) => {
96 + self.status = format!("profiles dir: {e}");
97 + return;
98 + }
99 + };
100 + match create_profile_in(&dir, display_name, unit) {
101 + Ok((profile, db)) => {
102 + self.status = format!("created {}", profile.slug);
103 + self.opened = Some(OpenedProfile {
104 + path: profile.path,
105 + db,
106 + });
107 + }
108 + Err(e) => self.status = format!("create failed: {e}"),
109 + }
110 + // Refresh the list so a re-open shows the new profile.
111 + if let Ok(list) = list_profiles() {
112 + self.picker.profiles = list;
113 + }
114 + }
115 + }
@@ -1,5 +1,30 @@
1 - //! ripgrow — ratatui frontend. Placeholder entry point; screens land next.
1 + use std::time::Duration;
2 2
3 - fn main() {
4 - println!("ripgrow: TUI not implemented yet. See ripgrow-core for schema.");
3 + use crossterm::event::{self, Event};
4 +
5 + mod app;
6 + mod screens;
7 + mod tui;
8 +
9 + use app::App;
10 +
11 + fn main() -> Result<(), Box<dyn std::error::Error>> {
12 + let mut terminal = tui::init()?;
13 + let result = run(&mut terminal);
14 + tui::restore()?;
15 + result
16 + }
17 +
18 + fn run(terminal: &mut tui::Tui) -> Result<(), Box<dyn std::error::Error>> {
19 + let mut app = App::new()?;
20 + while !app.should_quit {
21 + terminal.draw(|f| app.render(f))?;
22 + if event::poll(Duration::from_millis(200))?
23 + && let Event::Key(key) = event::read()?
24 + && key.kind == crossterm::event::KeyEventKind::Press
25 + {
26 + app.on_key(key);
27 + }
28 + }
29 + Ok(())
5 30 }
@@ -0,0 +1 @@
1 + pub mod profile_picker;
@@ -0,0 +1,300 @@
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, Unit};
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: Unit },
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<Unit>,
138 + }
139 +
140 + enum ModalAction {
141 + None,
142 + Cancel,
143 + Submit { display_name: String, unit: Unit },
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 => Unit::Kg,
168 + Some(Unit::Kg) => Unit::Lb,
169 + Some(Unit::Lb) => Unit::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(Unit::Kg) => "kg",
186 + Some(Unit::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, Unit::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(Unit::Kg));
295 + m.on_key(key(KeyCode::Tab));
296 + assert_eq!(m.unit, Some(Unit::Lb));
297 + m.on_key(key(KeyCode::Tab));
298 + assert_eq!(m.unit, Some(Unit::Kg));
299 + }
300 + }
@@ -0,0 +1,26 @@
1 + //! Terminal setup and teardown. Owns the raw-mode + alternate-screen
2 + //! transitions so the rest of the app never has to think about them.
3 +
4 + use std::io::{Stdout, stdout};
5 +
6 + use crossterm::execute;
7 + use crossterm::terminal::{
8 + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
9 + };
10 + use ratatui::Terminal;
11 + use ratatui::backend::CrosstermBackend;
12 +
13 + pub type Tui = Terminal<CrosstermBackend<Stdout>>;
14 +
15 + pub fn init() -> std::io::Result<Tui> {
16 + enable_raw_mode()?;
17 + let mut out = stdout();
18 + execute!(out, EnterAlternateScreen)?;
19 + Terminal::new(CrosstermBackend::new(out))
20 + }
21 +
22 + pub fn restore() -> std::io::Result<()> {
23 + disable_raw_mode()?;
24 + execute!(stdout(), LeaveAlternateScreen)?;
25 + Ok(())
26 + }