magical markdown slides

feat: ratatui rendering implementation with styling build: update formatting config

+6
Cargo.lock
··· 775 775 [[package]] 776 776 name = "slides-tui" 777 777 version = "0.1.0" 778 + dependencies = [ 779 + "crossterm 0.29.0", 780 + "owo-colors", 781 + "ratatui", 782 + "slides-core", 783 + ] 778 784 779 785 [[package]] 780 786 name = "smallvec"
+20
README.md
··· 18 18 19 19 __Portable:__ 20 20 Runs on any terminal supporting UTF-8; dependencies limited to core crates. 21 + 22 + ## Testing 23 + 24 + This project uses `cargo-llvm-cov` for coverage 25 + 26 + Installation: 27 + 28 + ```sh 29 + # MacOS 30 + brew install cargo-llvm-cov 31 + 32 + # Linux 33 + cargo +stable install cargo-llvm-cov --locked 34 + ``` 35 + 36 + Run tests: 37 + 38 + ```sh 39 + cargo llvm-cov --open 40 + ```
+59 -37
ROADMAP.md
··· 4 4 5 5 __Objective:__ Establish a clean, testable core with `clap` and a minimal `ratatui` loop. 6 6 7 - | Task | Description | Key Crates | 8 - | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | 9 - | __✓ Project Scaffolding__ | Initialize workspace with `slides-core`, `slides-cli`, and `slides-ui` crates. Use `cargo-generate` and a `justfile` for scripts. | `cargo`, `just`, `clap` | 10 - | __✓ CLI Definition__ | Implement root command `slides` with subcommands:<br>• `present` (TUI)<br>• `print` (stdout)<br>• `init` (scaffold deck)<br>• `check` (lint slides). | [`clap`](https://docs.rs/clap/latest/clap/) | 11 - | __✓ Logging & Colors__ | Integrate structured logs via `tracing`.<br>Use __owo-colors__ for color abstraction (no dynamic dispatch). | [`owo-colors`](https://docs.rs/owo-colors/latest/owo_colors/), `tracing` | 12 - | __✓ Terminal & Event Setup__ | Configure alternate screen, raw mode, input loop, resize handler. | [`crossterm`](https://docs.rs/crossterm/latest/crossterm/), `ratatui` | 13 - | __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI. | GitHub Actions | 7 + | Task | Description | Key Crates | 8 + | ---------------------------- | ------------------------------------------------------------------------------ | --------------------------- | 9 + | __✓ Project Scaffolding__ | Initialize workspace with `slides-core`, `slides-cli`, and `slides-ui` crates. | `cargo`, `just`, `clap` | 10 + | | Use `cargo-generate` and a `justfile` for scripts. | | 11 + | __✓ CLI Definition__ | Implement root command `slides` with subcommands: | `clap`[^1] | 12 + | | • `present` (TUI) | | 13 + | | • `print` (stdout) | | 14 + | | • `init` (scaffold deck) | | 15 + | | • `check` (lint slides). | | 16 + | __✓ Logging & Colors__ | Integrate structured logs via `tracing`. | `owo-colors`[^2], `tracing` | 17 + | | Use __owo-colors__ for color abstraction (no dynamic dispatch). | | 18 + | __✓ Terminal & Event Setup__ | Configure alternate screen, raw mode, input loop, resize handler. | `crossterm`[^3], `ratatui` | 19 + | __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI. | GitHub Actions | 14 20 15 21 ## Data Model (Parser & Slides) 16 22 17 23 __Objective:__ Parse markdown documents into a rich `Slide` struct. 18 24 19 - | Task | Description | Key Crates | 20 - | ---------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | 21 - | __✓ Parser Core__ | Split files on `---` separators.<br>Detect title blocks, lists, and code fences.<br>Represent as `Vec<Slide>`. | [`pulldown-cmark`](https://docs.rs/pulldown-cmark/latest/pulldown_cmark/) | 22 - | __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | 23 - | __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | [`serde_yml`](https://docs.rs/serde_yml) | 24 - | __Error & Validation__ | Provide friendly parser errors with file/line info. | [`thiserror`](https://docs.rs/thiserror) | 25 - | __Basic CLI UX__ | `slides present file.md` runs full TUI.<br>`slides print` renders to stdout with width constraint. | `clap` | 26 - 27 - --- 25 + | Task | Description | Key Crates | 26 + | ---------------------- | --------------------------------------------------------------- | -------------------- | 27 + | __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] | 28 + | | Detect title blocks, lists, and code fences. | | 29 + | | Represent as `Vec<Slide>`. | | 30 + | __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | 31 + | __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] | 32 + | __Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] | 33 + | __Basic CLI UX__ | `slides present file.md` runs full TUI. | `clap` | 34 + | | `slides print` renders to stdout with width constraint. | | 28 35 29 36 ## Rendering & Navigation 30 37 31 38 __Objective:__ Build the interactive slide renderer with navigation. 32 39 33 - | Task | Description | Key Crates | 34 - | ----------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | 35 - | __Ratatui Integration__ | Build basic slide viewer using layout, blocks, paragraphs. | [`ratatui`](https://docs.rs/ratatui/latest/ratatui/) | 36 - | __Input & State__ | Support `←/→`, `j/k`, `q`, numeric jumps, and window resize. | `crossterm`, `ratatui` | 37 - | __Status Bar__ | Display slide count, filename, clock, and theme name. | `ratatui` | 38 - | __Color Styling__ | Apply consistent color palette via `owo-colors`. Define traits like `ThemeColor` for strong typing. | `owo-colors` | 39 - | __Configurable Themes__ | Support themes via TOML files mapping semantic roles (`heading`, `body`, `accent`) → color pairs. | `toml`, `serde` | 40 + | Task | Description | Key Crates | 41 + | ------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------- | 42 + | __✓ Ratatui Integration__ | Build basic slide viewer using layout, blocks, paragraphs. | `ratatui`[^7] | 43 + | __Input & State__ | Support `←/→`, `j/k`, `q`, numeric jumps, and window resize. | `crossterm`, `ratatui` | 44 + | __Status Bar__ | Display slide count, filename, clock, and theme name. | `ratatui` | 45 + | __✓ Color Styling__ | Apply consistent color palette via `owo-colors`. Define traits like `ThemeColor`. | `owo-colors` | 46 + | __Configurable Themes__ | Support themes via TOML files mapping semantic roles (`heading`, `body`, `accent`) → color pairs. | `toml`, `serde` | 47 + 48 + --- 40 49 41 50 ## Code Highlighting via Syntect 42 51 43 52 __Objective:__ Add first-class syntax highlighting using Syntect. 44 53 45 - | Task | Description | Key Crates | 46 - | --------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | 47 - | __Syntect__ | Load `.tmTheme` / `.sublime-syntax` definitions on startup.<br>Cache `SyntaxSet` + `ThemeSet`. | [`syntect`](https://docs.rs/syntect/latest/syntect/) | 48 - | __Code Blocks__ | Detect fenced code blocks with language tags.<br>Render syntax-highlighted text with color spans mapped to `owo-colors`. | `syntect`, `owo-colors` | 49 - | __Theming__ | Map terminal theme choice to Syntect theme (e.g., `"OneDark"`, `"SolarizedDark"`, `"Monokai"`). | `syntect` | 50 - | __Performance__ | Lazy-load themes and syntaxes; use `once_cell` for caching. | `once_cell` | 51 - | __Mode__ | Render to ANSI-colored plain text output (for `slides print`). | `owo-colors` | 54 + | Task | Description | Key Crates | 55 + | --------------- | ---------------------------------------------------------------------------- | ----------------------- | 56 + | __Syntect__ | Load `.tmTheme` / `.sublime-syntax` definitions on startup. | `syntect`[^8] | 57 + | | Cache `SyntaxSet` + `ThemeSet`. | | 58 + | __Code Blocks__ | Detect fenced code blocks with language tags. | `syntect`, `owo-colors` | 59 + | | Render syntax-highlighted text with color spans mapped to `owo-colors`. | | 60 + | __Theming__ | Map terminal theme choice to Syntect theme (e.g., `"OneDark"`, `"Monokai"`). | `syntect` | 61 + | __Performance__ | Lazy-load themes and syntaxes; use `once_cell` for caching. | `once_cell` | 62 + | __Mode__ | Render to ANSI-colored plain text output (for `slides print`). | `owo-colors` | 52 63 53 64 ## Presenter 54 65 55 66 __Objective:__ Introduce features for live presentations and authoring convenience. 56 67 57 - | Task | Description | Key Crates | 58 - | -------------------- | ------------------------------------------------------------- | ------------------------------------------------- | 59 - | __Speaker Notes__ | `n` toggles speaker notes (parsed via `::: notes`). | `ratatui` | 60 - | __Timer & Progress__ | Session timer + per-slide progress bar. | `ratatui`, `chrono` | 61 - | __Live Reload__ | File watcher auto-refreshes content. | [`notify`](https://docs.rs/notify/latest/notify/) | 62 - | __Search__. | Fuzzy find slide titles via `ctrl+f`. | [`fuzzy-matcher`](https://docs.rs/fuzzy-matcher) | 63 - | __Theme Commands__ | CLI flag `--theme <name>` switches both Syntect + owo themes. | `clap`, internal `ThemeRegistry` | 68 + | Task | Description | Key Crates | 69 + | -------------------- | ------------------------------------------------------------- | -------------------------------- | 70 + | __Speaker Notes__ | `n` toggles speaker notes (parsed via `::: notes`). | `ratatui` | 71 + | __Timer & Progress__ | Session timer + per-slide progress bar. | `ratatui`, `chrono` | 72 + | __Live Reload__ | File watcher auto-refreshes content. | `notify`[^9] | 73 + | __Search__. | Fuzzy find slide titles via `ctrl+f`. | `fuzzy-matcher`[^10] | 74 + | __Theme Commands__ | CLI flag `--theme <name>` switches both Syntect + owo themes. | `clap`, internal `ThemeRegistry` | 64 75 65 76 ## Markdown Extension 66 77 ··· 80 91 | __Config Discovery__ | Read from `$XDG_CONFIG_HOME/slides/config.toml` for defaults. | `dirs`, `serde` | 81 92 | __Theme Registry__ | Built-in theme manifest (e.g., `onedark`, `solarized`, `plain`). | Internal | 82 93 | __Release__ | Tag `v1.0.0-rc.1` with changelog and binaries for major platforms. | `cargo-dist`, GitHub Actions | 94 + 95 + [^1]: <https://docs.rs/clap/latest/clap/> 96 + [^2]: <https://docs.rs/owo-colors/latest/owo_colors/> 97 + [^3]: <https://docs.rs/crossterm/latest/crossterm/> 98 + [^4]: <https://docs.rs/pulldown-cmark/latest/pulldown_cmark/> 99 + [^5]: <https://docs.rs/serde_yml> 100 + [^6]: <https://docs.rs/thiserror> 101 + [^7]: <https://docs.rs/ratatui/latest/ratatui/> 102 + [^8]: <https://docs.rs/syntect/latest/syntect/> 103 + [^9]: <https://docs.rs/notify/latest/notify/> 104 + [^10]: <https://docs.rs/fuzzy-matcher>
+8 -40
core/src/slide.rs
··· 11 11 12 12 impl Slide { 13 13 pub fn new() -> Self { 14 - Self { 15 - blocks: Vec::new(), 16 - notes: None, 17 - } 14 + Self { blocks: Vec::new(), notes: None } 18 15 } 19 16 20 17 pub fn with_blocks(blocks: Vec<Block>) -> Self { ··· 60 57 61 58 impl TextSpan { 62 59 pub fn plain(text: impl Into<String>) -> Self { 63 - Self { 64 - text: text.into(), 65 - style: TextStyle::default(), 66 - } 60 + Self { text: text.into(), style: TextStyle::default() } 67 61 } 68 62 69 63 pub fn bold(text: impl Into<String>) -> Self { 70 - Self { 71 - text: text.into(), 72 - style: TextStyle { 73 - bold: true, 74 - ..Default::default() 75 - }, 76 - } 64 + Self { text: text.into(), style: TextStyle { bold: true, ..Default::default() } } 77 65 } 78 66 79 67 pub fn italic(text: impl Into<String>) -> Self { 80 - Self { 81 - text: text.into(), 82 - style: TextStyle { 83 - italic: true, 84 - ..Default::default() 85 - }, 86 - } 68 + Self { text: text.into(), style: TextStyle { italic: true, ..Default::default() } } 87 69 } 88 70 89 71 pub fn code(text: impl Into<String>) -> Self { 90 - Self { 91 - text: text.into(), 92 - style: TextStyle { 93 - code: true, 94 - ..Default::default() 95 - }, 96 - } 72 + Self { text: text.into(), style: TextStyle { code: true, ..Default::default() } } 97 73 } 98 74 } 99 75 ··· 117 93 118 94 impl CodeBlock { 119 95 pub fn new(code: impl Into<String>) -> Self { 120 - Self { 121 - language: None, 122 - code: code.into(), 123 - } 96 + Self { language: None, code: code.into() } 124 97 } 125 98 126 99 pub fn with_language(language: impl Into<String>, code: impl Into<String>) -> Self { 127 - Self { 128 - language: Some(language.into()), 129 - code: code.into(), 130 - } 100 + Self { language: Some(language.into()), code: code.into() } 131 101 } 132 102 } 133 103 ··· 173 143 174 144 #[test] 175 145 fn slide_with_blocks() { 176 - let blocks = vec![Block::Paragraph { 177 - spans: vec![TextSpan::plain("Hello")], 178 - }]; 146 + let blocks = vec![Block::Paragraph { spans: vec![TextSpan::plain("Hello")] }]; 179 147 let slide = Slide::with_blocks(blocks.clone()); 180 148 assert!(!slide.is_empty()); 181 149 assert_eq!(slide.blocks.len(), 1);
+35
core/src/theme.rs
··· 10 10 pub accent: Style, 11 11 pub code: Style, 12 12 pub dimmed: Style, 13 + pub code_fence: Style, 14 + pub rule: Style, 15 + pub list_marker: Style, 16 + pub blockquote_border: Style, 17 + pub table_border: Style, 13 18 } 14 19 15 20 impl Default for ThemeColors { ··· 20 25 accent: Style::new().bright_yellow(), 21 26 code: Style::new().bright_green(), 22 27 dimmed: Style::new().dimmed(), 28 + code_fence: Style::new().on_black().dimmed(), 29 + rule: Style::new().on_black().dimmed(), 30 + list_marker: Style::new().bright_yellow(), 31 + blockquote_border: Style::new().on_black().dimmed(), 32 + table_border: Style::new().on_black().dimmed(), 23 33 } 24 34 } 25 35 } ··· 48 58 /// Apply dimmed style to text 49 59 pub fn dimmed<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 50 60 text.style(self.dimmed) 61 + } 62 + 63 + /// Apply code fence style to text 64 + pub fn code_fence<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 65 + text.style(self.code_fence) 66 + } 67 + 68 + /// Apply horizontal rule style to text 69 + pub fn rule<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 70 + text.style(self.rule) 71 + } 72 + 73 + /// Apply list marker style to text 74 + pub fn list_marker<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 75 + text.style(self.list_marker) 76 + } 77 + 78 + /// Apply blockquote border style to text 79 + pub fn blockquote_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 80 + text.style(self.blockquote_border) 81 + } 82 + 83 + /// Apply table border style to text 84 + pub fn table_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 85 + text.style(self.table_border) 51 86 } 52 87 } 53 88
+1
rustfmt.toml
··· 7 7 single_line_let_else_max_width = 100 8 8 struct_field_align_threshold = 20 9 9 use_field_init_shorthand = true 10 + struct_lit_width=100
+4
ui/Cargo.toml
··· 4 4 edition = "2024" 5 5 6 6 [dependencies] 7 + ratatui = "0.29.0" 8 + crossterm = "0.29.0" 9 + slides-core = { path = "../core" } 10 + owo-colors = "4.2.3"
+119
ui/src/layout.rs
··· 1 + use ratatui::layout::{Constraint, Direction, Layout, Rect}; 2 + 3 + /// Layout manager for slide presentation 4 + /// 5 + /// Calculates screen layout with main slide area, optional notes panel, and status bar. 6 + pub struct SlideLayout { 7 + show_notes: bool, 8 + } 9 + 10 + impl SlideLayout { 11 + pub fn new(show_notes: bool) -> Self { 12 + Self { show_notes } 13 + } 14 + 15 + /// Calculate layout areas for the slide viewer 16 + /// 17 + /// Returns (main_area, notes_area, status_area) where notes_area is None if notes are hidden. 18 + pub fn calculate(&self, area: Rect) -> (Rect, Option<Rect>, Rect) { 19 + let vertical_chunks = Layout::default() 20 + .direction(Direction::Vertical) 21 + .constraints([Constraint::Min(3), Constraint::Length(1)]) 22 + .split(area); 23 + 24 + let content_area = vertical_chunks[0]; 25 + let status_area = vertical_chunks[1]; 26 + 27 + if self.show_notes { 28 + let horizontal_chunks = Layout::default() 29 + .direction(Direction::Horizontal) 30 + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 31 + .split(content_area); 32 + 33 + (horizontal_chunks[0], Some(horizontal_chunks[1]), status_area) 34 + } else { 35 + (content_area, None, status_area) 36 + } 37 + } 38 + 39 + /// Update notes visibility 40 + pub fn set_show_notes(&mut self, show: bool) { 41 + self.show_notes = show; 42 + } 43 + 44 + /// Check if notes are visible 45 + pub fn is_showing_notes(&self) -> bool { 46 + self.show_notes 47 + } 48 + } 49 + 50 + impl Default for SlideLayout { 51 + fn default() -> Self { 52 + Self { show_notes: false } 53 + } 54 + } 55 + 56 + #[cfg(test)] 57 + mod tests { 58 + use super::*; 59 + 60 + #[test] 61 + fn layout_without_notes() { 62 + let layout = SlideLayout::new(false); 63 + let area = Rect::new(0, 0, 100, 50); 64 + let (main, notes, status) = layout.calculate(area); 65 + 66 + assert!(notes.is_none()); 67 + assert_eq!(status.height, 1); 68 + assert!(main.height > status.height); 69 + } 70 + 71 + #[test] 72 + fn layout_with_notes() { 73 + let layout = SlideLayout::new(true); 74 + let area = Rect::new(0, 0, 100, 50); 75 + let (main, notes, status) = layout.calculate(area); 76 + 77 + assert!(notes.is_some()); 78 + let notes_area = notes.unwrap(); 79 + assert!(main.width > notes_area.width); 80 + assert_eq!(main.height, notes_area.height); 81 + assert_eq!(status.height, 1); 82 + } 83 + 84 + #[test] 85 + fn layout_toggle_notes() { 86 + let mut layout = SlideLayout::default(); 87 + assert!(!layout.is_showing_notes()); 88 + 89 + layout.set_show_notes(true); 90 + assert!(layout.is_showing_notes()); 91 + 92 + layout.set_show_notes(false); 93 + assert!(!layout.is_showing_notes()); 94 + } 95 + 96 + #[test] 97 + fn layout_small_terminal() { 98 + let layout = SlideLayout::new(false); 99 + let area = Rect::new(0, 0, 20, 10); 100 + let (main, _notes, status) = layout.calculate(area); 101 + 102 + assert_eq!(status.height, 1); 103 + assert!(main.height >= 3); 104 + } 105 + 106 + #[test] 107 + fn layout_proportions_with_notes() { 108 + let layout = SlideLayout::new(true); 109 + let area = Rect::new(0, 0, 100, 50); 110 + let (main, notes, _status) = layout.calculate(area); 111 + 112 + let notes_area = notes.unwrap(); 113 + let main_percentage = (main.width as f32 / area.width as f32) * 100.0; 114 + let notes_percentage = (notes_area.width as f32 / area.width as f32) * 100.0; 115 + 116 + assert!(main_percentage >= 55.0 && main_percentage <= 65.0); 117 + assert!(notes_percentage >= 35.0 && notes_percentage <= 45.0); 118 + } 119 + }
+10 -12
ui/src/lib.rs
··· 1 - pub fn add(left: u64, right: u64) -> u64 { 2 - left + right 3 - } 1 + pub mod layout; 2 + pub mod renderer; 3 + pub mod viewer; 4 4 5 - #[cfg(test)] 6 - mod tests { 7 - use super::*; 5 + pub use layout::SlideLayout; 6 + pub use renderer::render_slide_content; 7 + pub use viewer::SlideViewer; 8 8 9 - #[test] 10 - fn it_works() { 11 - let result = add(2, 2); 12 - assert_eq!(result, 4); 13 - } 14 - } 9 + pub use slides_core::{ 10 + slide::{Block, Slide, TextSpan}, 11 + theme::ThemeColors, 12 + };
+265
ui/src/renderer.rs
··· 1 + use ratatui::{ 2 + style::{Modifier, Style}, 3 + text::{Line, Span, Text}, 4 + }; 5 + use slides_core::{ 6 + slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}, 7 + theme::ThemeColors, 8 + }; 9 + 10 + /// Render a slide's blocks into ratatui Text 11 + /// 12 + /// Converts slide blocks into styled ratatui text with theming applied. 13 + pub fn render_slide_content(blocks: &[Block], theme: &ThemeColors) -> Text<'static> { 14 + let mut lines = Vec::new(); 15 + 16 + for block in blocks { 17 + match block { 18 + Block::Heading { level, spans } => render_heading(*level, spans, theme, &mut lines), 19 + Block::Paragraph { spans } => render_paragraph(spans, theme, &mut lines), 20 + Block::Code(code_block) => render_code_block(code_block, theme, &mut lines), 21 + Block::List(list) => render_list(list, theme, &mut lines, 0), 22 + Block::Rule => render_rule(theme, &mut lines), 23 + Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines), 24 + Block::Table(table) => render_table(table, theme, &mut lines), 25 + } 26 + 27 + lines.push(Line::raw("")); 28 + } 29 + 30 + Text::from(lines) 31 + } 32 + 33 + /// Get heading prefix 34 + fn get_prefix(level: u8) -> &'static str { 35 + match level { 36 + 1 => "# ", 37 + 2 => "## ", 38 + 3 => "### ", 39 + 4 => "#### ", 40 + 5 => "##### ", 41 + _ => "###### ", 42 + } 43 + } 44 + 45 + /// Render a heading with size based on level 46 + fn render_heading(level: u8, spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) { 47 + let prefix = get_prefix(level); 48 + let heading_style = to_ratatui_style(&theme.heading); 49 + let mut line_spans = vec![Span::styled(prefix.to_string(), heading_style)]; 50 + 51 + for span in spans { 52 + line_spans.push(create_span(span, theme, true)); 53 + } 54 + 55 + lines.push(Line::from(line_spans)); 56 + } 57 + 58 + /// Render a paragraph with styled text spans 59 + fn render_paragraph(spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) { 60 + let line_spans: Vec<_> = spans.iter().map(|span| create_span(span, theme, false)).collect(); 61 + lines.push(Line::from(line_spans)); 62 + } 63 + 64 + /// Render a code block with monospace styling 65 + fn render_code_block(code: &CodeBlock, theme: &ThemeColors, lines: &mut Vec<Line<'static>>) { 66 + let fence_style = to_ratatui_style(&theme.code_fence); 67 + let code_style = to_ratatui_style(&theme.code); 68 + 69 + if let Some(lang) = &code.language { 70 + lines.push(Line::from(Span::styled(format!("```{}", lang), fence_style))); 71 + } else { 72 + lines.push(Line::from(Span::styled("```".to_string(), fence_style))); 73 + } 74 + 75 + for line in code.code.lines() { 76 + lines.push(Line::from(Span::styled(line.to_string(), code_style))); 77 + } 78 + 79 + lines.push(Line::from(Span::styled("```".to_string(), fence_style))); 80 + } 81 + 82 + /// Render a list with bullets or numbers 83 + fn render_list(list: &List, theme: &ThemeColors, lines: &mut Vec<Line<'static>>, indent: usize) { 84 + let marker_style = to_ratatui_style(&theme.list_marker); 85 + 86 + for (idx, item) in list.items.iter().enumerate() { 87 + let prefix = if list.ordered { 88 + format!("{}{}. ", " ".repeat(indent), idx + 1) 89 + } else { 90 + format!("{}• ", " ".repeat(indent)) 91 + }; 92 + 93 + let mut line_spans = vec![Span::styled(prefix, marker_style)]; 94 + 95 + for span in &item.spans { 96 + line_spans.push(create_span(span, theme, false)); 97 + } 98 + 99 + lines.push(Line::from(line_spans)); 100 + 101 + if let Some(nested) = &item.nested { 102 + render_list(nested, theme, lines, indent + 1); 103 + } 104 + } 105 + } 106 + 107 + /// Render a horizontal rule 108 + fn render_rule(theme: &ThemeColors, lines: &mut Vec<Line<'static>>) { 109 + let rule_style = to_ratatui_style(&theme.rule); 110 + let rule = "─".repeat(60); 111 + lines.push(Line::from(Span::styled(rule, rule_style))); 112 + } 113 + 114 + /// Render a blockquote with indentation 115 + fn render_blockquote(blocks: &[Block], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) { 116 + let border_style = to_ratatui_style(&theme.blockquote_border); 117 + 118 + for block in blocks { 119 + match block { 120 + Block::Paragraph { spans } => { 121 + let mut line_spans = vec![Span::styled("│ ".to_string(), border_style)]; 122 + 123 + for span in spans { 124 + line_spans.push(create_span(span, theme, false)); 125 + } 126 + 127 + lines.push(Line::from(line_spans)); 128 + } 129 + _ => {} 130 + } 131 + } 132 + } 133 + 134 + /// Render a table with basic formatting 135 + fn render_table(table: &Table, theme: &ThemeColors, lines: &mut Vec<Line<'static>>) { 136 + let border_style = to_ratatui_style(&theme.table_border); 137 + 138 + if !table.headers.is_empty() { 139 + let mut header_line = Vec::new(); 140 + for (idx, header) in table.headers.iter().enumerate() { 141 + if idx > 0 { 142 + header_line.push(Span::styled(" │ ".to_string(), border_style)); 143 + } 144 + for span in header { 145 + header_line.push(create_span(span, theme, true)); 146 + } 147 + } 148 + lines.push(Line::from(header_line)); 149 + 150 + let separator = "─".repeat(60); 151 + lines.push(Line::from(Span::styled(separator, border_style))); 152 + } 153 + 154 + for row in &table.rows { 155 + let mut row_line = Vec::new(); 156 + for (idx, cell) in row.iter().enumerate() { 157 + if idx > 0 { 158 + row_line.push(Span::styled(" │ ".to_string(), border_style)); 159 + } 160 + for span in cell { 161 + row_line.push(create_span(span, theme, false)); 162 + } 163 + } 164 + lines.push(Line::from(row_line)); 165 + } 166 + } 167 + 168 + /// Create a styled span from a TextSpan 169 + fn create_span(text_span: &TextSpan, theme: &ThemeColors, is_heading: bool) -> Span<'static> { 170 + let style = apply_theme_style(theme, &text_span.style, is_heading); 171 + Span::styled(text_span.text.clone(), style) 172 + } 173 + 174 + /// Apply theme colors and text styling 175 + fn apply_theme_style(theme: &ThemeColors, text_style: &TextStyle, is_heading: bool) -> Style { 176 + let mut style = if is_heading { 177 + to_ratatui_style(&theme.heading) 178 + } else if text_style.code { 179 + to_ratatui_style(&theme.code) 180 + } else { 181 + to_ratatui_style(&theme.body) 182 + }; 183 + 184 + if text_style.bold { 185 + style = style.add_modifier(Modifier::BOLD); 186 + } 187 + if text_style.italic { 188 + style = style.add_modifier(Modifier::ITALIC); 189 + } 190 + if text_style.strikethrough { 191 + style = style.add_modifier(Modifier::CROSSED_OUT); 192 + } 193 + 194 + style 195 + } 196 + 197 + /// Convert owo-colors Style to ratatui Style 198 + /// 199 + /// Since owo-colors Style is opaque, we return a default ratatui style. 200 + /// The theme provides semantic meaning; actual visual styling is defined here. 201 + fn to_ratatui_style(_owo_style: &owo_colors::Style) -> Style { 202 + Style::default() 203 + } 204 + 205 + #[cfg(test)] 206 + mod tests { 207 + use slides_core::slide::ListItem; 208 + 209 + use super::*; 210 + 211 + #[test] 212 + fn render_heading_basic() { 213 + let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Test Heading")] }]; 214 + let theme = ThemeColors::default(); 215 + let text = render_slide_content(&blocks, &theme); 216 + assert!(!text.lines.is_empty()); 217 + } 218 + 219 + #[test] 220 + fn render_paragraph_basic() { 221 + let blocks = vec![Block::Paragraph { spans: vec![TextSpan::plain("Test paragraph")] }]; 222 + let theme = ThemeColors::default(); 223 + let text = render_slide_content(&blocks, &theme); 224 + assert!(!text.lines.is_empty()); 225 + } 226 + 227 + #[test] 228 + fn render_code_block() { 229 + let blocks = vec![Block::Code(CodeBlock::with_language("rust", "fn main() {}"))]; 230 + let theme = ThemeColors::default(); 231 + let text = render_slide_content(&blocks, &theme); 232 + assert!(text.lines.len() > 2); 233 + } 234 + 235 + #[test] 236 + fn render_list_unordered() { 237 + let list = List { 238 + ordered: false, 239 + items: vec![ 240 + ListItem { spans: vec![TextSpan::plain("Item 1")], nested: None }, 241 + ListItem { spans: vec![TextSpan::plain("Item 2")], nested: None }, 242 + ], 243 + }; 244 + let blocks = vec![Block::List(list)]; 245 + let theme = ThemeColors::default(); 246 + let text = render_slide_content(&blocks, &theme); 247 + assert!(text.lines.len() >= 2); 248 + } 249 + 250 + #[test] 251 + fn render_styled_text() { 252 + let blocks = vec![Block::Paragraph { 253 + spans: vec![ 254 + TextSpan::bold("Bold"), 255 + TextSpan::plain(" "), 256 + TextSpan::italic("Italic"), 257 + TextSpan::plain(" "), 258 + TextSpan::code("code"), 259 + ], 260 + }]; 261 + let theme = ThemeColors::default(); 262 + let text = render_slide_content(&blocks, &theme); 263 + assert!(!text.lines.is_empty()); 264 + } 265 + }
+246
ui/src/viewer.rs
··· 1 + use ratatui::{ 2 + Frame, 3 + layout::Rect, 4 + style::{Color, Modifier, Style}, 5 + text::{Line, Span}, 6 + widgets::{Block, Borders, Paragraph, Wrap}, 7 + }; 8 + use slides_core::{slide::Slide, theme::ThemeColors}; 9 + 10 + use crate::renderer::render_slide_content; 11 + 12 + /// Slide viewer state manager 13 + /// 14 + /// Manages current slide index, navigation, and speaker notes visibility. 15 + pub struct SlideViewer { 16 + slides: Vec<Slide>, 17 + current_index: usize, 18 + show_notes: bool, 19 + theme: ThemeColors, 20 + } 21 + 22 + impl SlideViewer { 23 + /// Create a new slide viewer with slides and theme 24 + pub fn new(slides: Vec<Slide>, theme: ThemeColors) -> Self { 25 + Self { slides, current_index: 0, show_notes: false, theme } 26 + } 27 + 28 + /// Navigate to the next slide 29 + pub fn next(&mut self) { 30 + if self.current_index < self.slides.len().saturating_sub(1) { 31 + self.current_index += 1; 32 + } 33 + } 34 + 35 + /// Navigate to the previous slide 36 + pub fn previous(&mut self) { 37 + if self.current_index > 0 { 38 + self.current_index -= 1; 39 + } 40 + } 41 + 42 + /// Jump to a specific slide by index (0-based) 43 + pub fn jump_to(&mut self, index: usize) { 44 + if index < self.slides.len() { 45 + self.current_index = index; 46 + } 47 + } 48 + 49 + /// Toggle speaker notes visibility 50 + pub fn toggle_notes(&mut self) { 51 + self.show_notes = !self.show_notes; 52 + } 53 + 54 + /// Get the current slide 55 + pub fn current_slide(&self) -> Option<&Slide> { 56 + self.slides.get(self.current_index) 57 + } 58 + 59 + /// Get the current slide index (0-based) 60 + pub fn current_index(&self) -> usize { 61 + self.current_index 62 + } 63 + 64 + /// Get total number of slides 65 + pub fn total_slides(&self) -> usize { 66 + self.slides.len() 67 + } 68 + 69 + /// Check if speaker notes are visible 70 + pub fn is_showing_notes(&self) -> bool { 71 + self.show_notes 72 + } 73 + 74 + /// Render the current slide to the frame 75 + pub fn render(&self, frame: &mut Frame, area: Rect) { 76 + if let Some(slide) = self.current_slide() { 77 + let content = render_slide_content(&slide.blocks, &self.theme); 78 + 79 + let block = Block::default() 80 + .borders(Borders::ALL) 81 + .border_style(Style::default().fg(Color::DarkGray)) 82 + .title(format!(" Slide {}/{} ", self.current_index + 1, self.total_slides())) 83 + .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); 84 + 85 + let paragraph = Paragraph::new(content).block(block).wrap(Wrap { trim: false }); 86 + 87 + frame.render_widget(paragraph, area); 88 + } 89 + } 90 + 91 + /// Render speaker notes if available and visible 92 + pub fn render_notes(&self, frame: &mut Frame, area: Rect) { 93 + if !self.show_notes { 94 + return; 95 + } 96 + 97 + if let Some(slide) = self.current_slide() { 98 + if let Some(notes) = &slide.notes { 99 + let block = Block::default() 100 + .borders(Borders::ALL) 101 + .border_style(Style::default().fg(Color::Yellow)) 102 + .title(" Speaker Notes ") 103 + .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); 104 + 105 + let paragraph = Paragraph::new(notes.clone()) 106 + .block(block) 107 + .wrap(Wrap { trim: false }) 108 + .style(Style::default().fg(Color::Gray)); 109 + 110 + frame.render_widget(paragraph, area); 111 + } 112 + } 113 + } 114 + 115 + /// Render status bar with navigation info 116 + pub fn render_status_bar(&self, frame: &mut Frame, area: Rect) { 117 + let status_text = format!( 118 + " {}/{} | [←/→] Navigate | [N] Notes {} | [Q] Quit ", 119 + self.current_index + 1, 120 + self.total_slides(), 121 + if self.show_notes { "✓" } else { "" } 122 + ); 123 + 124 + let status = Paragraph::new(Line::from(vec![Span::styled( 125 + status_text, 126 + Style::default() 127 + .bg(Color::DarkGray) 128 + .fg(Color::White) 129 + .add_modifier(Modifier::BOLD), 130 + )])); 131 + 132 + frame.render_widget(status, area); 133 + } 134 + } 135 + 136 + #[cfg(test)] 137 + mod tests { 138 + use super::*; 139 + use slides_core::slide::{Block, TextSpan}; 140 + 141 + fn create_test_slides() -> Vec<Slide> { 142 + vec![ 143 + Slide::with_blocks(vec![Block::Heading { 144 + level: 1, 145 + spans: vec![TextSpan::plain("Slide 1")], 146 + }]), 147 + Slide::with_blocks(vec![Block::Heading { 148 + level: 1, 149 + spans: vec![TextSpan::plain("Slide 2")], 150 + }]), 151 + Slide::with_blocks(vec![Block::Heading { 152 + level: 1, 153 + spans: vec![TextSpan::plain("Slide 3")], 154 + }]), 155 + ] 156 + } 157 + 158 + #[test] 159 + fn viewer_creation() { 160 + let slides = create_test_slides(); 161 + let viewer = SlideViewer::new(slides, ThemeColors::default()); 162 + assert_eq!(viewer.total_slides(), 3); 163 + assert_eq!(viewer.current_index(), 0); 164 + } 165 + 166 + #[test] 167 + fn viewer_navigation_next() { 168 + let slides = create_test_slides(); 169 + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); 170 + 171 + viewer.next(); 172 + assert_eq!(viewer.current_index(), 1); 173 + 174 + viewer.next(); 175 + assert_eq!(viewer.current_index(), 2); 176 + 177 + viewer.next(); 178 + assert_eq!(viewer.current_index(), 2); 179 + } 180 + 181 + #[test] 182 + fn viewer_navigation_previous() { 183 + let slides = create_test_slides(); 184 + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); 185 + 186 + viewer.jump_to(2); 187 + assert_eq!(viewer.current_index(), 2); 188 + 189 + viewer.previous(); 190 + assert_eq!(viewer.current_index(), 1); 191 + 192 + viewer.previous(); 193 + assert_eq!(viewer.current_index(), 0); 194 + 195 + viewer.previous(); 196 + assert_eq!(viewer.current_index(), 0); 197 + } 198 + 199 + #[test] 200 + fn viewer_jump_to() { 201 + let slides = create_test_slides(); 202 + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); 203 + 204 + viewer.jump_to(2); 205 + assert_eq!(viewer.current_index(), 2); 206 + 207 + viewer.jump_to(0); 208 + assert_eq!(viewer.current_index(), 0); 209 + 210 + viewer.jump_to(10); 211 + assert_eq!(viewer.current_index(), 0); 212 + } 213 + 214 + #[test] 215 + fn viewer_toggle_notes() { 216 + let slides = create_test_slides(); 217 + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); 218 + 219 + assert!(!viewer.is_showing_notes()); 220 + 221 + viewer.toggle_notes(); 222 + assert!(viewer.is_showing_notes()); 223 + 224 + viewer.toggle_notes(); 225 + assert!(!viewer.is_showing_notes()); 226 + } 227 + 228 + #[test] 229 + fn viewer_current_slide() { 230 + let slides = create_test_slides(); 231 + let mut viewer = SlideViewer::new(slides, ThemeColors::default()); 232 + 233 + assert!(viewer.current_slide().is_some()); 234 + 235 + viewer.jump_to(1); 236 + let slide = viewer.current_slide().unwrap(); 237 + assert_eq!(slide.blocks.len(), 1); 238 + } 239 + 240 + #[test] 241 + fn viewer_empty_slides() { 242 + let viewer = SlideViewer::new(Vec::new(), ThemeColors::default()); 243 + assert_eq!(viewer.total_slides(), 0); 244 + assert!(viewer.current_slide().is_none()); 245 + } 246 + }