1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
10#![cfg_attr(coverage_nightly, allow(unused_features))]
13
14mod app;
15mod complete;
16mod diagnose;
17mod edit;
18mod fnhelp;
19mod ui;
20
21use app::App;
22use crossterm::{
23 event::{
24 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
25 KeyModifiers, MouseButton, MouseEventKind,
26 },
27 execute,
28 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
29};
30use improv_store::Store;
31use ratatui::{
32 backend::{Backend, CrosstermBackend},
33 Terminal,
34};
35use std::io;
36
37#[cfg_attr(coverage_nightly, coverage(off))]
40fn main() {
41 let Some(path) = std::env::args().nth(1) else {
42 eprintln!("usage: improv-tui <model.db>");
43 std::process::exit(1);
44 };
45 if let Err(e) = load(&path).and_then(|app| run(app, &path)) {
47 eprintln!("error: {e}");
48 std::process::exit(1);
49 }
50}
51
52fn load(path: &str) -> Result<App, String> {
54 let (model, save_token) = Store::open(path)
55 .and_then(|mut s| Ok((s.load_model()?, s.save_token())))
56 .map_err(|e| format!("cannot open model '{path}': {e}"))?;
57 let mut app = App::new(model)?;
58 app.save_token = save_token;
59 Ok(app)
60}
61
62fn save(app: &App, path: &str) -> Result<(), String> {
65 Store::open(path)
66 .and_then(|mut s| {
67 s.set_save_token(&app.save_token);
68 s.save_model(&app.model)
69 })
70 .map_err(|e| format!("autosave failed: {e}"))
71}
72
73#[cfg_attr(coverage_nightly, coverage(off))]
78fn run(mut app: App, path: &str) -> Result<(), String> {
79 enable_raw_mode().map_err(|e| e.to_string())?;
80 execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture).map_err(|e| e.to_string())?;
81
82 let prev_hook = std::panic::take_hook();
84 std::panic::set_hook(Box::new(move |info| {
85 let _ = restore();
86 prev_hook(info);
87 }));
88
89 let result = Terminal::new(CrosstermBackend::new(io::stdout()))
90 .and_then(|mut term| {
91 event_loop(
92 &mut term,
93 &mut app,
94 path,
95 std::iter::repeat_with(event::read),
96 )
97 })
98 .map_err(|e| e.to_string());
99
100 let _ = restore();
102 result?;
103 save(&app, path)
104}
105
106#[cfg_attr(coverage_nightly, coverage(off))]
109fn restore() -> io::Result<()> {
110 execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen)?;
111 disable_raw_mode()
112}
113
114fn event_loop<B: Backend>(
116 term: &mut Terminal<B>,
117 app: &mut App,
118 path: &str,
119 events: impl IntoIterator<Item = io::Result<Event>>,
120) -> io::Result<()> {
121 let mut events = events.into_iter();
122 while !app.should_quit {
123 term.draw(|f| ui::render(f, app))?;
124 let Some(ev) = events.next() else { break };
125 handle_event(app, ev?, path);
126 }
127 Ok(())
128}
129
130fn handle_event(app: &mut App, ev: Event, path: &str) {
132 match ev {
133 Event::Key(k) if k.kind != KeyEventKind::Release && app.help.is_some() => {
134 help_key(app, k.code)
135 }
136 Event::Key(k) if k.kind != KeyEventKind::Release => match (k.code, &mut app.edit) {
137 (KeyCode::F(1), _) => app.help = Some(fnhelp::FnHelp::new()),
138 (KeyCode::Enter, Some(_)) => app.commit_edit(),
139 (KeyCode::Esc, Some(_)) => app.cancel_edit(),
140 (code, Some(_)) => app.edit_key(code),
141 (code, None) => match (code, &mut app.command) {
142 (KeyCode::Enter, Some(_)) => app.commit_command(),
143 (KeyCode::Esc, Some(_)) => app.cancel_command(),
144 (code, Some((_, buf))) => line_key(buf, code),
145 (_, None) => normal_key(app, k, path),
146 },
147 },
148 Event::Mouse(m) if app.edit.is_none() && app.command.is_none() && app.help.is_none() => {
151 if let MouseEventKind::Down(MouseButton::Left) = m.kind {
152 app.click_at(m.column, m.row);
153 }
154 }
155 _ => {}
156 }
157}
158
159fn line_key(buf: &mut String, code: KeyCode) {
162 match code {
163 KeyCode::Backspace => {
164 buf.pop();
165 }
166 KeyCode::Char(c) => buf.push(c),
167 _ => {}
168 }
169}
170
171fn help_key(app: &mut App, code: KeyCode) {
174 let Some(h) = &mut app.help else { return };
175 match code {
176 KeyCode::Esc => app.help = None,
177 KeyCode::Enter => {
178 if let (Some(d), Some(buf)) = (h.current(), &mut app.edit) {
179 buf.insert(&format!("{}(", d.name));
180 app.tab_cycle = None;
181 app.diagnostic = None; }
183 app.help = None;
184 }
185 KeyCode::Up => h.step(-1),
186 KeyCode::Down => h.step(1),
187 KeyCode::PageUp => h.page_by(-1),
188 KeyCode::PageDown => h.page_by(1),
189 KeyCode::Backspace => h.pop(),
190 KeyCode::Char(c) => h.push(c),
191 _ => {}
192 }
193}
194
195fn normal_key(app: &mut App, k: KeyEvent, path: &str) {
197 let shift = k.modifiers.contains(KeyModifiers::SHIFT);
198 match k.code {
199 KeyCode::Char('q') => app.should_quit = true,
200 KeyCode::Char('e') | KeyCode::Enter => app.begin_edit(),
201 KeyCode::Tab | KeyCode::Char('m') => app.next_measure(),
202 KeyCode::Up => app.step(-1, 0, shift),
204 KeyCode::Down => app.step(1, 0, shift),
205 KeyCode::Left => app.step(0, -1, shift),
206 KeyCode::Right => app.step(0, 1, shift),
207 KeyCode::Char('[') => app.page(-1),
208 KeyCode::Char(']') => app.page(1),
209 KeyCode::Char('p') => app.pivot(),
210 KeyCode::Char('f') => filter_row_under_cursor(app),
212 KeyCode::Char('F') => app.clear_filters(),
213 KeyCode::Char('S') => save_current_view(app, path),
215 KeyCode::Char('v') => app.cycle_view(),
216 KeyCode::Char('I') => app.begin_import_csv(),
218 KeyCode::Char('E') => app.begin_export_csv(),
219 KeyCode::Char('R') => revert(app, path),
221 KeyCode::Char('?') => app.help = Some(fnhelp::FnHelp::new()),
222 KeyCode::Esc => app.status = None,
223 _ => {}
224 }
225}
226
227fn revert(app: &mut App, path: &str) {
232 match load(path) {
233 Ok(fresh) => {
234 *app = fresh;
235 app.status = Some("reverted to the saved model".into());
236 }
237 Err(e) => app.status = Some(format!("revert failed: {e}")),
238 }
239}
240
241fn filter_row_under_cursor(app: &mut App) {
244 let (Some((cat, _)), Some((item, _))) = (
245 app.grid.row_cat.clone(),
246 app.grid.rows.get(app.cursor_row).cloned(),
247 ) else {
248 app.status = Some("no row category to filter".into());
249 return;
250 };
251 app.toggle_filter_item(cat, item);
252}
253
254fn save_current_view(app: &mut App, path: &str) {
258 let n = app.model.views.len() + 1;
259 app.save_view(&format!("view {n}"), Some(path));
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::app::tests::{revenue_model, tmp_path, view};
266 use crossterm::event::MouseEvent;
267 use improv_core_model::{CategoryId, Coordinate, ItemId, MeasureId, Value};
268 use ratatui::backend::TestBackend;
269
270 fn key(code: KeyCode) -> io::Result<Event> {
271 Ok(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)))
272 }
273 fn chars(s: &str) -> Vec<io::Result<Event>> {
274 s.chars().map(|c| key(KeyCode::Char(c))).collect()
275 }
276 fn click(kind: MouseEventKind, column: u16, row: u16) -> io::Result<Event> {
277 Ok(Event::Mouse(MouseEvent {
278 kind,
279 column,
280 row,
281 modifiers: KeyModifiers::NONE,
282 }))
283 }
284 fn load_err(p: &str) -> String {
285 load(p).err().expect("load should fail")
286 }
287 fn run_events(app: &mut App, path: &str, events: Vec<io::Result<Event>>) -> io::Result<()> {
289 let mut term = Terminal::new(TestBackend::new(100, 30)).unwrap();
290 event_loop(&mut term, app, path, events)
291 }
292
293 #[test]
294 fn typing_an_edit_updates_the_model() {
295 let mut app = App::new(revenue_model()).unwrap();
296 view(&mut app, MeasureId(101));
297 let mut ev = vec![key(KeyCode::Enter), key(KeyCode::Backspace)];
298 ev.extend(chars("x"));
299 ev.extend([key(KeyCode::Backspace), key(KeyCode::Backspace)]);
300 ev.extend(chars("7"));
301 ev.extend([key(KeyCode::F(5)), key(KeyCode::Enter)]);
302 run_events(&mut app, "", ev).unwrap();
303 let at = Coordinate::from_pairs([(CategoryId(1), ItemId(10)), (CategoryId(2), ItemId(20))]);
304 assert_eq!(
305 app.model.input(MeasureId(101), &at),
306 Some(&Value::Number(17.0))
307 );
308 run_events(
310 &mut app,
311 "",
312 vec![key(KeyCode::Char('e')), key(KeyCode::Esc)],
313 )
314 .unwrap();
315 assert!(app.edit.is_none());
316 }
317
318 #[test]
319 fn navigation_keys_and_release_events() {
320 let mut app = App::new(revenue_model()).unwrap();
321 view(&mut app, MeasureId(101));
322 let mut release = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
323 release.kind = KeyEventKind::Release;
324 let ev = vec![
325 key(KeyCode::Down),
326 key(KeyCode::Right),
327 Ok(Event::Key(release)), Ok(Event::FocusGained), ];
330 run_events(&mut app, "", ev).unwrap();
331 assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
332 run_events(&mut app, "", vec![key(KeyCode::Up), key(KeyCode::Left)]).unwrap();
333 assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
334 let before = (app.selected, app.cursor_row, app.cursor_col);
336 run_events(&mut app, "", vec![key(KeyCode::Char('z'))]).unwrap();
337 assert_eq!((app.selected, app.cursor_row, app.cursor_col), before);
338 assert!(!app.should_quit);
339 let before = app.selected;
341 let ev = vec![
342 key(KeyCode::Tab),
343 key(KeyCode::Char('m')),
344 key(KeyCode::Char('q')),
345 key(KeyCode::Tab),
346 ];
347 run_events(&mut app, "", ev).unwrap();
348 assert!(app.should_quit);
349 assert_eq!(app.selected, (before + 2) % app.measures.len());
350 }
351
352 #[test]
353 fn layout_keys_page_pivot_filter_and_status() {
354 let mut app = App::new(revenue_model()).unwrap();
355 view(&mut app, MeasureId(101));
356 let ev = vec![
357 key(KeyCode::Char('[')),
358 key(KeyCode::Char(']')),
359 key(KeyCode::Esc),
360 ];
361 run_events(&mut app, "", ev).unwrap();
362 assert_eq!(app.status, None, "Esc clears the paging message");
363 run_events(&mut app, "", chars("f")).unwrap();
364 assert_eq!(app.grid.n_rows(), 1);
365 run_events(&mut app, "", chars("F")).unwrap();
366 assert_eq!(app.grid.n_rows(), 2);
367 run_events(&mut app, "", chars("p")).unwrap();
368 assert_eq!(app.grid.row_cat.as_ref().unwrap().1, "Product");
369 run_events(&mut app, "", chars("v")).unwrap();
370 assert!(app.status.as_deref().unwrap().starts_with("no saved views"));
371 let mut m = improv_core_model::Model::new();
373 m.add_measure(improv_core_model::Measure {
374 id: MeasureId(1),
375 name: improv_core_model::Name("S".into()),
376 value_type: improv_core_model::ValueType::Number,
377 categories: vec![],
378 kind: improv_core_model::MeasureKind::Input,
379 description: None,
380 });
381 let mut scalar = App::new(m).unwrap();
382 run_events(&mut scalar, "", chars("f")).unwrap();
383 assert_eq!(scalar.status.as_deref(), Some("no row category to filter"));
384 }
385
386 #[test]
387 fn mouse_selects_only_in_normal_mode() {
388 let mut app = App::new(revenue_model()).unwrap();
389 view(&mut app, MeasureId(101));
390 run_events(&mut app, "", vec![]).unwrap();
393 let g = app.grid_geom.unwrap();
394 let (x, y) = (g.x0 + g.col_w + g.spacing + 1, g.y0 + 1);
395 let ev = vec![
396 click(MouseEventKind::Moved, x, y),
397 key(KeyCode::Char('e')),
398 click(MouseEventKind::Down(MouseButton::Left), x, y), key(KeyCode::Esc),
400 ];
401 run_events(&mut app, "", ev).unwrap();
402 assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
403 run_events(
404 &mut app,
405 "",
406 vec![click(MouseEventKind::Down(MouseButton::Left), x, y)],
407 )
408 .unwrap();
409 assert_eq!((app.cursor_row, app.cursor_col), (1, 1));
410 }
411
412 #[test]
413 fn shift_arrows_extend_the_selection() {
414 let mut app = App::new(revenue_model()).unwrap();
415 view(&mut app, MeasureId(101));
416 let shift = |c| Ok(Event::Key(KeyEvent::new(c, KeyModifiers::SHIFT)));
417 let ev = vec![
418 shift(KeyCode::Down),
419 shift(KeyCode::Right),
420 shift(KeyCode::Up),
421 shift(KeyCode::Left),
422 ];
423 run_events(&mut app, "", ev).unwrap();
424 assert_eq!(app.anchor, Some((0, 0)));
425 run_events(
426 &mut app,
427 "",
428 vec![shift(KeyCode::Right), shift(KeyCode::Down)],
429 )
430 .unwrap();
431 assert_eq!(app.selection_sum(), Some(350.0));
432 run_events(&mut app, "", vec![key(KeyCode::Left)]).unwrap();
433 assert_eq!(app.anchor, None);
434 }
435
436 #[test]
437 fn command_prompt_keys() {
438 let mut app = App::new(revenue_model()).unwrap();
439 let mut ev = vec![key(KeyCode::Char('E'))];
440 ev.extend(chars("xy"));
441 ev.extend([
442 key(KeyCode::Backspace),
443 key(KeyCode::Home),
444 key(KeyCode::Enter),
445 ]);
446 run_events(&mut app, "", ev).unwrap();
447 assert!(app.command.is_none());
448 assert!(app.status.as_deref().unwrap().starts_with("export-csv:"));
449 run_events(
450 &mut app,
451 "",
452 vec![key(KeyCode::Char('I')), key(KeyCode::Esc)],
453 )
454 .unwrap();
455 assert!(app.command.is_none());
456 view(&mut app, MeasureId(101));
459 run_events(&mut app, "", vec![]).unwrap();
460 let g = app.grid_geom.unwrap();
461 let (x, y) = (g.x0 + g.col_w + g.spacing + 1, g.y0 + 1);
462 let ev = vec![
463 key(KeyCode::Char('I')),
464 click(MouseEventKind::Down(MouseButton::Left), x, y),
465 key(KeyCode::Esc),
466 ];
467 run_events(&mut app, "", ev).unwrap();
468 assert_eq!((app.cursor_row, app.cursor_col), (0, 0));
469 }
470
471 #[test]
472 fn tab_completes_in_a_formula_only() {
473 let mut app = App::new(revenue_model()).unwrap();
474 view(&mut app, MeasureId(102));
475 let mut ev = vec![key(KeyCode::Char('e'))];
476 ev.extend((0..30).map(|_| key(KeyCode::Backspace)));
477 ev.extend(chars("Pri"));
478 ev.push(key(KeyCode::Tab));
479 ev.extend(chars(" * quan"));
480 ev.push(key(KeyCode::Tab));
481 run_events(&mut app, "", ev).unwrap();
482 assert_eq!(app.edit.as_deref(), Some("Price * Quantity"));
483 let mut ev = chars(" + sumx");
485 ev.extend([key(KeyCode::Tab), key(KeyCode::Tab)]);
486 run_events(&mut app, "", ev).unwrap();
487 assert_eq!(app.edit.as_deref(), Some("Price * Quantity + SUMXMY2("));
488 assert!(app.status.as_deref().unwrap().starts_with("Tab cycles:"));
489 run_events(&mut app, "", vec![key(KeyCode::Backspace)]).unwrap();
490 assert!(app.tab_cycle.is_none());
491 assert!(app.status.is_none());
492 run_events(&mut app, "", vec![key(KeyCode::Esc)]).unwrap();
493 view(&mut app, MeasureId(101));
495 let mut ev = vec![key(KeyCode::Char('e'))];
496 ev.extend(chars("pm"));
497 ev.push(key(KeyCode::Tab));
498 run_events(&mut app, "", ev).unwrap();
499 assert_eq!(app.edit.as_deref(), Some("100pm"));
500 }
501
502 #[test]
503 fn help_overlay_keys() {
504 let mut app = App::new(revenue_model()).unwrap();
505 view(&mut app, MeasureId(101));
506 let mut ev = vec![key(KeyCode::Char('?'))];
507 ev.extend(chars("pmtx"));
508 ev.extend([
509 key(KeyCode::Backspace),
510 key(KeyCode::Down),
511 key(KeyCode::Up),
512 key(KeyCode::PageDown),
513 key(KeyCode::PageUp),
514 key(KeyCode::Right), click(MouseEventKind::Down(MouseButton::Left), 40, 20), ]);
517 run_events(&mut app, "", ev).unwrap();
518 let h = app.help.as_ref().expect("? opens the overlay");
519 assert_eq!(h.query, "pmt");
520 assert_eq!(h.current().unwrap().name, "PMT");
521 run_events(&mut app, "", vec![key(KeyCode::Enter)]).unwrap();
523 assert!(app.help.is_none());
524 assert!(app.edit.is_none());
525 run_events(&mut app, "", vec![key(KeyCode::F(1)), key(KeyCode::Esc)]).unwrap();
526 assert!(app.help.is_none());
527 let mut ev = vec![key(KeyCode::Char('e')), key(KeyCode::F(1))];
529 ev.extend(chars("pmt"));
530 ev.push(key(KeyCode::Enter));
531 run_events(&mut app, "", ev).unwrap();
532 assert!(app.help.is_none());
533 assert_eq!(app.edit.as_deref(), Some("100PMT("));
534 help_key(&mut app, KeyCode::Enter);
536 assert!(app.help.is_none());
537 assert_eq!(app.edit.as_deref(), Some("100PMT("));
538 }
539
540 #[test]
544 fn help_insert_drops_the_stale_diagnostic() {
545 let mut app = App::new(revenue_model()).unwrap();
546 view(&mut app, MeasureId(102));
547 let mut ev = vec![key(KeyCode::Char('e'))];
548 ev.extend((0..30).map(|_| key(KeyCode::Backspace)));
549 ev.extend(chars("\"€\"+Quantty"));
550 ev.extend([key(KeyCode::Enter), key(KeyCode::Home)]);
551 run_events(&mut app, "", ev).unwrap();
552 assert_eq!(app.diagnostic.as_ref().unwrap().span, Some(6..13));
553 let mut ev = vec![key(KeyCode::F(1))];
554 ev.extend(chars("pmt"));
555 ev.push(key(KeyCode::Enter));
556 run_events(&mut app, "", ev).unwrap();
557 assert_eq!(app.edit.as_deref(), Some("PMT(\"€\"+Quantty"));
558 assert!(app.diagnostic.is_none());
559 }
560
561 #[test]
562 fn event_errors_stop_the_loop() {
563 let mut app = App::new(revenue_model()).unwrap();
564 let err = run_events(&mut app, "", vec![Err(io::Error::other("boom"))]);
565 assert_eq!(err.unwrap_err().to_string(), "boom");
566 }
567
568 #[test]
569 fn load_save_round_trip_and_save_view_key() {
570 let path = tmp_path("tui-main.db");
571 let p = path.to_str().unwrap();
572 assert!(load_err(p).contains("no measures"), "fresh store is empty");
573 let mut app = App::new(revenue_model()).unwrap();
574 save(&app, p).unwrap();
575 run_events(&mut app, p, chars("S")).unwrap();
576 assert_eq!(app.status.as_deref(), Some("saved view 'view 1'"));
577 let loaded = load(p).unwrap();
578 assert_eq!(loaded.model.views.len(), 1);
579 assert!(load_err("/nonexistent/dir/x.db").starts_with("cannot open model"));
580 assert!(save(&app, "/nonexistent/dir/x.db")
581 .unwrap_err()
582 .starts_with("autosave failed"));
583 }
584
585 #[test]
588 fn revert_key_recovers_from_a_save_conflict() {
589 let path = tmp_path("tui-revert.db");
590 let p = path.to_str().unwrap();
591 save(&App::new(revenue_model()).unwrap(), p).unwrap();
592 let mut a = load(p).unwrap();
593 let mut b = load(p).unwrap();
594 run_events(&mut a, p, chars("S")).unwrap();
595 b.model.views.clear();
596 b.model
597 .add_view(b.build_view(improv_core_model::ViewId(77), "mine"));
598 let e = save(&b, p).unwrap_err();
599 assert!(e.contains("conflict"), "{e}");
600 run_events(&mut b, p, chars("R")).unwrap();
601 assert_eq!(b.status.as_deref(), Some("reverted to the saved model"));
602 assert_eq!(b.model.views.len(), 1, "picked up the other session's view");
603 save(&b, p).expect("saves after the revert");
604 run_events(&mut b, "/nonexistent/dir/x.db", chars("R")).unwrap();
605 assert!(b.status.as_deref().unwrap().starts_with("revert failed"));
606 assert_eq!(b.model.views.len(), 1, "a failed revert keeps the session");
607 }
608}