magical markdown slides
1use lantern_core::{slide::Slide, theme::ThemeColors};
2use ratatui::{
3 Frame,
4 layout::{Alignment, Constraint, Direction, Flex, Layout, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{Block, Borders, Padding, Paragraph, Wrap},
8};
9use ratatui_image::{Resize, StatefulImage};
10use std::time::Instant;
11
12use crate::image::ImageManager;
13use crate::renderer::render_slide_with_images;
14
15#[derive(Clone, Copy)]
16struct Stylesheet {
17 theme: ThemeColors,
18}
19
20impl Stylesheet {
21 fn new(theme: ThemeColors) -> Self {
22 Self { theme }
23 }
24
25 fn slide_padding() -> Padding {
26 Padding::new(4, 4, 2, 2)
27 }
28
29 fn status_bar(&self) -> Style {
30 Style::default()
31 .bg(Color::Rgb(
32 self.theme.ui_border.r,
33 self.theme.ui_border.g,
34 self.theme.ui_border.b,
35 ))
36 .fg(self.ui_text_color())
37 .add_modifier(Modifier::BOLD)
38 }
39
40 fn border_color(&self) -> Color {
41 Color::Rgb(self.theme.ui_border.r, self.theme.ui_border.g, self.theme.ui_border.b)
42 }
43
44 fn title_color(&self) -> Color {
45 Color::Rgb(self.theme.ui_title.r, self.theme.ui_title.g, self.theme.ui_title.b)
46 }
47
48 fn text_color(&self) -> Color {
49 Color::Rgb(self.theme.body.r, self.theme.body.g, self.theme.body.b)
50 }
51
52 fn ui_text_color(&self) -> Color {
53 Color::Rgb(self.theme.ui_text.r, self.theme.ui_text.g, self.theme.ui_text.b)
54 }
55}
56
57impl From<ThemeColors> for Stylesheet {
58 fn from(value: ThemeColors) -> Self {
59 Self::new(value)
60 }
61}
62
63/// Slide viewer state manager
64///
65/// Manages current slide index, navigation, and speaker notes visibility.
66pub struct SlideViewer {
67 slides: Vec<Slide>,
68 current_index: usize,
69 show_notes: bool,
70 filename: Option<String>,
71 stylesheet: Stylesheet,
72 theme_name: String,
73 start_time: Option<Instant>,
74 image_manager: ImageManager,
75}
76
77impl SlideViewer {
78 /// Create a new slide viewer with slides and theme
79 pub fn new(slides: Vec<Slide>, theme: ThemeColors) -> Self {
80 Self {
81 slides,
82 current_index: 0,
83 show_notes: false,
84 stylesheet: theme.into(),
85 filename: None,
86 theme_name: "oxocarbon-dark".to_string(),
87 start_time: None,
88 image_manager: ImageManager::default(),
89 }
90 }
91
92 /// Create a slide viewer with full presentation context
93 pub fn with_context(
94 slides: Vec<Slide>, theme: ThemeColors, filename: Option<String>, theme_name: String,
95 start_time: Option<Instant>,
96 ) -> Self {
97 let mut image_manager = ImageManager::default();
98 if let Some(ref path) = filename {
99 image_manager.set_base_path(path);
100 }
101
102 Self {
103 slides,
104 current_index: 0,
105 show_notes: false,
106 stylesheet: theme.into(),
107 filename,
108 theme_name,
109 start_time,
110 image_manager,
111 }
112 }
113
114 /// Navigate to the next slide
115 pub fn next(&mut self) {
116 if self.current_index < self.slides.len().saturating_sub(1) {
117 self.current_index += 1;
118 }
119 }
120
121 /// Navigate to the previous slide
122 pub fn previous(&mut self) {
123 if self.current_index > 0 {
124 self.current_index -= 1;
125 }
126 }
127
128 /// Jump to a specific slide by number (1-based)
129 pub fn jump_to(&mut self, slide_number: usize) {
130 if slide_number > 0 && slide_number <= self.slides.len() {
131 self.current_index = slide_number - 1;
132 }
133 }
134
135 /// Toggle speaker notes visibility
136 pub fn toggle_notes(&mut self) {
137 self.show_notes = !self.show_notes;
138 }
139
140 /// Get the current slide
141 pub fn current_slide(&self) -> Option<&Slide> {
142 self.slides.get(self.current_index)
143 }
144
145 /// Get the current slide index (0-based)
146 pub fn current_index(&self) -> usize {
147 self.current_index
148 }
149
150 /// Get total number of slides
151 pub fn total_slides(&self) -> usize {
152 self.slides.len()
153 }
154
155 /// Check if speaker notes are visible
156 pub fn is_showing_notes(&self) -> bool {
157 self.show_notes
158 }
159
160 /// Check if any slides have speaker notes
161 pub fn has_notes(&self) -> bool {
162 self.slides.iter().any(|slide| slide.notes.is_some())
163 }
164
165 /// Render the current slide to the frame
166 pub fn render(&mut self, frame: &mut Frame, area: Rect) {
167 if let Some(slide) = self.current_slide() {
168 let (content, images) = render_slide_with_images(&slide.blocks, &self.theme());
169 let border_color = self.stylesheet.border_color();
170 let title_color = self.stylesheet.title_color();
171
172 let block = Block::default()
173 .borders(Borders::ALL)
174 .border_style(Style::default().fg(border_color))
175 .title(format!(" Slide {}/{} ", self.current_index + 1, self.total_slides()))
176 .title_style(Style::default().fg(title_color).add_modifier(Modifier::BOLD))
177 .padding(Stylesheet::slide_padding());
178
179 let inner_area = block.inner(area);
180 frame.render_widget(block, area);
181
182 let text_height = content.height() as u16;
183 let mut text_content = Some(content);
184
185 if !images.is_empty() {
186 let total_images = images.len() as u16;
187 let border_height_per_image = 1;
188 let caption_height_per_image = 1;
189 let min_image_content_height = 1;
190 let min_height_per_image =
191 border_height_per_image + min_image_content_height + caption_height_per_image;
192 let min_images_height = total_images * min_height_per_image;
193
194 let available_height = inner_area.height;
195 let max_text_height = available_height.saturating_sub(min_images_height);
196 let text_area_height = text_height.min(max_text_height);
197
198 let chunks = Layout::default()
199 .direction(Direction::Vertical)
200 .constraints([Constraint::Length(text_area_height), Constraint::Min(min_images_height)])
201 .split(inner_area);
202
203 if chunks[0].height > 0 {
204 if let Some(text) = text_content.take() {
205 let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
206 frame.render_widget(paragraph, chunks[0]);
207 }
208 }
209
210 let constraints: Vec<Constraint> = (0..total_images)
211 .map(|_| Constraint::Ratio(1, total_images as u32))
212 .collect();
213
214 let image_chunks = Layout::default()
215 .direction(Direction::Vertical)
216 .constraints(constraints)
217 .split(chunks[1]);
218
219 for (idx, img_info) in images.iter().enumerate() {
220 if let Ok(protocol) = self.image_manager.load_image(&img_info.path) {
221 let image_area = image_chunks[idx];
222
223 let horizontal_chunks = Layout::default()
224 .direction(Direction::Horizontal)
225 .constraints([
226 Constraint::Percentage(25),
227 Constraint::Percentage(50),
228 Constraint::Percentage(25),
229 ])
230 .split(image_area);
231
232 let centered_area = horizontal_chunks[1];
233
234 let image_block = Block::default()
235 .borders(Borders::ALL)
236 .border_style(Style::default().fg(border_color));
237
238 let image_inner = image_block.inner(centered_area);
239 frame.render_widget(image_block, centered_area);
240
241 let caption_height = if img_info.alt.is_empty() { 0 } else { 1 };
242 let content_chunks = Layout::default()
243 .direction(Direction::Vertical)
244 .constraints([Constraint::Length(caption_height), Constraint::Min(1)])
245 .flex(Flex::Center)
246 .split(image_inner);
247
248 if caption_height > 0 {
249 let caption_style = Style::default()
250 .fg(Color::Rgb(150, 150, 150))
251 .add_modifier(Modifier::ITALIC);
252 let caption = Paragraph::new(Line::from(Span::styled(&img_info.alt, caption_style)))
253 .alignment(Alignment::Center);
254 frame.render_widget(caption, content_chunks[0]);
255 }
256
257 let resize = Resize::Fit(None);
258 let image_size = protocol.size_for(resize, content_chunks[1]);
259
260 let [centered_area] = Layout::horizontal([Constraint::Length(image_size.width)])
261 .flex(Flex::Center)
262 .areas(content_chunks[1]);
263 let [image_area] = Layout::vertical([Constraint::Length(image_size.height)])
264 .flex(Flex::Center)
265 .areas(centered_area);
266
267 let image_widget = StatefulImage::default();
268 frame.render_stateful_widget(image_widget, image_area, protocol);
269 }
270 }
271 } else if let Some(text) = text_content.take() {
272 let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
273 frame.render_widget(paragraph, inner_area);
274 }
275 }
276 }
277
278 /// Render speaker notes if available and visible
279 pub fn render_notes(&self, frame: &mut Frame, area: Rect) {
280 if !self.show_notes {
281 return;
282 }
283
284 if let Some(slide) = self.current_slide() {
285 if let Some(notes) = &slide.notes {
286 let border_color = self.stylesheet.border_color();
287 let title_color = self.stylesheet.title_color();
288 let text_color = self.stylesheet.text_color();
289
290 let block = Block::default()
291 .borders(Borders::ALL)
292 .border_style(Style::default().fg(border_color))
293 .title(" Speaker Notes ")
294 .title_style(Style::default().fg(title_color).add_modifier(Modifier::BOLD))
295 .padding(Stylesheet::slide_padding());
296
297 let paragraph = Paragraph::new(notes.clone())
298 .block(block)
299 .wrap(Wrap { trim: false })
300 .style(Style::default().fg(text_color));
301
302 frame.render_widget(paragraph, area);
303 }
304 }
305 }
306
307 /// Render status bar with navigation info
308 pub fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
309 let filename_part = self.filename.as_ref().map(|f| format!("{f} | ")).unwrap_or_default();
310
311 let elapsed = self
312 .start_time
313 .map(|start| {
314 let duration = start.elapsed();
315 let secs = duration.as_secs();
316 let hours = secs / 3600;
317 let minutes = (secs % 3600) / 60;
318 let seconds = secs % 60;
319 format!(" | {hours:02}:{minutes:02}:{seconds:02}")
320 })
321 .unwrap_or_default();
322
323 let notes_part = if self.has_notes() {
324 format!(" | [N] Notes {}", if self.show_notes { "✓" } else { "" })
325 } else {
326 String::new()
327 };
328
329 let status_text = format!(
330 " {}{}/{} | Theme: {}{}{} | [?] Help ",
331 filename_part,
332 self.current_index + 1,
333 self.total_slides(),
334 self.theme_name,
335 notes_part,
336 elapsed
337 );
338
339 let width = area.width as usize;
340 let text_len = status_text.chars().count();
341 let padding = if text_len < width { " ".repeat(width - text_len) } else { String::new() };
342
343 let status = Paragraph::new(Line::from(vec![Span::styled(
344 format!("{status_text}{padding}"),
345 self.stylesheet.status_bar(),
346 )]));
347
348 frame.render_widget(status, area);
349 }
350
351 /// Render help line with keybinding reference
352 pub fn render_help_line(&self, frame: &mut Frame, area: Rect) {
353 let help_text = " [j/→/Space] Next | [k/←] Previous | [N] Toggle notes | [Q/Esc] Quit ";
354
355 let width = area.width as usize;
356 let text_len = help_text.chars().count();
357 let padding = if text_len < width { " ".repeat(width - text_len) } else { String::new() };
358
359 let full_text = format!("{help_text}{padding}");
360
361 let dimmed_style = Style::default().fg(Color::Rgb(100, 100, 100)).bg(Color::Rgb(
362 self.theme().ui_background.r,
363 self.theme().ui_background.g,
364 self.theme().ui_background.b,
365 ));
366
367 let help_line = Paragraph::new(Line::from(vec![Span::styled(full_text, dimmed_style)]));
368
369 frame.render_widget(help_line, area);
370 }
371
372 fn theme(&self) -> ThemeColors {
373 self.stylesheet.theme
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use lantern_core::slide::{Block, TextSpan};
381
382 fn create_test_slides() -> Vec<Slide> {
383 vec![
384 Slide::with_blocks(vec![Block::Heading {
385 level: 1,
386 spans: vec![TextSpan::plain("Slide 1")],
387 }]),
388 Slide::with_blocks(vec![Block::Heading {
389 level: 1,
390 spans: vec![TextSpan::plain("Slide 2")],
391 }]),
392 Slide::with_blocks(vec![Block::Heading {
393 level: 1,
394 spans: vec![TextSpan::plain("Slide 3")],
395 }]),
396 ]
397 }
398
399 #[test]
400 fn viewer_creation() {
401 let slides = create_test_slides();
402 let viewer = SlideViewer::new(slides, ThemeColors::default());
403 assert_eq!(viewer.total_slides(), 3);
404 assert_eq!(viewer.current_index(), 0);
405 }
406
407 #[test]
408 fn viewer_navigation_next() {
409 let slides = create_test_slides();
410 let mut viewer = SlideViewer::new(slides, ThemeColors::default());
411
412 viewer.next();
413 assert_eq!(viewer.current_index(), 1);
414
415 viewer.next();
416 assert_eq!(viewer.current_index(), 2);
417
418 viewer.next();
419 assert_eq!(viewer.current_index(), 2);
420 }
421
422 #[test]
423 fn viewer_navigation_previous() {
424 let slides = create_test_slides();
425 let mut viewer = SlideViewer::new(slides, ThemeColors::default());
426
427 viewer.jump_to(3);
428 assert_eq!(viewer.current_index(), 2);
429
430 viewer.previous();
431 assert_eq!(viewer.current_index(), 1);
432
433 viewer.previous();
434 assert_eq!(viewer.current_index(), 0);
435
436 viewer.previous();
437 assert_eq!(viewer.current_index(), 0);
438 }
439
440 #[test]
441 fn viewer_jump_to() {
442 let slides = create_test_slides();
443 let mut viewer = SlideViewer::new(slides, ThemeColors::default());
444
445 viewer.jump_to(3);
446 assert_eq!(viewer.current_index(), 2);
447
448 viewer.jump_to(1);
449 assert_eq!(viewer.current_index(), 0);
450
451 viewer.jump_to(10);
452 assert_eq!(viewer.current_index(), 0);
453
454 viewer.jump_to(0);
455 assert_eq!(viewer.current_index(), 0);
456 }
457
458 #[test]
459 fn viewer_toggle_notes() {
460 let slides = create_test_slides();
461 let mut viewer = SlideViewer::new(slides, ThemeColors::default());
462
463 assert!(!viewer.is_showing_notes());
464
465 viewer.toggle_notes();
466 assert!(viewer.is_showing_notes());
467
468 viewer.toggle_notes();
469 assert!(!viewer.is_showing_notes());
470 }
471
472 #[test]
473 fn viewer_current_slide() {
474 let slides = create_test_slides();
475 let mut viewer = SlideViewer::new(slides, ThemeColors::default());
476
477 assert!(viewer.current_slide().is_some());
478
479 viewer.jump_to(2);
480 let slide = viewer.current_slide().unwrap();
481 assert_eq!(slide.blocks.len(), 1);
482 }
483
484 #[test]
485 fn viewer_empty_slides() {
486 let viewer = SlideViewer::new(Vec::new(), ThemeColors::default());
487 assert_eq!(viewer.total_slides(), 0);
488 assert!(viewer.current_slide().is_none());
489 }
490
491 #[test]
492 fn viewer_with_context() {
493 let slides = create_test_slides();
494 let start_time = Instant::now();
495 let viewer = SlideViewer::with_context(
496 slides,
497 ThemeColors::default(),
498 Some("presentation.md".to_string()),
499 "dark".to_string(),
500 Some(start_time),
501 );
502
503 assert_eq!(viewer.filename, Some("presentation.md".to_string()));
504 assert_eq!(viewer.theme_name, "dark");
505 assert!(viewer.start_time.is_some());
506 }
507
508 #[test]
509 fn viewer_with_context_none_values() {
510 let slides = create_test_slides();
511 let viewer =
512 SlideViewer::with_context(slides, ThemeColors::default(), None, "oxocarbon-dark".to_string(), None);
513
514 assert_eq!(viewer.filename, None);
515 assert_eq!(viewer.theme_name, "oxocarbon-dark");
516 assert_eq!(viewer.start_time, None);
517 }
518
519 #[test]
520 fn viewer_default_constructor() {
521 let slides = create_test_slides();
522 let viewer = SlideViewer::new(slides, ThemeColors::default());
523
524 assert_eq!(viewer.filename, None);
525 assert_eq!(viewer.theme_name, "oxocarbon-dark");
526 assert_eq!(viewer.start_time, None);
527 }
528
529 #[test]
530 fn viewer_has_notes() {
531 let slides_without_notes = create_test_slides();
532 let viewer_no_notes = SlideViewer::new(slides_without_notes, ThemeColors::default());
533 assert!(!viewer_no_notes.has_notes());
534
535 let slides_with_notes = vec![Slide {
536 blocks: vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Slide with notes")] }],
537 notes: Some("These are speaker notes".to_string()),
538 }];
539 let viewer_with_notes = SlideViewer::new(slides_with_notes, ThemeColors::default());
540 assert!(viewer_with_notes.has_notes());
541 }
542}