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

            
5
mod form;
6

            
7
use crate::app::App;
8
use crate::modal::Modal;
9
use crate::view::{DrawCtx, Tab};
10
use crate::widgets::{EditMode, VimMode};
11
use ratatui::Frame;
12
use ratatui::layout::{Constraint, Direction, Layout, Rect};
13
use ratatui::style::{Color, Style};
14
use ratatui::text::{Span, Text};
15
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs};
16

            
17
31
pub fn draw(frame: &mut Frame, app: &App) {
18
31
    let area = frame.area();
19
31
    let chunks = Layout::default()
20
31
        .direction(Direction::Vertical)
21
31
        .constraints([
22
31
            Constraint::Length(3),
23
31
            Constraint::Min(1),
24
31
            Constraint::Length(3),
25
31
        ])
26
31
        .split(area);
27

            
28
31
    draw_tabs(frame, chunks[0], app);
29
31
    draw_body(frame, chunks[1], app);
30
31
    draw_status(frame, chunks[2], app);
31

            
32
31
    if let Some(modal) = app.overlays.top() {
33
6
        draw_modal(frame, area, modal);
34
25
    }
35
31
}
36

            
37
31
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
38
    use ratatui::style::Modifier;
39
    use ratatui::text::Line;
40
186
    let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
41
31
    let selected = app.active_tab.index();
42
31
    let tabs = Tabs::new(titles)
43
31
        .select(selected)
44
31
        .block(Block::default().borders(Borders::ALL).title("nomisync-tui"))
45
31
        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
46
31
    frame.render_widget(tabs, area);
47
31
}
48

            
49
31
fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
50
31
    let ctx = DrawCtx {
51
31
        edit_mode: app.edit_mode,
52
31
        is_active: app.console_focused,
53
31
    };
54
31
    app.active_view().draw(frame, area, &ctx);
55
31
}
56

            
57
31
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
58
31
    let edit_indicator = match app.cmdline.editor.mode() {
59
31
        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
31
    let content = if app.cmdline.active {
66
10
        let buf = app.cmdline.editor.buffer();
67
10
        if app.cmdline.completions.is_empty() {
68
9
            format!(":{}  (cursor={})", buf, app.cmdline.editor.cursor())
69
        } else {
70
1
            format!(":{}  [{}]", buf, app.cmdline.completions.join("  "))
71
        }
72
21
    } else if app.status.is_empty() {
73
21
        format!("[{edit_indicator}]  Tab/BTab tabs  : cmdline  ? help  C-v edit-mode  q quit")
74
    } else {
75
        format!("[{edit_indicator}] {}", app.status)
76
    };
77
31
    let line = Span::styled(content, Style::default().fg(Color::Gray));
78
31
    let para = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
79
31
    frame.render_widget(para, area);
80
31
}
81

            
82
6
fn 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
6
    let (pct_x, pct_y) = match modal {
85
4
        Modal::Help => (95, 95),
86
2
        _ => (60, 30),
87
    };
88
6
    let area = centered_rect(pct_x, pct_y, full);
89
6
    frame.render_widget(Clear, area);
90
6
    let (title, body) = modal_content(modal);
91
6
    let widget = Paragraph::new(body).block(Block::default().title(title).borders(Borders::ALL));
92
6
    frame.render_widget(widget, area);
93
6
}
94

            
95
6
fn modal_content(modal: &Modal) -> (&'static str, Text<'static>) {
96
6
    match modal {
97
4
        Modal::Help => (
98
4
            "Help",
99
4
            Text::from(
100
4
                concat!(
101
4
                    "Tabs: 1-6 jump  Tab/BTab next/prev  q quit  C-v emacs/vim  ? help\n",
102
4
                    "\n",
103
4
                    "Palette (:):  Tab complete  Enter run  Esc cancel (twice in vim)\n",
104
4
                    "  account create|list|balance|tag   transaction create|list|tag\n",
105
4
                    "  commodity create|list   config get|set   reports ...   version\n",
106
4
                    "\n",
107
4
                    "Lists: Up/Down or j/k select   r refresh\n",
108
4
                    "  Accounts:     e tag\n",
109
4
                    "  Transactions: d delete (confirm)  e edit  (tag: :transaction tag)\n",
110
4
                    "  Commodities:  view only\n",
111
4
                    "  Config:       Up/Down select  e/Enter edit  r refresh\n",
112
4
                    "\n",
113
4
                    "Forms: Tab/BTab fields  Up/Down Select  Enter submit  Esc cancel\n",
114
4
                    "  Tx splits:  +/C-n add row   -/C-d remove row\n",
115
4
                    "\n",
116
4
                    "Console: i/Enter focus  Esc blur  C-c interrupt\n",
117
4
                    "  Up/Down history   Tab/BTab cycle panes\n",
118
4
                )
119
4
                .to_string(),
120
4
            ),
121
4
        ),
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
2
        Modal::Form(form) => form::form_modal_content(form),
129
    }
130
6
}
131

            
132
#[must_use]
133
7
pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
134
7
    let popup_layout = Layout::default()
