Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

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

Merge pull request #11 from RivoLink/chore/code-review-optimization

chore: code review optimization

authored by

Rivo Link and committed by
GitHub
8be1dbd0 07c24c6f

+1514 -711
+84
ARCHITECTURE.md
··· 1 + # Architecture 2 + 3 + `leaf` is a terminal Markdown previewer built around a small set of focused modules: 4 + 5 + - `src/main.rs` 6 + - entrypoint 7 + - loads CLI options 8 + - reads the initial document or opens the file picker 9 + - initializes terminal + syntax/theme assets 10 + 11 + - `src/app.rs` 12 + - central runtime state 13 + - document content, TOC, search state, watch state 14 + - theme picker + file picker state 15 + - cache invalidation and document replacement helpers 16 + 17 + - `src/markdown.rs` 18 + - Markdown parsing and render preparation 19 + - heading, TOC, list, table, blockquote, code block rendering 20 + - width-aware wrapping helpers 21 + 22 + - `src/render.rs` 23 + - draws the TUI with `ratatui` 24 + - main content, TOC, status bar 25 + - modal rendering for help, theme picker, and file picker 26 + 27 + - `src/runtime.rs` 28 + - event loop 29 + - keyboard/mouse handling 30 + - watch polling 31 + - resize-driven render width synchronization 32 + 33 + - `src/theme.rs` 34 + - UI and Markdown theme presets 35 + - active theme preset selection 36 + - syntect theme mapping 37 + 38 + - `src/cli.rs` 39 + - command-line parsing 40 + - usage/version text 41 + 42 + - `src/terminal.rs` 43 + - raw mode / alternate screen lifecycle 44 + - terminal restore guarantees 45 + 46 + - `src/tests.rs` 47 + - regression tests for rendering and state behavior 48 + 49 + ## Execution flow 50 + 51 + 1. `main.rs` parses CLI options. 52 + 2. A document is loaded from: 53 + - a file argument, or 54 + - `stdin`, or 55 + - the file picker if no input is provided interactively. 56 + 3. `markdown.rs` parses the source into rendered lines + TOC. 57 + 4. `App` stores the state and caches. 58 + 5. `runtime.rs` runs the event loop. 59 + 6. `render.rs` draws each frame from `App`. 60 + 61 + ## Important state transitions 62 + 63 + - document reload / open: 64 + - source changes 65 + - rendered lines and TOC are rebuilt 66 + - caches are refreshed 67 + 68 + - resize: 69 + - effective render width is recomputed 70 + - Markdown is reparsed width-aware 71 + 72 + - theme preview: 73 + - previewed content is reparsed and cached per preset 74 + - `Esc` restores the original theme 75 + 76 + - search: 77 + - query state lives in `App` 78 + - active match drives highlight + scroll position 79 + 80 + ## Current hotspots 81 + 82 + - `src/app.rs` still centralizes many responsibilities 83 + - `src/markdown.rs` is the densest module and the main future split candidate 84 + - `src/render.rs` is growing as more modal UI is added
+339 -150
src/app.rs
··· 37 37 38 38 #[derive(Clone, Debug, PartialEq, Eq)] 39 39 pub(crate) struct StatusCacheKey { 40 - pub(crate) pct: u16, 41 - pub(crate) search_mode: bool, 42 - pub(crate) search_draft_hash: u64, 43 - pub(crate) search_query_hash: u64, 44 - pub(crate) search_draft_len: usize, 45 - pub(crate) search_query_len: usize, 46 - pub(crate) search_match_count: usize, 47 - pub(crate) search_idx: usize, 48 - pub(crate) watch: bool, 49 - pub(crate) flash_active: bool, 40 + pct: u16, 41 + search_mode: bool, 42 + search_draft_hash: u64, 43 + search_query_hash: u64, 44 + search_draft_len: usize, 45 + search_query_len: usize, 46 + search_match_count: usize, 47 + search_idx: usize, 48 + watch: bool, 49 + flash_active: bool, 50 50 } 51 51 52 52 #[derive(Clone)] 53 53 pub(crate) struct ThemePreviewCacheEntry { 54 - pub(crate) lines: Vec<Line<'static>>, 55 - pub(crate) toc: Vec<TocEntry>, 54 + lines: Vec<Line<'static>>, 55 + toc: Vec<TocEntry>, 56 + } 57 + 58 + pub(crate) struct SearchState { 59 + mode: bool, 60 + draft: String, 61 + query: String, 62 + matches: Vec<usize>, 63 + idx: usize, 56 64 } 57 65 58 66 #[derive(Clone, Debug, PartialEq, Eq)] 59 67 pub(crate) struct FilePickerEntry { 60 - pub(crate) label: String, 61 - pub(crate) path: PathBuf, 62 - pub(crate) is_dir: bool, 68 + label: String, 69 + path: PathBuf, 70 + is_dir: bool, 71 + } 72 + 73 + impl FilePickerEntry { 74 + pub(crate) fn label(&self) -> &str { 75 + &self.label 76 + } 77 + 78 + pub(crate) fn is_dir(&self) -> bool { 79 + self.is_dir 80 + } 81 + } 82 + 83 + pub(crate) struct FilePickerState { 84 + open: bool, 85 + dir: PathBuf, 86 + entries: Vec<FilePickerEntry>, 87 + index: usize, 88 + } 89 + 90 + pub(crate) struct ThemePickerState { 91 + open: bool, 92 + index: usize, 93 + original: Option<ThemePreset>, 94 + preview_cache: Vec<Option<ThemePreviewCacheEntry>>, 63 95 } 64 96 65 - pub(crate) struct App { 66 - pub(crate) lines: Vec<Line<'static>>, 67 - pub(crate) plain_lines: Vec<String>, 68 - pub(crate) folded_plain_lines: Option<Vec<String>>, 69 - pub(crate) scroll: usize, 70 - pub(crate) toc: Vec<TocEntry>, 71 - pub(crate) toc_visible: bool, 72 - pub(crate) search_mode: bool, 73 - pub(crate) search_draft: String, 74 - pub(crate) search_query: String, 75 - pub(crate) search_matches: Vec<usize>, 76 - pub(crate) search_idx: usize, 77 - pub(crate) debug_input: bool, 97 + pub(crate) struct AppConfig { 78 98 pub(crate) filename: String, 79 99 pub(crate) source: String, 100 + pub(crate) debug_input: bool, 80 101 pub(crate) watch: bool, 81 102 pub(crate) filepath: Option<PathBuf>, 82 103 pub(crate) last_file_state: Option<FileState>, 83 - pub(crate) last_content_hash: u64, 84 - pub(crate) last_hash_check: Option<Instant>, 85 - pub(crate) reload_flash: Option<Instant>, 86 - pub(crate) highlighted_line_cache: Option<(usize, Line<'static>)>, 87 - pub(crate) toc_display_lines: Vec<Line<'static>>, 88 - pub(crate) toc_header_line: Line<'static>, 89 - pub(crate) toc_active_idx: Option<usize>, 90 - pub(crate) status_line: Line<'static>, 91 - pub(crate) status_cache_key: Option<StatusCacheKey>, 92 - pub(crate) help_open: bool, 93 - pub(crate) file_picker_open: bool, 94 - pub(crate) file_picker_dir: PathBuf, 95 - pub(crate) file_picker_entries: Vec<FilePickerEntry>, 96 - pub(crate) file_picker_index: usize, 97 - pub(crate) theme_picker_open: bool, 98 - pub(crate) theme_picker_index: usize, 99 - pub(crate) theme_picker_original: Option<ThemePreset>, 100 - pub(crate) theme_preview_cache: Vec<Option<ThemePreviewCacheEntry>>, 101 - pub(crate) render_width: usize, 104 + } 105 + 106 + pub(crate) struct App { 107 + lines: Vec<Line<'static>>, 108 + plain_lines: Vec<String>, 109 + folded_plain_lines: Option<Vec<String>>, 110 + scroll: usize, 111 + toc: Vec<TocEntry>, 112 + toc_visible: bool, 113 + search: SearchState, 114 + debug_input: bool, 115 + filename: String, 116 + source: String, 117 + watch: bool, 118 + filepath: Option<PathBuf>, 119 + last_file_state: Option<FileState>, 120 + last_content_hash: u64, 121 + last_hash_check: Option<Instant>, 122 + reload_flash: Option<Instant>, 123 + highlighted_line_cache: Option<(usize, Line<'static>)>, 124 + toc_display_lines: Vec<Line<'static>>, 125 + toc_header_line: Line<'static>, 126 + toc_active_idx: Option<usize>, 127 + status_line: Line<'static>, 128 + status_cache_key: Option<StatusCacheKey>, 129 + help_open: bool, 130 + file_picker: FilePickerState, 131 + theme_picker: ThemePickerState, 132 + render_width: usize, 102 133 } 103 134 104 135 impl App { ··· 153 184 Ok(entries) 154 185 } 155 186 187 + fn file_picker_entry_path(entry: &FilePickerEntry) -> PathBuf { 188 + entry.path.clone() 189 + } 190 + 156 191 #[cfg(test)] 157 192 pub(crate) fn new( 158 193 lines: Vec<Line<'static>>, ··· 173 208 }) 174 209 .collect::<Vec<_>>() 175 210 .join("\n"); 176 - Self::new_with_source( 177 - lines, 178 - toc, 211 + Self::new_with_source(lines, toc, AppConfig { 179 212 filename, 180 213 source, 181 214 debug_input, 182 215 watch, 183 216 filepath, 184 217 last_file_state, 185 - ) 218 + }) 186 219 } 187 220 188 - #[allow(clippy::too_many_arguments)] 189 221 pub(crate) fn new_with_source( 190 222 lines: Vec<Line<'static>>, 191 223 toc: Vec<TocEntry>, 192 - filename: String, 193 - source: String, 194 - debug_input: bool, 195 - watch: bool, 196 - filepath: Option<PathBuf>, 197 - last_file_state: Option<FileState>, 224 + config: AppConfig, 198 225 ) -> Self { 226 + let AppConfig { 227 + filename, 228 + source, 229 + debug_input, 230 + watch, 231 + filepath, 232 + last_file_state, 233 + } = config; 199 234 let plain_lines = build_plain_lines(&lines); 200 235 let mut app = Self { 201 236 lines, ··· 204 239 scroll: 0, 205 240 toc, 206 241 toc_visible: false, 207 - search_mode: false, 208 - search_draft: String::new(), 209 - search_query: String::new(), 210 - search_matches: vec![], 211 - search_idx: 0, 242 + search: SearchState { 243 + mode: false, 244 + draft: String::new(), 245 + query: String::new(), 246 + matches: vec![], 247 + idx: 0, 248 + }, 212 249 debug_input, 213 250 filename, 214 251 source, ··· 225 262 status_line: Line::default(), 226 263 status_cache_key: None, 227 264 help_open: false, 228 - file_picker_open: false, 229 - file_picker_dir: PathBuf::from("."), 230 - file_picker_entries: Vec::new(), 231 - file_picker_index: 0, 232 - theme_picker_open: false, 233 - theme_picker_index: theme_preset_index(current_theme_preset()), 234 - theme_picker_original: None, 235 - theme_preview_cache: vec![None; crate::theme::THEME_PRESETS.len()], 265 + file_picker: FilePickerState { 266 + open: false, 267 + dir: PathBuf::from("."), 268 + entries: Vec::new(), 269 + index: 0, 270 + }, 271 + theme_picker: ThemePickerState { 272 + open: false, 273 + index: theme_preset_index(current_theme_preset()), 274 + original: None, 275 + preview_cache: vec![None; crate::theme::THEME_PRESETS.len()], 276 + }, 236 277 render_width: 80, 237 278 }; 238 279 app.store_current_theme_preview(); ··· 244 285 self.last_content_hash = last_content_hash; 245 286 } 246 287 288 + pub(crate) fn is_watch_enabled(&self) -> bool { 289 + self.watch 290 + } 291 + 292 + pub(crate) fn debug_input_enabled(&self) -> bool { 293 + self.debug_input 294 + } 295 + 296 + pub(crate) fn is_toc_visible(&self) -> bool { 297 + self.toc_visible 298 + } 299 + 300 + pub(crate) fn has_toc(&self) -> bool { 301 + !self.toc.is_empty() 302 + } 303 + 247 304 pub(crate) fn total(&self) -> usize { 248 305 self.lines.len() 249 306 } 250 307 308 + pub(crate) fn scroll(&self) -> usize { 309 + self.scroll 310 + } 311 + 312 + pub(crate) fn visible_lines(&self, start: usize, end: usize) -> &[Line<'static>] { 313 + &self.lines[start..end] 314 + } 315 + 316 + pub(crate) fn highlighted_line_cache(&self) -> Option<&(usize, Line<'static>)> { 317 + self.highlighted_line_cache.as_ref() 318 + } 319 + 320 + pub(crate) fn toc_display_lines(&self) -> &[Line<'static>] { 321 + &self.toc_display_lines 322 + } 323 + 324 + pub(crate) fn toc_header_line(&self) -> &Line<'static> { 325 + &self.toc_header_line 326 + } 327 + 328 + pub(crate) fn status_line(&self) -> &Line<'static> { 329 + &self.status_line 330 + } 331 + 332 + pub(crate) fn filename(&self) -> &str { 333 + &self.filename 334 + } 335 + 251 336 pub(crate) fn replace_content(&mut self, lines: Vec<Line<'static>>, toc: Vec<TocEntry>) { 252 337 self.plain_lines = build_plain_lines(&lines); 253 338 self.folded_plain_lines = None; ··· 259 344 } 260 345 261 346 pub(crate) fn active_highlight_line(&self) -> Option<usize> { 262 - if self.search_matches.is_empty() { 347 + if self.search.matches.is_empty() { 263 348 None 264 349 } else { 265 - Some(self.search_matches[self.search_idx]) 350 + Some(self.search.matches[self.search.idx]) 266 351 } 267 352 } 268 353 354 + pub(crate) fn is_search_mode(&self) -> bool { 355 + self.search.mode 356 + } 357 + 358 + pub(crate) fn search_draft(&self) -> &str { 359 + &self.search.draft 360 + } 361 + 362 + pub(crate) fn search_query(&self) -> &str { 363 + &self.search.query 364 + } 365 + 366 + #[cfg(test)] 367 + pub(crate) fn set_search_query(&mut self, query: impl Into<String>) { 368 + self.search.query = query.into(); 369 + } 370 + 371 + pub(crate) fn search_match_count(&self) -> usize { 372 + self.search.matches.len() 373 + } 374 + 375 + pub(crate) fn search_index(&self) -> usize { 376 + self.search.idx 377 + } 378 + 379 + #[cfg(test)] 380 + pub(crate) fn search_matches(&self) -> &[usize] { 381 + &self.search.matches 382 + } 383 + 384 + #[cfg(test)] 385 + pub(crate) fn line(&self, idx: usize) -> Option<&Line<'static>> { 386 + self.lines.get(idx) 387 + } 388 + 389 + #[cfg(test)] 390 + pub(crate) fn set_search_draft(&mut self, draft: impl Into<String>) { 391 + self.search.draft = draft.into(); 392 + } 393 + 394 + pub(crate) fn pop_search_draft(&mut self) { 395 + self.search.draft.pop(); 396 + } 397 + 398 + pub(crate) fn push_search_draft(&mut self, ch: char) { 399 + self.search.draft.push(ch); 400 + } 401 + 269 402 pub(crate) fn active_toc_index(&self) -> Option<usize> { 270 403 let hide_single_h1 = should_hide_single_h1(&self.toc); 271 404 let mut first_visible = None; ··· 352 485 pub(crate) fn refresh_status_cache(&mut self, pct: u16) { 353 486 let cache_key = StatusCacheKey { 354 487 pct, 355 - search_mode: self.search_mode, 356 - search_draft_hash: hash_str(&self.search_draft), 357 - search_query_hash: hash_str(&self.search_query), 358 - search_draft_len: self.search_draft.len(), 359 - search_query_len: self.search_query.len(), 360 - search_match_count: self.search_matches.len(), 361 - search_idx: self.search_idx, 488 + search_mode: self.search.mode, 489 + search_draft_hash: hash_str(&self.search.draft), 490 + search_query_hash: hash_str(&self.search.query), 491 + search_draft_len: self.search.draft.len(), 492 + search_query_len: self.search.query.len(), 493 + search_match_count: self.search.matches.len(), 494 + search_idx: self.search.idx, 362 495 watch: self.watch, 363 496 flash_active: self 364 497 .reload_flash ··· 382 515 } 383 516 384 517 pub(crate) fn invalidate_theme_preview_cache(&mut self) { 385 - self.theme_preview_cache.fill(None); 518 + self.theme_picker.preview_cache.fill(None); 386 519 } 387 520 388 521 fn store_theme_preview( ··· 392 525 toc: &[TocEntry], 393 526 ) { 394 527 let idx = theme_preset_index(preset); 395 - if let Some(slot) = self.theme_preview_cache.get_mut(idx) { 528 + if let Some(slot) = self.theme_picker.preview_cache.get_mut(idx) { 396 529 *slot = Some(ThemePreviewCacheEntry { 397 530 lines: lines.to_vec(), 398 531 toc: toc.to_vec(), ··· 408 541 } 409 542 410 543 pub(crate) fn open_theme_picker(&mut self) { 411 - self.theme_picker_open = true; 544 + self.theme_picker.open = true; 412 545 let current = current_theme_preset(); 413 - self.theme_picker_index = theme_preset_index(current); 414 - self.theme_picker_original = Some(current); 546 + self.theme_picker.index = theme_preset_index(current); 547 + self.theme_picker.original = Some(current); 415 548 self.store_current_theme_preview(); 416 549 } 417 550 418 551 pub(crate) fn close_theme_picker(&mut self) { 419 - self.theme_picker_open = false; 420 - self.theme_picker_original = None; 552 + self.theme_picker.open = false; 553 + self.theme_picker.original = None; 421 554 } 422 555 423 556 pub(crate) fn is_theme_picker_open(&self) -> bool { 424 - self.theme_picker_open 557 + self.theme_picker.open 425 558 } 426 559 427 560 pub(crate) fn open_help(&mut self) { ··· 439 572 pub(crate) fn open_file_picker(&mut self, dir: PathBuf) -> bool { 440 573 match Self::build_file_picker_entries(&dir) { 441 574 Ok(entries) => { 442 - self.file_picker_open = true; 443 - self.file_picker_dir = dir; 444 - self.file_picker_entries = entries; 445 - self.file_picker_index = 0; 575 + self.file_picker.open = true; 576 + self.file_picker.dir = dir; 577 + self.file_picker.entries = entries; 578 + self.file_picker.index = 0; 446 579 true 447 580 } 448 581 Err(_) => false, ··· 450 583 } 451 584 452 585 pub(crate) fn is_file_picker_open(&self) -> bool { 453 - self.file_picker_open 586 + self.file_picker.open 587 + } 588 + 589 + pub(crate) fn file_picker_dir(&self) -> &std::path::Path { 590 + &self.file_picker.dir 591 + } 592 + 593 + pub(crate) fn file_picker_entries(&self) -> &[FilePickerEntry] { 594 + &self.file_picker.entries 595 + } 596 + 597 + pub(crate) fn file_picker_index(&self) -> usize { 598 + self.file_picker.index 454 599 } 455 600 456 601 pub(crate) fn move_file_picker_up(&mut self) { 457 - let total = self.file_picker_entries.len(); 602 + let total = self.file_picker.entries.len(); 458 603 if total == 0 { 459 604 return; 460 605 } 461 - if self.file_picker_index == 0 { 462 - self.file_picker_index = total - 1; 606 + if self.file_picker.index == 0 { 607 + self.file_picker.index = total - 1; 463 608 } else { 464 - self.file_picker_index -= 1; 609 + self.file_picker.index -= 1; 465 610 } 466 611 } 467 612 468 613 pub(crate) fn move_file_picker_down(&mut self) { 469 - let total = self.file_picker_entries.len(); 614 + let total = self.file_picker.entries.len(); 470 615 if total == 0 { 471 616 return; 472 617 } 473 - self.file_picker_index = (self.file_picker_index + 1) % total; 618 + self.file_picker.index = (self.file_picker.index + 1) % total; 474 619 } 475 620 476 621 pub(crate) fn open_file_picker_parent(&mut self) -> bool { 477 - let Some(parent) = self.file_picker_dir.parent() else { 622 + let Some(parent) = self.file_picker.dir.parent() else { 478 623 return false; 479 624 }; 480 625 self.open_file_picker(parent.to_path_buf()) 481 626 } 482 627 483 628 pub(crate) fn theme_picker_index(&self) -> usize { 484 - self.theme_picker_index 629 + self.theme_picker.index 630 + } 631 + 632 + #[cfg(test)] 633 + pub(crate) fn theme_picker_original(&self) -> Option<ThemePreset> { 634 + self.theme_picker.original 635 + } 636 + 637 + pub(crate) fn clear_reload_flash(&mut self) { 638 + self.reload_flash = None; 639 + } 640 + 641 + pub(crate) fn reload_flash_started(&self) -> Option<Instant> { 642 + self.reload_flash 643 + } 644 + 645 + pub(crate) fn set_last_file_state(&mut self, state: FileState) { 646 + self.last_file_state = Some(state); 485 647 } 486 648 487 649 pub(crate) fn theme_picker_reference_preset(&self) -> ThemePreset { 488 - self.theme_picker_original.unwrap_or(current_theme_preset()) 650 + self.theme_picker.original.unwrap_or(current_theme_preset()) 489 651 } 490 652 491 653 pub(crate) fn move_theme_picker_up(&mut self) { ··· 493 655 if total == 0 { 494 656 return; 495 657 } 496 - if self.theme_picker_index == 0 { 497 - self.theme_picker_index = total - 1; 658 + if self.theme_picker.index == 0 { 659 + self.theme_picker.index = total - 1; 498 660 } else { 499 - self.theme_picker_index -= 1; 661 + self.theme_picker.index -= 1; 500 662 } 501 663 } 502 664 ··· 505 667 if total == 0 { 506 668 return; 507 669 } 508 - self.theme_picker_index = (self.theme_picker_index + 1) % total; 670 + self.theme_picker.index = (self.theme_picker.index + 1) % total; 509 671 } 510 672 511 673 pub(crate) fn set_theme_picker_index(&mut self, idx: usize) -> bool { 512 674 if idx < THEME_PRESETS.len() { 513 - self.theme_picker_index = idx; 675 + self.theme_picker.index = idx; 514 676 true 515 677 } else { 516 678 false ··· 518 680 } 519 681 520 682 pub(crate) fn selected_theme_preset(&self) -> Option<ThemePreset> { 521 - THEME_PRESETS.get(self.theme_picker_index).copied() 683 + THEME_PRESETS.get(self.theme_picker.index).copied() 684 + } 685 + 686 + #[cfg(test)] 687 + pub(crate) fn has_cached_theme_preview(&self, preset: ThemePreset) -> bool { 688 + self.theme_picker 689 + .preview_cache 690 + .get(theme_preset_index(preset)) 691 + .and_then(|entry| entry.as_ref()) 692 + .is_some() 522 693 } 523 694 524 695 pub(crate) fn preview_theme_preset( ··· 532 703 } 533 704 set_theme_preset(preset); 534 705 let cached = self 535 - .theme_preview_cache 706 + .theme_picker 707 + .preview_cache 536 708 .get(theme_preset_index(preset)) 537 709 .and_then(|entry| entry.as_ref()) 538 710 .cloned(); ··· 549 721 } 550 722 551 723 pub(crate) fn restore_theme_picker_preview(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 552 - if let Some(original) = self.theme_picker_original { 724 + if let Some(original) = self.theme_picker.original { 553 725 self.preview_theme_preset(original, ss, themes); 554 726 } 555 727 self.close_theme_picker(); ··· 563 735 self.scroll = self.scroll.saturating_sub(n); 564 736 } 565 737 738 + pub(crate) fn scroll_top(&mut self) { 739 + self.scroll = 0; 740 + } 741 + 742 + pub(crate) fn scroll_bottom(&mut self) { 743 + self.scroll = self.total().saturating_sub(1); 744 + } 745 + 746 + pub(crate) fn toggle_toc(&mut self) { 747 + self.toc_visible = !self.toc_visible; 748 + } 749 + 750 + pub(crate) fn request_reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 751 + self.last_file_state = None; 752 + self.reload(ss, themes) 753 + } 754 + 566 755 pub(crate) fn jump_to_toc(&mut self, idx: usize) { 567 756 if let Some(e) = self.toc.get(idx) { 568 757 self.scroll = e.line; ··· 570 759 } 571 760 572 761 pub(crate) fn run_search(&mut self) { 573 - let q = self.search_query.to_lowercase(); 762 + let q = self.search.query.to_lowercase(); 574 763 if q.is_empty() { 575 764 return; 576 765 } ··· 583 772 .map(|(i, _)| i) 584 773 .collect() 585 774 }; 586 - self.search_matches = search_matches; 587 - self.search_idx = 0; 588 - if let Some(&f) = self.search_matches.first() { 775 + self.search.matches = search_matches; 776 + self.search.idx = 0; 777 + if let Some(&f) = self.search.matches.first() { 589 778 self.scroll = f; 590 779 } 591 780 } 592 781 593 782 pub(crate) fn begin_search(&mut self) { 594 - self.search_mode = true; 595 - self.search_draft = self.search_query.clone(); 783 + self.search.mode = true; 784 + self.search.draft = self.search.query.clone(); 596 785 crate::runtime::debug_log( 597 786 self.debug_input, 598 787 &format!( 599 788 "begin_search query={:?} draft={:?} matches={} idx={}", 600 - self.search_query, 601 - self.search_draft, 602 - self.search_matches.len(), 603 - self.search_idx 789 + self.search.query, 790 + self.search.draft, 791 + self.search.matches.len(), 792 + self.search.idx 604 793 ), 605 794 ); 606 795 } 607 796 608 797 pub(crate) fn reset_search_state(&mut self) { 609 - self.search_draft.clear(); 610 - self.search_query.clear(); 611 - self.search_matches.clear(); 612 - self.search_idx = 0; 798 + self.search.draft.clear(); 799 + self.search.query.clear(); 800 + self.search.matches.clear(); 801 + self.search.idx = 0; 613 802 } 614 803 615 804 pub(crate) fn cancel_search(&mut self) { 616 - self.search_mode = false; 805 + self.search.mode = false; 617 806 self.reset_search_state(); 618 807 crate::runtime::debug_log(self.debug_input, "cancel_search cleared query and matches"); 619 808 } 620 809 621 810 pub(crate) fn confirm_search(&mut self) { 622 - self.search_mode = false; 623 - let draft = std::mem::take(&mut self.search_draft); 624 - self.search_query = draft; 625 - if self.search_query.is_empty() { 811 + self.search.mode = false; 812 + let draft = std::mem::take(&mut self.search.draft); 813 + self.search.query = draft; 814 + if self.search.query.is_empty() { 626 815 self.reset_search_state(); 627 816 crate::runtime::debug_log( 628 817 self.debug_input, ··· 635 824 self.debug_input, 636 825 &format!( 637 826 "confirm_search query={:?} matches={} idx={} scroll={}", 638 - self.search_query, 639 - self.search_matches.len(), 640 - self.search_idx, 827 + self.search.query, 828 + self.search.matches.len(), 829 + self.search.idx, 641 830 self.scroll 642 831 ), 643 832 ); 644 833 } 645 834 646 835 pub(crate) fn clear_active_search(&mut self) { 647 - self.search_mode = false; 836 + self.search.mode = false; 648 837 self.reset_search_state(); 649 838 crate::runtime::debug_log( 650 839 self.debug_input, ··· 653 842 } 654 843 655 844 pub(crate) fn has_active_search(&self) -> bool { 656 - !self.search_query.is_empty() || !self.search_matches.is_empty() 845 + !self.search.query.is_empty() || !self.search.matches.is_empty() 657 846 } 658 847 659 848 pub(crate) fn next_match(&mut self) { 660 - if self.search_matches.is_empty() { 849 + if self.search.matches.is_empty() { 661 850 return; 662 851 } 663 - self.search_idx = (self.search_idx + 1) % self.search_matches.len(); 664 - self.scroll = self.search_matches[self.search_idx]; 852 + self.search.idx = (self.search.idx + 1) % self.search.matches.len(); 853 + self.scroll = self.search.matches[self.search.idx]; 665 854 } 666 855 667 856 pub(crate) fn prev_match(&mut self) { 668 - if self.search_matches.is_empty() { 857 + if self.search.matches.is_empty() { 669 858 return; 670 859 } 671 - if self.search_idx == 0 { 672 - self.search_idx = self.search_matches.len() - 1; 860 + if self.search.idx == 0 { 861 + self.search.idx = self.search.matches.len() - 1; 673 862 } else { 674 - self.search_idx -= 1; 863 + self.search.idx -= 1; 675 864 } 676 - self.scroll = self.search_matches[self.search_idx]; 865 + self.scroll = self.search.matches[self.search.idx]; 677 866 } 678 867 679 868 pub(crate) fn scroll_percent(&self, vh: usize) -> u16 { ··· 738 927 self.invalidate_theme_preview_cache(); 739 928 self.store_theme_preview(current_theme_preset(), &new_lines, &new_toc); 740 929 self.replace_content(new_lines, new_toc); 741 - if !self.search_query.is_empty() && !self.search_mode { 930 + if !self.search.query.is_empty() && !self.search.mode { 742 931 self.run_search(); 743 932 } 744 933 } ··· 766 955 self.reload_flash = None; 767 956 self.scroll = 0; 768 957 self.help_open = false; 769 - self.file_picker_open = false; 770 - self.theme_picker_open = false; 771 - self.search_mode = false; 958 + self.file_picker.open = false; 959 + self.theme_picker.open = false; 960 + self.search.mode = false; 772 961 self.reset_search_state(); 773 962 self.invalidate_theme_preview_cache(); 774 963 self.store_theme_preview(current_theme_preset(), &lines, &toc); ··· 781 970 ss: &SyntaxSet, 782 971 themes: &ThemeSet, 783 972 ) -> bool { 784 - let Some(entry) = self.file_picker_entries.get(self.file_picker_index).cloned() else { 973 + let Some(entry) = self.file_picker.entries.get(self.file_picker.index).cloned() else { 785 974 return false; 786 975 }; 787 - if entry.is_dir { 788 - self.open_file_picker(entry.path) 976 + if entry.is_dir() { 977 + self.open_file_picker(Self::file_picker_entry_path(&entry)) 789 978 } else { 790 - self.load_path(entry.path, ss, themes) 979 + self.load_path(Self::file_picker_entry_path(&entry), ss, themes) 791 980 } 792 981 } 793 982
+35 -12
src/main.rs
··· 1 - use anyhow::{Context, Result}; 1 + use anyhow::{bail, Context, Result}; 2 2 use ratatui::{backend::CrosstermBackend, Terminal}; 3 3 use std::{fs::OpenOptions, io, io::IsTerminal, io::Read, io::Write, path::PathBuf}; 4 4 use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; ··· 13 13 mod tests; 14 14 mod theme; 15 15 16 - use app::App; 16 + use app::{App, AppConfig}; 17 17 use cli::{parse_cli, print_usage, print_version, CliOptions}; 18 18 use markdown::{hash_str, parse_markdown, read_file_state}; 19 19 use runtime::run; 20 20 use terminal::{finish_with_restore, TerminalSession}; 21 21 use theme::{current_syntect_theme, set_theme_preset}; 22 + 23 + const MAX_STDIN_BYTES: usize = 8 * 1024 * 1024; 22 24 23 25 #[cfg(test)] 24 26 pub(crate) use app::{ ··· 30 32 pub(crate) use runtime::should_handle_key; 31 33 #[cfg(test)] 32 34 pub(crate) use theme::{parse_theme_preset, theme_preset_label, ThemePreset, THEME_PRESETS}; 35 + #[cfg(test)] 36 + pub(crate) use read_stdin_limited as read_stdin_with_limit; 37 + 38 + fn read_stdin_limited<R: Read>(reader: &mut R, max_bytes: usize) -> Result<String> { 39 + let mut buf = Vec::with_capacity(max_bytes.min(8192)); 40 + let limit = u64::try_from(max_bytes) 41 + .ok() 42 + .and_then(|value| value.checked_add(1)) 43 + .context("stdin size limit is too large")?; 44 + reader 45 + .take(limit) 46 + .read_to_end(&mut buf) 47 + .context("Cannot read stdin")?; 48 + if buf.len() > max_bytes { 49 + bail!( 50 + "stdin exceeds the maximum supported size of {} bytes", 51 + max_bytes 52 + ); 53 + } 54 + String::from_utf8(buf).context("stdin is not valid UTF-8") 55 + } 33 56 34 57 fn main() -> Result<()> { 35 58 let args: Vec<String> = std::env::args().collect(); ··· 86 109 eprintln!("Error: --watch requires a file path (stdin cannot be watched)"); 87 110 std::process::exit(1); 88 111 } 89 - let mut buf = String::new(); 90 - io::stdin() 91 - .read_to_string(&mut buf) 92 - .context("Cannot read stdin")?; 112 + let mut stdin = io::stdin().lock(); 113 + let buf = read_stdin_limited(&mut stdin, MAX_STDIN_BYTES)?; 93 114 (buf, "stdin".to_string(), None) 94 115 } 95 116 }; ··· 105 126 let mut app = App::new_with_source( 106 127 lines, 107 128 toc, 108 - filename, 109 - src, 110 - debug_input, 111 - watch, 112 - filepath, 113 - last_file_state, 129 + AppConfig { 130 + filename, 131 + source: src, 132 + debug_input, 133 + watch, 134 + filepath, 135 + last_file_state, 136 + }, 114 137 ); 115 138 app.set_last_content_hash(last_content_hash); 116 139 if let Some(dir) = open_picker_dir {
+578 -284
src/markdown.rs
··· 1 1 use crate::{ 2 2 app::{normalize_toc, TocEntry}, 3 - theme::app_theme, 3 + theme::{app_theme, MarkdownTheme}, 4 4 }; 5 5 use pulldown_cmark::{ 6 6 Alignment, CodeBlockKind, Event as MdEvent, HeadingLevel, Options, Parser, Tag, TagEnd, ··· 19 19 }; 20 20 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; 21 21 22 + const TAB_STOP: usize = 4; 23 + 22 24 #[derive(Clone, Copy)] 23 25 enum ListKind { 24 26 Unordered, ··· 34 36 struct ItemState { 35 37 marker_emitted: bool, 36 38 continuation_indent: usize, 39 + } 40 + 41 + #[derive(Clone, Copy, Default)] 42 + struct InlineStyleState { 43 + in_strong: bool, 44 + in_em: bool, 45 + in_strike: bool, 46 + in_link: bool, 37 47 } 38 48 39 49 struct TableBuf { ··· 101 111 } 102 112 103 113 pub(crate) fn display_width(text: &str) -> usize { 104 - const TAB_STOP: usize = 4; 105 - 106 114 let mut width = 0; 107 115 for ch in text.chars() { 108 116 if ch == '\t' { ··· 115 123 } 116 124 117 125 fn expand_tabs(text: &str, start_width: usize) -> String { 118 - const TAB_STOP: usize = 4; 119 - 120 126 let mut out = String::new(); 121 127 let mut width = start_width; 122 128 for ch in text.chars() { ··· 169 175 Color::Rgb(c.r, c.g, c.b) 170 176 } 171 177 172 - fn resolve_syntax<'a>(lang: &str, ss: &'a SyntaxSet) -> &'a syntect::parsing::SyntaxReference { 178 + pub(crate) fn resolve_syntax<'a>( 179 + lang: &str, 180 + ss: &'a SyntaxSet, 181 + ) -> &'a syntect::parsing::SyntaxReference { 173 182 let raw = lang.trim(); 174 183 let normalized = raw 175 184 .split(|c: char| c.is_whitespace() || c == ',' || c == '{') ··· 191 200 "js" | "javascript" => &["JavaScript", "js", "javascript"], 192 201 "jsx" => &["JSX", "jsx", "JavaScript React"], 193 202 "shell" | "bash" | "sh" | "zsh" => &["Bourne Again Shell (bash)", "bash", "sh"], 203 + "py" | "python" => &["Python", "py", "python"], 204 + "c" => &["C", "c"], 205 + "cpp" | "cxx" | "cc" | "c++" => &["C++", "cpp", "cxx", "cc"], 206 + "json" => &["JSON", "json"], 207 + "toml" => &["TOML", "toml"], 208 + "java" => &["Java", "java"], 209 + "kt" | "kotlin" => &["Kotlin", "kt", "kotlin"], 210 + "ps1" | "powershell" | "pwsh" => &["PowerShell", "ps1", "powershell"], 211 + "docker" | "dockerfile" => &["Dockerfile", "dockerfile"], 194 212 "yml" | "yaml" => &["YAML", "yml", "yaml"], 195 213 "rs" | "rust" => &["Rust", "rs", "rust"], 196 214 _ if normalized.is_empty() => &[], ··· 491 509 push_wrapped_prefixed_lines(lines, body_spans, prefix.clone(), prefix, render_width); 492 510 } 493 511 512 + fn flush_wrapped_spans( 513 + lines: &mut Vec<Line<'static>>, 514 + spans: &mut Vec<Span<'static>>, 515 + blockquote_depth: usize, 516 + list_stack: &[ListKind], 517 + item_stack: &mut [ItemState], 518 + render_width: usize, 519 + ) { 520 + if blockquote_depth > 0 && item_stack.is_empty() { 521 + push_wrapped_blockquote_lines(lines, spans, render_width); 522 + } else if !item_stack.is_empty() { 523 + let first_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 524 + let continuation_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 525 + push_wrapped_prefixed_lines( 526 + lines, 527 + spans, 528 + first_prefix, 529 + continuation_prefix, 530 + render_width, 531 + ); 532 + } else if !spans.is_empty() { 533 + let mut all = block_prefix(false); 534 + all.append(spans); 535 + lines.push(Line::from(all)); 536 + } 537 + } 538 + 539 + fn trim_paragraph_gap_before_block( 540 + lines: &mut Vec<Line<'static>>, 541 + last_block: LastBlock, 542 + item_stack: &[ItemState], 543 + ) { 544 + if last_block == LastBlock::Paragraph 545 + && item_stack.is_empty() 546 + && lines.last().is_some_and(|line| line_plain_text(line).is_empty()) 547 + { 548 + lines.pop(); 549 + } 550 + } 551 + 552 + fn push_heading_lines( 553 + lines: &mut Vec<Line<'static>>, 554 + toc: &mut Vec<TocEntry>, 555 + spans: &mut Vec<Span<'static>>, 556 + level: u8, 557 + render_width: usize, 558 + theme: &MarkdownTheme, 559 + ) { 560 + let color: Color = match level { 561 + 1 => theme.heading_1, 562 + 2 => theme.heading_2, 563 + 3 => theme.heading_3, 564 + _ => theme.heading_other, 565 + }; 566 + let style = Style::default().fg(color).add_modifier(match level { 567 + 1..=3 => Modifier::BOLD, 568 + _ => Modifier::empty(), 569 + }); 570 + let title: String = spans.iter().map(|s| s.content.as_ref()).collect(); 571 + let rendered_title = if level == 3 { 572 + format!("{title} ") 573 + } else { 574 + title.clone() 575 + }; 576 + toc.push(TocEntry { 577 + level, 578 + title: title.clone(), 579 + line: lines.len(), 580 + }); 581 + spans.clear(); 582 + lines.push(Line::from(vec![Span::styled(rendered_title, style)])); 583 + 584 + match level { 585 + 1 => lines.push(Line::from(Span::styled( 586 + "═".repeat(display_width(&title).min(rule_width(render_width, 0))), 587 + Style::default().fg(theme.heading_underline), 588 + ))), 589 + 2 => lines.push(Line::from(Span::styled( 590 + "─".repeat(display_width(&title).min(rule_width(render_width, 0))), 591 + Style::default().fg(theme.heading_underline), 592 + ))), 593 + _ => {} 594 + } 595 + } 596 + 597 + fn push_code_block_lines( 598 + lines: &mut Vec<Line<'static>>, 599 + code_buf: &mut String, 600 + code_lang: &mut String, 601 + ss: &SyntaxSet, 602 + theme: &Theme, 603 + render_width: usize, 604 + theme_colors: &MarkdownTheme, 605 + ) { 606 + let label = if code_lang.is_empty() { 607 + "text".to_string() 608 + } else { 609 + code_lang.clone() 610 + }; 611 + let (code_lines, inner_width) = highlight_code(code_buf, code_lang, ss, theme, render_width); 612 + let header_width = UnicodeWidthStr::width(label.as_str()) + 3; 613 + let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 614 + lines.push(Line::from(vec![ 615 + Span::styled( 616 + "┌─ ".to_string(), 617 + Style::default().fg(theme_colors.code_frame), 618 + ), 619 + Span::styled( 620 + format!("{label} "), 621 + Style::default().fg(theme_colors.code_label), 622 + ), 623 + Span::styled( 624 + format!("{top_bar}┐"), 625 + Style::default().fg(theme_colors.code_frame), 626 + ), 627 + ])); 628 + lines.extend(code_lines); 629 + lines.push(Line::from(Span::styled( 630 + format!("└{}┘", "─".repeat(inner_width)), 631 + Style::default().fg(theme_colors.code_frame), 632 + ))); 633 + lines.push(Line::from("")); 634 + code_lang.clear(); 635 + code_buf.clear(); 636 + } 637 + 638 + fn inline_text_style( 639 + theme: &MarkdownTheme, 640 + blockquote_depth: usize, 641 + inline: InlineStyleState, 642 + ) -> Style { 643 + let mut style = if blockquote_depth > 0 { 644 + Style::default() 645 + .fg(theme.blockquote_text) 646 + .add_modifier(Modifier::ITALIC) 647 + } else if inline.in_link { 648 + Style::default().fg(theme.link_text) 649 + } else { 650 + Style::default().fg(theme.text) 651 + }; 652 + 653 + if inline.in_strong { 654 + style = style 655 + .fg(theme.strong_text) 656 + .add_modifier(Modifier::BOLD); 657 + } 658 + if inline.in_em { 659 + style = style.add_modifier(Modifier::ITALIC); 660 + } 661 + if inline.in_strike { 662 + style = style.add_modifier(Modifier::CROSSED_OUT); 663 + } 664 + 665 + style 666 + } 667 + 668 + fn flush_list_item_spans( 669 + lines: &mut Vec<Line<'static>>, 670 + spans: &mut Vec<Span<'static>>, 671 + list_stack: &[ListKind], 672 + item_stack: &mut [ItemState], 673 + blockquote_depth: usize, 674 + render_width: usize, 675 + ) { 676 + if spans.is_empty() { 677 + return; 678 + } 679 + 680 + let first_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 681 + let continuation_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 682 + push_wrapped_prefixed_lines( 683 + lines, 684 + spans, 685 + first_prefix, 686 + continuation_prefix, 687 + render_width, 688 + ); 689 + } 690 + 691 + fn handle_table_event( 692 + table: &mut Option<TableBuf>, 693 + ev: &MdEvent<'_>, 694 + lines: &mut Vec<Line<'static>>, 695 + render_width: usize, 696 + ) -> bool { 697 + let Some(tb) = table.as_mut() else { 698 + return false; 699 + }; 700 + 701 + match ev { 702 + MdEvent::Text(t) | MdEvent::Code(t) => { 703 + tb.push_text(t.as_ref()); 704 + true 705 + } 706 + MdEvent::Start(Tag::TableCell) => true, 707 + MdEvent::End(TagEnd::TableCell) => { 708 + tb.end_cell(); 709 + true 710 + } 711 + MdEvent::Start(Tag::TableRow) => true, 712 + MdEvent::End(TagEnd::TableRow) => { 713 + tb.end_row(); 714 + true 715 + } 716 + MdEvent::Start(Tag::TableHead) => { 717 + tb.in_header = true; 718 + true 719 + } 720 + MdEvent::End(TagEnd::TableHead) => { 721 + tb.end_header(); 722 + true 723 + } 724 + MdEvent::Start(Tag::Strong) 725 + | MdEvent::End(TagEnd::Strong) 726 + | MdEvent::Start(Tag::Emphasis) 727 + | MdEvent::End(TagEnd::Emphasis) 728 + | MdEvent::Start(Tag::Link { .. }) 729 + | MdEvent::End(TagEnd::Link) => true, 730 + MdEvent::End(TagEnd::Table) => { 731 + let rendered = tb.render(render_width); 732 + lines.extend(rendered); 733 + *table = None; 734 + true 735 + } 736 + _ => true, 737 + } 738 + } 739 + 740 + fn start_list( 741 + lines: &mut Vec<Line<'static>>, 742 + last_block: LastBlock, 743 + item_stack: &[ItemState], 744 + list_stack: &mut Vec<ListKind>, 745 + start: Option<u64>, 746 + ) { 747 + trim_paragraph_gap_before_block(lines, last_block, item_stack); 748 + list_stack.push(match start { 749 + Some(n) => ListKind::Ordered(n), 750 + None => ListKind::Unordered, 751 + }); 752 + } 753 + 754 + fn start_table(table: &mut Option<TableBuf>, aligns: &[Alignment]) { 755 + *table = Some(TableBuf::new(aligns.to_vec())); 756 + } 757 + 758 + fn end_list(lines: &mut Vec<Line<'static>>, list_stack: &mut Vec<ListKind>) { 759 + list_stack.pop(); 760 + if list_stack.is_empty() { 761 + lines.push(Line::from("")); 762 + } 763 + } 764 + 765 + fn start_item(item_stack: &mut Vec<ItemState>) { 766 + item_stack.push(ItemState { 767 + marker_emitted: false, 768 + continuation_indent: 0, 769 + }); 770 + } 771 + 772 + fn end_item( 773 + lines: &mut Vec<Line<'static>>, 774 + spans: &mut Vec<Span<'static>>, 775 + list_stack: &mut [ListKind], 776 + item_stack: &mut Vec<ItemState>, 777 + blockquote_depth: usize, 778 + render_width: usize, 779 + ) { 780 + flush_list_item_spans( 781 + lines, 782 + spans, 783 + list_stack, 784 + item_stack, 785 + blockquote_depth, 786 + render_width, 787 + ); 788 + item_stack.pop(); 789 + if let Some(ListKind::Ordered(next)) = list_stack.last_mut() { 790 + *next += 1; 791 + } 792 + } 793 + 794 + fn end_blockquote( 795 + lines: &mut Vec<Line<'static>>, 796 + spans: &mut Vec<Span<'static>>, 797 + blockquote_depth: &mut usize, 798 + theme: &MarkdownTheme, 799 + ) { 800 + if !spans.is_empty() { 801 + let mut all = vec![Span::styled( 802 + "▏ ", 803 + Style::default().fg(theme.blockquote_marker), 804 + )]; 805 + all.append(spans); 806 + lines.push(Line::from(all)); 807 + } 808 + *blockquote_depth = blockquote_depth.saturating_sub(1); 809 + lines.push(Line::from("")); 810 + } 811 + 812 + fn push_rule_line(lines: &mut Vec<Line<'static>>, render_width: usize, theme: &MarkdownTheme) { 813 + lines.push(Line::from(Span::styled( 814 + "─".repeat(rule_width(render_width, 0)), 815 + Style::default().fg(theme.rule), 816 + ))); 817 + lines.push(Line::from("")); 818 + } 819 + 820 + fn push_inline_code_span(spans: &mut Vec<Span<'static>>, text: &str, theme: &MarkdownTheme) { 821 + spans.push(Span::styled( 822 + format!(" {} ", text), 823 + Style::default() 824 + .fg(theme.inline_code_fg) 825 + .bg(theme.inline_code_bg), 826 + )); 827 + } 828 + 829 + fn push_link_marker(spans: &mut Vec<Span<'static>>, theme: &MarkdownTheme) { 830 + spans.push(Span::styled("⌗", Style::default().fg(theme.link_icon))); 831 + } 832 + 833 + fn handle_inline_style_event( 834 + ev: &MdEvent<'_>, 835 + inline: &mut InlineStyleState, 836 + spans: &mut Vec<Span<'static>>, 837 + theme: &MarkdownTheme, 838 + ) -> bool { 839 + match ev { 840 + MdEvent::Start(Tag::Strong) => { 841 + inline.in_strong = true; 842 + true 843 + } 844 + MdEvent::End(TagEnd::Strong) => { 845 + inline.in_strong = false; 846 + true 847 + } 848 + MdEvent::Start(Tag::Emphasis) => { 849 + inline.in_em = true; 850 + true 851 + } 852 + MdEvent::End(TagEnd::Emphasis) => { 853 + inline.in_em = false; 854 + true 855 + } 856 + MdEvent::Start(Tag::Strikethrough) => { 857 + inline.in_strike = true; 858 + true 859 + } 860 + MdEvent::End(TagEnd::Strikethrough) => { 861 + inline.in_strike = false; 862 + true 863 + } 864 + MdEvent::Start(Tag::Link { .. }) => { 865 + inline.in_link = true; 866 + push_link_marker(spans, theme); 867 + true 868 + } 869 + MdEvent::End(TagEnd::Link) => { 870 + inline.in_link = false; 871 + true 872 + } 873 + _ => false, 874 + } 875 + } 876 + 877 + fn heading_level(level: HeadingLevel) -> u8 { 878 + match level { 879 + HeadingLevel::H1 => 1, 880 + HeadingLevel::H2 => 2, 881 + HeadingLevel::H3 => 3, 882 + _ => 4, 883 + } 884 + } 885 + 886 + fn start_heading(in_heading: &mut Option<u8>, level: HeadingLevel) { 887 + *in_heading = Some(heading_level(level)); 888 + } 889 + 890 + fn end_heading( 891 + lines: &mut Vec<Line<'static>>, 892 + toc: &mut Vec<TocEntry>, 893 + spans: &mut Vec<Span<'static>>, 894 + in_heading: &mut Option<u8>, 895 + render_width: usize, 896 + theme: &MarkdownTheme, 897 + ) { 898 + push_heading_lines( 899 + lines, 900 + toc, 901 + spans, 902 + in_heading.unwrap_or(1), 903 + render_width, 904 + theme, 905 + ); 906 + *in_heading = None; 907 + } 908 + 909 + fn start_code_block( 910 + lines: &mut Vec<Line<'static>>, 911 + last_block: LastBlock, 912 + item_stack: &[ItemState], 913 + in_code: &mut bool, 914 + code_buf: &mut String, 915 + code_lang: &mut String, 916 + kind: &CodeBlockKind<'_>, 917 + ) { 918 + trim_paragraph_gap_before_block(lines, last_block, item_stack); 919 + *in_code = true; 920 + code_buf.clear(); 921 + *code_lang = match kind { 922 + CodeBlockKind::Fenced(lang) => lang.to_string(), 923 + CodeBlockKind::Indented => String::new(), 924 + }; 925 + } 926 + 927 + fn end_line_break( 928 + lines: &mut Vec<Line<'static>>, 929 + spans: &mut Vec<Span<'static>>, 930 + in_code: bool, 931 + blockquote_depth: usize, 932 + list_stack: &[ListKind], 933 + item_stack: &mut [ItemState], 934 + render_width: usize, 935 + ) { 936 + if !in_code { 937 + flush_wrapped_spans( 938 + lines, 939 + spans, 940 + blockquote_depth, 941 + list_stack, 942 + item_stack, 943 + render_width, 944 + ); 945 + } 946 + } 947 + 948 + fn end_paragraph( 949 + lines: &mut Vec<Line<'static>>, 950 + spans: &mut Vec<Span<'static>>, 951 + blockquote_depth: usize, 952 + list_stack: &[ListKind], 953 + item_stack: &mut [ItemState], 954 + render_width: usize, 955 + ) { 956 + flush_wrapped_spans( 957 + lines, 958 + spans, 959 + blockquote_depth, 960 + list_stack, 961 + item_stack, 962 + render_width, 963 + ); 964 + lines.push(Line::from("")); 965 + } 966 + 967 + fn push_text_event( 968 + spans: &mut Vec<Span<'static>>, 969 + code_buf: &mut String, 970 + text: &str, 971 + in_code: bool, 972 + theme: &MarkdownTheme, 973 + blockquote_depth: usize, 974 + inline: InlineStyleState, 975 + ) { 976 + if in_code { 977 + code_buf.push_str(text); 978 + } else { 979 + spans.push(Span::styled( 980 + text.to_string(), 981 + inline_text_style(theme, blockquote_depth, inline), 982 + )); 983 + } 984 + } 985 + 494 986 impl TableBuf { 495 987 fn new(alignments: Vec<Alignment>) -> Self { 496 988 Self { ··· 814 1306 let mut code_lang = String::new(); 815 1307 let mut code_buf = String::new(); 816 1308 let mut blockquote_depth = 0usize; 817 - let mut in_strong = false; 818 - let mut in_em = false; 819 - let mut in_strike = false; 820 - let mut in_link = false; 1309 + let mut inline = InlineStyleState::default(); 821 1310 let mut list_stack: Vec<ListKind> = Vec::new(); 822 1311 let mut item_stack: Vec<ItemState> = Vec::new(); 823 1312 let mut table: Option<TableBuf> = None; 824 1313 let mut last_block = LastBlock::Other; 825 1314 826 - macro_rules! flush { 827 - ($prefix:expr) => {{ 828 - if !spans.is_empty() { 829 - let mut all: Vec<Span<'static>> = $prefix; 830 - all.append(&mut spans); 831 - lines.push(Line::from(all)); 832 - } 833 - }}; 834 - } 835 - 836 1315 for ev in Parser::new_ext(src, Options::all()) { 837 - if let Some(ref mut tb) = table { 838 - match &ev { 839 - MdEvent::Text(t) => { 840 - tb.push_text(t.as_ref()); 841 - continue; 842 - } 843 - MdEvent::Code(t) => { 844 - tb.push_text(t.as_ref()); 845 - continue; 846 - } 847 - MdEvent::Start(Tag::TableCell) | MdEvent::End(TagEnd::TableCell) => { 848 - if matches!(&ev, MdEvent::End(_)) { 849 - tb.end_cell(); 850 - } 851 - continue; 852 - } 853 - MdEvent::Start(Tag::TableRow) | MdEvent::End(TagEnd::TableRow) => { 854 - if matches!(&ev, MdEvent::End(_)) { 855 - tb.end_row(); 856 - } 857 - continue; 858 - } 859 - MdEvent::Start(Tag::TableHead) | MdEvent::End(TagEnd::TableHead) => { 860 - if matches!(&ev, MdEvent::End(_)) { 861 - tb.end_header(); 862 - } else { 863 - tb.in_header = true; 864 - } 865 - continue; 866 - } 867 - MdEvent::Start(Tag::Strong) 868 - | MdEvent::End(TagEnd::Strong) 869 - | MdEvent::Start(Tag::Emphasis) 870 - | MdEvent::End(TagEnd::Emphasis) 871 - | MdEvent::Start(Tag::Link { .. }) 872 - | MdEvent::End(TagEnd::Link) => { 873 - continue; 874 - } 875 - MdEvent::End(TagEnd::Table) => { 876 - lines.extend(tb.render(render_width)); 877 - table = None; 878 - continue; 879 - } 880 - _ => continue, 881 - } 1316 + if table.is_some() && handle_table_event(&mut table, &ev, &mut lines, render_width) { 1317 + continue; 1318 + } 1319 + if handle_inline_style_event( 1320 + &ev, 1321 + &mut inline, 1322 + &mut spans, 1323 + theme_colors, 1324 + ) { 1325 + continue; 882 1326 } 883 1327 884 1328 match ev { 885 1329 MdEvent::Start(Tag::Table(aligns)) => { 886 - table = Some(TableBuf::new(aligns.clone())); 1330 + start_table(&mut table, &aligns); 887 1331 } 888 1332 MdEvent::Start(Tag::Heading { level, .. }) => { 889 - in_heading = Some(match level { 890 - HeadingLevel::H1 => 1, 891 - HeadingLevel::H2 => 2, 892 - HeadingLevel::H3 => 3, 893 - _ => 4, 894 - }); 1333 + start_heading(&mut in_heading, level); 895 1334 } 896 1335 MdEvent::End(TagEnd::Heading(_)) => { 897 - let lvl = in_heading.unwrap_or(1); 898 - let color: Color = match lvl { 899 - 1 => theme_colors.heading_1, 900 - 2 => theme_colors.heading_2, 901 - 3 => theme_colors.heading_3, 902 - _ => theme_colors.heading_other, 903 - }; 904 - let style = Style::default().fg(color).add_modifier(match lvl { 905 - 1 => Modifier::BOLD, 906 - 2 => Modifier::BOLD, 907 - 3 => Modifier::BOLD, 908 - _ => Modifier::empty(), 909 - }); 910 - let title: String = spans.iter().map(|s| s.content.as_ref()).collect(); 911 - let rendered_title = if lvl == 3 { 912 - format!("{title} ") 913 - } else { 914 - title.clone() 915 - }; 916 - toc.push(TocEntry { 917 - level: lvl, 918 - title: title.clone(), 919 - line: lines.len(), 920 - }); 921 - let all = vec![Span::styled(rendered_title, style)]; 922 - spans.clear(); 923 - lines.push(Line::from(all)); 924 - if lvl == 1 { 925 - lines.push(Line::from(Span::styled( 926 - "═".repeat(display_width(&title).min(rule_width(render_width, 0))), 927 - Style::default().fg(theme_colors.heading_underline), 928 - ))); 929 - } 930 - if lvl == 2 { 931 - lines.push(Line::from(Span::styled( 932 - "─".repeat(display_width(&title).min(rule_width(render_width, 0))), 933 - Style::default().fg(theme_colors.heading_underline), 934 - ))); 935 - } 1336 + end_heading( 1337 + &mut lines, 1338 + &mut toc, 1339 + &mut spans, 1340 + &mut in_heading, 1341 + render_width, 1342 + theme_colors, 1343 + ); 936 1344 last_block = LastBlock::Other; 937 - in_heading = None; 938 1345 } 939 1346 MdEvent::Start(Tag::Paragraph) => {} 940 1347 MdEvent::End(TagEnd::Paragraph) => { 941 - if blockquote_depth > 0 && item_stack.is_empty() { 942 - push_wrapped_blockquote_lines(&mut lines, &mut spans, render_width); 943 - } else if !item_stack.is_empty() { 944 - let first_prefix = 945 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack); 946 - let continuation_prefix = 947 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack); 948 - push_wrapped_prefixed_lines( 949 - &mut lines, 950 - &mut spans, 951 - first_prefix, 952 - continuation_prefix, 953 - render_width, 954 - ); 955 - } else { 956 - flush!(block_prefix(false)); 957 - } 958 - lines.push(Line::from("")); 1348 + end_paragraph( 1349 + &mut lines, 1350 + &mut spans, 1351 + blockquote_depth, 1352 + &list_stack, 1353 + &mut item_stack, 1354 + render_width, 1355 + ); 959 1356 last_block = LastBlock::Paragraph; 960 1357 } 961 1358 MdEvent::Start(Tag::CodeBlock(kind)) => { 962 - if last_block == LastBlock::Paragraph 963 - && item_stack.is_empty() 964 - && lines.last().is_some_and(|line| line_plain_text(line).is_empty()) 965 - { 966 - lines.pop(); 967 - } 968 - in_code = true; 969 - code_buf.clear(); 970 - code_lang = match kind { 971 - CodeBlockKind::Fenced(l) => l.to_string(), 972 - CodeBlockKind::Indented => String::new(), 973 - }; 1359 + start_code_block( 1360 + &mut lines, 1361 + last_block, 1362 + &item_stack, 1363 + &mut in_code, 1364 + &mut code_buf, 1365 + &mut code_lang, 1366 + &kind, 1367 + ); 974 1368 last_block = LastBlock::Other; 975 1369 } 976 1370 MdEvent::End(TagEnd::CodeBlock) => { 977 1371 in_code = false; 978 - let ld = if code_lang.is_empty() { 979 - "text".to_string() 980 - } else { 981 - code_lang.clone() 982 - }; 983 - let (code_lines, inner_width) = 984 - highlight_code(&code_buf, &code_lang, ss, theme, render_width); 985 - let header_width = UnicodeWidthStr::width(ld.as_str()) + 3; 986 - let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 987 - lines.push(Line::from(vec![ 988 - Span::styled( 989 - "┌─ ".to_string(), 990 - Style::default().fg(theme_colors.code_frame), 991 - ), 992 - Span::styled( 993 - format!("{} ", ld), 994 - Style::default().fg(theme_colors.code_label), 995 - ), 996 - Span::styled( 997 - format!("{}┐", top_bar), 998 - Style::default().fg(theme_colors.code_frame), 999 - ), 1000 - ])); 1001 - lines.extend(code_lines); 1002 - lines.push(Line::from(Span::styled( 1003 - format!("└{}┘", "─".repeat(inner_width)), 1004 - Style::default().fg(theme_colors.code_frame), 1005 - ))); 1006 - lines.push(Line::from("")); 1007 - code_lang.clear(); 1008 - code_buf.clear(); 1372 + push_code_block_lines( 1373 + &mut lines, 1374 + &mut code_buf, 1375 + &mut code_lang, 1376 + ss, 1377 + theme, 1378 + render_width, 1379 + theme_colors, 1380 + ); 1009 1381 last_block = LastBlock::Other; 1010 1382 } 1011 1383 MdEvent::Code(text) => { 1012 - spans.push(Span::styled( 1013 - format!(" {} ", text.as_ref()), 1014 - Style::default() 1015 - .fg(theme_colors.inline_code_fg) 1016 - .bg(theme_colors.inline_code_bg), 1017 - )); 1384 + push_inline_code_span(&mut spans, text.as_ref(), theme_colors); 1018 1385 } 1019 1386 MdEvent::Start(Tag::BlockQuote(_)) => { 1020 1387 blockquote_depth += 1; 1021 1388 } 1022 1389 MdEvent::End(TagEnd::BlockQuote(_)) => { 1023 - flush!(vec![Span::styled( 1024 - "▏ ", 1025 - Style::default().fg(theme_colors.blockquote_marker) 1026 - )]); 1027 - blockquote_depth = blockquote_depth.saturating_sub(1); 1028 - lines.push(Line::from("")); 1390 + end_blockquote(&mut lines, &mut spans, &mut blockquote_depth, theme_colors); 1029 1391 last_block = LastBlock::Other; 1030 1392 } 1031 1393 MdEvent::Start(Tag::List(start)) => { 1032 - if last_block == LastBlock::Paragraph 1033 - && item_stack.is_empty() 1034 - && lines.last().is_some_and(|line| line_plain_text(line).is_empty()) 1035 - { 1036 - lines.pop(); 1037 - } 1038 - list_stack.push(match start { 1039 - Some(n) => ListKind::Ordered(n), 1040 - None => ListKind::Unordered, 1041 - }); 1394 + start_list(&mut lines, last_block, &item_stack, &mut list_stack, start); 1042 1395 last_block = LastBlock::Other; 1043 1396 } 1044 1397 MdEvent::End(TagEnd::List(_)) => { 1045 - list_stack.pop(); 1046 - if list_stack.is_empty() { 1047 - lines.push(Line::from("")); 1048 - } 1398 + end_list(&mut lines, &mut list_stack); 1049 1399 last_block = LastBlock::Other; 1050 1400 } 1051 1401 MdEvent::Start(Tag::Item) => { 1052 - item_stack.push(ItemState { 1053 - marker_emitted: false, 1054 - continuation_indent: 0, 1055 - }); 1402 + start_item(&mut item_stack); 1056 1403 } 1057 1404 MdEvent::End(TagEnd::Item) => { 1058 - if !spans.is_empty() { 1059 - let first_prefix = 1060 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack); 1061 - let continuation_prefix = 1062 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack); 1063 - push_wrapped_prefixed_lines( 1064 - &mut lines, 1065 - &mut spans, 1066 - first_prefix, 1067 - continuation_prefix, 1068 - render_width, 1069 - ); 1070 - } 1071 - item_stack.pop(); 1072 - if let Some(ListKind::Ordered(next)) = list_stack.last_mut() { 1073 - *next += 1; 1074 - } 1405 + end_item( 1406 + &mut lines, 1407 + &mut spans, 1408 + &mut list_stack, 1409 + &mut item_stack, 1410 + blockquote_depth, 1411 + render_width, 1412 + ); 1413 + last_block = LastBlock::Other; 1075 1414 } 1076 1415 MdEvent::Rule => { 1077 - lines.push(Line::from(Span::styled( 1078 - "─".repeat(rule_width(render_width, 0)), 1079 - Style::default().fg(theme_colors.rule), 1080 - ))); 1081 - lines.push(Line::from("")); 1416 + push_rule_line(&mut lines, render_width, theme_colors); 1082 1417 last_block = LastBlock::Other; 1083 1418 } 1084 - MdEvent::Start(Tag::Strong) => in_strong = true, 1085 - MdEvent::End(TagEnd::Strong) => in_strong = false, 1086 - MdEvent::Start(Tag::Emphasis) => in_em = true, 1087 - MdEvent::End(TagEnd::Emphasis) => in_em = false, 1088 - MdEvent::Start(Tag::Strikethrough) => in_strike = true, 1089 - MdEvent::End(TagEnd::Strikethrough) => in_strike = false, 1090 - MdEvent::Start(Tag::Link { .. }) => { 1091 - in_link = true; 1092 - spans.push(Span::styled( 1093 - "⌗", 1094 - Style::default().fg(theme_colors.link_icon), 1095 - )); 1096 - } 1097 - MdEvent::End(TagEnd::Link) => in_link = false, 1098 1419 MdEvent::Text(text) => { 1099 - if in_code { 1100 - code_buf.push_str(text.as_ref()); 1101 - } else { 1102 - let content = text.to_string(); 1103 - let mut style = if blockquote_depth > 0 { 1104 - Style::default() 1105 - .fg(theme_colors.blockquote_text) 1106 - .add_modifier(Modifier::ITALIC) 1107 - } else if in_link { 1108 - Style::default().fg(theme_colors.link_text) 1109 - } else { 1110 - Style::default().fg(theme_colors.text) 1111 - }; 1112 - if in_strong { 1113 - style = style 1114 - .fg(theme_colors.strong_text) 1115 - .add_modifier(Modifier::BOLD); 1116 - } 1117 - if in_em { 1118 - style = style.add_modifier(Modifier::ITALIC); 1119 - } 1120 - if in_strike { 1121 - style = style.add_modifier(Modifier::CROSSED_OUT); 1122 - } 1123 - spans.push(Span::styled(content, style)); 1124 - } 1420 + push_text_event( 1421 + &mut spans, 1422 + &mut code_buf, 1423 + text.as_ref(), 1424 + in_code, 1425 + theme_colors, 1426 + blockquote_depth, 1427 + inline, 1428 + ); 1125 1429 } 1126 1430 MdEvent::SoftBreak | MdEvent::HardBreak => { 1127 - if !in_code { 1128 - if blockquote_depth > 0 && item_stack.is_empty() { 1129 - push_wrapped_blockquote_lines(&mut lines, &mut spans, render_width); 1130 - } else if !item_stack.is_empty() { 1131 - let first_prefix = 1132 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack); 1133 - let continuation_prefix = 1134 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack); 1135 - push_wrapped_prefixed_lines( 1136 - &mut lines, 1137 - &mut spans, 1138 - first_prefix, 1139 - continuation_prefix, 1140 - render_width, 1141 - ); 1142 - } else { 1143 - flush!(block_prefix(false)); 1144 - } 1145 - } 1431 + end_line_break( 1432 + &mut lines, 1433 + &mut spans, 1434 + in_code, 1435 + blockquote_depth, 1436 + &list_stack, 1437 + &mut item_stack, 1438 + render_width, 1439 + ); 1146 1440 } 1147 1441 _ => {} 1148 1442 }
+33 -32
src/render.rs
··· 14 14 Frame, 15 15 }; 16 16 17 - const CONTENT_HORIZONTAL_PADDING: u16 = 1; 18 - const SCROLLBAR_WIDTH: u16 = 1; 17 + pub(crate) const CONTENT_HORIZONTAL_PADDING: u16 = 1; 18 + pub(crate) const SCROLLBAR_WIDTH: u16 = 1; 19 19 20 20 pub(crate) fn ui(f: &mut Frame, app: &mut App) { 21 21 let area = f.area(); ··· 24 24 .constraints([Constraint::Min(0), Constraint::Length(1)]) 25 25 .split(area); 26 26 27 - let (toc_area, content_area): (Option<Rect>, Rect) = if app.toc_visible && !app.toc.is_empty() { 27 + let (toc_area, content_area): (Option<Rect>, Rect) = 28 + if app.is_toc_visible() && app.has_toc() { 28 29 let cols = Layout::default() 29 30 .direction(Direction::Horizontal) 30 31 .constraints([Constraint::Length(30), Constraint::Min(0)]) ··· 71 72 toc_chunks[0], 72 73 ); 73 74 f.render_widget( 74 - Paragraph::new(app.toc_display_lines.clone()) 75 + Paragraph::new(app.toc_display_lines().to_vec()) 75 76 .style(Style::default().bg(theme.ui.toc_bg)) 76 77 .block( 77 78 Block::default() ··· 82 83 toc_chunks[1], 83 84 ); 84 85 f.render_widget( 85 - Paragraph::new(vec![app.toc_header_line.clone()]) 86 + Paragraph::new(vec![app.toc_header_line().clone()]) 86 87 .style(Style::default().bg(theme.ui.toc_bg)), 87 88 Rect { 88 89 x: toc_chunks[0].x, ··· 100 101 area, 101 102 ); 102 103 let content_area = inner_content_area(area); 103 - let scroll = app.scroll; 104 + let scroll = app.scroll(); 104 105 let active_highlight_line = app.active_highlight_line(); 105 106 if let Some(line_idx) = active_highlight_line { 106 107 let _ = app.refresh_highlighted_line_cache(line_idx); 107 108 } 108 109 109 - let visible_end = (scroll + viewport_height).min(app.lines.len()); 110 - let mut visible_lines = app.lines[scroll..visible_end].to_vec(); 110 + let visible_end = (scroll + viewport_height).min(app.total()); 111 + let mut visible_lines = app.visible_lines(scroll, visible_end).to_vec(); 111 112 112 113 if let Some(line_idx) = active_highlight_line { 113 114 if (scroll..visible_end).contains(&line_idx) { 114 - if let Some((_, highlighted_line)) = &app.highlighted_line_cache { 115 + if let Some((_, highlighted_line)) = app.highlighted_line_cache() { 115 116 visible_lines[line_idx - scroll] = highlighted_line.clone(); 116 117 } 117 118 } ··· 124 125 content_area, 125 126 ); 126 127 127 - let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll); 128 + let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll()); 128 129 f.render_stateful_widget( 129 130 Scrollbar::new(ScrollbarOrientation::VerticalRight) 130 131 .begin_symbol(None) ··· 154 155 app.refresh_status_cache(pct); 155 156 156 157 f.render_widget( 157 - Paragraph::new(vec![app.status_line.clone()]).style(Style::default().bg(bar_bg)), 158 + Paragraph::new(vec![app.status_line().clone()]).style(Style::default().bg(bar_bg)), 158 159 area, 159 160 ); 160 161 } ··· 206 207 207 208 pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 208 209 let theme = app_theme(); 209 - if !app.watch { 210 + if !app.is_watch_enabled() { 210 211 return None; 211 212 } 212 213 213 214 let flash_active = app 214 - .reload_flash 215 + .reload_flash_started() 215 216 .map(|t| t.elapsed() < std::time::Duration::from_millis(1500)) 216 217 .unwrap_or(false); 217 218 let span = if flash_active { ··· 235 236 236 237 pub(crate) fn status_search_section(app: &App) -> Option<Vec<Span<'static>>> { 237 238 let theme = app_theme(); 238 - if app.search_mode { 239 + if app.is_search_mode() { 239 240 return Some(vec![Span::styled( 240 - format!(" /{}", app.search_draft), 241 + format!(" /{}", app.search_draft()), 241 242 Style::default() 242 243 .fg(theme.ui.status_search_fg) 243 244 .bg(theme.ui.status_search_bg), 244 245 )]); 245 246 } 246 247 247 - if app.search_query.is_empty() { 248 + if app.search_query().is_empty() { 248 249 return None; 249 250 } 250 251 251 - let span = if app.search_matches.is_empty() { 252 + let span = if app.search_match_count() == 0 { 252 253 Span::styled( 253 - format!(" ✗ {} ", app.search_query), 254 + format!(" ✗ {} ", app.search_query()), 254 255 Style::default() 255 256 .fg(theme.ui.status_search_error_fg) 256 257 .bg(theme.ui.status_search_bg), 257 258 ) 258 259 } else { 259 260 Span::styled( 260 - format!(" {}/{} ", app.search_idx + 1, app.search_matches.len()), 261 + format!(" {}/{} ", app.search_index() + 1, app.search_match_count()), 261 262 Style::default() 262 263 .fg(theme.ui.status_search_match_fg) 263 264 .bg(theme.ui.status_search_bg), ··· 267 268 } 268 269 269 270 pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { 270 - if app.search_mode { 271 + if app.is_search_mode() { 271 272 &["enter confirm", "esc cancel"] 272 - } else if app.file_picker_open { 273 + } else if app.is_file_picker_open() { 273 274 &["j/k move", "enter open", "backspace up", "q quit"] 274 - } else if app.theme_picker_open { 275 + } else if app.is_theme_picker_open() { 275 276 &["j/k preview", "enter keep", "esc restore"] 276 - } else if app.help_open { 277 + } else if app.is_help_open() { 277 278 &["esc close", "? close"] 278 279 } else if app.has_active_search() { 279 280 &[ ··· 475 476 let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 476 477 let inner_height = area.height.saturating_sub(2) as usize; 477 478 let visible_slots = inner_height.saturating_sub(5); 478 - let total = app.file_picker_entries.len(); 479 - let start = if visible_slots == 0 || app.file_picker_index < visible_slots { 479 + let total = app.file_picker_entries().len(); 480 + let start = if visible_slots == 0 || app.file_picker_index() < visible_slots { 480 481 0 481 482 } else { 482 - app.file_picker_index + 1 - visible_slots 483 + app.file_picker_index() + 1 - visible_slots 483 484 }; 484 485 let end = (start + visible_slots).min(total); 485 486 486 487 let mut lines = vec![ 487 488 Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 488 489 Line::from(vec![Span::styled( 489 - app.file_picker_dir.display().to_string(), 490 + app.file_picker_dir().display().to_string(), 490 491 section_style, 491 492 )]), 492 493 Line::from(""), ··· 498 499 Style::default().fg(theme.ui.toc_primary_inactive), 499 500 )])); 500 501 } else { 501 - for (idx, entry) in app.file_picker_entries[start..end].iter().enumerate() { 502 + for (idx, entry) in app.file_picker_entries()[start..end].iter().enumerate() { 502 503 let actual_idx = start + idx; 503 - let selected = actual_idx == app.file_picker_index; 504 + let selected = actual_idx == app.file_picker_index(); 504 505 let bg = if selected { 505 506 theme.ui.toc_active_bg 506 507 } else { ··· 520 521 }), 521 522 ), 522 523 Span::styled( 523 - entry.label.clone(), 524 + entry.label().to_string(), 524 525 Style::default() 525 - .fg(if entry.is_dir { 526 + .fg(if entry.is_dir() { 526 527 theme.ui.toc_primary_active 527 528 } else { 528 529 theme.ui.toc_primary_inactive ··· 599 600 let outer_separator = Span::raw(" "); 600 601 601 602 let mut left_section = status_brand_section(); 602 - left_section.extend(status_filename_section(&app.filename)); 603 + left_section.extend(status_filename_section(app.filename())); 603 604 604 605 if let Some(section) = status_search_section(app) { 605 606 left_section.extend(section);
+60 -48
src/runtime.rs
··· 1 1 use crate::{ 2 2 app::{App, FileChange}, 3 - render::ui, 3 + render::{ui, CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH}, 4 4 }; 5 5 use anyhow::Result; 6 6 use crossterm::event::{self, poll, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; 7 7 use ratatui::{backend::CrosstermBackend, Terminal}; 8 - use std::{fs::OpenOptions, io, io::Write, time::Duration}; 8 + use std::{ 9 + fs::OpenOptions, 10 + io, 11 + io::Write, 12 + time::{Duration, Instant}, 13 + }; 9 14 use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 10 - 11 - const CONTENT_HORIZONTAL_PADDING: u16 = 1; 12 - const SCROLLBAR_WIDTH: u16 = 1; 13 15 14 16 pub(crate) fn should_handle_key(kind: KeyEventKind) -> bool { 15 17 !matches!(kind, KeyEventKind::Release) ··· 37 39 const WATCH_INTERVAL: Duration = Duration::from_millis(250); 38 40 const FLASH_DURATION: Duration = Duration::from_millis(1500); 39 41 const MOUSE_SCROLL_STEP: usize = 3; 42 + const RESIZE_DEBOUNCE: Duration = Duration::from_millis(120); 40 43 let mut needs_redraw = true; 44 + let mut pending_resize: Option<Instant> = None; 41 45 sync_render_width(terminal, app, ss, themes)?; 42 46 43 47 loop { ··· 46 50 needs_redraw = false; 47 51 } 48 52 49 - let poll_timeout = if app.watch { 50 - let flash_timeout = app.reload_flash.and_then(|started| { 51 - let elapsed = started.elapsed(); 52 - (elapsed < FLASH_DURATION).then_some(FLASH_DURATION - elapsed) 53 - }); 54 - flash_timeout 55 - .map(|remaining| remaining.min(WATCH_INTERVAL)) 56 - .unwrap_or(WATCH_INTERVAL) 53 + let flash_timeout = app.reload_flash_started().and_then(|started| { 54 + let elapsed = started.elapsed(); 55 + (elapsed < FLASH_DURATION).then_some(FLASH_DURATION - elapsed) 56 + }); 57 + let resize_timeout = pending_resize.and_then(|started| { 58 + let elapsed = started.elapsed(); 59 + (elapsed < RESIZE_DEBOUNCE).then_some(RESIZE_DEBOUNCE - elapsed) 60 + }); 61 + let poll_timeout = [if app.is_watch_enabled() { 62 + Some(WATCH_INTERVAL) 57 63 } else { 58 - Duration::MAX 64 + None 65 + }, flash_timeout, resize_timeout] 66 + .into_iter() 67 + .flatten() 68 + .min() 69 + .unwrap_or(Duration::MAX); 70 + 71 + let event_available = if poll_timeout == Duration::MAX { 72 + true 73 + } else { 74 + poll(poll_timeout)? 59 75 }; 60 - 61 - let event_available = if app.watch { poll(poll_timeout)? } else { true }; 62 76 63 77 if event_available { 64 78 match event::read()? { 65 79 Event::Key(key) => { 66 80 debug_log( 67 - app.debug_input, 81 + app.debug_input_enabled(), 68 82 &format!( 69 83 "key_event kind={:?} code={:?} modifiers={:?} search_mode={} query={:?} draft={:?} matches={} idx={}", 70 84 key.kind, 71 85 key.code, 72 86 key.modifiers, 73 - app.search_mode, 74 - app.search_query, 75 - app.search_draft, 76 - app.search_matches.len(), 77 - app.search_idx 87 + app.is_search_mode(), 88 + app.search_query(), 89 + app.search_draft(), 90 + app.search_match_count(), 91 + app.search_index() 78 92 ), 79 93 ); 80 94 if !should_handle_key(key.kind) { ··· 129 143 app.preview_theme_preset(preset, ss, themes); 130 144 } 131 145 } 132 - } else if app.search_mode { 146 + } else if app.is_search_mode() { 133 147 match key.code { 134 148 KeyCode::Esc => app.cancel_search(), 135 149 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 136 150 app.cancel_search(); 137 151 } 138 152 KeyCode::Enter => app.confirm_search(), 139 - KeyCode::Backspace => { 140 - app.search_draft.pop(); 141 - } 142 - KeyCode::Char(c) => { 143 - app.search_draft.push(c); 144 - } 153 + KeyCode::Backspace => app.pop_search_draft(), 154 + KeyCode::Char(c) => app.push_search_draft(c), 145 155 _ => state_changed = false, 146 156 } 147 157 } else { ··· 160 170 KeyCode::Char('k') | KeyCode::Up => app.scroll_up(1), 161 171 KeyCode::Char('d') | KeyCode::PageDown => app.scroll_down(20), 162 172 KeyCode::Char('u') | KeyCode::PageUp => app.scroll_up(20), 163 - KeyCode::Char('g') | KeyCode::Home => { 164 - app.scroll = 0; 165 - } 166 - KeyCode::Char('G') | KeyCode::End => { 167 - app.scroll = app.total().saturating_sub(1); 168 - } 169 - KeyCode::Char('t') => { 170 - app.toc_visible = !app.toc_visible; 171 - } 173 + KeyCode::Char('g') | KeyCode::Home => app.scroll_top(), 174 + KeyCode::Char('G') | KeyCode::End => app.scroll_bottom(), 175 + KeyCode::Char('t') => app.toggle_toc(), 172 176 KeyCode::Char('T') => { 173 177 app.open_theme_picker(); 174 178 } 175 179 KeyCode::Char('?') => { 176 180 app.open_help(); 177 181 } 178 - KeyCode::Char('r') if app.watch => { 179 - app.last_file_state = None; 180 - app.reload(ss, themes); 182 + KeyCode::Char('r') if app.is_watch_enabled() => { 183 + app.request_reload(ss, themes); 181 184 } 182 185 KeyCode::Char('f') 183 186 if key.modifiers.contains(KeyModifiers::CONTROL) => ··· 223 226 } 224 227 } 225 228 Event::Resize(_, _) => { 226 - let _ = sync_render_width(terminal, app, ss, themes)?; 227 - needs_redraw = true; 229 + pending_resize = Some(Instant::now()); 228 230 } 229 231 _ => {} 230 232 } 231 233 } 232 234 233 - if app.watch { 235 + if pending_resize 236 + .map(|started| started.elapsed() >= RESIZE_DEBOUNCE) 237 + .unwrap_or(false) 238 + { 239 + pending_resize = None; 240 + if sync_render_width(terminal, app, ss, themes)? { 241 + needs_redraw = true; 242 + } 243 + } 244 + 245 + if app.is_watch_enabled() { 234 246 if let Some(change) = app.check_modified() { 235 247 std::thread::sleep(Duration::from_millis(50)); 236 248 if app.reload(ss, themes) { 237 - app.last_file_state = Some(match change { 249 + app.set_last_file_state(match change { 238 250 FileChange::Metadata(state) | FileChange::Content(state) => state, 239 251 }); 240 252 needs_redraw = true; 241 253 } 242 254 } 243 - if let Some(t) = app.reload_flash { 255 + if let Some(t) = app.reload_flash_started() { 244 256 if t.elapsed() >= FLASH_DURATION { 245 - app.reload_flash = None; 257 + app.clear_reload_flash(); 246 258 needs_redraw = true; 247 259 } 248 260 } ··· 258 270 themes: &ThemeSet, 259 271 ) -> Result<bool> { 260 272 let area = terminal.size()?; 261 - let content_width = if app.toc_visible && !app.toc.is_empty() { 273 + let content_width = if app.is_toc_visible() && app.has_toc() { 262 274 area.width.saturating_sub(30) 263 275 } else { 264 276 area.width
+256 -67
src/tests.rs
··· 1 1 use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 2 2 use crate::*; 3 - use crate::markdown::{parse_markdown, parse_markdown_with_width}; 3 + use crate::app::FileChange; 4 + use crate::markdown::{ 5 + hash_str, parse_markdown, parse_markdown_with_width, read_file_state, resolve_syntax, 6 + }; 4 7 use crossterm::event::KeyEventKind; 5 8 use ratatui::backend::TestBackend; 6 9 use ratatui::{text::Line, widgets::Paragraph, Terminal}; ··· 73 76 let (lines, toc) = parse_markdown("hello **world**", &ss, &theme); 74 77 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 75 78 76 - app.search_query = "hello world".to_string(); 79 + app.set_search_query("hello world"); 77 80 app.run_search(); 78 81 79 - assert_eq!(app.search_matches.len(), 1); 80 - assert!(line_plain_text(&app.lines[app.search_matches[0]]).contains("hello world")); 82 + assert_eq!(app.search_match_count(), 1); 83 + assert!( 84 + line_plain_text(app.line(app.search_matches()[0]).unwrap()).contains("hello world") 85 + ); 81 86 } 82 87 83 88 #[test] ··· 88 93 } 89 94 90 95 #[test] 96 + fn stdin_read_is_rejected_when_over_limit() { 97 + let mut cursor = std::io::Cursor::new(vec![b'a'; 5]); 98 + let err = read_stdin_with_limit(&mut cursor, 4).unwrap_err(); 99 + assert!( 100 + err.to_string() 101 + .contains("stdin exceeds the maximum supported size") 102 + ); 103 + } 104 + 105 + #[test] 91 106 fn cancelling_search_clears_query_and_matches() { 92 107 let (ss, theme) = test_assets(); 93 108 let (lines, toc) = parse_markdown("alpha\nbeta\nalpha beta\n", &ss, &theme); 94 109 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 95 110 96 - app.search_query = "alpha".to_string(); 111 + app.set_search_query("alpha"); 97 112 app.run_search(); 98 113 99 114 app.begin_search(); 100 - app.search_draft.push_str(" gamma"); 115 + app.set_search_draft("alpha gamma"); 101 116 app.cancel_search(); 102 117 103 - assert!(!app.search_mode); 104 - assert!(app.search_draft.is_empty()); 105 - assert!(app.search_query.is_empty()); 106 - assert!(app.search_matches.is_empty()); 107 - assert_eq!(app.search_idx, 0); 118 + assert!(!app.is_search_mode()); 119 + assert!(app.search_draft().is_empty()); 120 + assert!(app.search_query().is_empty()); 121 + assert!(app.search_matches().is_empty()); 122 + assert_eq!(app.search_index(), 0); 108 123 } 109 124 110 125 #[test] ··· 114 129 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 115 130 116 131 app.begin_search(); 117 - app.search_draft = "beta".to_string(); 132 + app.set_search_draft("beta"); 118 133 app.confirm_search(); 119 134 120 - assert!(!app.search_mode); 121 - assert!(app.search_draft.is_empty()); 122 - assert_eq!(app.search_query, "beta"); 123 - assert_eq!(app.search_matches.len(), 2); 135 + assert!(!app.is_search_mode()); 136 + assert!(app.search_draft().is_empty()); 137 + assert_eq!(app.search_query(), "beta"); 138 + assert_eq!(app.search_match_count(), 2); 124 139 } 125 140 126 141 #[test] ··· 129 144 let (lines, toc) = parse_markdown("alpha\nbeta\nbeta again\n", &ss, &theme); 130 145 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 131 146 132 - app.search_query = "alpha".to_string(); 147 + app.set_search_query("alpha"); 133 148 app.run_search(); 134 149 135 150 app.begin_search(); 136 - app.search_draft = "beta".to_string(); 151 + app.set_search_draft("beta"); 137 152 app.confirm_search(); 138 153 139 - assert_eq!(app.search_query, "beta"); 140 - assert_eq!(app.search_idx, 0); 141 - assert_eq!(app.scroll, app.search_matches[0]); 142 - assert_eq!(app.search_matches.len(), 2); 154 + assert_eq!(app.search_query(), "beta"); 155 + assert_eq!(app.search_index(), 0); 156 + assert_eq!(app.scroll(), app.search_matches()[0]); 157 + assert_eq!(app.search_match_count(), 2); 143 158 } 144 159 145 160 #[test] ··· 148 163 let (lines, toc) = parse_markdown("alpha\nbeta alpha\nalpha again\n", &ss, &theme); 149 164 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 150 165 151 - app.search_query = "alpha".to_string(); 166 + app.set_search_query("alpha"); 152 167 app.run_search(); 153 - let second_match = app.search_matches[1]; 168 + let second_match = app.search_matches()[1]; 154 169 155 170 app.next_match(); 156 171 157 - assert_eq!(app.search_idx, 1); 158 - assert_eq!(app.scroll, second_match); 172 + assert_eq!(app.search_index(), 1); 173 + assert_eq!(app.scroll(), second_match); 159 174 } 160 175 161 176 #[test] ··· 164 179 let (lines, toc) = parse_markdown("alpha\nbeta\n", &ss, &theme); 165 180 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 166 181 167 - app.search_query = "alpha".to_string(); 182 + app.set_search_query("alpha"); 168 183 app.run_search(); 169 184 170 185 app.begin_search(); 171 - app.search_draft.push('z'); 186 + app.push_search_draft('z'); 172 187 app.cancel_search(); 173 188 174 - assert!(!app.search_mode); 175 - assert!(app.search_query.is_empty()); 176 - assert!(app.search_matches.is_empty()); 177 - assert_eq!(app.search_idx, 0); 189 + assert!(!app.is_search_mode()); 190 + assert!(app.search_query().is_empty()); 191 + assert!(app.search_matches().is_empty()); 192 + assert_eq!(app.search_index(), 0); 178 193 } 179 194 180 195 #[test] ··· 183 198 let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 184 199 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 185 200 186 - app.search_query = "alpha".to_string(); 201 + app.set_search_query("alpha"); 187 202 app.run_search(); 188 203 app.clear_active_search(); 189 204 190 - assert!(!app.search_mode); 191 - assert!(app.search_draft.is_empty()); 192 - assert!(app.search_query.is_empty()); 193 - assert!(app.search_matches.is_empty()); 194 - assert_eq!(app.search_idx, 0); 205 + assert!(!app.is_search_mode()); 206 + assert!(app.search_draft().is_empty()); 207 + assert!(app.search_query().is_empty()); 208 + assert!(app.search_matches().is_empty()); 209 + assert_eq!(app.search_index(), 0); 195 210 } 196 211 197 212 #[test] ··· 200 215 let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 201 216 let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 202 217 203 - app.search_query = "alpha".to_string(); 218 + app.set_search_query("alpha"); 204 219 app.run_search(); 205 220 app.clear_active_search(); 206 221 207 222 assert!(!app.has_active_search()); 208 - assert!(app.search_query.is_empty()); 209 - assert!(app.search_matches.is_empty()); 223 + assert!(app.search_query().is_empty()); 224 + assert!(app.search_matches().is_empty()); 210 225 } 211 226 212 227 #[test] ··· 553 568 } 554 569 555 570 #[test] 571 + fn resolve_syntax_supports_common_language_aliases() { 572 + let ss = SyntaxSet::load_defaults_newlines(); 573 + 574 + assert_eq!(resolve_syntax("py", &ss).name, resolve_syntax("python", &ss).name); 575 + assert_eq!(resolve_syntax("cpp", &ss).name, resolve_syntax("c++", &ss).name); 576 + assert_eq!(resolve_syntax("json", &ss).name, "JSON"); 577 + assert_eq!(resolve_syntax("ps1", &ss).name, resolve_syntax("powershell", &ss).name); 578 + } 579 + 580 + #[test] 556 581 fn theme_presets_are_in_alphabetical_order() { 557 582 let labels: Vec<_> = THEME_PRESETS 558 583 .iter() ··· 572 597 let mut app = App::new_with_source( 573 598 lines, 574 599 toc, 575 - "stdin".to_string(), 576 - "# Demo\n".to_string(), 577 - false, 578 - false, 579 - None, 580 - None, 600 + AppConfig { 601 + filename: "stdin".to_string(), 602 + source: "# Demo\n".to_string(), 603 + debug_input: false, 604 + watch: false, 605 + filepath: None, 606 + last_file_state: None, 607 + }, 581 608 ); 582 609 583 610 let original = current_theme_preset(); 584 611 set_theme_preset(ThemePreset::OceanDark); 585 612 app.open_theme_picker(); 586 - app.theme_picker_index = theme_preset_index(ThemePreset::Forest); 613 + assert!(app.set_theme_picker_index(theme_preset_index(ThemePreset::Forest))); 587 614 app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 588 615 589 616 assert_eq!(current_theme_preset(), ThemePreset::Forest); ··· 591 618 app.restore_theme_picker_preview(&ss, &ts); 592 619 593 620 assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 594 - assert!(!app.theme_picker_open); 595 - assert_eq!(app.theme_picker_original, None); 621 + assert!(!app.is_theme_picker_open()); 622 + assert_eq!(app.theme_picker_original(), None); 596 623 set_theme_preset(original); 597 624 } 598 625 ··· 605 632 let mut app = App::new_with_source( 606 633 lines, 607 634 toc, 608 - "stdin".to_string(), 609 - "# Demo\n\n```rs\nfn main() {}\n```\n".to_string(), 610 - false, 611 - false, 612 - None, 613 - None, 635 + AppConfig { 636 + filename: "stdin".to_string(), 637 + source: "# Demo\n\n```rs\nfn main() {}\n```\n".to_string(), 638 + debug_input: false, 639 + watch: false, 640 + filepath: None, 641 + last_file_state: None, 642 + }, 614 643 ); 615 644 616 645 let original = current_theme_preset(); ··· 618 647 app.open_theme_picker(); 619 648 app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 620 649 621 - let cached = app.theme_preview_cache[theme_preset_index(ThemePreset::Forest)].as_ref(); 622 - assert!(cached.is_some()); 650 + assert!(app.has_cached_theme_preview(ThemePreset::Forest)); 623 651 assert_eq!(current_theme_preset(), ThemePreset::Forest); 624 652 625 653 app.preview_theme_preset(ThemePreset::OceanDark, &ss, &ts); 626 654 assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 627 - assert!(app.theme_preview_cache[theme_preset_index(ThemePreset::OceanDark)].is_some()); 655 + assert!(app.has_cached_theme_preview(ThemePreset::OceanDark)); 628 656 set_theme_preset(original); 629 657 } 630 658 ··· 644 672 let mut app = App::new_with_source( 645 673 Vec::new(), 646 674 Vec::new(), 647 - "picker".to_string(), 648 - String::new(), 649 - false, 650 - false, 651 - None, 652 - None, 675 + AppConfig { 676 + filename: "picker".to_string(), 677 + source: String::new(), 678 + debug_input: false, 679 + watch: false, 680 + filepath: None, 681 + last_file_state: None, 682 + }, 653 683 ); 654 684 655 685 assert!(app.open_file_picker(root.clone())); 656 686 657 687 let labels: Vec<_> = app 658 - .file_picker_entries 688 + .file_picker_entries() 659 689 .iter() 660 - .map(|entry| entry.label.as_str()) 690 + .map(|entry| entry.label()) 661 691 .collect(); 662 692 assert!(labels.contains(&"notes/")); 663 693 assert!(labels.contains(&"README.md")); ··· 670 700 671 701 let _ = fs::remove_dir_all(root); 672 702 } 703 + 704 + #[test] 705 + fn check_modified_detects_file_metadata_change() { 706 + let (ss, theme) = test_assets(); 707 + let unique = SystemTime::now() 708 + .duration_since(UNIX_EPOCH) 709 + .unwrap() 710 + .as_nanos(); 711 + let path = std::env::temp_dir().join(format!("leaf-check-modified-{unique}.md")); 712 + fs::write(&path, "# Before\n").unwrap(); 713 + 714 + let src = fs::read_to_string(&path).unwrap(); 715 + let (lines, toc) = parse_markdown(&src, &ss, &theme); 716 + let state = read_file_state(&path).unwrap(); 717 + let mut app = App::new_with_source( 718 + lines, 719 + toc, 720 + AppConfig { 721 + filename: path.file_name().unwrap().to_string_lossy().to_string(), 722 + source: src.clone(), 723 + debug_input: false, 724 + watch: true, 725 + filepath: Some(path.clone()), 726 + last_file_state: Some(state), 727 + }, 728 + ); 729 + app.set_last_content_hash(hash_str(&src)); 730 + 731 + std::thread::sleep(std::time::Duration::from_millis(10)); 732 + fs::write(&path, "# After\nextra\n").unwrap(); 733 + 734 + let change = app.check_modified(); 735 + assert!(matches!( 736 + change, 737 + Some(FileChange::Metadata(_)) | Some(FileChange::Content(_)) 738 + )); 739 + 740 + let _ = fs::remove_file(path); 741 + } 742 + 743 + #[test] 744 + fn reload_returns_false_when_file_cannot_be_read() { 745 + let (ss, _theme) = test_assets(); 746 + let ts = ThemeSet::load_defaults(); 747 + let unique = SystemTime::now() 748 + .duration_since(UNIX_EPOCH) 749 + .unwrap() 750 + .as_nanos(); 751 + let path = std::env::temp_dir().join(format!("leaf-reload-fail-{unique}.md")); 752 + fs::write(&path, "# Demo\n").unwrap(); 753 + 754 + let mut app = App::new_with_source( 755 + Vec::new(), 756 + Vec::new(), 757 + AppConfig { 758 + filename: "picker".to_string(), 759 + source: String::new(), 760 + debug_input: false, 761 + watch: true, 762 + filepath: None, 763 + last_file_state: None, 764 + }, 765 + ); 766 + assert!(app.load_path(path.clone(), &ss, &ts)); 767 + 768 + fs::remove_file(&path).unwrap(); 769 + assert!(!app.reload(&ss, &ts)); 770 + } 771 + 772 + #[test] 773 + fn sync_render_width_preserves_scroll_proportion() { 774 + let (ss, theme) = test_assets(); 775 + let ts = ThemeSet::load_defaults(); 776 + let source = (0..12) 777 + .map(|idx| { 778 + format!( 779 + "Paragraph {idx} has enough repeated content to wrap differently when the render width changes significantly across reparses." 780 + ) 781 + }) 782 + .collect::<Vec<_>>() 783 + .join("\n\n"); 784 + let (lines, toc) = parse_markdown_with_width(&source, &ss, &theme, 80); 785 + let mut app = App::new_with_source( 786 + lines, 787 + toc, 788 + AppConfig { 789 + filename: "stdin".to_string(), 790 + source, 791 + debug_input: false, 792 + watch: false, 793 + filepath: None, 794 + last_file_state: None, 795 + }, 796 + ); 797 + 798 + app.scroll_down(8); 799 + let old_scroll = app.scroll(); 800 + let old_total = app.total(); 801 + assert!(app.sync_render_width(24, &ss, &ts)); 802 + 803 + let new_total = app.total(); 804 + let expected = ((old_scroll as f64 / old_total as f64) * new_total as f64) as usize; 805 + assert_eq!(app.scroll(), expected.min(new_total.saturating_sub(1))); 806 + } 807 + 808 + #[test] 809 + fn check_modified_reports_metadata_when_no_previous_file_state() { 810 + let (ss, theme) = test_assets(); 811 + let unique = SystemTime::now() 812 + .duration_since(UNIX_EPOCH) 813 + .unwrap() 814 + .as_nanos(); 815 + let path = std::env::temp_dir().join(format!("leaf-check-modified-initial-{unique}.md")); 816 + fs::write(&path, "# Initial\n").unwrap(); 817 + 818 + let src = fs::read_to_string(&path).unwrap(); 819 + let (lines, toc) = parse_markdown(&src, &ss, &theme); 820 + let mut app = App::new_with_source( 821 + lines, 822 + toc, 823 + AppConfig { 824 + filename: path.file_name().unwrap().to_string_lossy().to_string(), 825 + source: src.clone(), 826 + debug_input: false, 827 + watch: true, 828 + filepath: Some(path.clone()), 829 + last_file_state: None, 830 + }, 831 + ); 832 + app.set_last_content_hash(hash_str(&src)); 833 + 834 + assert!(matches!(app.check_modified(), Some(FileChange::Metadata(_)))); 835 + 836 + let _ = fs::remove_file(path); 837 + } 838 + 839 + #[test] 840 + fn sync_render_width_returns_false_when_clamped_width_is_unchanged() { 841 + let (ss, theme) = test_assets(); 842 + let ts = ThemeSet::load_defaults(); 843 + let source = "One paragraph that does not matter much for this width clamp test."; 844 + let (lines, toc) = parse_markdown_with_width(source, &ss, &theme, 20); 845 + let mut app = App::new_with_source( 846 + lines, 847 + toc, 848 + AppConfig { 849 + filename: "stdin".to_string(), 850 + source: source.to_string(), 851 + debug_input: false, 852 + watch: false, 853 + filepath: None, 854 + last_file_state: None, 855 + }, 856 + ); 857 + 858 + assert!(app.sync_render_width(10, &ss, &ts)); 859 + assert!(!app.sync_render_width(10, &ss, &ts)); 860 + assert_eq!(app.total(), parse_markdown_with_width(source, &ss, &theme, 20).0.len()); 861 + }
+129 -118
src/theme.rs
··· 17 17 } 18 18 } 19 19 20 + #[derive(Clone, Copy)] 20 21 pub(crate) struct AppTheme { 21 22 pub(crate) syntax_theme_name: &'static str, 22 23 pub(crate) ui: UiTheme, 23 24 pub(crate) markdown: MarkdownTheme, 24 25 } 25 26 27 + #[derive(Clone, Copy)] 26 28 pub(crate) struct UiTheme { 27 29 pub(crate) toc_bg: Color, 28 30 pub(crate) toc_border: Color, ··· 55 57 pub(crate) toc_secondary_text_inactive: Color, 56 58 } 57 59 60 + #[derive(Clone, Copy)] 58 61 pub(crate) struct MarkdownTheme { 59 62 pub(crate) search_highlight_bg: Color, 60 63 pub(crate) code_gutter: Color, ··· 84 87 pub(crate) strong_text: Color, 85 88 } 86 89 90 + const BASE_LIGHT_UI: UiTheme = UiTheme { 91 + toc_bg: Color::Rgb(232, 239, 245), 92 + toc_border: Color::Rgb(170, 182, 194), 93 + content_bg: Color::Rgb(242, 247, 250), 94 + status_bg: Color::Rgb(224, 233, 240), 95 + status_separator: Color::Rgb(108, 126, 144), 96 + status_brand_fg: Color::Rgb(245, 248, 250), 97 + status_brand_bg: Color::Rgb(76, 122, 168), 98 + status_filename_fg: Color::Rgb(58, 84, 110), 99 + status_filename_bg: Color::Rgb(209, 221, 232), 100 + status_watch_fg: Color::Rgb(48, 140, 98), 101 + status_watch_bg: Color::Rgb(212, 234, 222), 102 + status_reloaded_fg: Color::Rgb(245, 248, 250), 103 + status_reloaded_bg: Color::Rgb(58, 168, 116), 104 + status_search_fg: Color::Rgb(142, 114, 24), 105 + status_search_bg: Color::Rgb(235, 238, 226), 106 + status_search_error_fg: Color::Rgb(188, 74, 74), 107 + status_search_match_fg: Color::Rgb(48, 140, 98), 108 + status_shortcut_fg: Color::Rgb(98, 116, 134), 109 + status_percent_fg: Color::Rgb(76, 122, 168), 110 + toc_header_fg: Color::Rgb(92, 108, 126), 111 + toc_active_bg: Color::Rgb(214, 224, 233), 112 + toc_inactive_bg: Color::Rgb(232, 239, 245), 113 + toc_accent: Color::Rgb(76, 122, 168), 114 + toc_index_inactive: Color::Rgb(126, 138, 152), 115 + toc_primary_active: Color::Rgb(34, 42, 52), 116 + toc_primary_inactive: Color::Rgb(82, 96, 110), 117 + toc_secondary_inactive: Color::Rgb(126, 138, 152), 118 + toc_secondary_text_active: Color::Rgb(48, 58, 68), 119 + toc_secondary_text_inactive: Color::Rgb(98, 112, 126), 120 + }; 121 + 122 + const BASE_DARK_UI: UiTheme = UiTheme { 123 + toc_bg: Color::Rgb(18, 18, 22), 124 + toc_border: Color::Rgb(52, 52, 58), 125 + content_bg: Color::Rgb(18, 20, 28), 126 + status_bg: Color::Rgb(18, 20, 32), 127 + status_separator: Color::Rgb(116, 126, 156), 128 + status_brand_fg: Color::Rgb(16, 18, 26), 129 + status_brand_bg: Color::Rgb(105, 178, 218), 130 + status_filename_fg: Color::Rgb(162, 192, 222), 131 + status_filename_bg: Color::Rgb(24, 28, 44), 132 + status_watch_fg: Color::Rgb(95, 200, 148), 133 + status_watch_bg: Color::Rgb(18, 30, 24), 134 + status_reloaded_fg: Color::Rgb(16, 18, 26), 135 + status_reloaded_bg: Color::Rgb(95, 200, 148), 136 + status_search_fg: Color::Rgb(240, 210, 95), 137 + status_search_bg: Color::Rgb(26, 28, 42), 138 + status_search_error_fg: Color::Rgb(218, 95, 95), 139 + status_search_match_fg: Color::Rgb(115, 208, 148), 140 + status_shortcut_fg: Color::Rgb(58, 68, 98), 141 + status_percent_fg: Color::Rgb(105, 178, 218), 142 + toc_header_fg: Color::Rgb(88, 88, 96), 143 + toc_active_bg: Color::Rgb(42, 40, 46), 144 + toc_inactive_bg: Color::Rgb(18, 18, 22), 145 + toc_accent: Color::Rgb(123, 109, 255), 146 + toc_index_inactive: Color::Rgb(60, 60, 66), 147 + toc_primary_active: Color::Rgb(224, 224, 228), 148 + toc_primary_inactive: Color::Rgb(136, 136, 142), 149 + toc_secondary_inactive: Color::Rgb(62, 62, 68), 150 + toc_secondary_text_active: Color::Rgb(224, 224, 228), 151 + toc_secondary_text_inactive: Color::Rgb(102, 102, 108), 152 + }; 153 + 154 + const BASE_LIGHT_MARKDOWN: MarkdownTheme = MarkdownTheme { 155 + search_highlight_bg: Color::Rgb(232, 223, 164), 156 + code_gutter: Color::Rgb(132, 148, 164), 157 + blockquote_marker: Color::Rgb(124, 134, 184), 158 + list_level_1: Color::Rgb(48, 140, 98), 159 + list_level_2: Color::Rgb(90, 118, 188), 160 + list_level_3: Color::Rgb(128, 132, 148), 161 + ordered_list: Color::Rgb(48, 140, 98), 162 + table_border: Color::Rgb(138, 154, 172), 163 + table_separator: Color::Rgb(158, 170, 184), 164 + table_header: Color::Rgb(58, 108, 168), 165 + table_cell: Color::Rgb(58, 68, 78), 166 + heading_1: Color::Rgb(58, 108, 168), 167 + heading_2: Color::Rgb(48, 140, 98), 168 + heading_3: Color::Rgb(176, 128, 48), 169 + heading_other: Color::Rgb(108, 116, 126), 170 + heading_underline: Color::Rgb(160, 176, 194), 171 + code_frame: Color::Rgb(132, 148, 164), 172 + code_label: Color::Rgb(92, 116, 140), 173 + inline_code_fg: Color::Rgb(170, 108, 76), 174 + inline_code_bg: Color::Rgb(232, 226, 222), 175 + rule: Color::Rgb(180, 192, 204), 176 + link_icon: Color::Rgb(62, 124, 188), 177 + link_text: Color::Rgb(62, 124, 188), 178 + blockquote_text: Color::Rgb(114, 116, 158), 179 + text: Color::Rgb(58, 68, 78), 180 + strong_text: Color::Rgb(26, 32, 40), 181 + }; 182 + 183 + const BASE_DARK_MARKDOWN: MarkdownTheme = MarkdownTheme { 184 + search_highlight_bg: Color::Rgb(72, 62, 16), 185 + code_gutter: Color::Rgb(40, 48, 68), 186 + blockquote_marker: Color::Rgb(75, 80, 148), 187 + list_level_1: Color::Rgb(95, 200, 148), 188 + list_level_2: Color::Rgb(138, 155, 200), 189 + list_level_3: Color::Rgb(168, 168, 185), 190 + ordered_list: Color::Rgb(95, 200, 148), 191 + table_border: Color::Rgb(65, 75, 108), 192 + table_separator: Color::Rgb(55, 65, 95), 193 + table_header: Color::Rgb(140, 190, 255), 194 + table_cell: Color::Rgb(205, 208, 218), 195 + heading_1: Color::Rgb(140, 190, 255), 196 + heading_2: Color::Rgb(120, 210, 170), 197 + heading_3: Color::Rgb(210, 180, 120), 198 + heading_other: Color::Rgb(180, 180, 190), 199 + heading_underline: Color::Rgb(40, 50, 75), 200 + code_frame: Color::Rgb(40, 48, 68), 201 + code_label: Color::Rgb(95, 110, 145), 202 + inline_code_fg: Color::Rgb(220, 150, 118), 203 + inline_code_bg: Color::Rgb(38, 32, 31), 204 + rule: Color::Rgb(48, 56, 76), 205 + link_icon: Color::Rgb(85, 148, 235), 206 + link_text: Color::Rgb(88, 152, 238), 207 + blockquote_text: Color::Rgb(148, 148, 195), 208 + text: Color::Rgb(208, 210, 218), 209 + strong_text: Color::Rgb(245, 245, 255), 210 + }; 211 + 87 212 pub(crate) const ARCTIC_THEME: AppTheme = AppTheme { 88 213 syntax_theme_name: "base16-ocean.light", 89 - ui: UiTheme { 90 - toc_bg: Color::Rgb(232, 239, 245), 91 - toc_border: Color::Rgb(170, 182, 194), 92 - content_bg: Color::Rgb(242, 247, 250), 93 - status_bg: Color::Rgb(224, 233, 240), 94 - status_separator: Color::Rgb(108, 126, 144), 95 - status_brand_fg: Color::Rgb(245, 248, 250), 96 - status_brand_bg: Color::Rgb(76, 122, 168), 97 - status_filename_fg: Color::Rgb(58, 84, 110), 98 - status_filename_bg: Color::Rgb(209, 221, 232), 99 - status_watch_fg: Color::Rgb(48, 140, 98), 100 - status_watch_bg: Color::Rgb(212, 234, 222), 101 - status_reloaded_fg: Color::Rgb(245, 248, 250), 102 - status_reloaded_bg: Color::Rgb(58, 168, 116), 103 - status_search_fg: Color::Rgb(142, 114, 24), 104 - status_search_bg: Color::Rgb(235, 238, 226), 105 - status_search_error_fg: Color::Rgb(188, 74, 74), 106 - status_search_match_fg: Color::Rgb(48, 140, 98), 107 - status_shortcut_fg: Color::Rgb(98, 116, 134), 108 - status_percent_fg: Color::Rgb(76, 122, 168), 109 - toc_header_fg: Color::Rgb(92, 108, 126), 110 - toc_active_bg: Color::Rgb(214, 224, 233), 111 - toc_inactive_bg: Color::Rgb(232, 239, 245), 112 - toc_accent: Color::Rgb(76, 122, 168), 113 - toc_index_inactive: Color::Rgb(126, 138, 152), 114 - toc_primary_active: Color::Rgb(34, 42, 52), 115 - toc_primary_inactive: Color::Rgb(82, 96, 110), 116 - toc_secondary_inactive: Color::Rgb(126, 138, 152), 117 - toc_secondary_text_active: Color::Rgb(48, 58, 68), 118 - toc_secondary_text_inactive: Color::Rgb(98, 112, 126), 119 - }, 120 - markdown: MarkdownTheme { 121 - search_highlight_bg: Color::Rgb(232, 223, 164), 122 - code_gutter: Color::Rgb(132, 148, 164), 123 - blockquote_marker: Color::Rgb(124, 134, 184), 124 - list_level_1: Color::Rgb(48, 140, 98), 125 - list_level_2: Color::Rgb(90, 118, 188), 126 - list_level_3: Color::Rgb(128, 132, 148), 127 - ordered_list: Color::Rgb(48, 140, 98), 128 - table_border: Color::Rgb(138, 154, 172), 129 - table_separator: Color::Rgb(158, 170, 184), 130 - table_header: Color::Rgb(58, 108, 168), 131 - table_cell: Color::Rgb(58, 68, 78), 132 - heading_1: Color::Rgb(58, 108, 168), 133 - heading_2: Color::Rgb(48, 140, 98), 134 - heading_3: Color::Rgb(176, 128, 48), 135 - heading_other: Color::Rgb(108, 116, 126), 136 - heading_underline: Color::Rgb(160, 176, 194), 137 - code_frame: Color::Rgb(132, 148, 164), 138 - code_label: Color::Rgb(92, 116, 140), 139 - inline_code_fg: Color::Rgb(170, 108, 76), 140 - inline_code_bg: Color::Rgb(232, 226, 222), 141 - rule: Color::Rgb(180, 192, 204), 142 - link_icon: Color::Rgb(62, 124, 188), 143 - link_text: Color::Rgb(62, 124, 188), 144 - blockquote_text: Color::Rgb(114, 116, 158), 145 - text: Color::Rgb(58, 68, 78), 146 - strong_text: Color::Rgb(26, 32, 40), 147 - }, 214 + ui: BASE_LIGHT_UI, 215 + markdown: BASE_LIGHT_MARKDOWN, 148 216 }; 149 217 150 218 pub(crate) const FOREST_THEME: AppTheme = AppTheme { ··· 212 280 213 281 pub(crate) const OCEAN_DARK_THEME: AppTheme = AppTheme { 214 282 syntax_theme_name: "base16-ocean.dark", 215 - ui: UiTheme { 216 - toc_bg: Color::Rgb(18, 18, 22), 217 - toc_border: Color::Rgb(52, 52, 58), 218 - content_bg: Color::Rgb(18, 20, 28), 219 - status_bg: Color::Rgb(18, 20, 32), 220 - status_separator: Color::Rgb(116, 126, 156), 221 - status_brand_fg: Color::Rgb(16, 18, 26), 222 - status_brand_bg: Color::Rgb(105, 178, 218), 223 - status_filename_fg: Color::Rgb(162, 192, 222), 224 - status_filename_bg: Color::Rgb(24, 28, 44), 225 - status_watch_fg: Color::Rgb(95, 200, 148), 226 - status_watch_bg: Color::Rgb(18, 30, 24), 227 - status_reloaded_fg: Color::Rgb(16, 18, 26), 228 - status_reloaded_bg: Color::Rgb(95, 200, 148), 229 - status_search_fg: Color::Rgb(240, 210, 95), 230 - status_search_bg: Color::Rgb(26, 28, 42), 231 - status_search_error_fg: Color::Rgb(218, 95, 95), 232 - status_search_match_fg: Color::Rgb(115, 208, 148), 233 - status_shortcut_fg: Color::Rgb(58, 68, 98), 234 - status_percent_fg: Color::Rgb(105, 178, 218), 235 - toc_header_fg: Color::Rgb(88, 88, 96), 236 - toc_active_bg: Color::Rgb(42, 40, 46), 237 - toc_inactive_bg: Color::Rgb(18, 18, 22), 238 - toc_accent: Color::Rgb(123, 109, 255), 239 - toc_index_inactive: Color::Rgb(60, 60, 66), 240 - toc_primary_active: Color::Rgb(224, 224, 228), 241 - toc_primary_inactive: Color::Rgb(136, 136, 142), 242 - toc_secondary_inactive: Color::Rgb(62, 62, 68), 243 - toc_secondary_text_active: Color::Rgb(224, 224, 228), 244 - toc_secondary_text_inactive: Color::Rgb(102, 102, 108), 245 - }, 246 - markdown: MarkdownTheme { 247 - search_highlight_bg: Color::Rgb(72, 62, 16), 248 - code_gutter: Color::Rgb(40, 48, 68), 249 - blockquote_marker: Color::Rgb(75, 80, 148), 250 - list_level_1: Color::Rgb(95, 200, 148), 251 - list_level_2: Color::Rgb(138, 155, 200), 252 - list_level_3: Color::Rgb(168, 168, 185), 253 - ordered_list: Color::Rgb(95, 200, 148), 254 - table_border: Color::Rgb(65, 75, 108), 255 - table_separator: Color::Rgb(55, 65, 95), 256 - table_header: Color::Rgb(140, 190, 255), 257 - table_cell: Color::Rgb(205, 208, 218), 258 - heading_1: Color::Rgb(140, 190, 255), 259 - heading_2: Color::Rgb(120, 210, 170), 260 - heading_3: Color::Rgb(210, 180, 120), 261 - heading_other: Color::Rgb(180, 180, 190), 262 - heading_underline: Color::Rgb(40, 50, 75), 263 - code_frame: Color::Rgb(40, 48, 68), 264 - code_label: Color::Rgb(95, 110, 145), 265 - inline_code_fg: Color::Rgb(220, 150, 118), 266 - inline_code_bg: Color::Rgb(38, 32, 31), 267 - rule: Color::Rgb(48, 56, 76), 268 - link_icon: Color::Rgb(85, 148, 235), 269 - link_text: Color::Rgb(88, 152, 238), 270 - blockquote_text: Color::Rgb(148, 148, 195), 271 - text: Color::Rgb(208, 210, 218), 272 - strong_text: Color::Rgb(245, 245, 255), 273 - }, 283 + ui: BASE_DARK_UI, 284 + markdown: BASE_DARK_MARKDOWN, 274 285 }; 275 286 276 287 pub(crate) const SOLARIZED_DARK_THEME: AppTheme = AppTheme {