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
use crate::app::{App, Tab};
6
use crate::modal::{self, Modal};
7
use crate::tabs;
8
use crate::widgets::{EditMode, VimMode};
9
use ratatui::Frame;
10
use ratatui::layout::{Constraint, Direction, Layout, Rect};
11
use ratatui::style::{Color, Modifier, Style};
12
use ratatui::text::{Line, Span};
13
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap};
14

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

            
26
23
    draw_tabs(frame, chunks[0], app);
27
23
    draw_body(frame, chunks[1], app);
28
23
    draw_status(frame, chunks[2], app);
29

            
30
23
    if let Some(modal) = app.modals.top() {
31
        draw_modal(frame, area, modal);
32
23
    }
33
23
}
34

            
35
23
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
36
138
    let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
37
23
    let selected = Tab::ALL
38
23
        .iter()
39
103
        .position(|t| *t == app.active_tab)
40
23
        .unwrap_or(0);
41
23
    let tabs = Tabs::new(titles)
42
23
        .select(selected)
43
23
        .block(Block::default().borders(Borders::ALL).title("nomisync-tui"))
44
23
        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
45
23
    frame.render_widget(tabs, area);
46
23
}
47

            
48
23
fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
49
23
    match app.active_tab {
50
7
        Tab::Console => draw_console(frame, area, app),
51
16
        _ => {
52
16
            let body = tabs::placeholder_body(app.active_tab);
53
16
            let widget = Paragraph::new(body).block(
54
16
                Block::default()
55
16
                    .borders(Borders::ALL)
56
16
                    .title(app.active_tab.label()),
57
16
            );
58
16
            frame.render_widget(widget, area);
59
16
        }
60
    }
61
23
}
62

            
63
7
fn draw_console(frame: &mut Frame, area: Rect, app: &App) {
64
7
    let block = Block::default().borders(Borders::ALL).title("Console");
65
7
    let inner = block.inner(area);
66
7
    frame.render_widget(block, area);
67

            
68
7
    let chunks = Layout::default()
69
7
        .direction(Direction::Vertical)
70
7
        .constraints([
71
7
            Constraint::Min(1),
72
7
            Constraint::Length(1),
73
7
            Constraint::Length(1),
74
7
        ])
75
7
        .split(inner);
76

            
77
7
    draw_console_scrollback(frame, chunks[0], app);
78
7
    draw_console_prompt(frame, chunks[1], app);
79
7
    draw_console_hint(frame, chunks[2], app);
80
7
}
81

            
82
7
fn draw_console_scrollback(frame: &mut Frame, area: Rect, app: &App) {
83
7
    let scrollback = &app.console.scrollback;
84
7
    let visible = usize::from(area.height);
85
7
    let start = scrollback.len().saturating_sub(visible);
86
7
    let lines: Vec<Line> = scrollback[start..]
87
7
        .iter()
88
15
        .map(|l| Line::from(l.as_str()))
89
7
        .collect();
90
7
    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
91
7
}
92

            
93
7
fn draw_console_prompt(frame: &mut Frame, area: Rect, app: &App) {
94
7
    let marker = if app.console.pending.is_empty() {
95
6
        "nms> "
96
    } else {
97
1
        "...> "
98
    };
99
7
    let content = format!("{marker}{}", app.console.input.buffer());
100
7
    let style = if app.console_input_active {
101
2
        Style::default()
102
2
            .fg(Color::White)
103
2
            .add_modifier(Modifier::BOLD)
104
    } else {
105
5
        Style::default().fg(Color::Gray)
106
    };
107
7
    frame.render_widget(Paragraph::new(Span::styled(content, style)), area);
108
7
}
109

            
110
7
fn draw_console_hint(frame: &mut Frame, area: Rect, app: &App) {
111
7
    let hint = if app.console_input_active {
112
2
        "Enter run  Esc blur  C-c interrupt  Up/Down history"
113
    } else {
114
5
        "i/Enter focus  1-6 tabs  Tab next  q quit"
115
    };
116
7
    let line = Span::styled(hint, Style::default().fg(Color::DarkGray));
117
7
    frame.render_widget(Paragraph::new(line), area);
118
7
}
119

            
120
23
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
121
23
    let edit_indicator = match app.command_line.mode() {
122
23
        EditMode::Emacs => "emacs",
123
        EditMode::Vim => match app.command_line.vim_mode() {
124
            VimMode::Normal => "vim:normal",
125
            VimMode::Insert => "vim:insert",
126
        },
127
    };
128
23
    let content = if app.command_line_active {
129
8
        format!(
130
            ":{}  (cursor={})",
131
8
            app.command_line.buffer(),
132
8
            app.command_line.cursor()
133
        )
134
15
    } else if app.status.is_empty() {
135
14
        format!("[{edit_indicator}]  Tab/BTab tabs  : cmdline  ? help  C-v edit-mode  q quit")
136
    } else {
137
1
        format!("[{edit_indicator}] {}", app.status)
138
    };
139
23
    let line = Span::styled(content, Style::default().fg(Color::Gray));
140
23
    let para = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
141
23
    frame.render_widget(para, area);
