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
5use crate::app::{App, Tab};
6use crate::modal::{self, Modal};
7use crate::tabs;
8use crate::widgets::{EditMode, VimMode};
9use ratatui::Frame;
10use ratatui::layout::{Constraint, Direction, Layout, Rect};
11use ratatui::style::{Color, Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap};
14
15pub fn draw(frame: &mut Frame, app: &App) {
16    let area = frame.area();
17    let chunks = Layout::default()
18        .direction(Direction::Vertical)
19        .constraints([
20            Constraint::Length(3),
21            Constraint::Min(1),
22            Constraint::Length(3),
23        ])
24        .split(area);
25
26    draw_tabs(frame, chunks[0], app);
27    draw_body(frame, chunks[1], app);
28    draw_status(frame, chunks[2], app);
29
30    if let Some(modal) = app.modals.top() {
31        draw_modal(frame, area, modal);
32    }
33}
34
35fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
36    let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
37    let selected = Tab::ALL
38        .iter()
39        .position(|t| *t == app.active_tab)
40        .unwrap_or(0);
41    let tabs = Tabs::new(titles)
42        .select(selected)
43        .block(Block::default().borders(Borders::ALL).title("nomisync-tui"))
44        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
45    frame.render_widget(tabs, area);
46}
47
48fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
49    match app.active_tab {
50        Tab::Console => draw_console(frame, area, app),
51        _ => {
52            let body = tabs::placeholder_body(app.active_tab);
53            let widget = Paragraph::new(body).block(
54                Block::default()
55                    .borders(Borders::ALL)
56                    .title(app.active_tab.label()),
57            );
58            frame.render_widget(widget, area);
59        }
60    }
61}
62
63fn draw_console(frame: &mut Frame, area: Rect, app: &App) {
64    let block = Block::default().borders(Borders::ALL).title("Console");
65    let inner = block.inner(area);
66    frame.render_widget(block, area);
67
68    let chunks = Layout::default()
69        .direction(Direction::Vertical)
70        .constraints([
71            Constraint::Min(1),
72            Constraint::Length(1),
73            Constraint::Length(1),
74        ])
75        .split(inner);
76
77    draw_console_scrollback(frame, chunks[0], app);
78    draw_console_prompt(frame, chunks[1], app);
79    draw_console_hint(frame, chunks[2], app);
80}
81
82fn draw_console_scrollback(frame: &mut Frame, area: Rect, app: &App) {
83    let scrollback = &app.console.scrollback;
84    let visible = usize::from(area.height);
85    let start = scrollback.len().saturating_sub(visible);
86    let lines: Vec<Line> = scrollback[start..]
87        .iter()
88        .map(|l| Line::from(l.as_str()))
89        .collect();
90    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
91}
92
93fn draw_console_prompt(frame: &mut Frame, area: Rect, app: &App) {
94    let marker = if app.console.pending.is_empty() {
95        "nms> "
96    } else {
97        "...> "
98    };
99    let content = format!("{marker}{}", app.console.input.buffer());
100    let style = if app.console_input_active {
101        Style::default()
102            .fg(Color::White)
103            .add_modifier(Modifier::BOLD)
104    } else {
105        Style::default().fg(Color::Gray)
106    };
107    frame.render_widget(Paragraph::new(Span::styled(content, style)), area);
108}
109
110fn draw_console_hint(frame: &mut Frame, area: Rect, app: &App) {
111    let hint = if app.console_input_active {
112        "Enter run  Esc blur  C-c interrupt  Up/Down history"
113    } else {
114        "i/Enter focus  1-6 tabs  Tab next  q quit"
115    };
116    let line = Span::styled(hint, Style::default().fg(Color::DarkGray));
117    frame.render_widget(Paragraph::new(line), area);
118}
119
120fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
121    let edit_indicator = match app.command_line.mode() {
122        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    let content = if app.command_line_active {
129        format!(
130            ":{}  (cursor={})",
131            app.command_line.buffer(),
132            app.command_line.cursor()
133        )
134    } else if app.status.is_empty() {
135        format!("[{edit_indicator}]  Tab/BTab tabs  : cmdline  ? help  C-v edit-mode  q quit")
136    } else {
137        format!("[{edit_indicator}] {}", app.status)
138    };
139    let line = Span::styled(content, Style::default().fg(Color::Gray));
140    let para = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
141    frame.render_widget(para, area);
142}
143
144fn 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
152fn 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]
177pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
178    let popup_layout = Layout::default()
179        .direction(Direction::Vertical)
180        .constraints([
181            Constraint::Percentage((100 - pct_y) / 2),
182            Constraint::Percentage(pct_y),
183            Constraint::Percentage((100 - pct_y) / 2),
184        ])
185        .split(r);
186    Layout::default()
187        .direction(Direction::Horizontal)
188        .constraints([
189            Constraint::Percentage((100 - pct_x) / 2),
190            Constraint::Percentage(pct_x),
191            Constraint::Percentage((100 - pct_x) / 2),
192        ])
193        .split(popup_layout[1])[1]
194}
195
196#[cfg(test)]
197mod 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    fn centered_rect_clamps_to_parent() {
206        let parent = Rect::new(0, 0, 100, 100);
207        let r = centered_rect(60, 30, parent);
208        assert!(r.x + r.width <= parent.x + parent.width);
209        assert!(r.y + r.height <= parent.y + parent.height);
210    }
211
212    fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
213        terminal
214            .backend()
215            .buffer()
216            .content()
217            .iter()
218            .map(|cell| cell.symbol())
219            .collect()
220    }
221
222    fn render_console(app: &App) -> String {
223        let backend = TestBackend::new(80, 24);
224        let mut terminal = Terminal::new(backend).expect("test terminal");
225        terminal
226            .draw(|frame| draw(frame, app))
227            .expect("draw must not panic");
228        buffer_text(&terminal)
229    }
230
231    fn console_app() -> App {
232        let mut app = App::new(Uuid::nil(), EditMode::Emacs);
233        app.active_tab = Tab::Console;
234        app
235    }
236
237    #[test]
238    fn console_tab_renders_scrollback_and_prompt() {
239        let mut app = console_app();
240        app.console.push_scrollback("(:id 0 :value 42)");
241        app.console.input.insert_char('(');
242
243        let text = render_console(&app);
244        assert!(text.contains("(:id 0 :value 42)"), "scrollback missing");
245        assert!(text.contains("nms>"), "prompt missing");
246    }
247
248    #[test]
249    fn console_prompt_shows_continuation_marker_when_pending() {
250        let mut app = console_app();
251        for c in "(list".chars() {
252            app.console.input.insert_char(c);
253        }
254        app.console.take_complete_form();
255        assert!(!app.console.pending.is_empty(), "form must be pending");
256
257        let text = render_console(&app);
258        assert!(text.contains("...>"), "continuation marker missing");
259        assert!(!text.contains("nms>"), "primary prompt should be hidden");
260    }
261
262    #[test]
263    fn console_hint_switches_with_focus() {
264        let mut app = console_app();
265        let unfocused = render_console(&app);
266        assert!(unfocused.contains("i/Enter focus"), "blurred hint missing");
267
268        app.console_input_active = true;
269        let focused = render_console(&app);
270        assert!(focused.contains("Esc blur"), "focused hint missing");
271        assert!(focused.contains("C-c interrupt"), "interrupt hint missing");
272    }
273
274    #[test]
275    fn console_prompt_style_bold_only_when_focused() {
276        let app = console_app();
277        let backend = TestBackend::new(80, 24);
278        let mut terminal = Terminal::new(backend).expect("test terminal");
279        terminal
280            .draw(|frame| draw(frame, &app))
281            .expect("draw must not panic");
282        let prompt_cell = prompt_marker_cell(&terminal);
283        assert!(
284            !prompt_cell.modifier.contains(Modifier::BOLD),
285            "blurred prompt must not be bold"
286        );
287
288        let mut focused = console_app();
289        focused.console_input_active = true;
290        let backend = TestBackend::new(80, 24);
291        let mut terminal = Terminal::new(backend).expect("test terminal");
292        terminal
293            .draw(|frame| draw(frame, &focused))
294            .expect("draw must not panic");
295        let prompt_cell = prompt_marker_cell(&terminal);
296        assert!(
297            prompt_cell.modifier.contains(Modifier::BOLD),
298            "focused prompt must be bold"
299        );
300    }
301
302    fn prompt_marker_cell(terminal: &Terminal<TestBackend>) -> ratatui::buffer::Cell {
303        let buffer = terminal.backend().buffer();
304        for y in 0..buffer.area.height {
305            if buffer[(1, y)].symbol() == "n" && buffer[(2, y)].symbol() == "m" {
306                return buffer[(1, y)].clone();
307            }
308        }
309        panic!("nms> prompt marker not found in rendered buffer");
310    }
311
312    #[test]
313    fn scrollback_shows_only_the_tail_when_longer_than_viewport() {
314        let mut app = console_app();
315        for i in 0..100 {
316            app.console.push_scrollback(format!("line-{i:03}"));
317        }
318        let text = render_console(&app);
319        assert!(
320            text.contains(&format!("line-{:03}", 99)),
321            "newest line must be visible"
322        );
323        assert!(
324            !text.contains("line-000"),
325            "oldest line must be scrolled out of the viewport"
326        );
327    }
328}