Skip to main content

tui/
draw.rs

1//! Ratatui rendering. The draw layer is a pure function of
2//! [`crate::app::App`]: take a `Frame` and an `App`, render the current
3//! visual state. No I/O of its own; I/O is the transport's job.
4
5mod form;
6
7use crate::app::App;
8use crate::modal::Modal;
9use crate::view::{DrawCtx, Tab};
10use crate::widgets::{EditMode, VimMode};
11use ratatui::Frame;
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::{Color, Style};
14use ratatui::text::{Span, Text};
15use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs};
16
17pub fn draw(frame: &mut Frame, app: &App) {
18    let area = frame.area();
19    let chunks = Layout::default()
20        .direction(Direction::Vertical)
21        .constraints([
22            Constraint::Length(3),
23            Constraint::Min(1),
24            Constraint::Length(3),
25        ])
26        .split(area);
27
28    draw_tabs(frame, chunks[0], app);
29    draw_body(frame, chunks[1], app);
30    draw_status(frame, chunks[2], app);
31
32    if let Some(modal) = app.overlays.top() {
33        draw_modal(frame, area, modal);
34    }
35}
36
37fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
38    use ratatui::style::Modifier;
39    use ratatui::text::Line;
40    let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
41    let selected = app.active_tab.index();
42    let tabs = Tabs::new(titles)
43        .select(selected)
44        .block(Block::default().borders(Borders::ALL).title("nomisync-tui"))
45        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
46    frame.render_widget(tabs, area);
47}
48
49fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
50    let ctx = DrawCtx {
51        edit_mode: app.edit_mode,
52        is_active: app.console_focused,
53    };
54    app.active_view().draw(frame, area, &ctx);
55}
56
57fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
58    let edit_indicator = match app.cmdline.editor.mode() {
59        EditMode::Emacs => "emacs",
60        EditMode::Vim => match app.cmdline.editor.vim_mode() {
61            VimMode::Normal => "vim:normal",
62            VimMode::Insert => "vim:insert",
63        },
64    };
65    let content = if app.cmdline.active {
66        let buf = app.cmdline.editor.buffer();
67        if app.cmdline.completions.is_empty() {
68            format!(":{}  (cursor={})", buf, app.cmdline.editor.cursor())
69        } else {
70            format!(":{}  [{}]", buf, app.cmdline.completions.join("  "))
71        }
72    } else if app.status.is_empty() {
73        format!("[{edit_indicator}]  Tab/BTab tabs  : cmdline  ? help  C-v edit-mode  q quit")
74    } else {
75        format!("[{edit_indicator}] {}", app.status)
76    };
77    let line = Span::styled(content, Style::default().fg(Color::Gray));
78    let para = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
79    frame.render_widget(para, area);
80}
81
82fn draw_modal(frame: &mut Frame, full: Rect, modal: &Modal) {
83    // Help is a near-full-size modal so its ~18 lines never clip on 80x24.
84    let (pct_x, pct_y) = match modal {
85        Modal::Help => (95, 95),
86        _ => (60, 30),
87    };
88    let area = centered_rect(pct_x, pct_y, full);
89    frame.render_widget(Clear, area);
90    let (title, body) = modal_content(modal);
91    let widget = Paragraph::new(body).block(Block::default().title(title).borders(Borders::ALL));
92    frame.render_widget(widget, area);
93}
94
95fn modal_content(modal: &Modal) -> (&'static str, Text<'static>) {
96    match modal {
97        Modal::Help => (
98            "Help",
99            Text::from(
100                concat!(
101                    "Tabs: 1-6 jump  Tab/BTab next/prev  q quit  C-v emacs/vim  ? help\n",
102                    "\n",
103                    "Palette (:):  Tab complete  Enter run  Esc cancel (twice in vim)\n",
104                    "  account create|list|balance|tag   transaction create|list|tag\n",
105                    "  commodity create|list   config get|set   reports ...   version\n",
106                    "\n",
107                    "Lists: Up/Down or j/k select   r refresh\n",
108                    "  Accounts:     e tag\n",
109                    "  Transactions: d delete (confirm)  e edit  (tag: :transaction tag)\n",
110                    "  Commodities:  view only\n",
111                    "  Config:       Up/Down select  e/Enter edit  r refresh\n",
112                    "\n",
113                    "Forms: Tab/BTab fields  Up/Down Select  Enter submit  Esc cancel\n",
114                    "  Tx splits:  +/C-n add row   -/C-d remove row\n",
115                    "\n",
116                    "Console: i/Enter focus  Esc blur  C-c interrupt\n",
117                    "  Up/Down history   Tab/BTab cycle panes\n",
118                )
119                .to_string(),
120            ),
121        ),
122        Modal::Confirm { prompt, .. } => (
123            "Confirm",
124            Text::from(format!(
125                "{prompt}\n\n  y / Enter — confirm\n  n / q / Esc — cancel"
126            )),
127        ),
128        Modal::Form(form) => form::form_modal_content(form),
129    }
130}
131
132#[must_use]
133pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
134    let popup_layout = Layout::default()
135        .direction(Direction::Vertical)
136        .constraints([
137            Constraint::Percentage((100 - pct_y) / 2),
138            Constraint::Percentage(pct_y),
139            Constraint::Percentage((100 - pct_y) / 2),
140        ])
141        .split(r);
142    Layout::default()
143        .direction(Direction::Horizontal)
144        .constraints([
145            Constraint::Percentage((100 - pct_x) / 2),
146            Constraint::Percentage(pct_x),
147            Constraint::Percentage((100 - pct_x) / 2),
148        ])
149        .split(popup_layout[1])[1]
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::event::{Intent, apply};
156    use crate::modal::Modal;
157    use crate::widgets::EditMode;
158    use ratatui::Terminal;
159    use ratatui::backend::TestBackend;
160    use ratatui::style::Modifier;
161    use sqlx::types::Uuid;
162
163    #[test]
164    fn centered_rect_clamps_to_parent() {
165        let parent = Rect::new(0, 0, 100, 100);
166        let r = centered_rect(60, 30, parent);
167        assert!(r.x + r.width <= parent.x + parent.width);
168        assert!(r.y + r.height <= parent.y + parent.height);
169    }
170
171    fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
172        terminal
173            .backend()
174            .buffer()
175            .content()
176            .iter()
177            .map(|cell| cell.symbol())
178            .collect()
179    }
180
181    fn render_console(app: &App) -> String {
182        let backend = TestBackend::new(80, 24);
183        let mut terminal = Terminal::new(backend).expect("test terminal");
184        terminal
185            .draw(|frame| draw(frame, app))
186            .expect("draw must not panic");
187        buffer_text(&terminal)
188    }
189
190    fn console_app() -> App {
191        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
192        app.active_tab = Tab::Console;
193        app
194    }
195
196    #[test]
197    fn transaction_modal_renders_split_row_details() {
198        use crate::form::Form;
199        use crate::modal::Modal;
200        use crate::widgets::splits::{COL_FROM, COL_VALUE};
201        use crate::widgets::{FocusedSubWidget, SelectOption, Widget};
202
203        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
204        let mut form = Form::transaction_create(EditMode::Emacs);
205        if let Widget::Splits(ref mut sw) = form.fields[2].widget {
206            sw.set_account_options(vec![
207                SelectOption {
208                    id: "acc-0".to_string(),
209                    label: "Cash".to_string(),
210                },
211                SelectOption {
212                    id: "acc-1".to_string(),
213                    label: "Food".to_string(),
214                },
215            ]);
216            sw.col_focus = COL_VALUE;
217            if let Some(FocusedSubWidget::Amount(aw)) = sw.focused_subwidget_mut() {
218                aw.insert_char('5');
219                aw.insert_char('0');
220            }
221            // Move focus off the value cell so the amount renders unbracketed.
222            sw.col_focus = COL_FROM;
223        }
224        app.overlays.push(Modal::Form(form));
225
226        let backend = TestBackend::new(120, 40);
227        let mut terminal = Terminal::new(backend).expect("test terminal");
228        terminal
229            .draw(|frame| draw(frame, &app))
230            .expect("draw must not panic");
231        let text = buffer_text(&terminal);
232        assert!(
233            text.contains("[Cash]"),
234            "focused from-account label must render bracketed"
235        );
236        assert!(
237            text.contains("val=50"),
238            "typed amount must render: not found"
239        );
240    }
241
242    #[test]
243    fn console_tab_renders_scrollback_and_prompt() {
244        let mut app = console_app();
245        app.console.push_scrollback("(:id 0 :value 42)");
246        app.console.input.insert_char('(');
247
248        let text = render_console(&app);
249        assert!(text.contains("(:id 0 :value 42)"), "scrollback missing");
250        assert!(text.contains("nms>"), "prompt missing");
251    }
252
253    #[test]
254    fn console_prompt_shows_continuation_marker_when_pending() {
255        let mut app = console_app();
256        for c in "(list".chars() {
257            app.console.input.insert_char(c);
258        }
259        app.console.take_complete_form();
260        assert!(!app.console.pending.is_empty(), "form must be pending");
261
262        let text = render_console(&app);
263        assert!(text.contains("...>"), "continuation marker missing");
264        assert!(!text.contains("nms>"), "primary prompt should be hidden");
265    }
266
267    #[test]
268    fn console_hint_switches_with_focus() {
269        let mut app = console_app();
270        let unfocused = render_console(&app);
271        assert!(unfocused.contains("i/Enter focus"), "blurred hint missing");
272
273        app.console_focused = true;
274        let focused = render_console(&app);
275        assert!(focused.contains("Esc blur"), "focused hint missing");
276        assert!(focused.contains("C-c interrupt"), "interrupt hint missing");
277    }
278
279    #[test]
280    fn console_prompt_style_bold_only_when_focused() {
281        let app = console_app();
282        let backend = TestBackend::new(80, 24);
283        let mut terminal = Terminal::new(backend).expect("test terminal");
284        terminal
285            .draw(|frame| draw(frame, &app))
286            .expect("draw must not panic");
287        let prompt_cell = prompt_marker_cell(&terminal);
288        assert!(
289            !prompt_cell.modifier.contains(Modifier::BOLD),
290            "blurred prompt must not be bold"
291        );
292
293        let mut focused = console_app();
294        focused.console_focused = true;
295        let backend = TestBackend::new(80, 24);
296        let mut terminal = Terminal::new(backend).expect("test terminal");
297        terminal
298            .draw(|frame| draw(frame, &focused))
299            .expect("draw must not panic");
300        let prompt_cell = prompt_marker_cell(&terminal);
301        assert!(
302            prompt_cell.modifier.contains(Modifier::BOLD),
303            "focused prompt must be bold"
304        );
305    }
306
307    fn prompt_marker_cell(terminal: &Terminal<TestBackend>) -> ratatui::buffer::Cell {
308        let buffer = terminal.backend().buffer();
309        for y in 0..buffer.area.height {
310            if buffer[(1, y)].symbol() == "n" && buffer[(2, y)].symbol() == "m" {
311                return buffer[(1, y)].clone();
312            }
313        }
314        panic!("nms> prompt marker not found in rendered buffer");
315    }
316
317    fn focused_field_marker_pos(terminal: &Terminal<TestBackend>) -> (u16, u16) {
318        let buffer = terminal.backend().buffer();
319        for y in 0..buffer.area.height {
320            for x in 0..buffer.area.width {
321                if buffer[(x, y)].symbol() == "▸" {
322                    return (x, y);
323                }
324            }
325        }
326        panic!("focused field marker '▸' not found in rendered buffer");
327    }
328
329    #[test]
330    fn form_focused_field_reversed_unfocused_not() {
331        use crate::form::Form;
332        use crate::widgets::Editor;
333
334        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
335        let form = Form::config_set(Editor::new(EditMode::Emacs), Editor::new(EditMode::Emacs));
336        assert_eq!(form.focus, 0, "focus must start on first field");
337        app.overlays.push(Modal::Form(form));
338
339        let backend = TestBackend::new(120, 40);
340        let mut terminal = Terminal::new(backend).expect("test terminal");
341        terminal
342            .draw(|frame| draw(frame, &app))
343            .expect("draw must not panic");
344
345        let (marker_x, marker_y) = focused_field_marker_pos(&terminal);
346        let buffer = terminal.backend().buffer();
347        assert!(
348            buffer[(marker_x, marker_y)]
349                .modifier
350                .contains(Modifier::REVERSED),
351            "focused field must carry REVERSED modifier"
352        );
353        assert!(
354            !buffer[(marker_x, marker_y + 1)]
355                .modifier
356                .contains(Modifier::REVERSED),
357            "unfocused field must not carry REVERSED modifier"
358        );
359    }
360
361    #[test]
362    fn scrollback_shows_only_the_tail_when_longer_than_viewport() {
363        let mut app = console_app();
364        for i in 0..100 {
365            app.console.push_scrollback(format!("line-{i:03}"));
366        }
367        let text = render_console(&app);
368        assert!(
369            text.contains(&format!("line-{:03}", 99)),
370            "newest line must be visible"
371        );
372        assert!(
373            !text.contains("line-000"),
374            "oldest line must be scrolled out of the viewport"
375        );
376    }
377
378    fn make_app() -> App {
379        App::new(Uuid::nil(), EditMode::Emacs)
380    }
381
382    fn render_app(app: &App) -> String {
383        let backend = TestBackend::new(120, 40);
384        let mut terminal = Terminal::new(backend).expect("test terminal");
385        terminal
386            .draw(|frame| draw(frame, app))
387            .expect("draw must not panic");
388        terminal
389            .backend()
390            .buffer()
391            .content()
392            .iter()
393            .map(|cell| cell.symbol())
394            .collect()
395    }
396
397    #[test]
398    fn help_modal_contains_command_palette_key() {
399        let mut app = make_app();
400        app.overlays.push(Modal::Help);
401        let text = render_app(&app);
402        assert!(text.contains(':'), "help must mention the ':' key");
403    }
404
405    #[test]
406    fn help_modal_contains_list_view_keys() {
407        let mut app = make_app();
408        app.overlays.push(Modal::Help);
409        let text = render_app(&app);
410        assert!(text.contains('d'), "help must mention 'd' for delete");
411        assert!(text.contains('e'), "help must mention 'e' for edit/tag");
412    }
413
414    #[test]
415    fn help_modal_contains_tab_key_description() {
416        let mut app = make_app();
417        app.overlays.push(Modal::Help);
418        let text = render_app(&app);
419        assert!(
420            text.contains("Tab"),
421            "help must mention Tab for completion/navigation"
422        );
423    }
424
425    #[test]
426    fn help_modal_does_not_clip_last_line_on_80x24() {
427        let mut app = make_app();
428        app.overlays.push(Modal::Help);
429        let backend = TestBackend::new(80, 24);
430        let mut terminal = Terminal::new(backend).expect("test terminal");
431        terminal
432            .draw(|frame| draw(frame, &app))
433            .expect("draw must not panic");
434        let text: String = terminal
435            .backend()
436            .buffer()
437            .content()
438            .iter()
439            .map(|cell| cell.symbol())
440            .collect();
441        assert!(
442            text.contains("cycle panes"),
443            "last help line must be visible on an 80x24 terminal (not clipped)"
444        );
445    }
446
447    #[test]
448    fn cmdline_active_with_completions_shows_them_in_status() {
449        let mut app = make_app();
450        apply(&mut app, Intent::OpenCommandLine);
451        for c in "account ".chars() {
452            apply(&mut app, Intent::InsertChar(c));
453        }
454        apply(&mut app, Intent::CompleteCommandLine);
455        let text = render_app(&app);
456        assert!(
457            text.contains("create"),
458            "completion candidates must appear in the status area"
459        );
460    }
461
462    #[test]
463    fn cmdline_active_without_completions_shows_cursor() {
464        let mut app = make_app();
465        apply(&mut app, Intent::OpenCommandLine);
466        for c in "ver".chars() {
467            apply(&mut app, Intent::InsertChar(c));
468        }
469        let text = render_app(&app);
470        assert!(
471            text.contains("cursor"),
472            "without completions the cursor position must be shown"
473        );
474    }
475}