1use ratatui::{
6 buffer::Buffer,
7 layout::{Constraint, Direction, Layout, Rect},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
11 Frame,
12};
13
14use super::app::CalculatorApp;
15use super::keypad::{Keypad, KeypadWidget};
16
17pub fn render(app: &CalculatorApp, frame: &mut Frame) {
19 let area = frame.area();
20 let ui = CalculatorUI::new(app);
21 frame.render_widget(ui, area);
22}
23
24pub struct CalculatorUI<'a> {
26 app: &'a CalculatorApp,
27 keypad: Keypad,
28}
29
30impl<'a> CalculatorUI<'a> {
31 #[must_use]
33 pub fn new(app: &'a CalculatorApp) -> Self {
34 Self {
35 app,
36 keypad: Keypad::new(),
37 }
38 }
39
40 fn create_horizontal_layout(&self, area: Rect) -> Vec<Rect> {
42 Layout::default()
43 .direction(Direction::Horizontal)
44 .margin(1)
45 .constraints([
46 Constraint::Min(35), Constraint::Length(22), Constraint::Length(22), ])
50 .split(area)
51 .to_vec()
52 }
53
54 fn render_keypad(&self, area: Rect, buf: &mut Buffer) {
56 let widget = KeypadWidget::new(&self.keypad);
57 widget.render(area, buf);
58 }
59
60 fn create_layout(&self, area: Rect) -> Vec<Rect> {
62 Layout::default()
63 .direction(Direction::Vertical)
64 .constraints([
65 Constraint::Length(3), Constraint::Length(3), Constraint::Min(5), Constraint::Length(5), ])
70 .split(area)
71 .to_vec()
72 }
73
74 fn render_help_sidebar(&self, area: Rect, buf: &mut Buffer) {
76 let chunks = Layout::default()
77 .direction(Direction::Vertical)
78 .constraints([
79 Constraint::Min(10), Constraint::Length(3), Constraint::Length(2), ])
83 .split(area);
84
85 let shortcuts: Vec<ListItem> = HELP_SHORTCUTS
87 .iter()
88 .map(|(key, desc)| {
89 ListItem::new(Line::from(vec![
90 Span::styled(format!("{:>7}", key), Style::default().fg(Color::Yellow)),
91 Span::raw(" "),
92 Span::styled(*desc, Style::default().fg(Color::Gray)),
93 ]))
94 })
95 .collect();
96
97 let shortcuts_list = List::new(shortcuts).block(
98 Block::default()
99 .title(" Help ")
100 .borders(Borders::ALL)
101 .border_style(Style::default().fg(Color::DarkGray)),
102 );
103 shortcuts_list.render(chunks[0], buf);
104
105 let ops = Paragraph::new(Span::styled(
107 HELP_OPERATORS,
108 Style::default().fg(Color::Cyan),
109 ))
110 .block(
111 Block::default()
112 .borders(Borders::LEFT | Borders::RIGHT)
113 .border_style(Style::default().fg(Color::DarkGray)),
114 );
115 ops.render(chunks[1], buf);
116
117 let badge = Paragraph::new(Span::styled(
119 PROBAR_BADGE,
120 Style::default()
121 .fg(Color::Magenta)
122 .add_modifier(Modifier::ITALIC),
123 ))
124 .block(
125 Block::default()
126 .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
127 .border_style(Style::default().fg(Color::DarkGray)),
128 );
129 badge.render(chunks[2], buf);
130 }
131
132 fn render_input(&self, area: Rect, buf: &mut Buffer) {
134 let input_text = self.app.input();
135 let cursor_pos = self.app.cursor();
136
137 let (before, after) = input_text.split_at(cursor_pos.min(input_text.len()));
139 let cursor_char = after.chars().next().unwrap_or(' ');
140 let after_cursor = if after.len() > 1 {
141 &after[cursor_char.len_utf8()..]
142 } else {
143 ""
144 };
145
146 let spans = vec![
147 Span::raw(before),
148 Span::styled(
149 cursor_char.to_string(),
150 Style::default().bg(Color::White).fg(Color::Black),
151 ),
152 Span::raw(after_cursor),
153 ];
154
155 let paragraph = Paragraph::new(Line::from(spans)).block(
156 Block::default()
157 .title(" Expression ")
158 .borders(Borders::ALL)
159 .border_style(Style::default().fg(Color::Cyan)),
160 );
161
162 paragraph.render(area, buf);
163 }
164
165 fn render_result(&self, area: Rect, buf: &mut Buffer) {
167 let result_text = self.app.result_display();
168
169 let style = if result_text.starts_with("Error") {
170 Style::default().fg(Color::Red)
171 } else {
172 Style::default()
173 .fg(Color::Green)
174 .add_modifier(Modifier::BOLD)
175 };
176
177 let paragraph = Paragraph::new(Span::styled(result_text, style)).block(
178 Block::default()
179 .title(" Result ")
180 .borders(Borders::ALL)
181 .border_style(Style::default().fg(Color::Yellow)),
182 );
183
184 paragraph.render(area, buf);
185 }
186
187 fn render_history(&self, area: Rect, buf: &mut Buffer) {
189 let history = self.app.history();
190
191 let items: Vec<ListItem> = history
192 .iter_rev()
193 .take(10)
194 .map(|entry| {
195 ListItem::new(Line::from(vec![
196 Span::styled(&entry.expression, Style::default().fg(Color::Gray)),
197 Span::raw(" = "),
198 Span::styled(
199 format!("{}", entry.result),
200 Style::default().fg(Color::Cyan),
201 ),
202 ]))
203 })
204 .collect();
205
206 let list = List::new(items).block(
207 Block::default()
208 .title(" History (newest first) ")
209 .borders(Borders::ALL)
210 .border_style(Style::default().fg(Color::Blue)),
211 );
212
213 list.render(area, buf);
214 }
215
216 fn render_jidoka_status(&self, area: Rect, buf: &mut Buffer) {
218 let status_lines = self.app.jidoka_status();
219
220 let items: Vec<ListItem> = status_lines
221 .iter()
222 .map(|line| {
223 let style = if line.starts_with('✓') {
224 Style::default().fg(Color::Green)
225 } else if line.starts_with('✗') {
226 Style::default().fg(Color::Red)
227 } else {
228 Style::default().fg(Color::Gray)
229 };
230 ListItem::new(Span::styled(line.as_str(), style))
231 })
232 .collect();
233
234 let list = List::new(items).block(
235 Block::default()
236 .title(" Anomaly Status ")
237 .borders(Borders::ALL)
238 .border_style(Style::default().fg(Color::Magenta)),
239 );
240
241 list.render(area, buf);
242 }
243}
244
245impl Widget for CalculatorUI<'_> {
246 fn render(self, area: Rect, buf: &mut Buffer) {
247 Block::default()
249 .title(DEMO_TITLE)
250 .borders(Borders::ALL)
251 .border_style(Style::default().fg(Color::White))
252 .render(area, buf);
253
254 let h_chunks = self.create_horizontal_layout(area);
256
257 if h_chunks.len() >= 3 {
259 let main_area = h_chunks[0];
260 let keypad_area = h_chunks[1];
261 let help_area = h_chunks[2];
262
263 let chunks = self.create_layout(main_area);
264
265 if chunks.len() >= 4 {
267 self.render_input(chunks[0], buf);
268 self.render_result(chunks[1], buf);
269 self.render_history(chunks[2], buf);
270 self.render_jidoka_status(chunks[3], buf);
271 }
272
273 self.render_keypad(keypad_area, buf);
275
276 self.render_help_sidebar(help_area, buf);
278 }
279 }
280}
281
282pub const DEMO_TITLE: &str = " Showcase Calculator - 100% Test Coverage Demo ";
284
285pub const HELP_SHORTCUTS: &[(&str, &str)] = &[
287 ("Enter", "Evaluate"),
288 ("Esc", "Clear"),
289 ("↑", "Recall"),
290 ("←/→", "Move cursor"),
291 ("Ctrl+C", "Quit"),
292 ("Ctrl+L", "Clear all"),
293 ("?", "Toggle help"),
294];
295
296pub const HELP_OPERATORS: &str = "Ops: + - * / % ^ ( )";
298
299pub const PROBAR_BADGE: &str = "Probar - paiml.com/probar";
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use ratatui::backend::TestBackend;
306 use ratatui::Terminal;
307
308 fn create_test_terminal() -> Terminal<TestBackend> {
309 let backend = TestBackend::new(80, 24);
310 Terminal::new(backend).unwrap()
311 }
312
313 #[test]
316 fn test_calculator_ui_new() {
317 let app = CalculatorApp::new();
318 let ui = CalculatorUI::new(&app);
319 let _ = format!("{:p}", ui.app);
321 }
322
323 #[test]
324 fn test_calculator_ui_create_layout() {
325 let app = CalculatorApp::new();
326 let ui = CalculatorUI::new(&app);
327 let area = Rect::new(0, 0, 80, 24);
328 let chunks = ui.create_layout(area);
329 assert_eq!(chunks.len(), 4);
330 }
331
332 #[test]
333 fn test_calculator_ui_render() {
334 let app = CalculatorApp::new();
335 let mut terminal = create_test_terminal();
336
337 terminal
338 .draw(|frame| {
339 render(&app, frame);
340 })
341 .unwrap();
342 }
343
344 #[test]
345 fn test_render_with_input() {
346 let mut app = CalculatorApp::new();
347 app.set_input("2 + 3");
348 let mut terminal = create_test_terminal();
349
350 terminal
351 .draw(|frame| {
352 render(&app, frame);
353 })
354 .unwrap();
355
356 let buffer = terminal.backend().buffer();
358 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
359 assert!(content.contains("2 + 3"));
360 }
361
362 #[test]
363 fn test_render_with_result() {
364 let mut app = CalculatorApp::new();
365 app.set_input("2 + 3");
366 app.evaluate();
367 let mut terminal = create_test_terminal();
368
369 terminal
370 .draw(|frame| {
371 render(&app, frame);
372 })
373 .unwrap();
374
375 let buffer = terminal.backend().buffer();
376 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
377 assert!(content.contains('5'));
378 }
379
380 #[test]
381 fn test_render_with_error() {
382 let mut app = CalculatorApp::new();
383 app.set_input("1 / 0");
384 app.evaluate();
385 let mut terminal = create_test_terminal();
386
387 terminal
388 .draw(|frame| {
389 render(&app, frame);
390 })
391 .unwrap();
392
393 let buffer = terminal.backend().buffer();
394 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
395 assert!(content.contains("Error"));
396 }
397
398 #[test]
399 fn test_render_with_history() {
400 let mut app = CalculatorApp::new();
401 app.set_input("1 + 1");
402 app.evaluate();
403 app.set_input("2 + 2");
404 app.evaluate();
405 let mut terminal = create_test_terminal();
406
407 terminal
408 .draw(|frame| {
409 render(&app, frame);
410 })
411 .unwrap();
412
413 let buffer = terminal.backend().buffer();
414 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
415 assert!(content.contains("1 + 1"));
417 }
418
419 #[test]
420 fn test_render_jidoka_success() {
421 let mut app = CalculatorApp::new();
422 app.set_input("5 + 5");
423 app.evaluate();
424 let mut terminal = create_test_terminal();
425
426 terminal
427 .draw(|frame| {
428 render(&app, frame);
429 })
430 .unwrap();
431
432 let buffer = terminal.backend().buffer();
433 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
434 assert!(content.contains("✓"));
435 }
436
437 #[test]
438 fn test_render_cursor_position() {
439 let mut app = CalculatorApp::new();
440 app.set_input("12345");
441 app.set_cursor(2); let mut terminal = create_test_terminal();
443
444 terminal
445 .draw(|frame| {
446 render(&app, frame);
447 })
448 .unwrap();
449
450 let _ = terminal.backend().buffer();
452 }
453
454 #[test]
455 fn test_render_cursor_at_end() {
456 let mut app = CalculatorApp::new();
457 app.set_input("abc");
458 let mut terminal = create_test_terminal();
460
461 terminal
462 .draw(|frame| {
463 render(&app, frame);
464 })
465 .unwrap();
466 }
467
468 #[test]
469 fn test_render_empty_input() {
470 let app = CalculatorApp::new();
471 let mut terminal = create_test_terminal();
472
473 terminal
474 .draw(|frame| {
475 render(&app, frame);
476 })
477 .unwrap();
478
479 let buffer = terminal.backend().buffer();
480 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
481 assert!(content.contains("Expression"));
482 }
483
484 #[test]
485 fn test_render_small_terminal() {
486 let app = CalculatorApp::new();
487 let backend = TestBackend::new(20, 10);
488 let mut terminal = Terminal::new(backend).unwrap();
489
490 terminal
491 .draw(|frame| {
492 render(&app, frame);
493 })
494 .unwrap();
495 }
496
497 #[test]
500 fn test_demo_title_constant() {
501 assert!(DEMO_TITLE.contains("Showcase Calculator"));
502 assert!(DEMO_TITLE.contains("100% Test Coverage"));
503 assert!(DEMO_TITLE.contains("Demo"));
504 }
505
506 #[test]
507 fn test_help_shortcuts_contains_essential_keys() {
508 let keys: Vec<&str> = HELP_SHORTCUTS.iter().map(|(k, _)| *k).collect();
509 assert!(keys.contains(&"Enter"));
510 assert!(keys.contains(&"Esc"));
511 assert!(keys.contains(&"Ctrl+C"));
512 }
513
514 #[test]
515 fn test_help_shortcuts_has_descriptions() {
516 for (key, desc) in HELP_SHORTCUTS {
517 assert!(!key.is_empty(), "Key should not be empty");
518 assert!(!desc.is_empty(), "Description should not be empty");
519 }
520 }
521
522 #[test]
523 fn test_help_shortcuts_count() {
524 assert!(
525 HELP_SHORTCUTS.len() >= 5,
526 "Should have at least 5 shortcuts"
527 );
528 }
529
530 #[test]
531 fn test_help_operators_contains_all_ops() {
532 assert!(HELP_OPERATORS.contains('+'));
533 assert!(HELP_OPERATORS.contains('-'));
534 assert!(HELP_OPERATORS.contains('*'));
535 assert!(HELP_OPERATORS.contains('/'));
536 assert!(HELP_OPERATORS.contains('%'));
537 assert!(HELP_OPERATORS.contains('^'));
538 assert!(HELP_OPERATORS.contains('('));
539 assert!(HELP_OPERATORS.contains(')'));
540 }
541
542 #[test]
543 fn test_probar_badge_contains_branding() {
544 assert!(PROBAR_BADGE.contains("Probar"));
545 assert!(PROBAR_BADGE.contains("paiml"));
546 }
547
548 #[test]
549 fn test_render_shows_demo_title() {
550 let app = CalculatorApp::new();
551 let mut terminal = create_test_terminal();
552
553 terminal
554 .draw(|frame| {
555 render(&app, frame);
556 })
557 .unwrap();
558
559 let buffer = terminal.backend().buffer();
560 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
561 assert!(content.contains("Showcase") || content.contains("Calculator"));
562 }
563
564 #[test]
565 fn test_render_shows_help_panel() {
566 let app = CalculatorApp::new();
567 let backend = TestBackend::new(100, 30); let mut terminal = Terminal::new(backend).unwrap();
569
570 terminal
571 .draw(|frame| {
572 render(&app, frame);
573 })
574 .unwrap();
575
576 let buffer = terminal.backend().buffer();
577 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
578 assert!(content.contains("Enter") || content.contains("Help") || content.contains("Esc"));
580 }
581
582 #[test]
583 fn test_render_shows_probar_badge() {
584 let app = CalculatorApp::new();
585 let backend = TestBackend::new(100, 30);
586 let mut terminal = Terminal::new(backend).unwrap();
587
588 terminal
589 .draw(|frame| {
590 render(&app, frame);
591 })
592 .unwrap();
593
594 let buffer = terminal.backend().buffer();
595 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
596 assert!(content.contains("Probar") || content.contains("paiml"));
598 }
599
600 #[test]
601 fn test_render_help_sidebar_directly() {
602 let app = CalculatorApp::new();
603 let ui = CalculatorUI::new(&app);
604 let area = Rect::new(0, 0, 22, 20);
605 let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
606
607 ui.render_help_sidebar(area, &mut buf);
608
609 let content: String = buf.content().iter().map(|c| c.symbol()).collect();
610 assert!(content.contains("Help"));
611 assert!(content.contains("Enter"));
612 assert!(content.contains("Esc"));
613 }
614
615 #[test]
616 fn test_create_horizontal_layout() {
617 let app = CalculatorApp::new();
618 let ui = CalculatorUI::new(&app);
619 let area = Rect::new(0, 0, 100, 30);
620 let chunks = ui.create_horizontal_layout(area);
621 assert_eq!(chunks.len(), 3);
622 assert_eq!(chunks[1].width, 22);
624 assert_eq!(chunks[2].width, 22);
626 }
627
628 #[test]
631 fn test_widget_render_direct() {
632 let app = CalculatorApp::new();
633 let ui = CalculatorUI::new(&app);
634 let area = Rect::new(0, 0, 80, 24);
635 let mut buf = Buffer::empty(area);
636
637 ui.render(area, &mut buf);
638
639 let content: String = buf.content().iter().map(|c| c.symbol()).collect();
641 assert!(content.contains("Calculator"));
642 }
643
644 #[test]
645 fn test_render_sections_individually() {
646 let app = CalculatorApp::new();
647 let ui = CalculatorUI::new(&app);
648
649 let input_area = Rect::new(0, 0, 40, 3);
650 let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
651 ui.render_input(input_area, &mut buf);
652
653 let result_area = Rect::new(0, 3, 40, 3);
654 ui.render_result(result_area, &mut buf);
655
656 let history_area = Rect::new(0, 6, 40, 10);
657 ui.render_history(history_area, &mut buf);
658
659 let jidoka_area = Rect::new(0, 16, 40, 5);
660 ui.render_jidoka_status(jidoka_area, &mut buf);
661 }
662
663 #[test]
664 fn test_render_history_many_entries() {
665 let mut app = CalculatorApp::new();
666 for i in 1..=20 {
667 app.set_input(&format!("{i} + {i}"));
668 app.evaluate();
669 }
670 let mut terminal = create_test_terminal();
671
672 terminal
673 .draw(|frame| {
674 render(&app, frame);
675 })
676 .unwrap();
677
678 let buffer = terminal.backend().buffer();
680 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
681 assert!(content.contains("20 + 20")); }
683
684 #[test]
687 fn test_calculator_ui_has_keypad() {
688 let app = CalculatorApp::new();
689 let ui = CalculatorUI::new(&app);
690 assert_eq!(ui.keypad.button_count(), 20);
692 }
693
694 #[test]
695 fn test_render_keypad_directly() {
696 let app = CalculatorApp::new();
697 let ui = CalculatorUI::new(&app);
698 let area = Rect::new(0, 0, 22, 12);
699 let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
700
701 ui.render_keypad(area, &mut buf);
702
703 let content: String = buf.content().iter().map(|c| c.symbol()).collect();
704 assert!(content.contains("Keypad"));
705 assert!(content.contains("[7]"));
706 }
707
708 #[test]
709 fn test_render_shows_keypad_in_full_layout() {
710 let app = CalculatorApp::new();
711 let backend = TestBackend::new(120, 30); let mut terminal = Terminal::new(backend).unwrap();
713
714 terminal
715 .draw(|frame| {
716 render(&app, frame);
717 })
718 .unwrap();
719
720 let buffer = terminal.backend().buffer();
721 let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
722 assert!(content.contains("Keypad"));
724 assert!(content.contains("[7]") || content.contains("[+]") || content.contains("[=]"));
726 }
727
728 #[test]
729 fn test_keypad_shows_operators() {
730 let app = CalculatorApp::new();
731 let backend = TestBackend::new(120, 30);
732 let mut terminal = Terminal::new(backend).unwrap();
733
734 terminal
735 .draw(|frame| {
736 render(&app, frame);
737 })
738 .unwrap();
739
740 let buffer = terminal.backend().buffer();
741 let content: String = buf_to_string(buffer);
742 assert!(content.contains("[/]") || content.contains("[*]") || content.contains("[-]"));
744 }
745
746 #[test]
747 fn test_full_layout_three_columns() {
748 let app = CalculatorApp::new();
749 let ui = CalculatorUI::new(&app);
750 let area = Rect::new(0, 0, 120, 30);
751 let mut buf = Buffer::empty(area);
752
753 ui.render(area, &mut buf);
754
755 let content: String = buf.content().iter().map(|c| c.symbol()).collect();
756 assert!(content.contains("Expression")); assert!(content.contains("Keypad")); assert!(content.contains("Help")); }
761
762 fn buf_to_string(buffer: &Buffer) -> String {
763 buffer.content().iter().map(|c| c.symbol()).collect()
764 }
765}