magical markdown slides
at main 18 kB view raw
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}