From 2e342e3add8f02b7e582ef026a283c4917281479 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 10 Oct 2025 15:27:10 -0500 Subject: [PATCH] feat: ratatui rendering implementation with styling build: update formatting config --- Cargo.lock | 6 + README.md | 20 ++++ ROADMAP.md | 96 +++++++++------- core/src/slide.rs | 48 ++------ core/src/theme.rs | 35 ++++++ rustfmt.toml | 1 + ui/Cargo.toml | 4 + ui/src/layout.rs | 119 ++++++++++++++++++++ ui/src/lib.rs | 22 ++-- ui/src/renderer.rs | 265 +++++++++++++++++++++++++++++++++++++++++++++ ui/src/viewer.rs | 246 +++++++++++++++++++++++++++++++++++++++++ 11 files changed, 773 insertions(+), 89 deletions(-) create mode 100644 ui/src/layout.rs create mode 100644 ui/src/renderer.rs create mode 100644 ui/src/viewer.rs diff --git a/Cargo.lock b/Cargo.lock index 825111c..639f325 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -775,6 +775,12 @@ dependencies = [ [[package]] name = "slides-tui" version = "0.1.0" +dependencies = [ + "crossterm 0.29.0", + "owo-colors", + "ratatui", + "slides-core", +] [[package]] name = "smallvec" diff --git a/README.md b/README.md index 942ce98..50a77a6 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,23 @@ Parser → Model → Renderer are independent modules with tests and traits. __Portable:__ Runs on any terminal supporting UTF-8; dependencies limited to core crates. + +## Testing + +This project uses `cargo-llvm-cov` for coverage + +Installation: + +```sh +# MacOS +brew install cargo-llvm-cov + +# Linux +cargo +stable install cargo-llvm-cov --locked +``` + +Run tests: + +```sh +cargo llvm-cov --open +``` diff --git a/ROADMAP.md b/ROADMAP.md index 0056aa7..a4d92f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,63 +4,74 @@ __Objective:__ Establish a clean, testable core with `clap` and a minimal `ratatui` loop. -| Task | Description | Key Crates | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| __✓ Project Scaffolding__ | Initialize workspace with `slides-core`, `slides-cli`, and `slides-ui` crates. Use `cargo-generate` and a `justfile` for scripts. | `cargo`, `just`, `clap` | -| __✓ CLI Definition__ | Implement root command `slides` with subcommands:
• `present` (TUI)
• `print` (stdout)
• `init` (scaffold deck)
• `check` (lint slides). | [`clap`](https://docs.rs/clap/latest/clap/) | -| __✓ Logging & Colors__ | Integrate structured logs via `tracing`.
Use __owo-colors__ for color abstraction (no dynamic dispatch). | [`owo-colors`](https://docs.rs/owo-colors/latest/owo_colors/), `tracing` | -| __✓ Terminal & Event Setup__ | Configure alternate screen, raw mode, input loop, resize handler. | [`crossterm`](https://docs.rs/crossterm/latest/crossterm/), `ratatui` | -| __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI. | GitHub Actions | +| Task | Description | Key Crates | +| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------- | +| __✓ Project Scaffolding__ | Initialize workspace with `slides-core`, `slides-cli`, and `slides-ui` crates. | `cargo`, `just`, `clap` | +| | Use `cargo-generate` and a `justfile` for scripts. | | +| __✓ CLI Definition__ | Implement root command `slides` with subcommands: | `clap`[^1] | +| | • `present` (TUI) | | +| | • `print` (stdout) | | +| | • `init` (scaffold deck) | | +| | • `check` (lint slides). | | +| __✓ Logging & Colors__ | Integrate structured logs via `tracing`. | `owo-colors`[^2], `tracing` | +| | Use __owo-colors__ for color abstraction (no dynamic dispatch). | | +| __✓ Terminal & Event Setup__ | Configure alternate screen, raw mode, input loop, resize handler. | `crossterm`[^3], `ratatui` | +| __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI. | GitHub Actions | ## Data Model (Parser & Slides) __Objective:__ Parse markdown documents into a rich `Slide` struct. -| Task | Description | Key Crates | -| ---------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| __✓ Parser Core__ | Split files on `---` separators.
Detect title blocks, lists, and code fences.
Represent as `Vec`. | [`pulldown-cmark`](https://docs.rs/pulldown-cmark/latest/pulldown_cmark/) | -| __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | -| __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | [`serde_yml`](https://docs.rs/serde_yml) | -| __Error & Validation__ | Provide friendly parser errors with file/line info. | [`thiserror`](https://docs.rs/thiserror) | -| __Basic CLI UX__ | `slides present file.md` runs full TUI.
`slides print` renders to stdout with width constraint. | `clap` | - ---- +| Task | Description | Key Crates | +| ---------------------- | --------------------------------------------------------------- | -------------------- | +| __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] | +| | Detect title blocks, lists, and code fences. | | +| | Represent as `Vec`. | | +| __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | +| __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] | +| __Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] | +| __Basic CLI UX__ | `slides present file.md` runs full TUI. | `clap` | +| | `slides print` renders to stdout with width constraint. | | ## Rendering & Navigation __Objective:__ Build the interactive slide renderer with navigation. -| Task | Description | Key Crates | -| ----------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| __Ratatui Integration__ | Build basic slide viewer using layout, blocks, paragraphs. | [`ratatui`](https://docs.rs/ratatui/latest/ratatui/) | -| __Input & State__ | Support `←/→`, `j/k`, `q`, numeric jumps, and window resize. | `crossterm`, `ratatui` | -| __Status Bar__ | Display slide count, filename, clock, and theme name. | `ratatui` | -| __Color Styling__ | Apply consistent color palette via `owo-colors`. Define traits like `ThemeColor` for strong typing. | `owo-colors` | -| __Configurable Themes__ | Support themes via TOML files mapping semantic roles (`heading`, `body`, `accent`) → color pairs. | `toml`, `serde` | +| Task | Description | Key Crates | +| ------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------- | +| __✓ Ratatui Integration__ | Build basic slide viewer using layout, blocks, paragraphs. | `ratatui`[^7] | +| __Input & State__ | Support `←/→`, `j/k`, `q`, numeric jumps, and window resize. | `crossterm`, `ratatui` | +| __Status Bar__ | Display slide count, filename, clock, and theme name. | `ratatui` | +| __✓ Color Styling__ | Apply consistent color palette via `owo-colors`. Define traits like `ThemeColor`. | `owo-colors` | +| __Configurable Themes__ | Support themes via TOML files mapping semantic roles (`heading`, `body`, `accent`) → color pairs. | `toml`, `serde` | + +--- ## Code Highlighting via Syntect __Objective:__ Add first-class syntax highlighting using Syntect. -| Task | Description | Key Crates | -| --------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | -| __Syntect__ | Load `.tmTheme` / `.sublime-syntax` definitions on startup.
Cache `SyntaxSet` + `ThemeSet`. | [`syntect`](https://docs.rs/syntect/latest/syntect/) | -| __Code Blocks__ | Detect fenced code blocks with language tags.
Render syntax-highlighted text with color spans mapped to `owo-colors`. | `syntect`, `owo-colors` | -| __Theming__ | Map terminal theme choice to Syntect theme (e.g., `"OneDark"`, `"SolarizedDark"`, `"Monokai"`). | `syntect` | -| __Performance__ | Lazy-load themes and syntaxes; use `once_cell` for caching. | `once_cell` | -| __Mode__ | Render to ANSI-colored plain text output (for `slides print`). | `owo-colors` | +| Task | Description | Key Crates | +| --------------- | ---------------------------------------------------------------------------- | ----------------------- | +| __Syntect__ | Load `.tmTheme` / `.sublime-syntax` definitions on startup. | `syntect`[^8] | +| | Cache `SyntaxSet` + `ThemeSet`. | | +| __Code Blocks__ | Detect fenced code blocks with language tags. | `syntect`, `owo-colors` | +| | Render syntax-highlighted text with color spans mapped to `owo-colors`. | | +| __Theming__ | Map terminal theme choice to Syntect theme (e.g., `"OneDark"`, `"Monokai"`). | `syntect` | +| __Performance__ | Lazy-load themes and syntaxes; use `once_cell` for caching. | `once_cell` | +| __Mode__ | Render to ANSI-colored plain text output (for `slides print`). | `owo-colors` | ## Presenter __Objective:__ Introduce features for live presentations and authoring convenience. -| Task | Description | Key Crates | -| -------------------- | ------------------------------------------------------------- | ------------------------------------------------- | -| __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`](https://docs.rs/notify/latest/notify/) | -| __Search__. | Fuzzy find slide titles via `ctrl+f`. | [`fuzzy-matcher`](https://docs.rs/fuzzy-matcher) | -| __Theme Commands__ | CLI flag `--theme ` switches both Syntect + owo themes. | `clap`, internal `ThemeRegistry` | +| Task | Description | Key Crates | +| -------------------- | ------------------------------------------------------------- | -------------------------------- | +| __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] | +| __Theme Commands__ | CLI flag `--theme ` switches both Syntect + owo themes. | `clap`, internal `ThemeRegistry` | ## Markdown Extension @@ -80,3 +91,14 @@ __Objective:__ Add richness and visual polish to text and layout. | __Config Discovery__ | Read from `$XDG_CONFIG_HOME/slides/config.toml` for defaults. | `dirs`, `serde` | | __Theme Registry__ | Built-in theme manifest (e.g., `onedark`, `solarized`, `plain`). | Internal | | __Release__ | Tag `v1.0.0-rc.1` with changelog and binaries for major platforms. | `cargo-dist`, GitHub Actions | + +[^1]: +[^2]: +[^3]: +[^4]: +[^5]: +[^6]: +[^7]: +[^8]: +[^9]: +[^10]: diff --git a/core/src/slide.rs b/core/src/slide.rs index e4a7628..c2ae069 100644 --- a/core/src/slide.rs +++ b/core/src/slide.rs @@ -11,10 +11,7 @@ pub struct Slide { impl Slide { pub fn new() -> Self { - Self { - blocks: Vec::new(), - notes: None, - } + Self { blocks: Vec::new(), notes: None } } pub fn with_blocks(blocks: Vec) -> Self { @@ -60,40 +57,19 @@ pub struct TextSpan { impl TextSpan { pub fn plain(text: impl Into) -> Self { - Self { - text: text.into(), - style: TextStyle::default(), - } + Self { text: text.into(), style: TextStyle::default() } } pub fn bold(text: impl Into) -> Self { - Self { - text: text.into(), - style: TextStyle { - bold: true, - ..Default::default() - }, - } + Self { text: text.into(), style: TextStyle { bold: true, ..Default::default() } } } pub fn italic(text: impl Into) -> Self { - Self { - text: text.into(), - style: TextStyle { - italic: true, - ..Default::default() - }, - } + Self { text: text.into(), style: TextStyle { italic: true, ..Default::default() } } } pub fn code(text: impl Into) -> Self { - Self { - text: text.into(), - style: TextStyle { - code: true, - ..Default::default() - }, - } + Self { text: text.into(), style: TextStyle { code: true, ..Default::default() } } } } @@ -117,17 +93,11 @@ pub struct CodeBlock { impl CodeBlock { pub fn new(code: impl Into) -> Self { - Self { - language: None, - code: code.into(), - } + Self { language: None, code: code.into() } } pub fn with_language(language: impl Into, code: impl Into) -> Self { - Self { - language: Some(language.into()), - code: code.into(), - } + Self { language: Some(language.into()), code: code.into() } } } @@ -173,9 +143,7 @@ mod tests { #[test] fn slide_with_blocks() { - let blocks = vec![Block::Paragraph { - spans: vec![TextSpan::plain("Hello")], - }]; + let blocks = vec![Block::Paragraph { spans: vec![TextSpan::plain("Hello")] }]; let slide = Slide::with_blocks(blocks.clone()); assert!(!slide.is_empty()); assert_eq!(slide.blocks.len(), 1); diff --git a/core/src/theme.rs b/core/src/theme.rs index 7693d51..1ca0005 100644 --- a/core/src/theme.rs +++ b/core/src/theme.rs @@ -10,6 +10,11 @@ pub struct ThemeColors { pub accent: Style, pub code: Style, pub dimmed: Style, + pub code_fence: Style, + pub rule: Style, + pub list_marker: Style, + pub blockquote_border: Style, + pub table_border: Style, } impl Default for ThemeColors { @@ -20,6 +25,11 @@ impl Default for ThemeColors { accent: Style::new().bright_yellow(), code: Style::new().bright_green(), dimmed: Style::new().dimmed(), + code_fence: Style::new().on_black().dimmed(), + rule: Style::new().on_black().dimmed(), + list_marker: Style::new().bright_yellow(), + blockquote_border: Style::new().on_black().dimmed(), + table_border: Style::new().on_black().dimmed(), } } } @@ -49,6 +59,31 @@ impl ThemeColors { pub fn dimmed<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { text.style(self.dimmed) } + + /// Apply code fence style to text + pub fn code_fence<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { + text.style(self.code_fence) + } + + /// Apply horizontal rule style to text + pub fn rule<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { + text.style(self.rule) + } + + /// Apply list marker style to text + pub fn list_marker<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { + text.style(self.list_marker) + } + + /// Apply blockquote border style to text + pub fn blockquote_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { + text.style(self.blockquote_border) + } + + /// Apply table border style to text + pub fn table_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { + text.style(self.table_border) + } } #[cfg(test)] diff --git a/rustfmt.toml b/rustfmt.toml index 934a567..b3b7482 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -7,3 +7,4 @@ single_line_if_else_max_width = 100 single_line_let_else_max_width = 100 struct_field_align_threshold = 20 use_field_init_shorthand = true +struct_lit_width=100 diff --git a/ui/Cargo.toml b/ui/Cargo.toml index bb5aea9..af8310c 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -4,3 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +ratatui = "0.29.0" +crossterm = "0.29.0" +slides-core = { path = "../core" } +owo-colors = "4.2.3" diff --git a/ui/src/layout.rs b/ui/src/layout.rs new file mode 100644 index 0000000..b1f2105 --- /dev/null +++ b/ui/src/layout.rs @@ -0,0 +1,119 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// Layout manager for slide presentation +/// +/// Calculates screen layout with main slide area, optional notes panel, and status bar. +pub struct SlideLayout { + show_notes: bool, +} + +impl SlideLayout { + pub fn new(show_notes: bool) -> Self { + Self { show_notes } + } + + /// Calculate layout areas for the slide viewer + /// + /// Returns (main_area, notes_area, status_area) where notes_area is None if notes are hidden. + pub fn calculate(&self, area: Rect) -> (Rect, Option, Rect) { + let vertical_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(1)]) + .split(area); + + let content_area = vertical_chunks[0]; + let status_area = vertical_chunks[1]; + + if self.show_notes { + let horizontal_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(content_area); + + (horizontal_chunks[0], Some(horizontal_chunks[1]), status_area) + } else { + (content_area, None, status_area) + } + } + + /// Update notes visibility + pub fn set_show_notes(&mut self, show: bool) { + self.show_notes = show; + } + + /// Check if notes are visible + pub fn is_showing_notes(&self) -> bool { + self.show_notes + } +} + +impl Default for SlideLayout { + fn default() -> Self { + Self { show_notes: false } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_without_notes() { + let layout = SlideLayout::new(false); + let area = Rect::new(0, 0, 100, 50); + let (main, notes, status) = layout.calculate(area); + + assert!(notes.is_none()); + assert_eq!(status.height, 1); + assert!(main.height > status.height); + } + + #[test] + fn layout_with_notes() { + let layout = SlideLayout::new(true); + let area = Rect::new(0, 0, 100, 50); + let (main, notes, status) = layout.calculate(area); + + assert!(notes.is_some()); + let notes_area = notes.unwrap(); + assert!(main.width > notes_area.width); + assert_eq!(main.height, notes_area.height); + assert_eq!(status.height, 1); + } + + #[test] + fn layout_toggle_notes() { + let mut layout = SlideLayout::default(); + assert!(!layout.is_showing_notes()); + + layout.set_show_notes(true); + assert!(layout.is_showing_notes()); + + layout.set_show_notes(false); + assert!(!layout.is_showing_notes()); + } + + #[test] + fn layout_small_terminal() { + let layout = SlideLayout::new(false); + let area = Rect::new(0, 0, 20, 10); + let (main, _notes, status) = layout.calculate(area); + + assert_eq!(status.height, 1); + assert!(main.height >= 3); + } + + #[test] + fn layout_proportions_with_notes() { + let layout = SlideLayout::new(true); + let area = Rect::new(0, 0, 100, 50); + let (main, notes, _status) = layout.calculate(area); + + let notes_area = notes.unwrap(); + let main_percentage = (main.width as f32 / area.width as f32) * 100.0; + let notes_percentage = (notes_area.width as f32 / area.width as f32) * 100.0; + + assert!(main_percentage >= 55.0 && main_percentage <= 65.0); + assert!(notes_percentage >= 35.0 && notes_percentage <= 45.0); + } +} diff --git a/ui/src/lib.rs b/ui/src/lib.rs index b93cf3f..d9a5202 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -1,14 +1,12 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +pub mod layout; +pub mod renderer; +pub mod viewer; -#[cfg(test)] -mod tests { - use super::*; +pub use layout::SlideLayout; +pub use renderer::render_slide_content; +pub use viewer::SlideViewer; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub use slides_core::{ + slide::{Block, Slide, TextSpan}, + theme::ThemeColors, +}; diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs new file mode 100644 index 0000000..fe0ba56 --- /dev/null +++ b/ui/src/renderer.rs @@ -0,0 +1,265 @@ +use ratatui::{ + style::{Modifier, Style}, + text::{Line, Span, Text}, +}; +use slides_core::{ + slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}, + theme::ThemeColors, +}; + +/// Render a slide's blocks into ratatui Text +/// +/// Converts slide blocks into styled ratatui text with theming applied. +pub fn render_slide_content(blocks: &[Block], theme: &ThemeColors) -> Text<'static> { + let mut lines = Vec::new(); + + for block in blocks { + match block { + Block::Heading { level, spans } => render_heading(*level, spans, theme, &mut lines), + Block::Paragraph { spans } => render_paragraph(spans, theme, &mut lines), + Block::Code(code_block) => render_code_block(code_block, theme, &mut lines), + Block::List(list) => render_list(list, theme, &mut lines, 0), + Block::Rule => render_rule(theme, &mut lines), + Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines), + Block::Table(table) => render_table(table, theme, &mut lines), + } + + lines.push(Line::raw("")); + } + + Text::from(lines) +} + +/// Get heading prefix +fn get_prefix(level: u8) -> &'static str { + match level { + 1 => "# ", + 2 => "## ", + 3 => "### ", + 4 => "#### ", + 5 => "##### ", + _ => "###### ", + } +} + +/// Render a heading with size based on level +fn render_heading(level: u8, spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec>) { + let prefix = get_prefix(level); + let heading_style = to_ratatui_style(&theme.heading); + let mut line_spans = vec![Span::styled(prefix.to_string(), heading_style)]; + + for span in spans { + line_spans.push(create_span(span, theme, true)); + } + + lines.push(Line::from(line_spans)); +} + +/// Render a paragraph with styled text spans +fn render_paragraph(spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec>) { + let line_spans: Vec<_> = spans.iter().map(|span| create_span(span, theme, false)).collect(); + lines.push(Line::from(line_spans)); +} + +/// Render a code block with monospace styling +fn render_code_block(code: &CodeBlock, theme: &ThemeColors, lines: &mut Vec>) { + let fence_style = to_ratatui_style(&theme.code_fence); + let code_style = to_ratatui_style(&theme.code); + + if let Some(lang) = &code.language { + lines.push(Line::from(Span::styled(format!("```{}", lang), fence_style))); + } else { + lines.push(Line::from(Span::styled("```".to_string(), fence_style))); + } + + for line in code.code.lines() { + lines.push(Line::from(Span::styled(line.to_string(), code_style))); + } + + lines.push(Line::from(Span::styled("```".to_string(), fence_style))); +} + +/// Render a list with bullets or numbers +fn render_list(list: &List, theme: &ThemeColors, lines: &mut Vec>, indent: usize) { + let marker_style = to_ratatui_style(&theme.list_marker); + + for (idx, item) in list.items.iter().enumerate() { + let prefix = if list.ordered { + format!("{}{}. ", " ".repeat(indent), idx + 1) + } else { + format!("{}• ", " ".repeat(indent)) + }; + + let mut line_spans = vec![Span::styled(prefix, marker_style)]; + + for span in &item.spans { + line_spans.push(create_span(span, theme, false)); + } + + lines.push(Line::from(line_spans)); + + if let Some(nested) = &item.nested { + render_list(nested, theme, lines, indent + 1); + } + } +} + +/// Render a horizontal rule +fn render_rule(theme: &ThemeColors, lines: &mut Vec>) { + let rule_style = to_ratatui_style(&theme.rule); + let rule = "─".repeat(60); + lines.push(Line::from(Span::styled(rule, rule_style))); +} + +/// Render a blockquote with indentation +fn render_blockquote(blocks: &[Block], theme: &ThemeColors, lines: &mut Vec>) { + let border_style = to_ratatui_style(&theme.blockquote_border); + + for block in blocks { + match block { + Block::Paragraph { spans } => { + let mut line_spans = vec![Span::styled("│ ".to_string(), border_style)]; + + for span in spans { + line_spans.push(create_span(span, theme, false)); + } + + lines.push(Line::from(line_spans)); + } + _ => {} + } + } +} + +/// Render a table with basic formatting +fn render_table(table: &Table, theme: &ThemeColors, lines: &mut Vec>) { + let border_style = to_ratatui_style(&theme.table_border); + + if !table.headers.is_empty() { + let mut header_line = Vec::new(); + for (idx, header) in table.headers.iter().enumerate() { + if idx > 0 { + header_line.push(Span::styled(" │ ".to_string(), border_style)); + } + for span in header { + header_line.push(create_span(span, theme, true)); + } + } + lines.push(Line::from(header_line)); + + let separator = "─".repeat(60); + lines.push(Line::from(Span::styled(separator, border_style))); + } + + for row in &table.rows { + let mut row_line = Vec::new(); + for (idx, cell) in row.iter().enumerate() { + if idx > 0 { + row_line.push(Span::styled(" │ ".to_string(), border_style)); + } + for span in cell { + row_line.push(create_span(span, theme, false)); + } + } + lines.push(Line::from(row_line)); + } +} + +/// Create a styled span from a TextSpan +fn create_span(text_span: &TextSpan, theme: &ThemeColors, is_heading: bool) -> Span<'static> { + let style = apply_theme_style(theme, &text_span.style, is_heading); + Span::styled(text_span.text.clone(), style) +} + +/// Apply theme colors and text styling +fn apply_theme_style(theme: &ThemeColors, text_style: &TextStyle, is_heading: bool) -> Style { + let mut style = if is_heading { + to_ratatui_style(&theme.heading) + } else if text_style.code { + to_ratatui_style(&theme.code) + } else { + to_ratatui_style(&theme.body) + }; + + if text_style.bold { + style = style.add_modifier(Modifier::BOLD); + } + if text_style.italic { + style = style.add_modifier(Modifier::ITALIC); + } + if text_style.strikethrough { + style = style.add_modifier(Modifier::CROSSED_OUT); + } + + style +} + +/// Convert owo-colors Style to ratatui Style +/// +/// Since owo-colors Style is opaque, we return a default ratatui style. +/// The theme provides semantic meaning; actual visual styling is defined here. +fn to_ratatui_style(_owo_style: &owo_colors::Style) -> Style { + Style::default() +} + +#[cfg(test)] +mod tests { + use slides_core::slide::ListItem; + + use super::*; + + #[test] + fn render_heading_basic() { + let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Test Heading")] }]; + let theme = ThemeColors::default(); + let text = render_slide_content(&blocks, &theme); + assert!(!text.lines.is_empty()); + } + + #[test] + fn render_paragraph_basic() { + let blocks = vec![Block::Paragraph { spans: vec![TextSpan::plain("Test paragraph")] }]; + let theme = ThemeColors::default(); + let text = render_slide_content(&blocks, &theme); + assert!(!text.lines.is_empty()); + } + + #[test] + fn render_code_block() { + let blocks = vec![Block::Code(CodeBlock::with_language("rust", "fn main() {}"))]; + let theme = ThemeColors::default(); + let text = render_slide_content(&blocks, &theme); + assert!(text.lines.len() > 2); + } + + #[test] + fn render_list_unordered() { + let list = List { + ordered: false, + items: vec![ + ListItem { spans: vec![TextSpan::plain("Item 1")], nested: None }, + ListItem { spans: vec![TextSpan::plain("Item 2")], nested: None }, + ], + }; + let blocks = vec![Block::List(list)]; + let theme = ThemeColors::default(); + let text = render_slide_content(&blocks, &theme); + assert!(text.lines.len() >= 2); + } + + #[test] + fn render_styled_text() { + let blocks = vec![Block::Paragraph { + spans: vec![ + TextSpan::bold("Bold"), + TextSpan::plain(" "), + TextSpan::italic("Italic"), + TextSpan::plain(" "), + TextSpan::code("code"), + ], + }]; + let theme = ThemeColors::default(); + let text = render_slide_content(&blocks, &theme); + assert!(!text.lines.is_empty()); + } +} diff --git a/ui/src/viewer.rs b/ui/src/viewer.rs new file mode 100644 index 0000000..27283ab --- /dev/null +++ b/ui/src/viewer.rs @@ -0,0 +1,246 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use slides_core::{slide::Slide, theme::ThemeColors}; + +use crate::renderer::render_slide_content; + +/// Slide viewer state manager +/// +/// Manages current slide index, navigation, and speaker notes visibility. +pub struct SlideViewer { + slides: Vec, + current_index: usize, + show_notes: bool, + theme: ThemeColors, +} + +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 } + } + + /// Navigate to the next slide + pub fn next(&mut self) { + if self.current_index < self.slides.len().saturating_sub(1) { + self.current_index += 1; + } + } + + /// Navigate to the previous slide + pub fn previous(&mut self) { + if self.current_index > 0 { + self.current_index -= 1; + } + } + + /// Jump to a specific slide by index (0-based) + pub fn jump_to(&mut self, index: usize) { + if index < self.slides.len() { + self.current_index = index; + } + } + + /// Toggle speaker notes visibility + pub fn toggle_notes(&mut self) { + self.show_notes = !self.show_notes; + } + + /// Get the current slide + pub fn current_slide(&self) -> Option<&Slide> { + self.slides.get(self.current_index) + } + + /// Get the current slide index (0-based) + pub fn current_index(&self) -> usize { + self.current_index + } + + /// Get total number of slides + pub fn total_slides(&self) -> usize { + self.slides.len() + } + + /// Check if speaker notes are visible + pub fn is_showing_notes(&self) -> bool { + self.show_notes + } + + /// Render the current slide to the frame + pub fn render(&self, frame: &mut Frame, area: Rect) { + if let Some(slide) = self.current_slide() { + let content = render_slide_content(&slide.blocks, &self.theme); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(format!(" Slide {}/{} ", self.current_index + 1, self.total_slides())) + .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + + let paragraph = Paragraph::new(content).block(block).wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); + } + } + + /// Render speaker notes if available and visible + pub fn render_notes(&self, frame: &mut Frame, area: Rect) { + if !self.show_notes { + return; + } + + if let Some(slide) = self.current_slide() { + if let Some(notes) = &slide.notes { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Speaker Notes ") + .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let paragraph = Paragraph::new(notes.clone()) + .block(block) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(Color::Gray)); + + frame.render_widget(paragraph, area); + } + } + } + + /// Render status bar with navigation info + pub fn render_status_bar(&self, frame: &mut Frame, area: Rect) { + let status_text = format!( + " {}/{} | [←/→] Navigate | [N] Notes {} | [Q] Quit ", + self.current_index + 1, + self.total_slides(), + if self.show_notes { "✓" } else { "" } + ); + + let status = Paragraph::new(Line::from(vec![Span::styled( + status_text, + Style::default() + .bg(Color::DarkGray) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )])); + + frame.render_widget(status, area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use slides_core::slide::{Block, TextSpan}; + + fn create_test_slides() -> Vec { + 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")], + }]), + Slide::with_blocks(vec![Block::Heading { + level: 1, + spans: vec![TextSpan::plain("Slide 3")], + }]), + ] + } + + #[test] + fn viewer_creation() { + let slides = create_test_slides(); + let viewer = SlideViewer::new(slides, ThemeColors::default()); + assert_eq!(viewer.total_slides(), 3); + assert_eq!(viewer.current_index(), 0); + } + + #[test] + fn viewer_navigation_next() { + let slides = create_test_slides(); + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); + + viewer.next(); + assert_eq!(viewer.current_index(), 1); + + viewer.next(); + assert_eq!(viewer.current_index(), 2); + + viewer.next(); + assert_eq!(viewer.current_index(), 2); + } + + #[test] + fn viewer_navigation_previous() { + let slides = create_test_slides(); + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); + + viewer.jump_to(2); + assert_eq!(viewer.current_index(), 2); + + viewer.previous(); + assert_eq!(viewer.current_index(), 1); + + viewer.previous(); + assert_eq!(viewer.current_index(), 0); + + viewer.previous(); + assert_eq!(viewer.current_index(), 0); + } + + #[test] + fn viewer_jump_to() { + let slides = create_test_slides(); + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); + + viewer.jump_to(2); + assert_eq!(viewer.current_index(), 2); + + viewer.jump_to(0); + assert_eq!(viewer.current_index(), 0); + + viewer.jump_to(10); + assert_eq!(viewer.current_index(), 0); + } + + #[test] + fn viewer_toggle_notes() { + let slides = create_test_slides(); + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); + + assert!(!viewer.is_showing_notes()); + + viewer.toggle_notes(); + assert!(viewer.is_showing_notes()); + + viewer.toggle_notes(); + assert!(!viewer.is_showing_notes()); + } + + #[test] + fn viewer_current_slide() { + let slides = create_test_slides(); + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); + + assert!(viewer.current_slide().is_some()); + + viewer.jump_to(1); + let slide = viewer.current_slide().unwrap(); + assert_eq!(slide.blocks.len(), 1); + } + + #[test] + fn viewer_empty_slides() { + let viewer = SlideViewer::new(Vec::new(), ThemeColors::default()); + assert_eq!(viewer.total_slides(), 0); + assert!(viewer.current_slide().is_none()); + } +} -- 2.51.2