magical markdown slides
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: integrate slides-core and slides-tui

+305 -9
+2
Cargo.lock
··· 753 753 "crossterm 0.29.0", 754 754 "owo-colors", 755 755 "ratatui", 756 + "slides-core", 757 + "slides-tui", 756 758 "tracing", 757 759 "tracing-subscriber", 758 760 ]
+3
README.md
··· 36 36 Run tests: 37 37 38 38 ```sh 39 + cargo llvm-cov 40 + 41 + # Open the browser 39 42 cargo llvm-cov --open 40 43 ```
+1 -1
ROADMAP.md
··· 70 70 | __Speaker Notes__ | `n` toggles speaker notes (parsed via `::: notes`). | `ratatui` | 71 71 | __Timer & Progress__ | Session timer + per-slide progress bar. | `ratatui`, `chrono` | 72 72 | __Live Reload__ | File watcher auto-refreshes content. | `notify`[^9] | 73 - | __Search__. | Fuzzy find slide titles via `ctrl+f`. | `fuzzy-matcher`[^10] | 73 + | __Search__ | Fuzzy find slide titles via `ctrl+f`. | `fuzzy-matcher`[^10] | 74 74 | __Theme Commands__ | CLI flag `--theme <name>` switches both Syntect + owo themes. | `clap`, internal `ThemeRegistry` | 75 75 76 76 ## Markdown Extension
+2
cli/Cargo.toml
··· 10 10 ratatui = "0.29.0" 11 11 tracing = "0.1.41" 12 12 tracing-subscriber = "0.3.20" 13 + slides-core = { path = "../core" } 14 + slides-tui = { path = "../ui" }
+51 -5
cli/src/main.rs
··· 1 1 use clap::{Parser, Subcommand}; 2 - use std::path::PathBuf; 2 + use ratatui::{Terminal, backend::CrosstermBackend}; 3 + use slides_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeColors}; 4 + use slides_tui::App; 5 + use std::{io, path::PathBuf}; 3 6 use tracing::Level; 4 7 5 8 /// A modern terminal-based presentation tool ··· 70 73 71 74 match cli.command { 72 75 Commands::Present { file, theme } => { 73 - tracing::info!("Presenting slides from: {}", file.display()); 74 - if let Some(theme) = theme { 75 - tracing::debug!("Using theme: {}", theme); 76 + if let Err(e) = run_present(&file, theme) { 77 + eprintln!("Error: {}", e); 78 + std::process::exit(1); 76 79 } 77 - eprintln!("TUI presentation mode not yet implemented"); 78 80 } 79 81 80 82 Commands::Print { file, width, theme } => { ··· 98 100 eprintln!("Check command not yet implemented"); 99 101 } 100 102 } 103 + } 104 + 105 + fn run_present(file: &PathBuf, theme_arg: Option<String>) -> io::Result<()> { 106 + tracing::info!("Presenting slides from: {}", file.display()); 107 + 108 + let markdown = std::fs::read_to_string(file) 109 + .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?; 110 + 111 + let (meta, slides) = parse_slides_with_meta(&markdown) 112 + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?; 113 + 114 + if slides.is_empty() { 115 + return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file")); 116 + } 117 + 118 + let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 119 + tracing::debug!("Using theme: {}", theme_name); 120 + 121 + let theme = ThemeColors::default(); 122 + 123 + let filename = file 124 + .file_name() 125 + .and_then(|n| n.to_str()) 126 + .unwrap_or("unknown") 127 + .to_string(); 128 + 129 + let mut slide_terminal = SlideTerminal::setup()?; 130 + 131 + let result = (|| -> io::Result<()> { 132 + let stdout = io::stdout(); 133 + let backend = CrosstermBackend::new(stdout); 134 + let mut terminal = Terminal::new(backend)?; 135 + 136 + terminal.clear()?; 137 + 138 + let mut app = App::new(slides, theme, filename, meta); 139 + app.run(&mut terminal)?; 140 + 141 + Ok(()) 142 + })(); 143 + 144 + slide_terminal.restore()?; 145 + 146 + result 101 147 } 102 148 103 149 #[cfg(test)]
+167
ui/src/app.rs
··· 1 + use ratatui::{Terminal as RatatuiTerminal, backend::Backend}; 2 + use slides_core::{metadata::Meta, slide::Slide, term::InputEvent, theme::ThemeColors}; 3 + use std::io; 4 + use std::time::{Duration, Instant}; 5 + 6 + use crate::{layout::SlideLayout, viewer::SlideViewer}; 7 + 8 + /// Main TUI application coordinator 9 + /// 10 + /// Manages the presentation lifecycle, event loop, and component coordination. 11 + pub struct App { 12 + viewer: SlideViewer, 13 + layout: SlideLayout, 14 + should_quit: bool, 15 + _filename: String, 16 + _start_time: Instant, 17 + } 18 + 19 + impl App { 20 + /// Create a new presentation application 21 + pub fn new(slides: Vec<Slide>, theme: ThemeColors, filename: String, meta: Meta) -> Self { 22 + let viewer = SlideViewer::with_context( 23 + slides, 24 + theme, 25 + Some(filename.clone()), 26 + meta.theme.clone(), 27 + Some(Instant::now()), 28 + ); 29 + 30 + Self { 31 + viewer, 32 + layout: SlideLayout::default(), 33 + _filename: filename, 34 + _start_time: Instant::now(), 35 + should_quit: false, 36 + } 37 + } 38 + 39 + /// Run the main event loop 40 + pub fn run<B: Backend>(&mut self, terminal: &mut RatatuiTerminal<B>) -> io::Result<()> { 41 + loop { 42 + terminal.draw(|frame| self.draw(frame))?; 43 + 44 + if self.should_quit { 45 + break; 46 + } 47 + 48 + if let Some(event) = InputEvent::poll(Duration::from_millis(50))? { 49 + self.handle_event(event); 50 + } 51 + } 52 + 53 + Ok(()) 54 + } 55 + 56 + fn toggle_notes(&mut self) { 57 + self.viewer.toggle_notes(); 58 + self.layout.set_show_notes(self.viewer.is_showing_notes()) 59 + } 60 + 61 + /// Handle input events 62 + fn handle_event(&mut self, event: InputEvent) { 63 + match event { 64 + InputEvent::Next => self.viewer.next(), 65 + InputEvent::Previous => self.viewer.previous(), 66 + InputEvent::Jump(n) => self.viewer.jump_to(n), 67 + InputEvent::ToggleNotes => self.toggle_notes(), 68 + InputEvent::Quit => self.should_quit = true, 69 + // NOTE: Terminal resize is handled automatically by ratatui 70 + InputEvent::Resize { .. } => {} 71 + // TODO: Implement search functionality 72 + InputEvent::Search => {} 73 + InputEvent::Other => {} 74 + } 75 + } 76 + 77 + /// Draw the UI 78 + fn draw(&mut self, frame: &mut ratatui::Frame) { 79 + let (main_area, notes_area, status_area) = self.layout.calculate(frame.area()); 80 + 81 + self.viewer.render(frame, main_area); 82 + 83 + if let Some(notes_area) = notes_area { 84 + self.viewer.render_notes(frame, notes_area); 85 + } 86 + 87 + self.viewer.render_status_bar(frame, status_area); 88 + } 89 + } 90 + 91 + #[cfg(test)] 92 + mod tests { 93 + use super::*; 94 + use slides_core::slide::{Block, TextSpan}; 95 + 96 + fn create_test_app() -> App { 97 + let slides = vec![ 98 + Slide::with_blocks(vec![Block::Heading { 99 + level: 1, 100 + spans: vec![TextSpan::plain("Slide 1")], 101 + }]), 102 + Slide::with_blocks(vec![Block::Heading { 103 + level: 1, 104 + spans: vec![TextSpan::plain("Slide 2")], 105 + }]), 106 + ]; 107 + 108 + App::new(slides, ThemeColors::default(), "test.md".to_string(), Meta::default()) 109 + } 110 + 111 + #[test] 112 + fn app_creation() { 113 + let app = create_test_app(); 114 + assert!(!app.should_quit); 115 + assert_eq!(app._filename, "test.md"); 116 + } 117 + 118 + #[test] 119 + fn app_handle_next() { 120 + let mut app = create_test_app(); 121 + let initial_index = app.viewer.current_index(); 122 + 123 + app.handle_event(InputEvent::Next); 124 + assert_eq!(app.viewer.current_index(), initial_index + 1); 125 + } 126 + 127 + #[test] 128 + fn app_handle_previous() { 129 + let mut app = create_test_app(); 130 + app.handle_event(InputEvent::Next); 131 + app.handle_event(InputEvent::Previous); 132 + assert_eq!(app.viewer.current_index(), 0); 133 + } 134 + 135 + #[test] 136 + fn app_handle_jump() { 137 + let mut app = create_test_app(); 138 + app.handle_event(InputEvent::Jump(1)); 139 + assert_eq!(app.viewer.current_index(), 1); 140 + } 141 + 142 + #[test] 143 + fn app_handle_toggle_notes() { 144 + let mut app = create_test_app(); 145 + assert!(!app.viewer.is_showing_notes()); 146 + 147 + app.handle_event(InputEvent::ToggleNotes); 148 + assert!(app.viewer.is_showing_notes()); 149 + assert!(app.layout.is_showing_notes()); 150 + } 151 + 152 + #[test] 153 + fn app_handle_quit() { 154 + let mut app = create_test_app(); 155 + assert!(!app.should_quit); 156 + 157 + app.handle_event(InputEvent::Quit); 158 + assert!(app.should_quit); 159 + } 160 + 161 + #[test] 162 + fn app_handle_resize() { 163 + let mut app = create_test_app(); 164 + app.handle_event(InputEvent::Resize { width: 100, height: 50 }); 165 + assert!(!app.should_quit); 166 + } 167 + }
+2
ui/src/lib.rs
··· 1 + pub mod app; 1 2 pub mod layout; 2 3 pub mod renderer; 3 4 pub mod viewer; 4 5 6 + pub use app::App; 5 7 pub use layout::SlideLayout; 6 8 pub use renderer::render_slide_content; 7 9 pub use viewer::SlideViewer;
+77 -3
ui/src/viewer.rs
··· 6 6 widgets::{Block, Borders, Paragraph, Wrap}, 7 7 }; 8 8 use slides_core::{slide::Slide, theme::ThemeColors}; 9 + use std::time::Instant; 9 10 10 11 use crate::renderer::render_slide_content; 11 12 ··· 17 18 current_index: usize, 18 19 show_notes: bool, 19 20 theme: ThemeColors, 21 + filename: Option<String>, 22 + theme_name: String, 23 + start_time: Option<Instant>, 20 24 } 21 25 22 26 impl SlideViewer { 23 27 /// Create a new slide viewer with slides and theme 24 28 pub fn new(slides: Vec<Slide>, theme: ThemeColors) -> Self { 25 - Self { slides, current_index: 0, show_notes: false, theme } 29 + Self { 30 + slides, 31 + current_index: 0, 32 + show_notes: false, 33 + theme, 34 + filename: None, 35 + theme_name: "default".to_string(), 36 + start_time: None, 37 + } 38 + } 39 + 40 + /// Create a slide viewer with full presentation context 41 + pub fn with_context( 42 + slides: Vec<Slide>, theme: ThemeColors, filename: Option<String>, theme_name: String, 43 + start_time: Option<Instant>, 44 + ) -> Self { 45 + Self { slides, current_index: 0, show_notes: false, theme, filename, theme_name, start_time } 26 46 } 27 47 28 48 /// Navigate to the next slide ··· 114 134 115 135 /// Render status bar with navigation info 116 136 pub fn render_status_bar(&self, frame: &mut Frame, area: Rect) { 137 + let filename_part = self.filename.as_ref().map(|f| format!("{} | ", f)).unwrap_or_default(); 138 + 139 + let elapsed = self 140 + .start_time 141 + .map(|start| { 142 + let duration = start.elapsed(); 143 + let secs = duration.as_secs(); 144 + let hours = secs / 3600; 145 + let minutes = (secs % 3600) / 60; 146 + let seconds = secs % 60; 147 + format!(" | {:02}:{:02}:{:02}", hours, minutes, seconds) 148 + }) 149 + .unwrap_or_default(); 150 + 117 151 let status_text = format!( 118 - " {}/{} | [←/→] Navigate | [N] Notes {} | [Q] Quit ", 152 + " {}{}/{} | Theme: {} | [←/→] Navigate | [N] Notes {} | [Q] Quit{} ", 153 + filename_part, 119 154 self.current_index + 1, 120 155 self.total_slides(), 121 - if self.show_notes { "✓" } else { "" } 156 + self.theme_name, 157 + if self.show_notes { "✓" } else { "" }, 158 + elapsed 122 159 ); 123 160 124 161 let status = Paragraph::new(Line::from(vec![Span::styled( ··· 242 279 let viewer = SlideViewer::new(Vec::new(), ThemeColors::default()); 243 280 assert_eq!(viewer.total_slides(), 0); 244 281 assert!(viewer.current_slide().is_none()); 282 + } 283 + 284 + #[test] 285 + fn viewer_with_context() { 286 + let slides = create_test_slides(); 287 + let start_time = Instant::now(); 288 + let viewer = SlideViewer::with_context( 289 + slides, 290 + ThemeColors::default(), 291 + Some("presentation.md".to_string()), 292 + "dark".to_string(), 293 + Some(start_time), 294 + ); 295 + 296 + assert_eq!(viewer.filename, Some("presentation.md".to_string())); 297 + assert_eq!(viewer.theme_name, "dark"); 298 + assert!(viewer.start_time.is_some()); 299 + } 300 + 301 + #[test] 302 + fn viewer_with_context_none_values() { 303 + let slides = create_test_slides(); 304 + let viewer = SlideViewer::with_context(slides, ThemeColors::default(), None, "default".to_string(), None); 305 + 306 + assert_eq!(viewer.filename, None); 307 + assert_eq!(viewer.theme_name, "default"); 308 + assert_eq!(viewer.start_time, None); 309 + } 310 + 311 + #[test] 312 + fn viewer_default_constructor() { 313 + let slides = create_test_slides(); 314 + let viewer = SlideViewer::new(slides, ThemeColors::default()); 315 + 316 + assert_eq!(viewer.filename, None); 317 + assert_eq!(viewer.theme_name, "default"); 318 + assert_eq!(viewer.start_time, None); 245 319 } 246 320 }