magical markdown slides
3
fork

Configure Feed

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

feat: add table parsing, full-width status bar, and themed backgrounds

* implemented and documented logging

* remove jump to slide

fix: help line toggle

+510 -146
+12 -10
ROADMAP.md
··· 47 47 | __Configurable Themes__ | Base16 YAML theme system with 10 prebuilt themes. | `serde_yml`, `serde` | 48 48 | | Add user theme loading from config directory and CLI `--theme-file` flag. | `dirs` | 49 49 50 - --- 51 - 52 50 ## Code Highlighting via Syntect 53 51 54 52 __Objective:__ Add first-class syntax highlighting using Syntect. ··· 63 61 | __✓ Performance__ | Lazy-load themes and syntaxes; use `OnceLock` for caching. | `std::sync::OnceLock` | 64 62 | __✓ Mode__ | Render to ANSI-colored plain text output (for `lantern print`). | `owo-colors` | 65 63 64 + --- 65 + 66 66 ## Presenter 67 67 68 68 __Objective:__ Introduce features for live presentations and authoring convenience. 69 69 70 70 | Task | Description | Key Crates | 71 71 | -------------------- | ------------------------------------------------------------- | -------------------------------- | 72 - | __Speaker Notes__ | `n` toggles speaker notes (parsed via `::: notes`). | `ratatui` | 72 + | __Speaker Notes__ | `N` toggles speaker notes (parsed via `::: notes`). | `ratatui` | 73 + | | Note: `n` & `p` move forward & backwards | | 73 74 | __Timer & Progress__ | Session timer + per-slide progress bar. | `ratatui`, `chrono` | 74 75 | __Live Reload__ | File watcher auto-refreshes content. | `notify`[^9] | 75 76 | __Search__ | Fuzzy find slide titles via `ctrl+f`. | `fuzzy-matcher`[^10] | ··· 79 80 80 81 __Objective:__ Add richness and visual polish to text and layout. 81 82 82 - | Task | Description | Key Crates | 83 - | -------------------- | ------------------------------------------------------------- | ----------------------------- | 84 - | __Tables & Lists__ | Render GitHub-style tables, bullets, and task lists | `pulldown-cmark`, `ratatui` | 85 - | __Horizontal Rules__ | Use box-drawing (`─`, `═`) and/or black horizontal bar (`▬`) | Unicode constants | 86 - | __Admonitions__ | Highlighted boxes with icons | `owo-colors`, internal glyphs | 87 - | __Generators__ | `lantern init` scaffolds an example deck with code and notes | `include_str!`, `fs` | 83 + | Task | Description | Key Crates | 84 + | ---------------------- | ------------------------------------------------------------- | ----------------------------- | 85 + | __✓ Tables & Lists__ | Render GitHub-style tables, bullets, and task lists | `pulldown-cmark`, `ratatui` | 86 + | __✓ Horizontal Rules__ | Use box-drawing (`─`, `═`) and/or black horizontal bar (`▬`) | Unicode constants | 87 + | __Admonitions__ | Highlighted boxes with icons (use `:::` directives) | `owo-colors`, internal glyphs | 88 + | | Support obsidian & GH admonitions | | 89 + | __Generators__ | `lantern init` scaffolds an example deck with code and notes | `include_str!`, `fs` | 88 90 89 91 ## RC 90 92 ··· 128 130 | __Frame Capture Loop__ | Drive the same layout/rasterizer used for images at N FPS, yielding a sequence of RGBA frames. | `tiny-skia`, `image` | | 129 131 | __FFmpeg Binding Layer__ | Wrap `ffmpeg-next` to open an encoder, configure codec/container, and accept raw frames. | `ffmpeg-next` | | 130 132 | __Video Export CLI__ | `lantern export-video deck.md --output demo.mp4 --fps 30 --duration 120s` (or auto-duration from events). | `clap`, internal encoder | | 131 - | __GIF / WebM Variants__ | Add `--format gif | webm` mapping to appropriate ffmpeg muxer/codec presets. | `ffmpeg-next`[^7] | 133 + | __GIF / WebM Variants__ | Add `--format gif | webm mapping to appropriate ffmpeg muxer/codec presets. | `ffmpeg-next`[^7] | 132 134 | __Typing & Cursor Effects__ | Represent typing, deletes, cursor blinks as timeline events, so video export matches live presentation feel. | internal `timeline`, terminal core | | 133 135 | __Audio-less Simplification__ | Keep V1 video export silent (no audio tracks) for simpler ffmpeg integration and smaller binaries. | `ffmpeg-next` | | 134 136 | __Performance Tuning__ | Measure memory/CPU for long decks; stream frames to ffmpeg (no full buffering) and expose `--quality` presets. | `ffmpeg-next`, `image` | |
+30 -14
cli/src/main.rs
··· 1 + /// TODO: Add --no-bg flag to present command to allow users to disable background color 1 2 use clap::{Parser, Subcommand}; 2 3 use lantern_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeRegistry}; 3 4 use lantern_ui::App; ··· 67 68 fn main() { 68 69 let cli = ArgParser::parse(); 69 70 70 - tracing_subscriber::fmt().with_max_level(cli.log_level).init(); 71 + if let Ok(log_path) = std::env::var("LANTERN_LOG_FILE") { 72 + let log_file = std::fs::OpenOptions::new() 73 + .create(true) 74 + .write(true) 75 + .truncate(true) 76 + .open(&log_path) 77 + .unwrap_or_else(|e| panic!("Failed to create log file at {}: {}", log_path, e)); 78 + 79 + tracing_subscriber::fmt() 80 + .with_max_level(cli.log_level) 81 + .with_writer(std::sync::Mutex::new(log_file)) 82 + .with_ansi(false) 83 + .init(); 84 + } else { 85 + tracing_subscriber::fmt() 86 + .with_max_level(cli.log_level) 87 + .with_writer(std::io::sink) 88 + .with_ansi(false) 89 + .init(); 90 + } 71 91 72 92 match cli.command { 73 93 Commands::Present { file, theme } => { ··· 76 96 std::process::exit(1); 77 97 } 78 98 } 79 - 80 99 Commands::Print { file, width, theme } => { 81 100 if let Err(e) = run_print(&file, width, theme) { 82 101 eprintln!("Error: {}", e); 83 102 std::process::exit(1); 84 103 } 85 104 } 86 - 87 105 Commands::Init { path, name } => { 88 106 tracing::info!("Initializing new deck: {} in {}", name, path.display()); 89 107 eprintln!("Init command not yet implemented"); 90 108 } 91 - 92 109 Commands::Check { file, strict, theme } => { 93 110 if let Err(e) = run_check(&file, strict, theme) { 94 111 eprintln!("Error: {}", e); ··· 111 128 return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file")); 112 129 } 113 130 114 - let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 115 - tracing::debug!("Using theme: {}", theme_name); 131 + let theme_name = theme_arg.clone().unwrap_or_else(|| meta.theme.clone()); 132 + tracing::info!( 133 + "Theme selection: CLI arg={:?}, frontmatter={}, final={}", 134 + theme_arg, 135 + meta.theme, 136 + theme_name 137 + ); 116 138 117 139 let theme = ThemeRegistry::get(&theme_name); 118 140 ··· 165 187 } 166 188 167 189 if !result.is_valid() { 168 - return Err(io::Error::new( 169 - io::ErrorKind::InvalidData, 170 - "Theme validation failed", 171 - )); 190 + return Err(io::Error::new(io::ErrorKind::InvalidData, "Theme validation failed")); 172 191 } 173 192 } else { 174 193 tracing::info!("Validating slides: {}", file.display()); ··· 195 214 } 196 215 197 216 if !result.is_valid() { 198 - return Err(io::Error::new( 199 - io::ErrorKind::InvalidData, 200 - "Slide validation failed", 201 - )); 217 + return Err(io::Error::new(io::ErrorKind::InvalidData, "Slide validation failed")); 202 218 } 203 219 } 204 220
+2 -2
core/src/metadata.rs
··· 74 74 } 75 75 } 76 76 77 - /// Get theme from environment variable or return "default" 77 + /// Get theme from environment variable or return "oxocarbon-dark" 78 78 fn default_theme() -> String { 79 - env::var("SLIDES_THEME").unwrap_or_else(|_| "default".to_string()) 79 + env::var("SLIDES_THEME").unwrap_or_else(|_| "oxocarbon-dark".to_string()) 80 80 } 81 81 82 82 /// Get current system user's name
+173 -19
core/src/parser.rs
··· 1 1 use crate::error::Result; 2 2 use crate::metadata::Meta; 3 3 use crate::slide::*; 4 - use pulldown_cmark::{Event, Parser, Tag, TagEnd}; 4 + use pulldown_cmark::{Alignment as PulldownAlignment, Event, Options, Parser, Tag, TagEnd}; 5 5 6 6 /// Parse markdown content into metadata and slides 7 7 /// ··· 45 45 46 46 /// Parse a single slide from markdown 47 47 fn parse_slide(markdown: String) -> Result<Slide> { 48 - let parser = Parser::new(&markdown); 48 + let mut options = Options::empty(); 49 + options.insert(Options::ENABLE_TABLES); 50 + options.insert(Options::ENABLE_STRIKETHROUGH); 51 + let parser = Parser::new_ext(&markdown, options); 49 52 let mut blocks = Vec::new(); 50 53 let mut block_stack: Vec<BlockBuilder> = Vec::new(); 51 54 let mut current_style = TextStyle::default(); ··· 57 60 block_stack.push(BlockBuilder::Heading { 58 61 level: level as u8, 59 62 spans: Vec::new(), 60 - style: current_style.clone(), 61 63 }); 62 64 } 63 65 Tag::Paragraph => { 64 66 block_stack.push(BlockBuilder::Paragraph { 65 67 spans: Vec::new(), 66 - style: current_style.clone(), 67 68 }); 68 69 } 69 70 Tag::CodeBlock(kind) => { ··· 87 88 ordered: first.is_some(), 88 89 items: Vec::new(), 89 90 current_item: Vec::new(), 90 - style: current_style.clone(), 91 91 }); 92 92 } 93 93 Tag::BlockQuote(_) => { 94 94 block_stack.push(BlockBuilder::BlockQuote { blocks: Vec::new() }); 95 95 } 96 + Tag::Table(alignments) => { 97 + let converted_alignments = alignments 98 + .iter() 99 + .map(|a| match a { 100 + PulldownAlignment::None | PulldownAlignment::Left => Alignment::Left, 101 + PulldownAlignment::Center => Alignment::Center, 102 + PulldownAlignment::Right => Alignment::Right, 103 + }) 104 + .collect(); 105 + block_stack.push(BlockBuilder::Table { 106 + headers: Vec::new(), 107 + rows: Vec::new(), 108 + current_row: Vec::new(), 109 + current_cell: Vec::new(), 110 + alignments: converted_alignments, 111 + in_header: false, 112 + }); 113 + } 114 + Tag::TableHead => { 115 + if let Some(BlockBuilder::Table { in_header, .. }) = block_stack.last_mut() { 116 + *in_header = true; 117 + } 118 + } 119 + Tag::TableRow => {} 120 + Tag::TableCell => {} 96 121 Tag::Item => {} 97 122 Tag::Emphasis => { 98 123 current_style.italic = true; ··· 122 147 blocks.push(builder.build()); 123 148 } 124 149 } 150 + TagEnd::Table => { 151 + if let Some(builder) = block_stack.pop() { 152 + blocks.push(builder.build()); 153 + } 154 + } 155 + TagEnd::TableHead => { 156 + if let Some(BlockBuilder::Table { 157 + current_row, 158 + headers, 159 + in_header, 160 + .. 161 + }) = block_stack.last_mut() 162 + { 163 + if !current_row.is_empty() { 164 + *headers = current_row.drain(..).collect(); 165 + } 166 + *in_header = false; 167 + } 168 + } 169 + TagEnd::TableRow => { 170 + if let Some(BlockBuilder::Table { 171 + current_row, 172 + rows, 173 + .. 174 + }) = block_stack.last_mut() 175 + { 176 + if !current_row.is_empty() { 177 + rows.push(current_row.drain(..).collect()); 178 + } 179 + } 180 + } 181 + TagEnd::TableCell => { 182 + if let Some(BlockBuilder::Table { 183 + current_cell, 184 + current_row, 185 + .. 186 + }) = block_stack.last_mut() 187 + { 188 + current_row.push(current_cell.drain(..).collect()); 189 + } 190 + } 125 191 TagEnd::Item => { 126 192 if let Some(BlockBuilder::List { 127 193 current_item, items, .. ··· 149 215 150 216 Event::Text(text) => { 151 217 if let Some(builder) = block_stack.last_mut() { 152 - builder.add_text(text.to_string()); 218 + builder.add_text(text.to_string(), &current_style); 153 219 } 154 220 } 155 221 ··· 161 227 162 228 Event::SoftBreak | Event::HardBreak => { 163 229 if let Some(builder) = block_stack.last_mut() { 164 - builder.add_text(" ".to_string()); 230 + builder.add_text(" ".to_string(), &current_style); 165 231 } 166 232 } 167 233 ··· 181 247 Heading { 182 248 level: u8, 183 249 spans: Vec<TextSpan>, 184 - style: TextStyle, 185 250 }, 186 251 Paragraph { 187 252 spans: Vec<TextSpan>, 188 - style: TextStyle, 189 253 }, 190 254 Code { 191 255 language: Option<String>, ··· 195 259 ordered: bool, 196 260 items: Vec<ListItem>, 197 261 current_item: Vec<TextSpan>, 198 - style: TextStyle, 199 262 }, 200 263 BlockQuote { 201 264 blocks: Vec<Block>, 202 265 }, 266 + Table { 267 + headers: Vec<Vec<TextSpan>>, 268 + rows: Vec<Vec<Vec<TextSpan>>>, 269 + current_row: Vec<Vec<TextSpan>>, 270 + current_cell: Vec<TextSpan>, 271 + alignments: Vec<Alignment>, 272 + in_header: bool, 273 + }, 203 274 } 204 275 205 276 impl BlockBuilder { 206 - fn add_text(&mut self, text: String) { 277 + fn add_text(&mut self, text: String, current_style: &TextStyle) { 207 278 match self { 208 - Self::Heading { spans, style, .. } | Self::Paragraph { spans, style } => { 279 + Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 209 280 if !text.is_empty() { 210 281 spans.push(TextSpan { 211 282 text, 212 - style: style.clone(), 283 + style: current_style.clone(), 213 284 }); 214 285 } 215 286 } 216 287 Self::Code { code, .. } => { 217 288 code.push_str(&text); 218 289 } 219 - Self::List { 220 - current_item, style, .. 221 - } => { 290 + Self::List { current_item, .. } => { 222 291 if !text.is_empty() { 223 292 current_item.push(TextSpan { 224 293 text, 225 - style: style.clone(), 294 + style: current_style.clone(), 295 + }); 296 + } 297 + } 298 + Self::Table { current_cell, .. } => { 299 + if !text.is_empty() { 300 + current_cell.push(TextSpan { 301 + text, 302 + style: current_style.clone(), 226 303 }); 227 304 } 228 305 } ··· 250 327 }, 251 328 }); 252 329 } 330 + Self::Table { current_cell, .. } => { 331 + current_cell.push(TextSpan { 332 + text: code, 333 + style: TextStyle { 334 + code: true, 335 + ..Default::default() 336 + }, 337 + }); 338 + } 253 339 _ => {} 254 340 } 255 341 } 256 342 257 343 fn build(self) -> Block { 258 344 match self { 259 - Self::Heading { level, spans, .. } => Block::Heading { level, spans }, 260 - Self::Paragraph { spans, .. } => Block::Paragraph { spans }, 345 + Self::Heading { level, spans } => Block::Heading { level, spans }, 346 + Self::Paragraph { spans } => Block::Paragraph { spans }, 261 347 Self::Code { language, code } => Block::Code(CodeBlock { language, code }), 262 348 Self::List { ordered, items, .. } => Block::List(List { ordered, items }), 263 349 Self::BlockQuote { blocks } => Block::BlockQuote { blocks }, 350 + Self::Table { 351 + headers, 352 + rows, 353 + alignments, 354 + .. 355 + } => Block::Table(Table { 356 + headers, 357 + rows, 358 + alignments, 359 + }), 264 360 } 265 361 } 266 362 } ··· 394 490 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 395 491 assert_eq!(meta, Meta::default()); 396 492 assert_eq!(slides.len(), 1); 493 + } 494 + 495 + #[test] 496 + fn parse_table() { 497 + let markdown = r#"| Name | Age | 498 + | ---- | --- | 499 + | Alice | 30 | 500 + | Bob | 25 |"#; 501 + let slides = parse_slides(markdown).unwrap(); 502 + assert_eq!(slides.len(), 1); 503 + 504 + match &slides[0].blocks[0] { 505 + Block::Table(table) => { 506 + assert_eq!(table.headers.len(), 2); 507 + assert_eq!(table.rows.len(), 2); 508 + assert_eq!(table.headers[0][0].text, "Name"); 509 + assert_eq!(table.headers[1][0].text, "Age"); 510 + assert_eq!(table.rows[0][0][0].text, "Alice"); 511 + assert_eq!(table.rows[0][1][0].text, "30"); 512 + assert_eq!(table.rows[1][0][0].text, "Bob"); 513 + assert_eq!(table.rows[1][1][0].text, "25"); 514 + } 515 + _ => panic!("Expected table"), 516 + } 517 + } 518 + 519 + #[test] 520 + fn parse_table_with_alignment() { 521 + let markdown = r#"| Left | Center | Right | 522 + | :--- | :----: | ----: | 523 + | A | B | C |"#; 524 + let slides = parse_slides(markdown).unwrap(); 525 + 526 + match &slides[0].blocks[0] { 527 + Block::Table(table) => { 528 + assert_eq!(table.alignments.len(), 3); 529 + assert!(matches!(table.alignments[0], Alignment::Left)); 530 + assert!(matches!(table.alignments[1], Alignment::Center)); 531 + assert!(matches!(table.alignments[2], Alignment::Right)); 532 + } 533 + _ => panic!("Expected table"), 534 + } 535 + } 536 + 537 + #[test] 538 + fn parse_table_with_styled_text() { 539 + let markdown = r#"| Name | Status | 540 + | ---- | ------ | 541 + | **Bold** | `code` |"#; 542 + let slides = parse_slides(markdown).unwrap(); 543 + 544 + match &slides[0].blocks[0] { 545 + Block::Table(table) => { 546 + assert_eq!(table.rows[0][0][0].style.bold, true); 547 + assert_eq!(table.rows[0][1][0].style.code, true); 548 + } 549 + _ => panic!("Expected table"), 550 + } 397 551 } 398 552 }
+14 -15
core/src/term.rs
··· 64 64 Next, 65 65 /// Move to previous slide 66 66 Previous, 67 - /// Jump to specific slide number 68 - Jump(usize), 69 67 /// Toggle speaker notes 70 68 ToggleNotes, 69 + /// Toggle help display 70 + ToggleHelp, 71 71 /// Search slides 72 + /// TODO: Implement search functionality 72 73 Search, 73 74 /// Quit presentation 74 75 Quit, 75 76 /// Terminal was resized 77 + /// NOTE: Terminal resize is handled automatically by ratatui 76 78 Resize { width: u16, height: u16 }, 77 79 /// Unknown/unhandled event 78 80 Other, ··· 101 103 (KeyCode::Char('c'), KeyModifiers::CONTROL) => Self::Quit, 102 104 (KeyCode::Esc, _) => Self::Quit, 103 105 (KeyCode::Char('n'), KeyModifiers::SHIFT) => Self::ToggleNotes, 106 + (KeyCode::Char('?'), _) => Self::ToggleHelp, 104 107 (KeyCode::Char('f'), KeyModifiers::CONTROL) => Self::Search, 105 108 (KeyCode::Char('/'), KeyModifiers::NONE) => Self::Search, 106 - (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() => { 107 - if let Some(num) = c.to_digit(10) { 108 - Self::Jump(num as usize) 109 - } else { 110 - Self::Other 111 - } 112 - } 113 109 _ => Self::Other, 114 110 } 115 111 } ··· 154 150 } 155 151 156 152 #[test] 157 - fn input_event_jump() { 158 - let jump = InputEvent::from_key(KeyCode::Char('5'), KeyModifiers::NONE); 159 - assert_eq!(jump, InputEvent::Jump(5)); 160 - } 161 - 162 - #[test] 163 153 fn input_event_search() { 164 154 let search_slash = InputEvent::from_key(KeyCode::Char('/'), KeyModifiers::NONE); 165 155 assert_eq!(search_slash, InputEvent::Search); ··· 172 162 fn input_event_resize() { 173 163 let resize = InputEvent::from_crossterm(Event::Resize(80, 24)); 174 164 assert_eq!(resize, InputEvent::Resize { width: 80, height: 24 }); 165 + } 166 + 167 + #[test] 168 + fn input_event_toggle_help() { 169 + let help = InputEvent::from_key(KeyCode::Char('?'), KeyModifiers::NONE); 170 + assert_eq!(help, InputEvent::ToggleHelp); 171 + 172 + let help_shift = InputEvent::from_key(KeyCode::Char('?'), KeyModifiers::SHIFT); 173 + assert_eq!(help_shift, InputEvent::ToggleHelp); 175 174 } 176 175 177 176 #[test]
+24 -23
core/src/theme.rs
··· 1 1 use owo_colors::{OwoColorize, Style}; 2 2 use serde::Deserialize; 3 - use terminal_colorsaurus::{QueryOptions, background_color}; 3 + use terminal_colorsaurus::{QueryOptions, ThemeMode, theme_mode}; 4 4 5 5 /// Parses a hex color string to RGB values. 6 6 /// ··· 107 107 108 108 /// Detects if the terminal background is dark. 109 109 /// 110 - /// Uses [terminal_colorsaurus] to query the terminal background color. 110 + /// Uses [terminal_colorsaurus] to query the terminal theme mode. 111 111 /// Defaults to true (dark) if detection fails. 112 112 pub fn detect_is_dark() -> bool { 113 - match background_color(QueryOptions::default()) { 114 - Ok(color) => { 115 - let r = color.r as f32 / 255.0; 116 - let g = color.g as f32 / 255.0; 117 - let b = color.b as f32 / 255.0; 118 - let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; 119 - luminance < 0.5 113 + match theme_mode(QueryOptions::default()) { 114 + Ok(mode) => { 115 + let is_dark = mode == ThemeMode::Dark; 116 + tracing::debug!("Terminal theme detection: mode={:?} -> is_dark={}", mode, is_dark); 117 + is_dark 118 + } 119 + Err(e) => { 120 + tracing::debug!("Terminal theme detection failed: {}, defaulting to dark", e); 121 + true 120 122 } 121 - Err(_) => true, 122 123 } 123 124 } 124 125 ··· 151 152 impl Default for ThemeColors { 152 153 fn default() -> Self { 153 154 let is_dark = detect_is_dark(); 154 - let theme_name = if is_dark { "nord" } else { "nord-light" }; 155 + let theme_name = if is_dark { "oxocarbon-dark" } else { "oxocarbon-light" }; 156 + tracing::debug!("ThemeColors::default() selecting theme: {}", theme_name); 155 157 ThemeRegistry::get(theme_name) 156 158 } 157 159 } ··· 175 177 /// 176 178 /// UI chrome colors: 177 179 /// - base00: UI background (darkest background) 178 - /// - base04: UI borders (dim foreground) 180 + /// - base02: UI borders/status bar background (selection background) 179 181 /// - base06: UI titles (bright foreground) 180 - /// - base07: UI text (brightest foreground) 182 + /// - base05: UI text (default foreground) 181 183 fn from_base16(scheme: &Base16Scheme) -> Option<Self> { 182 184 let palette = &scheme.palette; 183 185 ··· 196 198 let link = parse_hex_color(&palette.base0c)?; 197 199 let inline_code_bg = parse_hex_color(&palette.base02)?; 198 200 let ui_background = parse_hex_color(&palette.base00)?; 199 - let ui_border = parse_hex_color(&palette.base04)?; 201 + let ui_border = parse_hex_color(&palette.base02)?; 200 202 let ui_title = parse_hex_color(&palette.base06)?; 201 - let ui_text = parse_hex_color(&palette.base07)?; 203 + let ui_text = parse_hex_color(&palette.base05)?; 202 204 203 205 Some(Self { 204 206 heading: Color::new(heading.0, heading.1, heading.2), ··· 309 311 /// Get a theme by name. 310 312 /// 311 313 /// Loads and parses the corresponding YAML theme file embedded at compile time. 314 + /// "default" maps to oxocarbon-dark or oxocarbon-light based on terminal background detection. 312 315 /// Falls back to Nord theme if the requested theme is not found or parsing fails. 313 316 pub fn get(name: &str) -> ThemeColors { 314 317 let yaml = match name.to_lowercase().as_str() { 318 + "default" => { 319 + let is_dark = detect_is_dark(); 320 + if is_dark { OXOCARBON_DARK } else { OXOCARBON_LIGHT } 321 + } 315 322 "catppuccin-latte" => CATPPUCCIN_LATTE, 316 323 "catppuccin-mocha" => CATPPUCCIN_MOCHA, 317 324 "gruvbox-material-dark" => GRUVBOX_MATERIAL_DARK, ··· 481 488 assert_eq!(theme.link.r, 0); // base0C - #00ffff 482 489 assert_eq!(theme.inline_code_bg.r, 34); // base02 - #222222 483 490 assert_eq!(theme.ui_background.r, 0); // base00 - #000000 484 - assert_eq!(theme.ui_border.r, 68); // base04 - #444444 491 + assert_eq!(theme.ui_border.r, 34); // base02 - #222222 485 492 assert_eq!(theme.ui_title.r, 102); // base06 - #666666 486 - assert_eq!(theme.ui_text.r, 119); // base07 - #777777 493 + assert_eq!(theme.ui_text.r, 85); // base05 - #555555 487 494 } 488 495 489 496 #[test] ··· 619 626 assert!(themes.contains(&"solarized-dark")); 620 627 assert!(themes.contains(&"solarized-light")); 621 628 assert_eq!(themes.len(), 10); 622 - } 623 - 624 - #[test] 625 - fn detect_is_dark_returns_bool() { 626 - let result = detect_is_dark(); 627 - assert!(result || !result); 628 629 } 629 630 630 631 #[test]
+1
docs/src/SUMMARY.md
··· 8 8 9 9 # Reference 10 10 11 + - [Logging](./logging.md) 11 12 - [Themes](./appendices/themes.md) 12 13 - [Symbols](./appendices/symbols.md)
+60
docs/src/logging.md
··· 1 + # Logging 2 + 3 + Lantern uses the `tracing` framework for internal logging and diagnostics. By default, logging is disabled, but can be enabled via environment variables for debugging and troubleshooting. 4 + 5 + ## Configuration 6 + 7 + ### File Path 8 + 9 + To enable logging to a file, set the `LANTERN_LOG_FILE` environment variable: 10 + 11 + ```bash 12 + export LANTERN_LOG_FILE=/path/to/lantern.log 13 + lantern present slides.md 14 + ``` 15 + 16 + If `LANTERN_LOG_FILE` is not set, logs are discarded and won't appear anywhere. 17 + 18 + ### Level 19 + 20 + Control the verbosity of logs using the `--log-level` flag: 21 + 22 + ```bash 23 + LANTERN_LOG_FILE=debug.log lantern --log-level debug present slides.md 24 + ``` 25 + 26 + ## Usage Examples 27 + 28 + ### Basic Debugging 29 + 30 + Enable info-level logging for general troubleshooting: 31 + 32 + ```bash 33 + LANTERN_LOG_FILE=lantern.log lantern present slides.md 34 + ``` 35 + 36 + ### Detailed Diagnostics 37 + 38 + Enable trace-level logging for in-depth debugging: 39 + 40 + ```bash 41 + LANTERN_LOG_FILE=lantern-trace.log lantern --log-level trace present slides.md 42 + ``` 43 + 44 + ### Temporary Log File 45 + 46 + Use a temporary file that gets cleaned up automatically: 47 + 48 + ```bash 49 + LANTERN_LOG_FILE=/tmp/lantern-$$.log lantern present slides.md 50 + ``` 51 + 52 + ## Log Format 53 + 54 + Logs are written in plain text format without ANSI color codes, making them easy to read and process with standard tools: 55 + 56 + ```sh 57 + 2025-11-18T10:30:45.123Z INFO lantern_cli: Presenting slides from: slides.md 58 + 2025-11-18T10:30:45.234Z INFO lantern_cli: Theme selection: CLI arg=None, frontmatter=oxocarbon-dark, final=oxocarbon-dark 59 + 2025-11-18T10:30:45.345Z DEBUG lantern_core::parser: Parsed 15 slides from markdown 60 + ```
+45 -25
ui/src/app.rs
··· 1 1 use lantern_core::{metadata::Meta, slide::Slide, term::InputEvent, theme::ThemeColors}; 2 - use ratatui::{Terminal as RatatuiTerminal, backend::Backend}; 2 + use ratatui::{ 3 + Terminal as RatatuiTerminal, 4 + backend::Backend, 5 + style::{Color, Style}, 6 + widgets::Block, 7 + }; 3 8 use std::io; 4 9 use std::time::{Duration, Instant}; 5 10 ··· 12 17 viewer: SlideViewer, 13 18 layout: SlideLayout, 14 19 should_quit: bool, 15 - _filename: String, 16 - _start_time: Instant, 20 + theme: ThemeColors, 21 + help_visible: bool, 17 22 } 18 23 19 24 impl App { ··· 27 32 Some(Instant::now()), 28 33 ); 29 34 30 - Self { 31 - viewer, 32 - layout: SlideLayout::default(), 33 - _filename: filename, 34 - _start_time: Instant::now(), 35 - should_quit: false, 36 - } 35 + Self { viewer, layout: SlideLayout::default(), should_quit: false, theme, help_visible: false } 37 36 } 38 37 39 38 /// Run the main event loop ··· 58 57 self.layout.set_show_notes(self.viewer.is_showing_notes()) 59 58 } 60 59 60 + fn toggle_help(&mut self) { 61 + self.help_visible = !self.help_visible; 62 + self.layout.set_show_help(self.help_visible); 63 + } 64 + 61 65 /// Handle input events 62 66 fn handle_event(&mut self, event: InputEvent) { 63 67 match event { 64 68 InputEvent::Next => self.viewer.next(), 65 69 InputEvent::Previous => self.viewer.previous(), 66 - InputEvent::Jump(n) => self.viewer.jump_to(n), 67 70 InputEvent::ToggleNotes => self.toggle_notes(), 71 + InputEvent::ToggleHelp => self.toggle_help(), 68 72 InputEvent::Quit => self.should_quit = true, 69 - // NOTE: Terminal resize is handled automatically by ratatui 70 - InputEvent::Resize { .. } => {} 71 - // TODO: Implement search functionality 72 - InputEvent::Search => {} 73 - InputEvent::Other => {} 73 + InputEvent::Resize { .. } | InputEvent::Search | InputEvent::Other => {} 74 74 } 75 75 } 76 76 77 77 /// Draw the UI 78 78 fn draw(&mut self, frame: &mut ratatui::Frame) { 79 - let (main_area, notes_area, status_area) = self.layout.calculate(frame.area()); 79 + let bg_color = Color::Rgb( 80 + self.theme.ui_background.r, 81 + self.theme.ui_background.g, 82 + self.theme.ui_background.b, 83 + ); 84 + 85 + let background = Block::default().style(Style::default().bg(bg_color)); 86 + frame.render_widget(background, frame.area()); 87 + 88 + let (main_area, notes_area, status_area, help_area) = self.layout.calculate(frame.area()); 80 89 81 90 self.viewer.render(frame, main_area); 82 91 ··· 85 94 } 86 95 87 96 self.viewer.render_status_bar(frame, status_area); 97 + 98 + if let Some(help_area) = help_area { 99 + self.viewer.render_help_line(frame, help_area); 100 + } 88 101 } 89 102 } 90 103 ··· 112 125 fn app_creation() { 113 126 let app = create_test_app(); 114 127 assert!(!app.should_quit); 115 - assert_eq!(app._filename, "test.md"); 116 128 } 117 129 118 130 #[test] ··· 133 145 } 134 146 135 147 #[test] 136 - fn app_handle_jump() { 137 - let mut app = create_test_app(); 138 - app.handle_event(InputEvent::Jump(1)); 139 - assert_eq!(app.viewer.current_index(), 1); 140 - } 141 - 142 - #[test] 143 148 fn app_handle_toggle_notes() { 144 149 let mut app = create_test_app(); 145 150 assert!(!app.viewer.is_showing_notes()); ··· 163 168 let mut app = create_test_app(); 164 169 app.handle_event(InputEvent::Resize { width: 100, height: 50 }); 165 170 assert!(!app.should_quit); 171 + } 172 + 173 + #[test] 174 + fn app_handle_toggle_help() { 175 + let mut app = create_test_app(); 176 + assert!(!app.help_visible); 177 + assert!(!app.layout.is_showing_help()); 178 + 179 + app.handle_event(InputEvent::ToggleHelp); 180 + assert!(app.help_visible); 181 + assert!(app.layout.is_showing_help()); 182 + 183 + app.handle_event(InputEvent::ToggleHelp); 184 + assert!(!app.help_visible); 185 + assert!(!app.layout.is_showing_help()); 166 186 } 167 187 }
+75 -14
ui/src/layout.rs
··· 1 - use ratatui::layout::{Constraint, Direction, Layout, Rect}; 1 + use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; 2 2 3 3 /// Layout manager for slide presentation 4 4 /// 5 - /// Calculates screen layout with main slide area, optional notes panel, and status bar. 5 + /// Calculates screen layout with main slide area, optional notes panel, status bar, and optional help line. 6 6 pub struct SlideLayout { 7 7 show_notes: bool, 8 + show_help: bool, 8 9 } 9 10 10 11 impl SlideLayout { 11 12 pub fn new(show_notes: bool) -> Self { 12 - Self { show_notes } 13 + Self { show_notes, show_help: false } 13 14 } 14 15 16 + /// Panel margin (horizontal, vertical) around bordered panels 17 + const PANEL_MARGIN: Margin = Margin { 18 + horizontal: 2, 19 + vertical: 1, 20 + }; 21 + 15 22 /// Calculate layout areas for the slide viewer 16 23 /// 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) { 24 + /// Returns (main_area, notes_area, status_area, help_area) where notes_area and help_area are None if hidden. 25 + pub fn calculate(&self, area: Rect) -> (Rect, Option<Rect>, Rect, Option<Rect>) { 26 + let status_height = if self.show_help { 2 } else { 1 }; 27 + 19 28 let vertical_chunks = Layout::default() 20 29 .direction(Direction::Vertical) 21 - .constraints([Constraint::Min(3), Constraint::Length(1)]) 30 + .constraints([Constraint::Min(3), Constraint::Length(status_height)]) 22 31 .split(area); 23 32 24 33 let content_area = vertical_chunks[0]; 25 - let status_area = vertical_chunks[1]; 34 + let bottom_area = vertical_chunks[1]; 35 + 36 + let (status_area, help_area) = if self.show_help { 37 + let bottom_chunks = Layout::default() 38 + .direction(Direction::Vertical) 39 + .constraints([Constraint::Length(1), Constraint::Length(1)]) 40 + .split(bottom_area); 41 + (bottom_chunks[0], Some(bottom_chunks[1])) 42 + } else { 43 + (bottom_area, None) 44 + }; 26 45 27 46 if self.show_notes { 28 47 let horizontal_chunks = Layout::default() ··· 30 49 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 31 50 .split(content_area); 32 51 33 - (horizontal_chunks[0], Some(horizontal_chunks[1]), status_area) 52 + let main_with_margin = horizontal_chunks[0].inner(Self::PANEL_MARGIN); 53 + let notes_with_margin = horizontal_chunks[1].inner(Self::PANEL_MARGIN); 54 + 55 + (main_with_margin, Some(notes_with_margin), status_area, help_area) 34 56 } else { 35 - (content_area, None, status_area) 57 + let content_with_margin = content_area.inner(Self::PANEL_MARGIN); 58 + (content_with_margin, None, status_area, help_area) 36 59 } 37 60 } 38 61 ··· 45 68 pub fn is_showing_notes(&self) -> bool { 46 69 self.show_notes 47 70 } 71 + 72 + /// Update help visibility 73 + pub fn set_show_help(&mut self, show: bool) { 74 + self.show_help = show; 75 + } 76 + 77 + /// Check if help is visible 78 + pub fn is_showing_help(&self) -> bool { 79 + self.show_help 80 + } 48 81 } 49 82 50 83 impl Default for SlideLayout { 51 84 fn default() -> Self { 52 - Self { show_notes: false } 85 + Self { show_notes: false, show_help: false } 53 86 } 54 87 } 55 88 ··· 61 94 fn layout_without_notes() { 62 95 let layout = SlideLayout::new(false); 63 96 let area = Rect::new(0, 0, 100, 50); 64 - let (main, notes, status) = layout.calculate(area); 97 + let (main, notes, status, help) = layout.calculate(area); 65 98 66 99 assert!(notes.is_none()); 100 + assert!(help.is_none()); 67 101 assert_eq!(status.height, 1); 68 102 assert!(main.height > status.height); 69 103 } ··· 72 106 fn layout_with_notes() { 73 107 let layout = SlideLayout::new(true); 74 108 let area = Rect::new(0, 0, 100, 50); 75 - let (main, notes, status) = layout.calculate(area); 109 + let (main, notes, status, help) = layout.calculate(area); 76 110 77 111 assert!(notes.is_some()); 112 + assert!(help.is_none()); 78 113 let notes_area = notes.unwrap(); 79 114 assert!(main.width > notes_area.width); 80 115 assert_eq!(main.height, notes_area.height); ··· 97 132 fn layout_small_terminal() { 98 133 let layout = SlideLayout::new(false); 99 134 let area = Rect::new(0, 0, 20, 10); 100 - let (main, _notes, status) = layout.calculate(area); 135 + let (main, _notes, status, _help) = layout.calculate(area); 101 136 102 137 assert_eq!(status.height, 1); 103 138 assert!(main.height >= 3); ··· 107 142 fn layout_proportions_with_notes() { 108 143 let layout = SlideLayout::new(true); 109 144 let area = Rect::new(0, 0, 100, 50); 110 - let (main, notes, _status) = layout.calculate(area); 145 + let (main, notes, _status, _help) = layout.calculate(area); 111 146 112 147 let notes_area = notes.unwrap(); 113 148 let main_percentage = (main.width as f32 / area.width as f32) * 100.0; ··· 115 150 116 151 assert!(main_percentage >= 55.0 && main_percentage <= 65.0); 117 152 assert!(notes_percentage >= 35.0 && notes_percentage <= 45.0); 153 + } 154 + 155 + #[test] 156 + fn layout_with_help() { 157 + let mut layout = SlideLayout::new(false); 158 + layout.set_show_help(true); 159 + let area = Rect::new(0, 0, 100, 50); 160 + let (main, notes, status, help) = layout.calculate(area); 161 + 162 + assert!(notes.is_none()); 163 + assert!(help.is_some()); 164 + assert_eq!(status.height, 1); 165 + assert_eq!(help.unwrap().height, 1); 166 + assert!(main.height > status.height); 167 + } 168 + 169 + #[test] 170 + fn layout_toggle_help() { 171 + let mut layout = SlideLayout::default(); 172 + assert!(!layout.is_showing_help()); 173 + 174 + layout.set_show_help(true); 175 + assert!(layout.is_showing_help()); 176 + 177 + layout.set_show_help(false); 178 + assert!(!layout.is_showing_help()); 118 179 } 119 180 }
+74 -24
ui/src/viewer.rs
··· 26 26 27 27 fn status_bar(&self) -> Style { 28 28 Style::default() 29 - .bg(self.bg_color()) 29 + .bg(Color::Rgb( 30 + self.theme.ui_border.r, 31 + self.theme.ui_border.g, 32 + self.theme.ui_border.b, 33 + )) 30 34 .fg(self.ui_text_color()) 31 35 .add_modifier(Modifier::BOLD) 32 36 } ··· 43 47 Color::Rgb(self.theme.body.r, self.theme.body.g, self.theme.body.b) 44 48 } 45 49 46 - fn bg_color(&self) -> Color { 47 - Color::Rgb( 48 - self.theme.ui_background.r, 49 - self.theme.ui_background.g, 50 - self.theme.ui_background.b, 51 - ) 52 - } 53 - 54 50 fn ui_text_color(&self) -> Color { 55 51 Color::Rgb(self.theme.ui_text.r, self.theme.ui_text.g, self.theme.ui_text.b) 56 52 } ··· 84 80 show_notes: false, 85 81 stylesheet: theme.into(), 86 82 filename: None, 87 - theme_name: "default".to_string(), 83 + theme_name: "oxocarbon-dark".to_string(), 88 84 start_time: None, 89 85 } 90 86 } ··· 119 115 } 120 116 } 121 117 122 - /// Jump to a specific slide by index (0-based) 123 - pub fn jump_to(&mut self, index: usize) { 124 - if index < self.slides.len() { 125 - self.current_index = index; 118 + /// Jump to a specific slide by number (1-based) 119 + pub fn jump_to(&mut self, slide_number: usize) { 120 + if slide_number > 0 && slide_number <= self.slides.len() { 121 + self.current_index = slide_number - 1; 126 122 } 127 123 } 128 124 ··· 151 147 self.show_notes 152 148 } 153 149 150 + /// Check if any slides have speaker notes 151 + pub fn has_notes(&self) -> bool { 152 + self.slides.iter().any(|slide| slide.notes.is_some()) 153 + } 154 + 154 155 fn theme(&self) -> ThemeColors { 155 156 self.stylesheet.theme.clone() 156 157 } ··· 220 221 }) 221 222 .unwrap_or_default(); 222 223 224 + let notes_part = if self.has_notes() { 225 + format!(" | [N] Notes {}", if self.show_notes { "✓" } else { "" }) 226 + } else { 227 + String::new() 228 + }; 229 + 223 230 let status_text = format!( 224 - " {}{}/{} | Theme: {} | [←/→] Navigate | [N] Notes {} | [Q] Quit{} ", 231 + " {}{}/{} | Theme: {}{}{} | [?] Help ", 225 232 filename_part, 226 233 self.current_index + 1, 227 234 self.total_slides(), 228 235 self.theme_name, 229 - if self.show_notes { "✓" } else { "" }, 236 + notes_part, 230 237 elapsed 231 238 ); 232 239 240 + let width = area.width as usize; 241 + let text_len = status_text.chars().count(); 242 + let padding = if text_len < width { " ".repeat(width - text_len) } else { String::new() }; 243 + 233 244 let status = Paragraph::new(Line::from(vec![Span::styled( 234 - status_text, 245 + format!("{status_text}{padding}"), 235 246 self.stylesheet.status_bar(), 236 247 )])); 237 248 238 249 frame.render_widget(status, area); 239 250 } 251 + 252 + /// Render help line with keybinding reference 253 + pub fn render_help_line(&self, frame: &mut Frame, area: Rect) { 254 + let help_text = " [j/→/Space] Next | [k/←] Previous | [N] Toggle notes | [Q/Esc] Quit "; 255 + 256 + let width = area.width as usize; 257 + let text_len = help_text.chars().count(); 258 + let padding = if text_len < width { " ".repeat(width - text_len) } else { String::new() }; 259 + 260 + let full_text = format!("{}{}", help_text, padding); 261 + 262 + let dimmed_style = Style::default().fg(Color::Rgb(100, 100, 100)).bg(Color::Rgb( 263 + self.theme().ui_background.r, 264 + self.theme().ui_background.g, 265 + self.theme().ui_background.b, 266 + )); 267 + 268 + let help_line = Paragraph::new(Line::from(vec![Span::styled(full_text, dimmed_style)])); 269 + 270 + frame.render_widget(help_line, area); 271 + } 240 272 } 241 273 242 274 #[cfg(test)] ··· 289 321 let slides = create_test_slides(); 290 322 let mut viewer = SlideViewer::new(slides, ThemeColors::default()); 291 323 292 - viewer.jump_to(2); 324 + viewer.jump_to(3); 293 325 assert_eq!(viewer.current_index(), 2); 294 326 295 327 viewer.previous(); ··· 307 339 let slides = create_test_slides(); 308 340 let mut viewer = SlideViewer::new(slides, ThemeColors::default()); 309 341 310 - viewer.jump_to(2); 342 + viewer.jump_to(3); 311 343 assert_eq!(viewer.current_index(), 2); 312 344 313 - viewer.jump_to(0); 345 + viewer.jump_to(1); 314 346 assert_eq!(viewer.current_index(), 0); 315 347 316 348 viewer.jump_to(10); 317 349 assert_eq!(viewer.current_index(), 0); 350 + 351 + viewer.jump_to(0); 352 + assert_eq!(viewer.current_index(), 0); 318 353 } 319 354 320 355 #[test] ··· 338 373 339 374 assert!(viewer.current_slide().is_some()); 340 375 341 - viewer.jump_to(1); 376 + viewer.jump_to(2); 342 377 let slide = viewer.current_slide().unwrap(); 343 378 assert_eq!(slide.blocks.len(), 1); 344 379 } ··· 370 405 #[test] 371 406 fn viewer_with_context_none_values() { 372 407 let slides = create_test_slides(); 373 - let viewer = SlideViewer::with_context(slides, ThemeColors::default(), None, "default".to_string(), None); 408 + let viewer = 409 + SlideViewer::with_context(slides, ThemeColors::default(), None, "oxocarbon-dark".to_string(), None); 374 410 375 411 assert_eq!(viewer.filename, None); 376 - assert_eq!(viewer.theme_name, "default"); 412 + assert_eq!(viewer.theme_name, "oxocarbon-dark"); 377 413 assert_eq!(viewer.start_time, None); 378 414 } 379 415 ··· 383 419 let viewer = SlideViewer::new(slides, ThemeColors::default()); 384 420 385 421 assert_eq!(viewer.filename, None); 386 - assert_eq!(viewer.theme_name, "default"); 422 + assert_eq!(viewer.theme_name, "oxocarbon-dark"); 387 423 assert_eq!(viewer.start_time, None); 424 + } 425 + 426 + #[test] 427 + fn viewer_has_notes() { 428 + let slides_without_notes = create_test_slides(); 429 + let viewer_no_notes = SlideViewer::new(slides_without_notes, ThemeColors::default()); 430 + assert!(!viewer_no_notes.has_notes()); 431 + 432 + let slides_with_notes = vec![Slide { 433 + blocks: vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Slide with notes")] }], 434 + notes: Some("These are speaker notes".to_string()), 435 + }]; 436 + let viewer_with_notes = SlideViewer::new(slides_with_notes, ThemeColors::default()); 437 + assert!(viewer_with_notes.has_notes()); 388 438 } 389 439 }