Skip to main content

max / ripgrow

templates screen: list, edit form, tag toggles Adds Exercise + Tag + ResistanceType to ripgrow-core with full CRUD (list_exercises, create/update/delete_exercise, list_tags, upsert_tag, set_exercise_tags via update). FK RESTRICT on sets.exercise_id keeps delete safe once history exists. Templates screen in the TUI: left pane list, right pane details or an editor form. Fields cycle with tab; type field cycles with arrows; tag grid uses space to toggle and + to add a new tag inline. Ctrl-S saves, esc cancels. TemplatesScreen replaces the placeholder once a profile opens. Db::open_in_memory made pub so downstream test code can drive the screen without hitting the filesystem. 29 tests, all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:26 UTC
Commit: 698bb8ac68ba8ce58c9be9f667342095b3b87663
Parent: 2443cf1
7 files changed, +917 insertions, -37 deletions
@@ -55,8 +55,8 @@ impl Db {
55 55 Ok(db)
56 56 }
57 57
58 - /// In-memory database for tests.
59 - #[cfg(test)]
58 + /// In-memory database for tests. Kept public so downstream crates can
59 + /// exercise their TUI logic without touching the filesystem.
60 60 pub fn open_in_memory() -> Result<Self, Error> {
61 61 let conn = Connection::open_in_memory()?;
62 62 conn.pragma_update(None, "foreign_keys", "ON")?;
@@ -10,4 +10,16 @@ pub enum Error {
10 10
11 11 #[error("invalid unit: {0}")]
12 12 InvalidUnit(String),
13 +
14 + #[error("invalid resistance type: {0}")]
15 + InvalidResistanceType(String),
16 +
17 + #[error("tag name cannot be empty")]
18 + InvalidTagName,
19 +
20 + #[error("exercise name cannot be empty")]
21 + InvalidExerciseName,
22 +
23 + #[error("not found: {0}")]
24 + NotFound(String),
13 25 }
@@ -6,7 +6,9 @@
6 6 pub mod db;
7 7 pub mod error;
8 8 pub mod profiles;
9 + pub mod templates;
9 10
10 11 pub use db::{Db, Unit};
11 12 pub use error::Error;
12 13 pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
14 + pub use templates::{Exercise, ResistanceType, Tag};
@@ -0,0 +1,344 @@
1 + //! Exercise templates and their tag associations.
2 + //!
3 + //! Exercises carry a name, resistance type, load unit, per-exercise
4 + //! increment, and a set of muscle-agnostic tag ids. No target sets or reps;
5 + //! prescription is computed at generation time from history.
6 +
7 + use rusqlite::{OptionalExtension, params};
8 +
9 + use crate::db::Db;
10 + use crate::error::Error;
11 +
12 + /// Resistance type is a UI hint. It determines how the log screen renders
13 + /// input, not how the schema stores rows.
14 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
15 + pub enum ResistanceType {
16 + Bodyweight,
17 + Machine,
18 + Freeweight,
19 + CardioTime,
20 + CardioDistance,
21 + }
22 +
23 + impl ResistanceType {
24 + pub const ALL: [ResistanceType; 5] = [
25 + ResistanceType::Bodyweight,
26 + ResistanceType::Machine,
27 + ResistanceType::Freeweight,
28 + ResistanceType::CardioTime,
29 + ResistanceType::CardioDistance,
30 + ];
31 +
32 + pub fn as_str(&self) -> &'static str {
33 + match self {
34 + ResistanceType::Bodyweight => "bodyweight",
35 + ResistanceType::Machine => "machine",
36 + ResistanceType::Freeweight => "freeweight",
37 + ResistanceType::CardioTime => "cardio_time",
38 + ResistanceType::CardioDistance => "cardio_distance",
39 + }
40 + }
41 +
42 + pub fn parse(s: &str) -> Result<Self, Error> {
43 + match s {
44 + "bodyweight" => Ok(ResistanceType::Bodyweight),
45 + "machine" => Ok(ResistanceType::Machine),
46 + "freeweight" => Ok(ResistanceType::Freeweight),
47 + "cardio_time" => Ok(ResistanceType::CardioTime),
48 + "cardio_distance" => Ok(ResistanceType::CardioDistance),
49 + other => Err(Error::InvalidResistanceType(other.to_string())),
50 + }
51 + }
52 + }
53 +
54 + #[derive(Debug, Clone, PartialEq)]
55 + pub struct Tag {
56 + pub id: i64,
57 + pub name: String,
58 + }
59 +
60 + #[derive(Debug, Clone, PartialEq)]
61 + pub struct Exercise {
62 + pub id: i64,
63 + pub name: String,
64 + pub resistance_type: ResistanceType,
65 + pub load_unit: String,
66 + pub increment: f64,
67 + pub notes: String,
68 + pub tag_ids: Vec<i64>,
69 + }
70 +
71 + impl Db {
72 + // ---- tags -----------------------------------------------------------
73 +
74 + pub fn list_tags(&self) -> Result<Vec<Tag>, Error> {
75 + let mut stmt = self
76 + .conn()
77 + .prepare("SELECT id, name FROM tags ORDER BY name")?;
78 + let rows = stmt.query_map([], |row| {
79 + Ok(Tag {
80 + id: row.get(0)?,
81 + name: row.get(1)?,
82 + })
83 + })?;
84 + Ok(rows.collect::<Result<Vec<_>, _>>()?)
85 + }
86 +
87 + /// Insert a tag, or return the existing row if the name already exists.
88 + pub fn upsert_tag(&self, name: &str) -> Result<Tag, Error> {
89 + let name = name.trim();
90 + if name.is_empty() {
91 + return Err(Error::InvalidTagName);
92 + }
93 + if let Some(existing) = self
94 + .conn()
95 + .query_row(
96 + "SELECT id, name FROM tags WHERE name = ?1",
97 + params![name],
98 + |row| {
99 + Ok(Tag {
100 + id: row.get(0)?,
101 + name: row.get(1)?,
102 + })
103 + },
104 + )
105 + .optional()?
106 + {
107 + return Ok(existing);
108 + }
109 + self.conn()
110 + .execute("INSERT INTO tags (name) VALUES (?1)", params![name])?;
111 + let id = self.conn().last_insert_rowid();
112 + Ok(Tag {
113 + id,
114 + name: name.to_string(),
115 + })
116 + }
117 +
118 + // ---- exercises ------------------------------------------------------
119 +
120 + pub fn list_exercises(&self) -> Result<Vec<Exercise>, Error> {
121 + let mut stmt = self.conn().prepare(
122 + "SELECT id, name, resistance_type, load_unit, increment, notes \
123 + FROM exercises ORDER BY name",
124 + )?;
125 + let rows = stmt.query_map([], |row| {
126 + let rt: String = row.get(2)?;
127 + Ok((
128 + row.get::<_, i64>(0)?,
129 + row.get::<_, String>(1)?,
130 + rt,
131 + row.get::<_, String>(3)?,
132 + row.get::<_, f64>(4)?,
133 + row.get::<_, String>(5)?,
134 + ))
135 + })?;
136 +
137 + let mut out = Vec::new();
138 + for row in rows {
139 + let (id, name, rt, load_unit, increment, notes) = row?;
140 + out.push(Exercise {
141 + id,
142 + name,
143 + resistance_type: ResistanceType::parse(&rt)?,
144 + load_unit,
145 + increment,
146 + notes,
147 + tag_ids: self.tag_ids_for(id)?,
148 + });
149 + }
150 + Ok(out)
151 + }
152 +
153 + fn tag_ids_for(&self, exercise_id: i64) -> Result<Vec<i64>, Error> {
154 + let mut stmt = self.conn().prepare(
155 + "SELECT tag_id FROM exercise_tags WHERE exercise_id = ?1 ORDER BY tag_id",
156 + )?;
157 + let rows = stmt.query_map(params![exercise_id], |row| row.get(0))?;
158 + Ok(rows.collect::<Result<Vec<_>, _>>()?)
159 + }
160 +
161 + pub fn create_exercise(
162 + &self,
163 + name: &str,
164 + resistance_type: ResistanceType,
165 + load_unit: &str,
166 + increment: f64,
167 + tag_ids: &[i64],
168 + ) -> Result<i64, Error> {
169 + let name = name.trim();
170 + if name.is_empty() {
171 + return Err(Error::InvalidExerciseName);
172 + }
173 + self.conn().execute(
174 + "INSERT INTO exercises (name, resistance_type, load_unit, increment) \
175 + VALUES (?1, ?2, ?3, ?4)",
176 + params![name, resistance_type.as_str(), load_unit, increment],
177 + )?;
178 + let id = self.conn().last_insert_rowid();
179 + self.write_tag_ids(id, tag_ids)?;
180 + Ok(id)
181 + }
182 +
183 + pub fn update_exercise(
184 + &self,
185 + id: i64,
186 + name: &str,
187 + resistance_type: ResistanceType,
188 + load_unit: &str,
189 + increment: f64,
190 + tag_ids: &[i64],
191 + ) -> Result<(), Error> {
192 + let name = name.trim();
193 + if name.is_empty() {
194 + return Err(Error::InvalidExerciseName);
195 + }
196 + let n = self.conn().execute(
197 + "UPDATE exercises SET name = ?1, resistance_type = ?2, load_unit = ?3, \
198 + increment = ?4 WHERE id = ?5",
199 + params![name, resistance_type.as_str(), load_unit, increment, id],
200 + )?;
201 + if n == 0 {
202 + return Err(Error::NotFound(format!("exercise {id}")));
203 + }
204 + self.write_tag_ids(id, tag_ids)?;
205 + Ok(())
206 + }
207 +
208 + /// Delete an exercise. Errors if any set references it (FK RESTRICT on
209 + /// `sets.exercise_id`); the caller should surface that as "log
210 + /// history exists; delete blocked".
211 + pub fn delete_exercise(&self, id: i64) -> Result<(), Error> {
212 + let n = self
213 + .conn()
214 + .execute("DELETE FROM exercises WHERE id = ?1", params![id])?;
215 + if n == 0 {
216 + return Err(Error::NotFound(format!("exercise {id}")));
217 + }
218 + Ok(())
219 + }
220 +
221 + fn write_tag_ids(&self, exercise_id: i64, tag_ids: &[i64]) -> Result<(), Error> {
222 + self.conn().execute(
223 + "DELETE FROM exercise_tags WHERE exercise_id = ?1",
224 + params![exercise_id],
225 + )?;
226 + for tid in tag_ids {
227 + self.conn().execute(
228 + "INSERT INTO exercise_tags (exercise_id, tag_id) VALUES (?1, ?2)",
229 + params![exercise_id, tid],
230 + )?;
231 + }
232 + Ok(())
233 + }
234 + }
235 +
236 + #[cfg(test)]
237 + mod tests {
238 + use super::*;
239 + use crate::db::Unit;
240 +
241 + fn setup() -> Db {
242 + let db = Db::open_in_memory().unwrap();
243 + db.init_profile("self", Unit::Kg).unwrap();
244 + db
245 + }
246 +
247 + #[test]
248 + fn tag_upsert_idempotent_and_trims() {
249 + let db = setup();
250 + let a = db.upsert_tag("chest").unwrap();
251 + let b = db.upsert_tag(" chest ").unwrap();
252 + assert_eq!(a.id, b.id);
253 + assert_eq!(a.name, "chest");
254 + assert_eq!(db.list_tags().unwrap().len(), 1);
255 + }
256 +
257 + #[test]
258 + fn tag_upsert_rejects_empty() {
259 + let db = setup();
260 + assert!(db.upsert_tag(" ").is_err());
261 + }
262 +
263 + #[test]
264 + fn exercise_round_trip_with_tags() {
265 + let db = setup();
266 + let t1 = db.upsert_tag("chest").unwrap();
267 + let t2 = db.upsert_tag("triceps").unwrap();
268 + let id = db
269 + .create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[t1.id, t2.id])
270 + .unwrap();
271 + let list = db.list_exercises().unwrap();
272 + assert_eq!(list.len(), 1);
273 + let ex = &list[0];
274 + assert_eq!(ex.id, id);
275 + assert_eq!(ex.name, "bench");
276 + assert_eq!(ex.resistance_type, ResistanceType::Freeweight);
277 + assert_eq!(ex.increment, 2.5);
278 + assert_eq!(ex.tag_ids, vec![t1.id, t2.id]);
279 + }
280 +
281 + #[test]
282 + fn update_replaces_tag_set() {
283 + let db = setup();
284 + let t1 = db.upsert_tag("a").unwrap();
285 + let t2 = db.upsert_tag("b").unwrap();
286 + let t3 = db.upsert_tag("c").unwrap();
287 + let id = db
288 + .create_exercise("x", ResistanceType::Machine, "kg", 5.0, &[t1.id, t2.id])
289 + .unwrap();
290 + db.update_exercise(id, "x", ResistanceType::Machine, "kg", 5.0, &[t3.id])
291 + .unwrap();
292 + let list = db.list_exercises().unwrap();
293 + assert_eq!(list[0].tag_ids, vec![t3.id]);
294 + }
295 +
296 + #[test]
297 + fn delete_removes_exercise_and_tag_links() {
298 + let db = setup();
299 + let t = db.upsert_tag("t").unwrap();
300 + let id = db
301 + .create_exercise("y", ResistanceType::Bodyweight, "reps", 0.0, &[t.id])
302 + .unwrap();
303 + db.delete_exercise(id).unwrap();
304 + assert!(db.list_exercises().unwrap().is_empty());
305 + // exercise_tags row should have cascaded away.
306 + let n: i64 = db
307 + .conn()
308 + .query_row("SELECT COUNT(*) FROM exercise_tags", [], |row| row.get(0))
309 + .unwrap();
310 + assert_eq!(n, 0);
311 + }
312 +
313 + #[test]
314 + fn delete_blocked_when_sets_exist() {
315 + let db = setup();
316 + let id = db
317 + .create_exercise("z", ResistanceType::Freeweight, "kg", 2.5, &[])
318 + .unwrap();
319 + db.conn()
320 + .execute(
321 + "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
322 + VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)",
323 + params![id],
324 + )
325 + .unwrap();
326 + assert!(db.delete_exercise(id).is_err(), "FK RESTRICT should block");
327 + }
328 +
329 + #[test]
330 + fn create_rejects_empty_name_and_duplicate() {
331 + let db = setup();
332 + assert!(
333 + db.create_exercise(" ", ResistanceType::Machine, "kg", 5.0, &[])
334 + .is_err()
335 + );
336 + db.create_exercise("dup", ResistanceType::Machine, "kg", 5.0, &[])
337 + .unwrap();
338 + assert!(
339 + db.create_exercise("dup", ResistanceType::Machine, "kg", 5.0, &[])
340 + .is_err(),
341 + "UNIQUE(name) should block"
342 + );
343 + }
344 + }
@@ -1,7 +1,7 @@
1 1 //! Top-level app state and the render/event dispatch.
2 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.
3 + //! Two states so far: the profile picker (until a profile is opened) and the
4 + //! templates screen (after). More screens land on tabs later.
5 5
6 6 use std::path::PathBuf;
7 7
@@ -10,17 +10,19 @@ use ratatui::Frame;
10 10 use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir};
11 11
12 12 use crate::screens::profile_picker::{PickerAction, ProfilePicker};
13 + use crate::screens::templates::{TemplatesAction, TemplatesScreen};
13 14
14 15 pub struct App {
15 - pub picker: ProfilePicker,
16 - pub opened: Option<OpenedProfile>,
17 - pub status: String,
16 + picker: ProfilePicker,
17 + opened: Option<Opened>,
18 + status: String,
18 19 pub should_quit: bool,
19 20 }
20 21
21 - pub struct OpenedProfile {
22 - pub path: PathBuf,
23 - pub db: Db,
22 + struct Opened {
23 + path: PathBuf,
24 + db: Db,
25 + templates: TemplatesScreen,
24 26 }
25 27
26 28 impl App {
@@ -40,32 +42,35 @@ impl App {
40 42 let area = frame.area();
41 43 let chunks = Layout::default()
42 44 .direction(Direction::Vertical)
43 - .constraints([Constraint::Min(1), Constraint::Length(1)])
45 + .constraints([Constraint::Length(1), Constraint::Min(1)])
44 46 .split(area);
45 47
46 - if let Some(op) = &self.opened {
47 - let title = format!(
48 - "opened: {} (screens coming soon) q to quit",
48 + let header = match &self.opened {
49 + Some(op) => format!(
50 + "ripgrow | {} | {}",
51 + op.db.profile_name().ok().flatten().unwrap_or_default(),
49 52 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 - }
53 + ),
54 + None => "ripgrow".to_string(),
55 + };
56 + frame.render_widget(ratatui::widgets::Paragraph::new(header), chunks[0]);
55 57
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]);
58 + match &mut self.opened {
59 + Some(op) => op.templates.render(frame, chunks[1]),
60 + None => self.picker.render(frame, chunks[1]),
61 + }
59 62 }
60 63
61 64 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;
65 + if let Some(op) = self.opened.as_mut() {
66 + let action = op.templates.on_key(&op.db, key);
67 + match action {
68 + TemplatesAction::None => {}
69 + TemplatesAction::Quit => self.should_quit = true,
70 + TemplatesAction::Refresh => match TemplatesScreen::load(&op.db) {
71 + Ok(next) => op.templates = next,
72 + Err(e) => self.status = format!("reload failed: {e}"),
73 + },
69 74 }
70 75 return;
71 76 }
@@ -80,13 +85,22 @@ impl App {
80 85 }
81 86
82 87 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 });
88 + let db = match Db::open(&path) {
89 + Ok(d) => d,
90 + Err(e) => {
91 + self.status = format!("open failed: {e}");
92 + return;
87 93 }
88 - Err(e) => self.status = format!("open failed: {e}"),
89 - }
94 + };
95 + let templates = match TemplatesScreen::load(&db) {
96 + Ok(s) => s,
97 + Err(e) => {
98 + self.status = format!("load templates failed: {e}");
99 + return;
100 + }
101 + };
102 + self.status = format!("opened {}", path.display());
103 + self.opened = Some(Opened { path, db, templates });
90 104 }
91 105
92 106 fn create_profile(&mut self, display_name: &str, unit: Unit) {
@@ -99,15 +113,22 @@ impl App {
99 113 };
100 114 match create_profile_in(&dir, display_name, unit) {
101 115 Ok((profile, db)) => {
116 + let templates = match TemplatesScreen::load(&db) {
117 + Ok(s) => s,
118 + Err(e) => {
119 + self.status = format!("load templates failed: {e}");
120 + return;
121 + }
122 + };
102 123 self.status = format!("created {}", profile.slug);
103 - self.opened = Some(OpenedProfile {
124 + self.opened = Some(Opened {
104 125 path: profile.path,
105 126 db,
127 + templates,
106 128 });
107 129 }
108 130 Err(e) => self.status = format!("create failed: {e}"),
109 131 }
110 - // Refresh the list so a re-open shows the new profile.
111 132 if let Ok(list) = list_profiles() {
112 133 self.picker.profiles = list;
113 134 }
@@ -1 +1,2 @@
1 1 pub mod profile_picker;
2 + pub mod templates;
@@ -0,0 +1,621 @@
1 + //! Templates screen. Left pane lists exercises, right pane shows details or
2 + //! an editor form when creating/updating. Tag toggles live inside the form.
3 +
4 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5 + use ratatui::Frame;
6 + use ratatui::layout::{Constraint, Direction, Layout, Rect};
7 + use ratatui::style::{Modifier, Style};
8 + use ratatui::text::{Line, Span};
9 + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
10 + use ripgrow_core::{Db, Exercise, ResistanceType, Tag};
11 +
12 + pub struct TemplatesScreen {
13 + pub exercises: Vec<Exercise>,
14 + pub tags: Vec<Tag>,
15 + list_state: ListState,
16 + mode: Mode,
17 + pub status: String,
18 + }
19 +
20 + enum Mode {
21 + Browse,
22 + Edit(EditForm),
23 + }
24 +
25 + /// What the outer app should do after this tick.
26 + pub enum TemplatesAction {
27 + None,
28 + Quit,
29 + Refresh,
30 + }
31 +
32 + impl TemplatesScreen {
33 + pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
34 + let exercises = db.list_exercises()?;
35 + let tags = db.list_tags()?;
36 + let mut list_state = ListState::default();
37 + if !exercises.is_empty() {
38 + list_state.select(Some(0));
39 + }
40 + Ok(Self {
41 + exercises,
42 + tags,
43 + list_state,
44 + mode: Mode::Browse,
45 + status: String::new(),
46 + })
47 + }
48 +
49 + pub fn on_key(&mut self, db: &Db, key: KeyEvent) -> TemplatesAction {
50 + // Take the mode out so we can pattern-match on it and mutate self.
51 + let mode = std::mem::replace(&mut self.mode, Mode::Browse);
52 + let (next_mode, action) = match mode {
53 + Mode::Browse => {
54 + let a = self.on_browse_key(db, key);
55 + (std::mem::replace(&mut self.mode, Mode::Browse), a)
56 + }
57 + Mode::Edit(form) => self.on_edit_key(db, form, key),
58 + };
59 + self.mode = next_mode;
60 + action
61 + }
62 +
63 + fn on_browse_key(&mut self, db: &Db, key: KeyEvent) -> TemplatesAction {
64 + match (key.code, key.modifiers) {
65 + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
66 + TemplatesAction::Quit
67 + }
68 + (KeyCode::Down | KeyCode::Char('j'), _) => {
69 + self.move_selection(1);
70 + TemplatesAction::None
71 + }
72 + (KeyCode::Up | KeyCode::Char('k'), _) => {
73 + self.move_selection(-1);
74 + TemplatesAction::None
75 + }
76 + (KeyCode::Char('n'), _) => {
77 + self.mode = Mode::Edit(EditForm::new_blank(self.default_unit(db)));
78 + TemplatesAction::None
79 + }
80 + (KeyCode::Char('e'), _) => {
81 + if let Some(ex) = self.selected() {
82 + self.mode = Mode::Edit(EditForm::from_exercise(ex));
83 + }
84 + TemplatesAction::None
85 + }
86 + (KeyCode::Char('d'), _) => {
87 + if let Some(ex) = self.selected() {
88 + let id = ex.id;
89 + match db.delete_exercise(id) {
90 + Ok(()) => {
91 + self.status = format!("deleted");
92 + return TemplatesAction::Refresh;
93 + }
94 + Err(e) => {
95 + self.status = format!("delete failed: {e}");
96 + }
97 + }
98 + }
99 + TemplatesAction::None
100 + }
101 + _ => TemplatesAction::None,
102 + }
103 + }
104 +
105 + fn on_edit_key(
106 + &mut self,
107 + db: &Db,
108 + mut form: EditForm,
109 + key: KeyEvent,
110 + ) -> (Mode, TemplatesAction) {
111 + match (key.code, key.modifiers) {
112 + (KeyCode::Esc, _) => (Mode::Browse, TemplatesAction::None),
113 + (KeyCode::Char('s'), KeyModifiers::CONTROL) => match form.save(db) {
114 + Ok(()) => {
115 + self.status = format!("saved");
116 + (Mode::Browse, TemplatesAction::Refresh)
117 + }
118 + Err(e) => {
119 + self.status = format!("save failed: {e}");
120 + (Mode::Edit(form), TemplatesAction::None)
121 + }
122 + },
123 + (KeyCode::Tab, _) => {
124 + form.cycle_focus(1);
125 + (Mode::Edit(form), TemplatesAction::None)
126 + }
127 + (KeyCode::BackTab, _) => {
128 + form.cycle_focus(-1);
129 + (Mode::Edit(form), TemplatesAction::None)
130 + }
131 + _ => {
132 + if let Some(refresh) = form.on_field_key(db, key) {
133 + match refresh {
134 + FormRefresh::Tags => {
135 + if let Ok(tags) = db.list_tags() {
136 + self.tags = tags.clone();
137 + form.all_tags = tags;
138 + }
139 + }
140 + }
141 + }
142 + (Mode::Edit(form), TemplatesAction::None)
143 + }
144 + }
145 + }
146 +
147 + fn move_selection(&mut self, delta: isize) {
148 + if self.exercises.is_empty() {
149 + return;
150 + }
151 + let len = self.exercises.len() as isize;
152 + let cur = self.list_state.selected().unwrap_or(0) as isize;
153 + let next = (cur + delta).rem_euclid(len) as usize;
154 + self.list_state.select(Some(next));
155 + }
156 +
157 + fn selected(&self) -> Option<&Exercise> {
158 + self.list_state.selected().and_then(|i| self.exercises.get(i))
159 + }
160 +
161 + fn default_unit(&self, db: &Db) -> String {
162 + db.unit()
163 + .ok()
164 + .flatten()
165 + .map(|u| u.as_str().to_string())
166 + .unwrap_or_else(|| "kg".to_string())
167 + }
168 +
169 + pub fn render(&mut self, frame: &mut Frame, area: Rect) {
170 + let chunks = Layout::default()
171 + .direction(Direction::Vertical)
172 + .constraints([Constraint::Min(3), Constraint::Length(1)])
173 + .split(area);
174 +
175 + let panes = Layout::default()
176 + .direction(Direction::Horizontal)
177 + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
178 + .split(chunks[0]);
179 +
180 + // Left: exercise list.
181 + let items: Vec<ListItem> = if self.exercises.is_empty() {
182 + vec![ListItem::new("no templates yet — press n to add one")]
183 + } else {
184 + self.exercises
185 + .iter()
186 + .map(|e| ListItem::new(e.name.as_str()))
187 + .collect()
188 + };
189 + let list = List::new(items)
190 + .block(Block::default().borders(Borders::ALL).title(" templates "))
191 + .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
192 + .highlight_symbol("> ");
193 + frame.render_stateful_widget(list, panes[0], &mut self.list_state);
194 +
195 + // Right: details or edit form.
196 + match &self.mode {
197 + Mode::Browse => self.render_details(frame, panes[1]),
198 + Mode::Edit(form) => form.render(frame, panes[1], &self.tags),
199 + }
200 +
201 + // Footer.
202 + let hint = match &self.mode {
203 + Mode::Browse => "j/k move n new e edit d delete q quit",
204 + Mode::Edit(_) => "tab/shift-tab move space toggle + add tag ctrl-s save esc cancel",
205 + };
206 + frame.render_widget(
207 + Paragraph::new(Line::from(vec![
208 + Span::raw(hint),
209 + Span::raw(" "),
210 + Span::styled(
211 + self.status.as_str(),
212 + Style::default().add_modifier(Modifier::DIM),
213 + ),
214 + ])),
215 + chunks[1],
216 + );
217 + }
218 +
219 + fn render_details(&self, frame: &mut Frame, area: Rect) {
220 + let block = Block::default().borders(Borders::ALL).title(" details ");
221 + if let Some(ex) = self.selected() {
222 + let tag_names: Vec<String> = ex
223 + .tag_ids
224 + .iter()
225 + .filter_map(|tid| self.tags.iter().find(|t| t.id == *tid))
226 + .map(|t| t.name.clone())
227 + .collect();
228 + let body = format!(
229 + "name: {}\ntype: {}\nload unit: {}\nincrement: {}\ntags: {}",
230 + ex.name,
231 + ex.resistance_type.as_str(),
232 + ex.load_unit,
233 + ex.increment,
234 + if tag_names.is_empty() {
235 + "-".to_string()
236 + } else {
237 + tag_names.join(", ")
238 + },
239 + );
240 + frame.render_widget(Paragraph::new(body).block(block), area);
241 + } else {
242 + frame.render_widget(
243 + Paragraph::new("no template selected").block(block),
244 + area,
245 + );
246 + }
247 + }
248 + }
249 +
250 + // ---- edit form -------------------------------------------------------------
251 +
252 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
253 + enum Field {
254 + Name,
255 + Type,
256 + LoadUnit,
257 + Increment,
258 + Tags,
259 + }
260 +
261 + enum FormRefresh {
262 + Tags,
263 + }
264 +
265 + struct EditForm {
266 + editing_id: Option<i64>,
267 + name: String,
268 + resistance_type: ResistanceType,
269 + load_unit: String,
270 + increment: String,
271 + selected_tag_ids: Vec<i64>,
272 + all_tags: Vec<Tag>,
273 + focus: Field,
274 + tag_cursor: usize,
275 + new_tag_buffer: Option<String>,
276 + }
277 +
278 + impl EditForm {
279 + fn new_blank(load_unit: String) -> Self {
280 + Self {
281 + editing_id: None,
282 + name: String::new(),
283 + resistance_type: ResistanceType::Freeweight,
284 + load_unit,
285 + increment: "2.5".to_string(),
286 + selected_tag_ids: Vec::new(),
287 + all_tags: Vec::new(),
288 + focus: Field::Name,
289 + tag_cursor: 0,
290 + new_tag_buffer: None,
291 + }
292 + }
293 +
294 + fn from_exercise(ex: &Exercise) -> Self {
295 + Self {
296 + editing_id: Some(ex.id),
297 + name: ex.name.clone(),
298 + resistance_type: ex.resistance_type,
299 + load_unit: ex.load_unit.clone(),
300 + increment: format!("{}", ex.increment),
301 + selected_tag_ids: ex.tag_ids.clone(),
302 + all_tags: Vec::new(),
303 + focus: Field::Name,
304 + tag_cursor: 0,
305 + new_tag_buffer: None,
306 + }
307 + }
308 +
309 + fn cycle_focus(&mut self, delta: i32) {
310 + let order = [
311 + Field::Name,
312 + Field::Type,
313 + Field::LoadUnit,
314 + Field::Increment,
315 + Field::Tags,
316 + ];
317 + let idx = order.iter().position(|f| *f == self.focus).unwrap_or(0) as i32;
318 + let next = (idx + delta).rem_euclid(order.len() as i32) as usize;
319 + self.focus = order[next];
320 + self.new_tag_buffer = None;
321 + }
322 +
323 + fn on_field_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> {
324 + // Tag-add sub-prompt intercepts input first.
325 + if self.focus == Field::Tags && self.new_tag_buffer.is_some() {
326 + return self.on_tag_add_key(db, key);
327 + }
328 + match self.focus {
329 + Field::Name => text_edit(&mut self.name, key),
330 + Field::LoadUnit => text_edit(&mut self.load_unit, key),
331 + Field::Increment => text_edit(&mut self.increment, key),
332 + Field::Type => {
333 + let all = ResistanceType::ALL;
334 + let cur = all
335 + .iter()
336 + .position(|t| *t == self.resistance_type)
337 + .unwrap_or(0) as i32;
338 + let delta = match key.code {
339 + KeyCode::Left | KeyCode::Char('h') => -1,
340 + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => 1,
341 + _ => 0,
342 + };
343 + if delta != 0 {
344 + let next = (cur + delta).rem_euclid(all.len() as i32) as usize;
345 + self.resistance_type = all[next];
346 + }
347 + }
348 + Field::Tags => match key.code {
349 + KeyCode::Up | KeyCode::Char('k') => {
350 + if !self.all_tags.is_empty() {
351 + self.tag_cursor = (self.tag_cursor + self.all_tags.len() - 1)
352 + % self.all_tags.len();
353 + }
354 + }
355 + KeyCode::Down | KeyCode::Char('j') => {
356 + if !self.all_tags.is_empty() {
357 + self.tag_cursor = (self.tag_cursor + 1) % self.all_tags.len();
358 + }
359 + }
360 + KeyCode::Char(' ') => {
361 + if let Some(tag) = self.all_tags.get(self.tag_cursor) {
362 + if let Some(pos) =
363 + self.selected_tag_ids.iter().position(|id| *id == tag.id)
364 + {
365 + self.selected_tag_ids.remove(pos);
366 + } else {
367 + self.selected_tag_ids.push(tag.id);
368 + self.selected_tag_ids.sort();
369 + }
370 + }
371 + }
372 + KeyCode::Char('+') => {
373 + self.new_tag_buffer = Some(String::new());
374 + }
375 + _ => {}
376 + },
377 + }
378 + None
379 + }
380 +
381 + fn on_tag_add_key(&mut self, db: &Db, key: KeyEvent) -> Option<FormRefresh> {
382 + let buf = self.new_tag_buffer.as_mut()?;
383 + match key.code {
384 + KeyCode::Esc => {
385 + self.new_tag_buffer = None;
386 + None
387 + }
388 + KeyCode::Enter => {
389 + let name = std::mem::take(buf);
390 + self.new_tag_buffer = None;
391 + if name.trim().is_empty() {
392 + return None;
393 + }
394 + match db.upsert_tag(&name) {
395 + Ok(tag) => {
396 + if !self.selected_tag_ids.contains(&tag.id) {
397 + self.selected_tag_ids.push(tag.id);
398 + self.selected_tag_ids.sort();
399 + }
400 + Some(FormRefresh::Tags)
401 + }
402 + Err(_) => None,
403 + }
404 + }
405 + KeyCode::Backspace => {
406 + buf.pop();
407 + None
408 + }
409 + KeyCode::Char(c) => {
410 + buf.push(c);
411 + None
412 + }
413 + _ => None,
414 + }
415 + }
416 +
417 + fn save(&self, db: &Db) -> Result<(), ripgrow_core::Error> {
418 + let increment: f64 = self
419 + .increment
420 + .trim()
421 + .parse()
422 + .map_err(|_| ripgrow_core::Error::InvalidExerciseName)?;
423 + match self.editing_id {
424 + Some(id) => db.update_exercise(
425 + id,
426 + &self.name,
427 + self.resistance_type,
428 + &self.load_unit,
429 + increment,
430 + &self.selected_tag_ids,
431 + ),
432 + None => db
433 + .create_exercise(
434 + &self.name,
435 + self.resistance_type,
436 + &self.load_unit,
437 + increment,
438 + &self.selected_tag_ids,
439 + )
440 + .map(|_| ()),
441 + }
442 + }
443 +
444 + fn render(&self, frame: &mut Frame, area: Rect, all_tags: &[Tag]) {
445 + let block = Block::default()
446 + .borders(Borders::ALL)
447 + .title(if self.editing_id.is_some() {
448 + " edit template "
449 + } else {
450 + " new template "
451 + });
452 + let inner = block.inner(area);
453 + frame.render_widget(block, area);
454 +
455 + let tag_lines: Vec<Line> = if all_tags.is_empty() {
456 + vec![Line::from(" (no tags yet — press + to add one)")]
457 + } else {
458 + all_tags
459 + .iter()
460 + .enumerate()
461 + .map(|(i, t)| {
462 + let checked = self.selected_tag_ids.contains(&t.id);
463 + let cursor = self.focus == Field::Tags && self.tag_cursor == i;
464 + let marker = if checked { "[x]" } else { "[ ]" };
465 + let prefix = if cursor { "> " } else { " " };
466 + Line::from(format!("{prefix}{marker} {}", t.name))
467 + })
468 + .collect()
469 + };
470 +
471 + let mut lines: Vec<Line> = vec![
472 + field_line("name", &self.name, self.focus == Field::Name),
473 + field_line(
474 + "type",
475 + self.resistance_type.as_str(),
476 + self.focus == Field::Type,
477 + ),
478 + field_line("load unit", &self.load_unit, self.focus == Field::LoadUnit),
479 + field_line("increment", &self.increment, self.focus == Field::Increment),
480 + Line::from(""),
481 + Line::from(if self.focus == Field::Tags {
482 + Span::styled("tags:", Style::default().add_modifier(Modifier::REVERSED))
483 + } else {
484 + Span::raw("tags:")
485 + }),
486 + ];
487 + lines.extend(tag_lines);
488 + if let Some(buf) = self.new_tag_buffer.as_ref() {
489 + lines.push(Line::from(""));
490 + lines.push(Line::from(format!("new tag: {buf}_ (enter save, esc cancel)")));
491 + }
492 +
493 + frame.render_widget(Paragraph::new(lines), inner);
494 + }
495 + }
496 +
497 + fn field_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {
498 + let content = format!("{label:<12}{value}");
499 + if focused {
500 + Line::from(Span::styled(
Lines truncated