From b757e0ce0023de1cf5a37ded1256305d44dbc0e6 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 10 Oct 2025 15:45:36 -0500 Subject: [PATCH] feat: integrate slides-core and slides-tui --- Cargo.lock | 2 + README.md | 3 + ROADMAP.md | 2 +- cli/Cargo.toml | 2 + cli/src/main.rs | 56 ++++++++++++++-- ui/src/app.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++ ui/src/lib.rs | 2 + ui/src/viewer.rs | 80 ++++++++++++++++++++++- 8 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 ui/src/app.rs diff --git a/Cargo.lock b/Cargo.lock index 639f325..5f826a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,6 +753,8 @@ dependencies = [ "crossterm 0.29.0", "owo-colors", "ratatui", + "slides-core", + "slides-tui", "tracing", "tracing-subscriber", ] diff --git a/README.md b/README.md index 50a77a6..3e047b9 100644 --- a/README.md +++ b/README.md @@ -36,5 +36,8 @@ cargo +stable install cargo-llvm-cov --locked Run tests: ```sh +cargo llvm-cov + +# Open the browser cargo llvm-cov --open ``` diff --git a/ROADMAP.md b/ROADMAP.md index a4d92f1..c3ab546 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,7 +70,7 @@ __Objective:__ Introduce features for live presentations and authoring convenien | __Speaker Notes__ | `n` toggles speaker notes (parsed via `::: notes`). | `ratatui` | | __Timer & Progress__ | Session timer + per-slide progress bar. | `ratatui`, `chrono` | | __Live Reload__ | File watcher auto-refreshes content. | `notify`[^9] | -| __Search__. | Fuzzy find slide titles via `ctrl+f`. | `fuzzy-matcher`[^10] | +| __Search__ | Fuzzy find slide titles via `ctrl+f`. | `fuzzy-matcher`[^10] | | __Theme Commands__ | CLI flag `--theme ` switches both Syntect + owo themes. | `clap`, internal `ThemeRegistry` | ## Markdown Extension diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 041affa..2f41842 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -10,3 +10,5 @@ owo-colors = "4.2.3" ratatui = "0.29.0" tracing = "0.1.41" tracing-subscriber = "0.3.20" +slides-core = { path = "../core" } +slides-tui = { path = "../ui" } diff --git a/cli/src/main.rs b/cli/src/main.rs index 2541478..66f35a9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,5 +1,8 @@ use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use ratatui::{Terminal, backend::CrosstermBackend}; +use slides_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeColors}; +use slides_tui::App; +use std::{io, path::PathBuf}; use tracing::Level; /// A modern terminal-based presentation tool @@ -70,11 +73,10 @@ fn main() { match cli.command { Commands::Present { file, theme } => { - tracing::info!("Presenting slides from: {}", file.display()); - if let Some(theme) = theme { - tracing::debug!("Using theme: {}", theme); + if let Err(e) = run_present(&file, theme) { + eprintln!("Error: {}", e); + std::process::exit(1); } - eprintln!("TUI presentation mode not yet implemented"); } Commands::Print { file, width, theme } => { @@ -100,6 +102,50 @@ fn main() { } } +fn run_present(file: &PathBuf, theme_arg: Option) -> io::Result<()> { + tracing::info!("Presenting slides from: {}", file.display()); + + let markdown = std::fs::read_to_string(file) + .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?; + + let (meta, slides) = parse_slides_with_meta(&markdown) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?; + + if slides.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file")); + } + + let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); + tracing::debug!("Using theme: {}", theme_name); + + let theme = ThemeColors::default(); + + let filename = file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let mut slide_terminal = SlideTerminal::setup()?; + + let result = (|| -> io::Result<()> { + let stdout = io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + terminal.clear()?; + + let mut app = App::new(slides, theme, filename, meta); + app.run(&mut terminal)?; + + Ok(()) + })(); + + slide_terminal.restore()?; + + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/ui/src/app.rs b/ui/src/app.rs new file mode 100644 index 0000000..ca501e0 --- /dev/null +++ b/ui/src/app.rs @@ -0,0 +1,167 @@ +use ratatui::{Terminal as RatatuiTerminal, backend::Backend}; +use slides_core::{metadata::Meta, slide::Slide, term::InputEvent, theme::ThemeColors}; +use std::io; +use std::time::{Duration, Instant}; + +use crate::{layout::SlideLayout, viewer::SlideViewer}; + +/// Main TUI application coordinator +/// +/// Manages the presentation lifecycle, event loop, and component coordination. +pub struct App { + viewer: SlideViewer, + layout: SlideLayout, + should_quit: bool, + _filename: String, + _start_time: Instant, +} + +impl App { + /// Create a new presentation application + pub fn new(slides: Vec, theme: ThemeColors, filename: String, meta: Meta) -> Self { + let viewer = SlideViewer::with_context( + slides, + theme, + Some(filename.clone()), + meta.theme.clone(), + Some(Instant::now()), + ); + + Self { + viewer, + layout: SlideLayout::default(), + _filename: filename, + _start_time: Instant::now(), + should_quit: false, + } + } + + /// Run the main event loop + pub fn run(&mut self, terminal: &mut RatatuiTerminal) -> io::Result<()> { + loop { + terminal.draw(|frame| self.draw(frame))?; + + if self.should_quit { + break; + } + + if let Some(event) = InputEvent::poll(Duration::from_millis(50))? { + self.handle_event(event); + } + } + + Ok(()) + } + + fn toggle_notes(&mut self) { + self.viewer.toggle_notes(); + self.layout.set_show_notes(self.viewer.is_showing_notes()) + } + + /// Handle input events + fn handle_event(&mut self, event: InputEvent) { + match event { + InputEvent::Next => self.viewer.next(), + InputEvent::Previous => self.viewer.previous(), + InputEvent::Jump(n) => self.viewer.jump_to(n), + InputEvent::ToggleNotes => self.toggle_notes(), + InputEvent::Quit => self.should_quit = true, + // NOTE: Terminal resize is handled automatically by ratatui + InputEvent::Resize { .. } => {} + // TODO: Implement search functionality + InputEvent::Search => {} + InputEvent::Other => {} + } + } + + /// Draw the UI + fn draw(&mut self, frame: &mut ratatui::Frame) { + let (main_area, notes_area, status_area) = self.layout.calculate(frame.area()); + + self.viewer.render(frame, main_area); + + if let Some(notes_area) = notes_area { + self.viewer.render_notes(frame, notes_area); + } + + self.viewer.render_status_bar(frame, status_area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use slides_core::slide::{Block, TextSpan}; + + fn create_test_app() -> App { + let slides = vec![ + Slide::with_blocks(vec![Block::Heading { + level: 1, + spans: vec![TextSpan::plain("Slide 1")], + }]), + Slide::with_blocks(vec![Block::Heading { + level: 1, + spans: vec![TextSpan::plain("Slide 2")], + }]), + ]; + + App::new(slides, ThemeColors::default(), "test.md".to_string(), Meta::default()) + } + + #[test] + fn app_creation() { + let app = create_test_app(); + assert!(!app.should_quit); + assert_eq!(app._filename, "test.md"); + } + + #[test] + fn app_handle_next() { + let mut app = create_test_app(); + let initial_index = app.viewer.current_index(); + + app.handle_event(InputEvent::Next); + assert_eq!(app.viewer.current_index(), initial_index + 1); + } + + #[test] + fn app_handle_previous() { + let mut app = create_test_app(); + app.handle_event(InputEvent::Next); + app.handle_event(InputEvent::Previous); + assert_eq!(app.viewer.current_index(), 0); + } + + #[test] + fn app_handle_jump() { + let mut app = create_test_app(); + app.handle_event(InputEvent::Jump(1)); + assert_eq!(app.viewer.current_index(), 1); + } + + #[test] + fn app_handle_toggle_notes() { + let mut app = create_test_app(); + assert!(!app.viewer.is_showing_notes()); + + app.handle_event(InputEvent::ToggleNotes); + assert!(app.viewer.is_showing_notes()); + assert!(app.layout.is_showing_notes()); + } + + #[test] + fn app_handle_quit() { + let mut app = create_test_app(); + assert!(!app.should_quit); + + app.handle_event(InputEvent::Quit); + assert!(app.should_quit); + } + + #[test] + fn app_handle_resize() { + let mut app = create_test_app(); + app.handle_event(InputEvent::Resize { width: 100, height: 50 }); + assert!(!app.should_quit); + } +} diff --git a/ui/src/lib.rs b/ui/src/lib.rs index d9a5202..d310647 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -1,7 +1,9 @@ +pub mod app; pub mod layout; pub mod renderer; pub mod viewer; +pub use app::App; pub use layout::SlideLayout; pub use renderer::render_slide_content; pub use viewer::SlideViewer; diff --git a/ui/src/viewer.rs b/ui/src/viewer.rs index 27283ab..1bbae25 100644 --- a/ui/src/viewer.rs +++ b/ui/src/viewer.rs @@ -6,6 +6,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph, Wrap}, }; use slides_core::{slide::Slide, theme::ThemeColors}; +use std::time::Instant; use crate::renderer::render_slide_content; @@ -17,12 +18,31 @@ pub struct SlideViewer { current_index: usize, show_notes: bool, theme: ThemeColors, + filename: Option, + theme_name: String, + start_time: Option, } impl SlideViewer { /// Create a new slide viewer with slides and theme pub fn new(slides: Vec, theme: ThemeColors) -> Self { - Self { slides, current_index: 0, show_notes: false, theme } + Self { + slides, + current_index: 0, + show_notes: false, + theme, + filename: None, + theme_name: "default".to_string(), + start_time: None, + } + } + + /// Create a slide viewer with full presentation context + pub fn with_context( + slides: Vec, theme: ThemeColors, filename: Option, theme_name: String, + start_time: Option, + ) -> Self { + Self { slides, current_index: 0, show_notes: false, theme, filename, theme_name, start_time } } /// Navigate to the next slide @@ -114,11 +134,28 @@ impl SlideViewer { /// Render status bar with navigation info pub fn render_status_bar(&self, frame: &mut Frame, area: Rect) { + let filename_part = self.filename.as_ref().map(|f| format!("{} | ", f)).unwrap_or_default(); + + let elapsed = self + .start_time + .map(|start| { + let duration = start.elapsed(); + let secs = duration.as_secs(); + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + format!(" | {:02}:{:02}:{:02}", hours, minutes, seconds) + }) + .unwrap_or_default(); + let status_text = format!( - " {}/{} | [←/→] Navigate | [N] Notes {} | [Q] Quit ", + " {}{}/{} | Theme: {} | [←/→] Navigate | [N] Notes {} | [Q] Quit{} ", + filename_part, self.current_index + 1, self.total_slides(), - if self.show_notes { "✓" } else { "" } + self.theme_name, + if self.show_notes { "✓" } else { "" }, + elapsed ); let status = Paragraph::new(Line::from(vec![Span::styled( @@ -243,4 +280,41 @@ mod tests { assert_eq!(viewer.total_slides(), 0); assert!(viewer.current_slide().is_none()); } + + #[test] + fn viewer_with_context() { + let slides = create_test_slides(); + let start_time = Instant::now(); + let viewer = SlideViewer::with_context( + slides, + ThemeColors::default(), + Some("presentation.md".to_string()), + "dark".to_string(), + Some(start_time), + ); + + assert_eq!(viewer.filename, Some("presentation.md".to_string())); + assert_eq!(viewer.theme_name, "dark"); + assert!(viewer.start_time.is_some()); + } + + #[test] + fn viewer_with_context_none_values() { + let slides = create_test_slides(); + let viewer = SlideViewer::with_context(slides, ThemeColors::default(), None, "default".to_string(), None); + + assert_eq!(viewer.filename, None); + assert_eq!(viewer.theme_name, "default"); + assert_eq!(viewer.start_time, None); + } + + #[test] + fn viewer_default_constructor() { + let slides = create_test_slides(); + let viewer = SlideViewer::new(slides, ThemeColors::default()); + + assert_eq!(viewer.filename, None); + assert_eq!(viewer.theme_name, "default"); + assert_eq!(viewer.start_time, None); + } } -- 2.51.2