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};
14

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

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

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

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

            
48
16
fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
49
16
    let body = tabs::placeholder_body(app.active_tab);
50
16
    let widget = Paragraph::new(body).block(
51
16
        Block::default()
52
16
            .borders(Borders::ALL)
53
16
            .title(app.active_tab.label()),
54
    );
55
16
    frame.render_widget(widget, area);
56
16
}
57

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

            
82
fn 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

            
90
fn 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]
115
1
pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
116
1
    let popup_layout = Layout::default()
117
1
        .direction(Direction::Vertical)
118
1
        .constraints([
119
1
            Constraint::Percentage((100 - pct_y) / 2),
120
1
            Constraint::Percentage(pct_y),
121
1
            Constraint::Percentage((100 - pct_y) / 2),
122
1
        ])
123
1
        .split(r);
124
1
    Layout::default()
125
1
        .direction(Direction::Horizontal)
126
1
        .constraints([
127
1
            Constraint::Percentage((100 - pct_x) / 2),
128
1
            Constraint::Percentage(pct_x),
129
1
            Constraint::Percentage((100 - pct_x) / 2),
130
1
        ])
131
1
        .split(popup_layout[1])[1]
132
1
}
133

            
134
#[cfg(test)]
135
mod tests {
136
    use super::*;
137

            
138
    #[test]
139
1
    fn centered_rect_clamps_to_parent() {
140
1
        let parent = Rect::new(0, 0, 100, 100);
141
1
        let r = centered_rect(60, 30, parent);
142
1
        assert!(r.x + r.width <= parent.x + parent.width);
143
1
        assert!(r.y + r.height <= parent.y + parent.height);
144
1
    }
145
}