135
7
        .direction(Direction::Vertical)
136
7
        .constraints([
137
7
            Constraint::Percentage((100 - pct_y) / 2),
138
7
            Constraint::Percentage(pct_y),
139
7
            Constraint::Percentage((100 - pct_y) / 2),
140
7
        ])
141
7
        .split(r);
142
7
    Layout::default()
143
7
        .direction(Direction::Horizontal)
144
7
        .constraints([
145
7
            Constraint::Percentage((100 - pct_x) / 2),
146
7
            Constraint::Percentage(pct_x),
147
7
            Constraint::Percentage((100 - pct_x) / 2),
148
7
        ])
149
7
        .split(popup_layout[1])[1]
150
7
}
151

            
152
#[cfg(test)]
153
mod 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
1
    fn centered_rect_clamps_to_parent() {
165
1
        let parent = Rect::new(0, 0, 100, 100);
166
1
        let r = centered_rect(60, 30, parent);
167
1
        assert!(r.x + r.width <= parent.x + parent.width);
168
1
        assert!(r.y + r.height <= parent.y + parent.height);
169
1
    }
170

            
171
6
    fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
172
6
        terminal
173
6
            .backend()
174
6
            .buffer()
175
6
            .content()
176
6
            .iter()
177
14400
            .map(|cell| cell.symbol())
178
6
            .collect()
179
6
    }
180

            
181
5
    fn render_console(app: &App) -> String {
182
5
        let backend = TestBackend::new(80, 24);
183
5
        let mut terminal = Terminal::new(backend).expect("test terminal");
184
5
        terminal
185
5
            .draw(|frame| draw(frame, app))
186
5
            .expect("draw must not panic");
187
5
        buffer_text(&terminal)
188
5
    }
189

            
190
6
    fn console_app() -> App {
191
6
        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
192
6
        app.active_tab = Tab::Console;
193
6
        app
194
6
    }
195

            
196
    #[test]
197
1
    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
1
        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
204
1
        let mut form = Form::transaction_create(EditMode::Emacs);
205
1
        if let Widget::Splits(ref mut sw) = form.fields[2].widget {
206
1
            sw.set_account_options(vec![
207
1
                SelectOption {
208
1
                    id: "acc-0".to_string(),
209
1
                    label: "Cash".to_string(),
210
1
                },
211
1
                SelectOption {
212
1
                    id: "acc-1".to_string(),
213
1
                    label: "Food".to_string(),
214
1
                },
215
            ]);
216
1
            sw.col_focus = COL_VALUE;
217
1
            if let Some(FocusedSubWidget::Amount(aw)) = sw.focused_subwidget_mut() {
218
1
                aw.insert_char('5');
219
1
                aw.insert_char('0');
220
1
            }
221
            // Move focus off the value cell so the amount renders unbracketed.
222
1
            sw.col_focus = COL_FROM;
223
        }
224
1
        app.overlays.push(Modal::Form(form));
225

            
226
1
        let backend = TestBackend::new(120, 40);
227
1
        let mut terminal = Terminal::new(backend).expect("test terminal");
228
1
        terminal
229
1
            .draw(|frame| draw(frame, &app))
230
1
            .expect("draw must not panic");
231
1
        let text = buffer_text(&terminal);
232
1
        assert!(
233
1
            text.contains("[Cash]"),
234
            "focused from-account label must render bracketed"
235
        );
236
1
        assert!(
237
1
            text.contains("val=50"),
238
            "typed amount must render: not found"
239
        );
240
1
    }
241

            
242
    #[test]
243
1
    fn console_tab_renders_scrollback_and_prompt() {
244
1
        let mut app = console_app();
245
1
        app.console.push_scrollback("(:id 0 :value 42)");
246
1
        app.console.input.insert_char('(');
247

            
248
1
        let text = render_console(&app);
249
1
        assert!(text.contains("(:id 0 :value 42)"), "scrollback missing");
250
1
        assert!(text.contains("nms>"), "prompt missing");
251
1
    }
