magical markdown slides
1use lantern_core::{metadata::Meta, slide::Slide, term::InputEvent, theme::ThemeColors};
2use ratatui::{
3 Terminal as RatatuiTerminal,
4 backend::Backend,
5 style::{Color, Style},
6 widgets::Block,
7};
8use std::io;
9use std::time::{Duration, Instant};
10
11use crate::{layout::SlideLayout, viewer::SlideViewer};
12
13/// Main TUI application coordinator
14///
15/// Manages the presentation lifecycle, event loop, and component coordination.
16pub struct App {
17 viewer: SlideViewer,
18 layout: SlideLayout,
19 should_quit: bool,
20 theme: ThemeColors,
21 help_visible: bool,
22}
23
24impl App {
25 /// Create a new presentation application
26 pub fn new(slides: Vec<Slide>, theme: ThemeColors, filename: String, meta: Meta) -> Self {
27 let viewer = SlideViewer::with_context(
28 slides,
29 theme,
30 Some(filename.clone()),
31 meta.theme.clone(),
32 Some(Instant::now()),
33 );
34
35 Self { viewer, layout: SlideLayout::default(), should_quit: false, theme, help_visible: false }
36 }
37
38 /// Run the main event loop
39 pub fn run<B: Backend>(&mut self, terminal: &mut RatatuiTerminal<B>) -> io::Result<()> {
40 loop {
41 terminal.draw(|frame| self.draw(frame))?;
42
43 if self.should_quit {
44 break;
45 }
46
47 if let Some(event) = InputEvent::poll(Duration::from_millis(50))? {
48 self.handle_event(event);
49 }
50 }
51
52 Ok(())
53 }
54
55 fn toggle_notes(&mut self) {
56 self.viewer.toggle_notes();
57 self.layout.set_show_notes(self.viewer.is_showing_notes())
58 }
59
60 fn toggle_help(&mut self) {
61 self.help_visible = !self.help_visible;
62 self.layout.set_show_help(self.help_visible);
63 }
64
65 /// Handle input events
66 fn handle_event(&mut self, event: InputEvent) {
67 match event {
68 InputEvent::Next => self.viewer.next(),
69 InputEvent::Previous => self.viewer.previous(),
70 InputEvent::ToggleNotes => self.toggle_notes(),
71 InputEvent::ToggleHelp => self.toggle_help(),
72 InputEvent::Quit => self.should_quit = true,
73 InputEvent::Resize { .. } | InputEvent::Search | InputEvent::Other => {}
74 }
75 }
76
77 /// Draw the UI
78 fn draw(&mut self, frame: &mut ratatui::Frame) {
79 let bg_color = Color::Rgb(
80 self.theme.ui_background.r,
81 self.theme.ui_background.g,
82 self.theme.ui_background.b,
83 );
84
85 let background = Block::default().style(Style::default().bg(bg_color));
86 frame.render_widget(background, frame.area());
87
88 let (main_area, notes_area, status_area, help_area) = self.layout.calculate(frame.area());
89
90 self.viewer.render(frame, main_area);
91
92 if let Some(notes_area) = notes_area {
93 self.viewer.render_notes(frame, notes_area);
94 }
95
96 self.viewer.render_status_bar(frame, status_area);
97
98 if let Some(help_area) = help_area {
99 self.viewer.render_help_line(frame, help_area);
100 }
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use lantern_core::slide::{Block, TextSpan};
108
109 fn create_test_app() -> App {
110 let slides = vec![
111 Slide::with_blocks(vec![Block::Heading {
112 level: 1,
113 spans: vec![TextSpan::plain("Slide 1")],
114 }]),
115 Slide::with_blocks(vec![Block::Heading {
116 level: 1,
117 spans: vec![TextSpan::plain("Slide 2")],
118 }]),
119 ];
120
121 App::new(slides, ThemeColors::default(), "test.md".to_string(), Meta::default())
122 }
123
124 #[test]
125 fn app_creation() {
126 let app = create_test_app();
127 assert!(!app.should_quit);
128 }
129
130 #[test]
131 fn app_handle_next() {
132 let mut app = create_test_app();
133 let initial_index = app.viewer.current_index();
134
135 app.handle_event(InputEvent::Next);
136 assert_eq!(app.viewer.current_index(), initial_index + 1);
137 }
138
139 #[test]
140 fn app_handle_previous() {
141 let mut app = create_test_app();
142 app.handle_event(InputEvent::Next);
143 app.handle_event(InputEvent::Previous);
144 assert_eq!(app.viewer.current_index(), 0);
145 }
146
147 #[test]
148 fn app_handle_toggle_notes() {
149 let mut app = create_test_app();
150 assert!(!app.viewer.is_showing_notes());
151
152 app.handle_event(InputEvent::ToggleNotes);
153 assert!(app.viewer.is_showing_notes());
154 assert!(app.layout.is_showing_notes());
155 }
156
157 #[test]
158 fn app_handle_quit() {
159 let mut app = create_test_app();
160 assert!(!app.should_quit);
161
162 app.handle_event(InputEvent::Quit);
163 assert!(app.should_quit);
164 }
165
166 #[test]
167 fn app_handle_resize() {
168 let mut app = create_test_app();
169 app.handle_event(InputEvent::Resize { width: 100, height: 50 });
170 assert!(!app.should_quit);
171 }
172
173 #[test]
174 fn app_handle_toggle_help() {
175 let mut app = create_test_app();
176 assert!(!app.help_visible);
177 assert!(!app.layout.is_showing_help());
178
179 app.handle_event(InputEvent::ToggleHelp);
180 assert!(app.help_visible);
181 assert!(app.layout.is_showing_help());
182
183 app.handle_event(InputEvent::ToggleHelp);
184 assert!(!app.help_visible);
185 assert!(!app.layout.is_showing_help());
186 }
187}