max / ripgrow
9 files changed,
+683 insertions,
-3 deletions
| @@ -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 | + | } |