142
23
}
143

            
144
fn draw_modal(frame: &mut Frame, full: Rect, modal: &Modal) {
145
    let area = centered_rect(60, 30, full);
146
    frame.render_widget(Clear, area);
147
    let (title, body) = modal_content(modal);
148
    let widget = Paragraph::new(body).block(Block::default().title(title).borders(Borders::ALL));
149
    frame.render_widget(widget, area);
150
}
151

            
152
fn modal_content(modal: &Modal) -> (&'static str, String) {
153
    match modal {
154
        Modal::Help => (
155
            "Help",
156
            "Keys:\n  1-5   jump to tab\n  Tab   next tab\n  :     command palette\n\
157
             \n  ?     this help\n  q/Esc close modal\n  C-v   toggle emacs/vim mode"
158
                .to_string(),
159
        ),
160
        Modal::ConfigSet(m) => {
161
            let focus_marker = |field| {
162
                if m.focus == field { "â–¸" } else { " " }
163
            };
164
            let body = format!(
165
                "{} name:  [{}]\n{} value: [{}]\n\nEnter to save, Esc to cancel.",
166
                focus_marker(modal::ConfigSetField::Name),
167
                m.name.buffer(),
168
                focus_marker(modal::ConfigSetField::Value),
169
                m.value.buffer(),
170
            );
171
            ("Set config", body)
172
        }
173
    }
174
}
175

            
176
#[must_use]
177
1
pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
178
1
    let popup_layout = Layout::default()
179
1
        .direction(Direction::Vertical)
180
1
        .constraints([
181
1
            Constraint::Percentage((100 - pct_y) / 2),
182
1
            Constraint::Percentage(pct_y),
183
1
            Constraint::Percentage((100 - pct_y) / 2),
184
1
        ])
185
1
        .split(r);
186
1
    Layout::default()
187
1
        .direction(Direction::Horizontal)
188
1
        .constraints([
189
1
            Constraint::Percentage((100 - pct_x) / 2),
190
1
            Constraint::Percentage(pct_x),
191
1
            Constraint::Percentage((100 - pct_x) / 2),
192
1
        ])
193
1
        .split(popup_layout[1])[1]
194
1
}
195

            
196
#[cfg(test)]
197
mod tests {
198
    use super::*;
199
    use crate::widgets::EditMode;
200
    use ratatui::Terminal;
201
    use ratatui::backend::TestBackend;
202
    use sqlx::types::Uuid;
203

            
204
    #[test]
205
1
    fn centered_rect_clamps_to_parent() {
206
1
        let parent = Rect::new(0, 0, 100, 100);
207
1
        let r = centered_rect(60, 30, parent);
208
1
        assert!(r.x + r.width <= parent.x + parent.width);
209
1
        assert!(r.y + r.height <= parent.y + parent.height);
210
1
    }
211

            
212
5
    fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
213
5
        terminal
214
5
            .backend()
215
5
            .buffer()
216
5
            .content()
217
5
            .iter()
218
9600
            .map(|cell| cell.symbol())
219
5
            .collect()
220
5
    }
221

            
222
5
    fn render_console(app: &App) -> String {
223
5
        let backend = TestBackend::new(80, 24);
224
5
        let mut terminal = Terminal::new(backend).expect("test terminal");
225
5
        terminal
226
5
            .draw(|frame| draw(frame, app))
227
5
            .expect("draw must not panic");
228
5
        buffer_text(&terminal)
229
5
    }
230

            
231
6
    fn console_app() -> App {
232
6
        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
233
6
        app.active_tab = Tab::Console;
234
6
        app
235
6
    }
236

            
237
    #[test]
238
1
    fn console_tab_renders_scrollback_and_prompt() {
239
1
        let mut app = console_app();
240
1
        app.console.push_scrollback("(:id 0 :value 42)");
241
1
        app.console.input.insert_char('(');
242

            
243
1
        let text = render_console(&app);
244
1
        assert!(text.contains("(:id 0 :value 42)"), "scrollback missing");
245
1
        assert!(text.contains("nms>"), "prompt missing");
246
1
    }
247

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

            
257
1
        let text = render_console(&app);
258
1
        assert!(text.contains("...>"), "continuation marker missing");
259
1
        assert!(!text.contains("nms>"), "primary prompt should be hidden");
260
1
    }
261

            
262
    #[test]
263
1
    fn console_hint_switches_with_focus() {
264
1
        let mut app = console_app();
265
1
        let unfocused = render_console(&app);
266
1
        assert!(unfocused.contains("i/Enter focus"), "blurred hint missing");
267

            
268
1
        app.console_input_active = true;
269
1
        let focused = render_console(&app);
270
1
        assert!(focused.contains("Esc blur"), "focused hint missing");
271
1
        assert!(focused.contains("C-c interrupt"), "interrupt hint missing");
272
1
    }
273

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

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

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

            
312
    #[test]
313
1
    fn scrollback_shows_only_the_tail_when_longer_than_viewport() {
314
1
        let mut app = console_app();
315
100
        for i in 0..100 {
316
100
            app.console.push_scrollback(format!("line-{i:03}"));
317
100
        }
318
1
        let text = render_console(&app);
319
1
        assert!(
320
1
            text.contains(&format!("line-{:03}", 99)),
321
            "newest line must be visible"
322
        );
323
1
        assert!(
324
1
            !text.contains("line-000"),
325
            "oldest line must be scrolled out of the viewport"
326
        );
327
1
    }
328
}