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};
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    let body = tabs::placeholder_body(app.active_tab);
50    let widget = Paragraph::new(body).block(
51        Block::default()
52            .borders(Borders::ALL)
53            .title(app.active_tab.label()),
54    );
55    frame.render_widget(widget, area);
56}
57
58fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
59    let edit_indicator = match app.command_line.mode() {
60        EditMode::Emacs => "emacs",
61        EditMode::Vim => match app.command_line.vim_mode() {
62            VimMode::Normal => "vim:normal",
63            VimMode::Insert => "vim:insert",
64        },
65    };
66    let content = if app.command_line_active {
67        format!(
68            ":{}  (cursor={})",
69            app.command_line.buffer(),
70            app.command_line.cursor()
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    let area = centered_rect(60, 30, full);
84    frame.render_widget(Clear, area);
85    let (title, body) = modal_content(modal);
86    let widget = Paragraph::new(body).block(Block::default().title(title).borders(Borders::ALL));
87    frame.render_widget(widget, area);
88}
89
90fn modal_content(modal: &Modal) -> (&'static str, String) {
91    match modal {
92        Modal::Help => (
93            "Help",
94            "Keys:\n  1-5   jump to tab\n  Tab   next tab\n  :     command palette\n\
95             \n  ?     this help\n  q/Esc close modal\n  C-v   toggle emacs/vim mode"
96                .to_string(),
97        ),
98        Modal::ConfigSet(m) => {
99            let focus_marker = |field| {
100                if m.focus == field { "▸" } else { " " }
101            };
102            let body = format!(
103                "{} name:  [{}]\n{} value: [{}]\n\nEnter to save, Esc to cancel.",
104                focus_marker(modal::ConfigSetField::Name),
105                m.name.buffer(),
106                focus_marker(modal::ConfigSetField::Value),
107                m.value.buffer(),
108            );
109            ("Set config", body)
110        }
111    }
112}
113
114#[must_use]
115pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
116    let popup_layout = Layout::default()
117        .direction(Direction::Vertical)
118        .constraints([
119            Constraint::Percentage((100 - pct_y) / 2),
120            Constraint::Percentage(pct_y),
121            Constraint::Percentage((100 - pct_y) / 2),
122        ])
123        .split(r);
124    Layout::default()
125        .direction(Direction::Horizontal)
126        .constraints([
127            Constraint::Percentage((100 - pct_x) / 2),
128            Constraint::Percentage(pct_x),
129            Constraint::Percentage((100 - pct_x) / 2),
130        ])
131        .split(popup_layout[1])[1]
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn centered_rect_clamps_to_parent() {
140        let parent = Rect::new(0, 0, 100, 100);
141        let r = centered_rect(60, 30, parent);
142        assert!(r.x + r.width <= parent.x + parent.width);
143        assert!(r.y + r.height <= parent.y + parent.height);
144    }
145}