1mod form;
6
7use crate::app::App;
8use crate::modal::Modal;
9use crate::view::{DrawCtx, Tab};
10use crate::widgets::{EditMode, VimMode};
11use ratatui::Frame;
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::{Color, Style};
14use ratatui::text::{Span, Text};
15use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs};
16
17pub fn draw(frame: &mut Frame, app: &App) {
18 let area = frame.area();
19 let chunks = Layout::default()
20 .direction(Direction::Vertical)
21 .constraints([
22 Constraint::Length(3),
23 Constraint::Min(1),
24 Constraint::Length(3),
25 ])
26 .split(area);
27
28 draw_tabs(frame, chunks[0], app);
29 draw_body(frame, chunks[1], app);
30 draw_status(frame, chunks[2], app);
31
32 if let Some(modal) = app.overlays.top() {
33 draw_modal(frame, area, modal);
34 }
35}
36
37fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
38 use ratatui::style::Modifier;
39 use ratatui::text::Line;
40 let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
41 let selected = app.active_tab.index();
42 let tabs = Tabs::new(titles)
43 .select(selected)
44 .block(Block::default().borders(Borders::ALL).title("nomisync-tui"))
45 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
46 frame.render_widget(tabs, area);
47}
48
49fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
50 let ctx = DrawCtx {
51 edit_mode: app.edit_mode,
52 is_active: app.console_focused,
53 };
54 app.active_view().draw(frame, area, &ctx);
55}
56
57fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
58 let edit_indicator = match app.cmdline.editor.mode() {
59 EditMode::Emacs => "emacs",
60 EditMode::Vim => match app.cmdline.editor.vim_mode() {
61 VimMode::Normal => "vim:normal",
62 VimMode::Insert => "vim:insert",
63 },
64 };
65 let content = if app.cmdline.active {
66 let buf = app.cmdline.editor.buffer();
67 if app.cmdline.completions.is_empty() {
68 format!(":{} (cursor={})", buf, app.cmdline.editor.cursor())
69 } else {
70 format!(":{} [{}]", buf, app.cmdline.completions.join(" "))
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 (pct_x, pct_y) = match modal {
85 Modal::Help => (95, 95),
86 _ => (60, 30),
87 };
88 let area = centered_rect(pct_x, pct_y, full);
89 frame.render_widget(Clear, area);
90 let (title, body) = modal_content(modal);
91 let widget = Paragraph::new(body).block(Block::default().title(title).borders(Borders::ALL));
92 frame.render_widget(widget, area);
93}
94
95fn modal_content(modal: &Modal) -> (&'static str, Text<'static>) {
96 match modal {
97 Modal::Help => (
98 "Help",
99 Text::from(
100 concat!(
101 "Tabs: 1-6 jump Tab/BTab next/prev q quit C-v emacs/vim ? help\n",
102 "\n",
103 "Palette (:): Tab complete Enter run Esc cancel (twice in vim)\n",
104 " account create|list|balance|tag transaction create|list|tag\n",
105 " commodity create|list config get|set reports ... version\n",
106 "\n",
107 "Lists: Up/Down or j/k select r refresh\n",
108 " Accounts: e tag\n",
109 " Transactions: d delete (confirm) e edit (tag: :transaction tag)\n",
110 " Commodities: view only\n",
111 " Config: Up/Down select e/Enter edit r refresh\n",
112 "\n",
113 "Forms: Tab/BTab fields Up/Down Select Enter submit Esc cancel\n",
114 " Tx splits: +/C-n add row -/C-d remove row\n",
115 "\n",
116 "Console: i/Enter focus Esc blur C-c interrupt\n",
117 " Up/Down history Tab/BTab cycle panes\n",
118 )
119 .to_string(),
120 ),
121 ),
122 Modal::Confirm { prompt, .. } => (
123 "Confirm",
124 Text::from(format!(
125 "{prompt}\n\n y / Enter — confirm\n n / q / Esc — cancel"
126 )),
127 ),
128 Modal::Form(form) => form::form_modal_content(form),
129 }
130}
131
132#[must_use]
133pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
134 let popup_layout = Layout::default()
135 .direction(Direction::Vertical)
136 .constraints([
137 Constraint::Percentage((100 - pct_y) / 2),
138 Constraint::Percentage(pct_y),
139 Constraint::Percentage((100 - pct_y) / 2),
140 ])
141 .split(r);
142 Layout::default()
143 .direction(Direction::Horizontal)
144 .constraints([
145 Constraint::Percentage((100 - pct_x) / 2),
146 Constraint::Percentage(pct_x),
147 Constraint::Percentage((100 - pct_x) / 2),
148 ])
149 .split(popup_layout[1])[1]
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::event::{Intent, apply};
156 use crate::modal::Modal;
157 use crate::widgets::EditMode;
158 use ratatui::Terminal;
159 use ratatui::backend::TestBackend;
160 use ratatui::style::Modifier;
161 use sqlx::types::Uuid;
162
163 #[test]
164 fn centered_rect_clamps_to_parent() {
165 let parent = Rect::new(0, 0, 100, 100);
166 let r = centered_rect(60, 30, parent);
167 assert!(r.x + r.width <= parent.x + parent.width);
168 assert!(r.y + r.height <= parent.y + parent.height);
169 }
170
171 fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
172 terminal
173 .backend()
174 .buffer()
175 .content()
176 .iter()
177 .map(|cell| cell.symbol())
178 .collect()
179 }
180
181 fn render_console(app: &App) -> String {
182 let backend = TestBackend::new(80, 24);
183 let mut terminal = Terminal::new(backend).expect("test terminal");
184 terminal
185 .draw(|frame| draw(frame, app))
186 .expect("draw must not panic");
187 buffer_text(&terminal)
188 }
189
190 fn console_app() -> App {
191 let mut app = App::new(Uuid::nil(), EditMode::Emacs);
192 app.active_tab = Tab::Console;
193 app
194 }
195
196 #[test]
197 fn transaction_modal_renders_split_row_details() {
198 use crate::form::Form;
199 use crate::modal::Modal;
200 use crate::widgets::splits::{COL_FROM, COL_VALUE};
201 use crate::widgets::{FocusedSubWidget, SelectOption, Widget};
202
203 let mut app = App::new(Uuid::nil(), EditMode::Emacs);
204 let mut form = Form::transaction_create(EditMode::Emacs);
205 if let Widget::Splits(ref mut sw) = form.fields[2].widget {
206 sw.set_account_options(vec![
207 SelectOption {
208 id: "acc-0".to_string(),
209 label: "Cash".to_string(),
210 },
211 SelectOption {
212 id: "acc-1".to_string(),
213 label: "Food".to_string(),
214 },
215 ]);
216 sw.col_focus = COL_VALUE;
217 if let Some(FocusedSubWidget::Amount(aw)) = sw.focused_subwidget_mut() {
218 aw.insert_char('5');
219 aw.insert_char('0');
220 }
221 sw.col_focus = COL_FROM;
223 }
224 app.overlays.push(Modal::Form(form));
225
226 let backend = TestBackend::new(120, 40);
227 let mut terminal = Terminal::new(backend).expect("test terminal");
228 terminal
229 .draw(|frame| draw(frame, &app))
230 .expect("draw must not panic");
231 let text = buffer_text(&terminal);
232 assert!(
233 text.contains("[Cash]"),
234 "focused from-account label must render bracketed"
235 );
236 assert!(
237 text.contains("val=50"),
238 "typed amount must render: not found"
239 );
240 }
241
242 #[test]
243 fn console_tab_renders_scrollback_and_prompt() {
244 let mut app = console_app();
245 app.console.push_scrollback("(:id 0 :value 42)");
246 app.console.input.insert_char('(');
247
248 let text = render_console(&app);
249 assert!(text.contains("(:id 0 :value 42)"), "scrollback missing");
250 assert!(text.contains("nms>"), "prompt missing");
251 }
252
253 #[test]
254 fn console_prompt_shows_continuation_marker_when_pending() {
255 let mut app = console_app();
256 for c in "(list".chars() {
257 app.console.input.insert_char(c);
258 }
259 app.console.take_complete_form();
260 assert!(!app.console.pending.is_empty(), "form must be pending");
261
262 let text = render_console(&app);
263 assert!(text.contains("...>"), "continuation marker missing");
264 assert!(!text.contains("nms>"), "primary prompt should be hidden");
265 }
266
267 #[test]
268 fn console_hint_switches_with_focus() {
269 let mut app = console_app();
270 let unfocused = render_console(&app);
271 assert!(unfocused.contains("i/Enter focus"), "blurred hint missing");
272
273 app.console_focused = true;
274 let focused = render_console(&app);
275 assert!(focused.contains("Esc blur"), "focused hint missing");
276 assert!(focused.contains("C-c interrupt"), "interrupt hint missing");
277 }
278
279 #[test]
280 fn console_prompt_style_bold_only_when_focused() {
281 let app = console_app();
282 let backend = TestBackend::new(80, 24);
283 let mut terminal = Terminal::new(backend).expect("test terminal");
284 terminal
285 .draw(|frame| draw(frame, &app))
286 .expect("draw must not panic");
287 let prompt_cell = prompt_marker_cell(&terminal);
288 assert!(
289 !prompt_cell.modifier.contains(Modifier::BOLD),
290 "blurred prompt must not be bold"
291 );
292
293 let mut focused = console_app();
294 focused.console_focused = true;
295 let backend = TestBackend::new(80, 24);
296 let mut terminal = Terminal::new(backend).expect("test terminal");
297 terminal
298 .draw(|frame| draw(frame, &focused))
299 .expect("draw must not panic");
300 let prompt_cell = prompt_marker_cell(&terminal);
301 assert!(
302 prompt_cell.modifier.contains(Modifier::BOLD),
303 "focused prompt must be bold"
304 );
305 }
306
307 fn prompt_marker_cell(terminal: &Terminal<TestBackend>) -> ratatui::buffer::Cell {
308 let buffer = terminal.backend().buffer();
309 for y in 0..buffer.area.height {
310 if buffer[(1, y)].symbol() == "n" && buffer[(2, y)].symbol() == "m" {
311 return buffer[(1, y)].clone();
312 }
313 }
314 panic!("nms> prompt marker not found in rendered buffer");
315 }
316
317 fn focused_field_marker_pos(terminal: &Terminal<TestBackend>) -> (u16, u16) {
318 let buffer = terminal.backend().buffer();
319 for y in 0..buffer.area.height {
320 for x in 0..buffer.area.width {
321 if buffer[(x, y)].symbol() == "▸" {
322 return (x, y);
323 }
324 }
325 }
326 panic!("focused field marker '▸' not found in rendered buffer");
327 }
328
329 #[test]
330 fn form_focused_field_reversed_unfocused_not() {
331 use crate::form::Form;
332 use crate::widgets::Editor;
333
334 let mut app = App::new(Uuid::nil(), EditMode::Emacs);
335 let form = Form::config_set(Editor::new(EditMode::Emacs), Editor::new(EditMode::Emacs));
336 assert_eq!(form.focus, 0, "focus must start on first field");
337 app.overlays.push(Modal::Form(form));
338
339 let backend = TestBackend::new(120, 40);
340 let mut terminal = Terminal::new(backend).expect("test terminal");
341 terminal
342 .draw(|frame| draw(frame, &app))
343 .expect("draw must not panic");
344
345 let (marker_x, marker_y) = focused_field_marker_pos(&terminal);
346 let buffer = terminal.backend().buffer();
347 assert!(
348 buffer[(marker_x, marker_y)]
349 .modifier
350 .contains(Modifier::REVERSED),
351 "focused field must carry REVERSED modifier"
352 );
353 assert!(
354 !buffer[(marker_x, marker_y + 1)]
355 .modifier
356 .contains(Modifier::REVERSED),
357 "unfocused field must not carry REVERSED modifier"
358 );
359 }
360
361 #[test]
362 fn scrollback_shows_only_the_tail_when_longer_than_viewport() {
363 let mut app = console_app();
364 for i in 0..100 {
365 app.console.push_scrollback(format!("line-{i:03}"));
366 }
367 let text = render_console(&app);
368 assert!(
369 text.contains(&format!("line-{:03}", 99)),
370 "newest line must be visible"
371 );
372 assert!(
373 !text.contains("line-000"),
374 "oldest line must be scrolled out of the viewport"
375 );
376 }
377
378 fn make_app() -> App {
379 App::new(Uuid::nil(), EditMode::Emacs)
380 }
381
382 fn render_app(app: &App) -> String {
383 let backend = TestBackend::new(120, 40);
384 let mut terminal = Terminal::new(backend).expect("test terminal");
385 terminal
386 .draw(|frame| draw(frame, app))
387 .expect("draw must not panic");
388 terminal
389 .backend()
390 .buffer()
391 .content()
392 .iter()
393 .map(|cell| cell.symbol())
394 .collect()
395 }
396
397 #[test]
398 fn help_modal_contains_command_palette_key() {
399 let mut app = make_app();
400 app.overlays.push(Modal::Help);
401 let text = render_app(&app);
402 assert!(text.contains(':'), "help must mention the ':' key");
403 }
404
405 #[test]
406 fn help_modal_contains_list_view_keys() {
407 let mut app = make_app();
408 app.overlays.push(Modal::Help);
409 let text = render_app(&app);
410 assert!(text.contains('d'), "help must mention 'd' for delete");
411 assert!(text.contains('e'), "help must mention 'e' for edit/tag");
412 }
413
414 #[test]
415 fn help_modal_contains_tab_key_description() {
416 let mut app = make_app();
417 app.overlays.push(Modal::Help);
418 let text = render_app(&app);
419 assert!(
420 text.contains("Tab"),
421 "help must mention Tab for completion/navigation"
422 );
423 }
424
425 #[test]
426 fn help_modal_does_not_clip_last_line_on_80x24() {
427 let mut app = make_app();
428 app.overlays.push(Modal::Help);
429 let backend = TestBackend::new(80, 24);
430 let mut terminal = Terminal::new(backend).expect("test terminal");
431 terminal
432 .draw(|frame| draw(frame, &app))
433 .expect("draw must not panic");
434 let text: String = terminal
435 .backend()
436 .buffer()
437 .content()
438 .iter()
439 .map(|cell| cell.symbol())
440 .collect();
441 assert!(
442 text.contains("cycle panes"),
443 "last help line must be visible on an 80x24 terminal (not clipped)"
444 );
445 }
446
447 #[test]
448 fn cmdline_active_with_completions_shows_them_in_status() {
449 let mut app = make_app();
450 apply(&mut app, Intent::OpenCommandLine);
451 for c in "account ".chars() {
452 apply(&mut app, Intent::InsertChar(c));
453 }
454 apply(&mut app, Intent::CompleteCommandLine);
455 let text = render_app(&app);
456 assert!(
457 text.contains("create"),
458 "completion candidates must appear in the status area"
459 );
460 }
461
462 #[test]
463 fn cmdline_active_without_completions_shows_cursor() {
464 let mut app = make_app();
465 apply(&mut app, Intent::OpenCommandLine);
466 for c in "ver".chars() {
467 apply(&mut app, Intent::InsertChar(c));
468 }
469 let text = render_app(&app);
470 assert!(
471 text.contains("cursor"),
472 "without completions the cursor position must be shown"
473 );
474 }
475}