1use 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}