252

            
253
    #[test]
254
1
    fn console_prompt_shows_continuation_marker_when_pending() {
255
1
        let mut app = console_app();
256
5
        for c in "(list".chars() {
257
5
            app.console.input.insert_char(c);
258
5
        }
259
1
        app.console.take_complete_form();
260
1
        assert!(!app.console.pending.is_empty(), "form must be pending");
261

            
262
1
        let text = render_console(&app);
263
1
        assert!(text.contains("...>"), "continuation marker missing");
264
1
        assert!(!text.contains("nms>"), "primary prompt should be hidden");
265
1
    }
266

            
267
    #[test]
268
1
    fn console_hint_switches_with_focus() {
269
1
        let mut app = console_app();
270
1
        let unfocused = render_console(&app);
271
1
        assert!(unfocused.contains("i/Enter focus"), "blurred hint missing");
272

            
273
1
        app.console_focused = true;
274
1
        let focused = render_console(&app);
275
1
        assert!(focused.contains("Esc blur"), "focused hint missing");
276
1
        assert!(focused.contains("C-c interrupt"), "interrupt hint missing");
277
1
    }
278

            
279
    #[test]
280
1
    fn console_prompt_style_bold_only_when_focused() {
281
1
        let app = console_app();
282
1
        let backend = TestBackend::new(80, 24);
283
1
        let mut terminal = Terminal::new(backend).expect("test terminal");
284
1
        terminal
285
1
            .draw(|frame| draw(frame, &app))
286
1
            .expect("draw must not panic");
287
1
        let prompt_cell = prompt_marker_cell(&terminal);
288
1
        assert!(
289
1
            !prompt_cell.modifier.contains(Modifier::BOLD),
290
            "blurred prompt must not be bold"
291
        );
292

            
293
1
        let mut focused = console_app();
294
1
        focused.console_focused = true;
295
1
        let backend = TestBackend::new(80, 24);
296
1
        let mut terminal = Terminal::new(backend).expect("test terminal");
297
1
        terminal
298
1
            .draw(|frame| draw(frame, &focused))
299
1
            .expect("draw must not panic");
300
1
        let prompt_cell = prompt_marker_cell(&terminal);
301
1
        assert!(
302
1
            prompt_cell.modifier.contains(Modifier::BOLD),
303
            "focused prompt must be bold"
304
        );
305
1
    }
306

            
307
2
    fn prompt_marker_cell(terminal: &Terminal<TestBackend>) -> ratatui::buffer::Cell {
308
2
        let buffer = terminal.backend().buffer();
309
38
        for y in 0..buffer.area.height {
310
38
            if buffer[(1, y)].symbol() == "n" && buffer[(2, y)].symbol() == "m" {
311
2
                return buffer[(1, y)].clone();
312
36
            }
313
        }
314
        panic!("nms> prompt marker not found in rendered buffer");
315
2
    }
316

            
317
1
    fn focused_field_marker_pos(terminal: &Terminal<TestBackend>) -> (u16, u16) {
318
1
        let buffer = terminal.backend().buffer();
319
16
        for y in 0..buffer.area.height {
320
1826
            for x in 0..buffer.area.width {
321
1826
                if buffer[(x, y)].symbol() == "â–¸" {
322
1
                    return (x, y);
323
1825
                }
324
            }
325
        }
326
        panic!("focused field marker 'â–¸' not found in rendered buffer");
327
1
    }
328

            
329
    #[test]
330
1
    fn form_focused_field_reversed_unfocused_not() {
331
        use crate::form::Form;
332
        use crate::widgets::Editor;
333

            
334
1
        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
335
1
        let form = Form::config_set(Editor::new(EditMode::Emacs), Editor::new(EditMode::Emacs));
336
1
        assert_eq!(form.focus, 0, "focus must start on first field");
337
1
        app.overlays.push(Modal::Form(form));
338

            
339
1
        let backend = TestBackend::new(120, 40);
340
1
        let mut terminal = Terminal::new(backend).expect("test terminal");
341
1
        terminal
342
1
            .draw(|frame| draw(frame, &app))
343
1
            .expect("draw must not panic");
344

            
345
1
        let (marker_x, marker_y) = focused_field_marker_pos(&terminal);
346
1
        let buffer = terminal.backend().buffer();
347
1
        assert!(
348
1
            buffer[(marker_x, marker_y)]
349
1
                .modifier
350
1
                .contains(Modifier::REVERSED),
351
            "focused field must carry REVERSED modifier"
352
        );
353
1
        assert!(
354
1
            !buffer[(marker_x, marker_y + 1)]
355
1
                .modifier
356
1
                .contains(Modifier::REVERSED),
357
            "unfocused field must not carry REVERSED modifier"
358
        );
359
1
    }
