+6
Cargo.lock
+6
Cargo.lock
+20
README.md
+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
+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
+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
+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
+1
rustfmt.toml
+4
ui/Cargo.toml
+4
ui/Cargo.toml
+119
ui/src/layout.rs
+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
+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
+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
+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
+
}