360

            
361
    #[test]
362
1
    fn scrollback_shows_only_the_tail_when_longer_than_viewport() {
363
1
        let mut app = console_app();
364
100
        for i in 0..100 {
365
100
            app.console.push_scrollback(format!("line-{i:03}"));
366
100
        }
367
1
        let text = render_console(&app);
368
1
        assert!(
369
1
            text.contains(&format!("line-{:03}", 99)),
370
            "newest line must be visible"
371
        );
372
1
        assert!(
373
1
            !text.contains("line-000"),
374
            "oldest line must be scrolled out of the viewport"
375
        );
376
1
    }
377

            
378
6
    fn make_app() -> App {
379
6
        App::new(Uuid::nil(), EditMode::Emacs)
380
6
    }
381

            
382
5
    fn render_app(app: &App) -> String {
383
5
        let backend = TestBackend::new(120, 40);
384
5
        let mut terminal = Terminal::new(backend).expect("test terminal");
385
5
        terminal
386
5
            .draw(|frame| draw(frame, app))
387
5
            .expect("draw must not panic");
388
5
        terminal
389
5
            .backend()
390
5
            .buffer()
391
5
            .content()
392
5
            .iter()
393
24000
            .map(|cell| cell.symbol())
394
5
            .collect()
395
5
    }
396

            
397
    #[test]
398
1
    fn help_modal_contains_command_palette_key() {
399
1
        let mut app = make_app();
400
1
        app.overlays.push(Modal::Help);
401
1
        let text = render_app(&app);
402
1
        assert!(text.contains(':'), "help must mention the ':' key");
403
1
    }
404

            
405
    #[test]
406
1
    fn help_modal_contains_list_view_keys() {
407
1
        let mut app = make_app();
408
1
        app.overlays.push(Modal::Help);
409
1
        let text = render_app(&app);
410
1
        assert!(text.contains('d'), "help must mention 'd' for delete");
411
1
        assert!(text.contains('e'), "help must mention 'e' for edit/tag");
412
1
    }
413

            
414
    #[test]
415
1
    fn help_modal_contains_tab_key_description() {
416
1
        let mut app = make_app();
417
1
        app.overlays.push(Modal::Help);
418
1
        let text = render_app(&app);
419
1
        assert!(
420
1
            text.contains("Tab"),
421
            "help must mention Tab for completion/navigation"
422
        );
423
1
    }
424

            
425
    #[test]
426
1
    fn help_modal_does_not_clip_last_line_on_80x24() {
427
1
        let mut app = make_app();
428
1
        app.overlays.push(Modal::Help);
429
1
        let backend = TestBackend::new(80, 24);
430
1
        let mut terminal = Terminal::new(backend).expect("test terminal");
431
1
        terminal
432
1
            .draw(|frame| draw(frame, &app))
433
1
            .expect("draw must not panic");
434
1
        let text: String = terminal
435
1
            .backend()
436
1
            .buffer()
437
1
            .content()
438
1
            .iter()
439
1920
            .map(|cell| cell.symbol())
440
1
            .collect();
441
1
        assert!(
442
1
            text.contains("cycle panes"),
443
            "last help line must be visible on an 80x24 terminal (not clipped)"
444
        );
445
1
    }
446

            
447
    #[test]
448
1
    fn cmdline_active_with_completions_shows_them_in_status() {
449
1
        let mut app = make_app();
450
1
        apply(&mut app, Intent::OpenCommandLine);
451
8
        for c in "account ".chars() {
452
8
            apply(&mut app, Intent::InsertChar(c));
453
8
        }
454
1
        apply(&mut app, Intent::CompleteCommandLine);
455
1
        let text = render_app(&app);
456
1
        assert!(
457
1
            text.contains("create"),
458
            "completion candidates must appear in the status area"
459
        );
460
1
    }
461

            
462
    #[test]
463
1
    fn cmdline_active_without_completions_shows_cursor() {
464
1
        let mut app = make_app();
465
1
        apply(&mut app, Intent::OpenCommandLine);
466
3
        for c in "ver".chars() {
467
3
            apply(&mut app, Intent::InsertChar(c));
468
3
        }
469
1
        let text = render_app(&app);
470
1
        assert!(
471
1
            text.contains("cursor"),
472
            "without completions the cursor position must be shown"
473
        );
474
1
    }
475
}