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 #23 from RivoLink/chore/refacto-and-optimize

chore: refacto and optimize

authored by

Rivo Link and committed by
GitHub
076f49fe 5db82c76

+6112 -5977
-1707
src/app.rs
··· 1 - use crate::{ 2 - markdown::{ 3 - build_plain_lines, hash_file_contents, hash_str, parse_markdown_with_width, read_file_state, 4 - }, 5 - render::{build_status_bar, build_toc_line_with_index, toc_header_line}, 6 - theme::{ 7 - current_syntect_theme, current_theme_preset, set_theme_preset, theme_preset_index, 8 - ThemePreset, THEME_PRESETS, 9 - }, 10 - }; 11 - use ratatui::text::Line; 12 - use std::{ 13 - fs, 14 - path::PathBuf, 15 - sync::mpsc::{self, Receiver, TryRecvError}, 16 - thread, 17 - time::{Duration, Instant, SystemTime}, 18 - }; 19 - use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 20 - 21 - const MAX_FUZZY_PICKER_DIRS_VISITED: usize = 5_000; 22 - const MAX_FUZZY_PICKER_FILES_INDEXED: usize = 10_000; 23 - const MAX_FUZZY_PICKER_INDEX_DURATION: Duration = Duration::from_secs(5); 24 - const IGNORED_FUZZY_PICKER_DIRS: &[&str] = &[ 25 - ".git", 26 - "node_modules", 27 - "target", 28 - ".venv", 29 - "venv", 30 - "vendor", 31 - "var", 32 - "dist", 33 - "build", 34 - ".next", 35 - ".cache", 36 - ]; 37 - 38 - #[derive(Clone)] 39 - pub(crate) struct TocEntry { 40 - pub(crate) level: u8, 41 - pub(crate) title: String, 42 - pub(crate) line: usize, 43 - } 44 - 45 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 46 - pub(crate) struct FileState { 47 - pub(crate) modified: SystemTime, 48 - pub(crate) len: u64, 49 - } 50 - 51 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 52 - pub(crate) enum FileChange { 53 - Metadata(FileState), 54 - Content(FileState), 55 - } 56 - 57 - #[derive(Clone, Debug, PartialEq, Eq)] 58 - pub(crate) struct StatusCacheKey { 59 - pct: u16, 60 - search_mode: bool, 61 - search_draft_hash: u64, 62 - search_query_hash: u64, 63 - search_draft_len: usize, 64 - search_query_len: usize, 65 - search_match_count: usize, 66 - search_idx: usize, 67 - watch: bool, 68 - flash_active: bool, 69 - } 70 - 71 - #[derive(Clone)] 72 - pub(crate) struct ThemePreviewCacheEntry { 73 - lines: Vec<Line<'static>>, 74 - toc: Vec<TocEntry>, 75 - } 76 - 77 - pub(crate) struct SearchState { 78 - mode: bool, 79 - draft: String, 80 - query: String, 81 - matches: Vec<usize>, 82 - idx: usize, 83 - } 84 - 85 - #[derive(Clone, Debug, PartialEq, Eq)] 86 - pub(crate) struct FilePickerEntry { 87 - label: String, 88 - path: PathBuf, 89 - label_lower: String, 90 - file_name: String, 91 - file_name_lower: String, 92 - file_name_offset: usize, 93 - path_depth: usize, 94 - } 95 - 96 - impl FilePickerEntry { 97 - fn new(label: String, path: PathBuf) -> Self { 98 - let file_name = Self::file_name_component(&label).to_string(); 99 - let file_name_offset = label 100 - .rfind(std::path::MAIN_SEPARATOR) 101 - .map(|idx| label[..idx + 1].chars().count()) 102 - .unwrap_or(0); 103 - let path_depth = label.matches(std::path::MAIN_SEPARATOR).count(); 104 - 105 - Self { 106 - label_lower: label.to_lowercase(), 107 - file_name_lower: file_name.to_lowercase(), 108 - label, 109 - path, 110 - file_name, 111 - file_name_offset, 112 - path_depth, 113 - } 114 - } 115 - 116 - pub(crate) fn label(&self) -> &str { 117 - &self.label 118 - } 119 - 120 - fn label_lower(&self) -> &str { 121 - &self.label_lower 122 - } 123 - 124 - fn file_name_lower(&self) -> &str { 125 - &self.file_name_lower 126 - } 127 - 128 - fn file_name_offset(&self) -> usize { 129 - self.file_name_offset 130 - } 131 - 132 - fn path_depth(&self) -> usize { 133 - self.path_depth 134 - } 135 - 136 - fn file_name_component(path: &str) -> &str { 137 - path.rsplit(std::path::MAIN_SEPARATOR) 138 - .next() 139 - .unwrap_or(path) 140 - } 141 - 142 - fn is_dir_like(&self) -> bool { 143 - self.label == ".." || self.label.ends_with('/') 144 - } 145 - } 146 - 147 - pub(crate) struct FilePickerState { 148 - open: bool, 149 - mode: FilePickerMode, 150 - dir: PathBuf, 151 - entries: Vec<FilePickerEntry>, 152 - filtered: Vec<usize>, 153 - match_positions: Vec<Vec<usize>>, 154 - index: usize, 155 - query: String, 156 - truncation: Option<PickerIndexTruncation>, 157 - } 158 - 159 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 160 - pub(crate) enum FilePickerMode { 161 - Browser, 162 - Fuzzy, 163 - } 164 - 165 - #[derive(Clone, Debug, PartialEq, Eq)] 166 - enum PendingPicker { 167 - None, 168 - Browser(PathBuf), 169 - Fuzzy(PathBuf), 170 - } 171 - 172 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 173 - pub(crate) enum PickerIndexTruncation { 174 - Directory, 175 - File, 176 - Time, 177 - } 178 - 179 - struct PickerIndexResult { 180 - entries: Vec<FilePickerEntry>, 181 - truncated: Option<PickerIndexTruncation>, 182 - } 183 - 184 - enum PickerLoadState { 185 - Idle, 186 - Loading { 187 - mode: FilePickerMode, 188 - dir: PathBuf, 189 - started_at: Instant, 190 - receiver: Receiver<std::io::Result<PickerIndexResult>>, 191 - pending_result: Option<std::io::Result<PickerIndexResult>>, 192 - }, 193 - Failed { 194 - mode: FilePickerMode, 195 - dir: PathBuf, 196 - message: String, 197 - }, 198 - } 199 - 200 - pub(crate) struct ThemePickerState { 201 - open: bool, 202 - index: usize, 203 - original: Option<ThemePreset>, 204 - preview_cache: Vec<Option<ThemePreviewCacheEntry>>, 205 - } 206 - 207 - pub(crate) struct AppConfig { 208 - pub(crate) filename: String, 209 - pub(crate) source: String, 210 - pub(crate) debug_input: bool, 211 - pub(crate) watch: bool, 212 - pub(crate) filepath: Option<PathBuf>, 213 - pub(crate) last_file_state: Option<FileState>, 214 - } 215 - 216 - pub(crate) struct App { 217 - lines: Vec<Line<'static>>, 218 - plain_lines: Vec<String>, 219 - folded_plain_lines: Option<Vec<String>>, 220 - scroll: usize, 221 - toc: Vec<TocEntry>, 222 - toc_visible: bool, 223 - search: SearchState, 224 - debug_input: bool, 225 - filename: String, 226 - source: String, 227 - watch: bool, 228 - filepath: Option<PathBuf>, 229 - last_file_state: Option<FileState>, 230 - last_content_hash: u64, 231 - last_hash_check: Option<Instant>, 232 - reload_flash: Option<Instant>, 233 - highlighted_line_cache: Option<(usize, Line<'static>)>, 234 - toc_display_lines: Vec<Line<'static>>, 235 - toc_header_line: Line<'static>, 236 - toc_active_idx: Option<usize>, 237 - status_line: Line<'static>, 238 - status_cache_key: Option<StatusCacheKey>, 239 - help_open: bool, 240 - file_picker: FilePickerState, 241 - pending_picker: PendingPicker, 242 - picker_load_state: PickerLoadState, 243 - theme_picker: ThemePickerState, 244 - render_width: usize, 245 - } 246 - 247 - impl App { 248 - fn min_picker_loading_duration() -> Duration { 249 - Duration::from_millis(500) 250 - } 251 - 252 - fn is_markdown_path(path: &std::path::Path) -> bool { 253 - matches!( 254 - path.extension().and_then(|ext| ext.to_str()), 255 - Some("md" | "markdown" | "mdown" | "mkd") 256 - ) 257 - } 258 - 259 - fn build_file_picker_entries(dir: &std::path::Path) -> std::io::Result<Vec<FilePickerEntry>> { 260 - let mut entries = Vec::new(); 261 - 262 - if let Some(parent) = dir.parent() { 263 - entries.push(FilePickerEntry::new("..".to_string(), parent.to_path_buf())); 264 - } 265 - 266 - let mut dirs = Vec::new(); 267 - let mut files = Vec::new(); 268 - for entry in fs::read_dir(dir)? { 269 - let entry = entry?; 270 - let path = entry.path(); 271 - let file_type = match entry.file_type() { 272 - Ok(file_type) => file_type, 273 - Err(_) => continue, 274 - }; 275 - let name = entry.file_name().to_string_lossy().to_string(); 276 - 277 - if file_type.is_dir() { 278 - dirs.push(FilePickerEntry::new(format!("{name}/"), path)); 279 - } else if file_type.is_file() && Self::is_markdown_path(&path) { 280 - files.push(FilePickerEntry::new(name, path)); 281 - } 282 - } 283 - 284 - dirs.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 285 - files.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 286 - entries.extend(dirs); 287 - entries.extend(files); 288 - Ok(entries) 289 - } 290 - 291 - fn is_ignored_fuzzy_picker_dir_name(name: &str) -> bool { 292 - IGNORED_FUZZY_PICKER_DIRS.contains(&name) 293 - } 294 - 295 - fn fuzzy_directory_sort_key(root: &std::path::Path, path: &std::path::Path) -> (bool, String) { 296 - let label = path 297 - .strip_prefix(root) 298 - .unwrap_or(path) 299 - .display() 300 - .to_string(); 301 - ( 302 - !label 303 - .split(std::path::MAIN_SEPARATOR) 304 - .next() 305 - .unwrap_or(&label) 306 - .starts_with('.'), 307 - label.to_lowercase(), 308 - ) 309 - } 310 - 311 - fn build_fuzzy_file_picker_entries( 312 - dir: &std::path::Path, 313 - ) -> std::io::Result<PickerIndexResult> { 314 - let mut entries = Vec::new(); 315 - let mut stack = vec![dir.to_path_buf()]; 316 - let started_at = Instant::now(); 317 - let mut dirs_visited = 0usize; 318 - let mut files_indexed = 0usize; 319 - let mut truncated = None; 320 - 321 - while let Some(current_dir) = stack.pop() { 322 - if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 323 - truncated = Some(PickerIndexTruncation::Time); 324 - break; 325 - } 326 - if dirs_visited >= MAX_FUZZY_PICKER_DIRS_VISITED { 327 - truncated = Some(PickerIndexTruncation::Directory); 328 - break; 329 - } 330 - dirs_visited += 1; 331 - 332 - let mut dirs = Vec::new(); 333 - let mut files = Vec::new(); 334 - 335 - let read_dir = match fs::read_dir(&current_dir) { 336 - Ok(read_dir) => read_dir, 337 - Err(err) => { 338 - if current_dir == dir { 339 - return Err(err); 340 - } 341 - continue; 342 - } 343 - }; 344 - 345 - for entry in read_dir { 346 - if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 347 - truncated = Some(PickerIndexTruncation::Time); 348 - break; 349 - } 350 - let entry = match entry { 351 - Ok(entry) => entry, 352 - Err(_) => continue, 353 - }; 354 - let path = entry.path(); 355 - let file_type = match entry.file_type() { 356 - Ok(file_type) => file_type, 357 - Err(_) => continue, 358 - }; 359 - 360 - if file_type.is_dir() { 361 - let name = entry.file_name(); 362 - if Self::is_ignored_fuzzy_picker_dir_name(name.to_string_lossy().as_ref()) { 363 - continue; 364 - } 365 - dirs.push(path); 366 - continue; 367 - } 368 - 369 - if file_type.is_file() && Self::is_markdown_path(&path) { 370 - if files_indexed >= MAX_FUZZY_PICKER_FILES_INDEXED { 371 - truncated = Some(PickerIndexTruncation::File); 372 - break; 373 - } 374 - let label = path 375 - .strip_prefix(dir) 376 - .unwrap_or(&path) 377 - .display() 378 - .to_string(); 379 - files.push(FilePickerEntry::new(label, path)); 380 - files_indexed += 1; 381 - } 382 - } 383 - 384 - files.sort_by(|left, right| { 385 - Self::fuzzy_entry_sort_key(left).cmp(&Self::fuzzy_entry_sort_key(right)) 386 - }); 387 - dirs.sort_by_key(|path| Self::fuzzy_directory_sort_key(dir, path)); 388 - 389 - entries.extend(files); 390 - if truncated.is_some() { 391 - break; 392 - } 393 - dirs.reverse(); 394 - stack.extend(dirs); 395 - } 396 - 397 - Ok(PickerIndexResult { entries, truncated }) 398 - } 399 - 400 - fn fuzzy_entry_sort_key(entry: &FilePickerEntry) -> (bool, &str) { 401 - let first_component = entry 402 - .label 403 - .split(std::path::MAIN_SEPARATOR) 404 - .next() 405 - .unwrap_or(entry.label()); 406 - (!first_component.starts_with('.'), entry.label_lower()) 407 - } 408 - 409 - fn fuzzy_component_match(candidate: &str, query: &str) -> Option<(usize, Vec<usize>)> { 410 - if let Some(start) = candidate.find(query) { 411 - let start_chars = candidate[..start].chars().count(); 412 - let query_len = query.chars().count(); 413 - let len_diff = candidate.chars().count().saturating_sub(query_len); 414 - let prefix_bonus = usize::from(start_chars == 0).saturating_mul(80); 415 - let boundary_bonus = 416 - usize::from(Self::is_match_boundary(candidate, start_chars)).saturating_mul(40); 417 - let score = start_chars 418 - .saturating_mul(10) 419 - .saturating_add(len_diff) 420 - .saturating_sub(prefix_bonus) 421 - .saturating_sub(boundary_bonus); 422 - let positions = (start_chars..start_chars + query_len).collect::<Vec<_>>(); 423 - return Some((score, positions)); 424 - } 425 - 426 - let mut search_from = 0usize; 427 - let mut positions = Vec::with_capacity(query.len()); 428 - 429 - for needle in query.chars() { 430 - let found = candidate[search_from..] 431 - .char_indices() 432 - .find(|(_, ch)| *ch == needle) 433 - .map(|(idx, _)| search_from + idx)?; 434 - let char_pos = candidate[..found].chars().count(); 435 - positions.push(char_pos); 436 - search_from = found + needle.len_utf8(); 437 - } 438 - 439 - let first = *positions.first()?; 440 - let last = *positions.last()?; 441 - let span = last.saturating_sub(first); 442 - let gaps = positions 443 - .windows(2) 444 - .map(|window| window[1].saturating_sub(window[0]).saturating_sub(1)) 445 - .sum::<usize>(); 446 - let len_diff = candidate 447 - .chars() 448 - .count() 449 - .saturating_sub(query.chars().count()); 450 - let prefix_bonus = usize::from(first == 0).saturating_mul(80); 451 - let boundary_bonus = 452 - usize::from(Self::is_match_boundary(candidate, first)).saturating_mul(40); 453 - let score = 1_000usize 454 - .saturating_add(gaps.saturating_mul(120)) 455 - .saturating_add(first.saturating_mul(10)) 456 - .saturating_add(span) 457 - .saturating_add(len_diff) 458 - .saturating_sub(prefix_bonus) 459 - .saturating_sub(boundary_bonus); 460 - Some((score, positions)) 461 - } 462 - 463 - fn is_match_boundary(candidate: &str, char_pos: usize) -> bool { 464 - if char_pos == 0 { 465 - return true; 466 - } 467 - 468 - candidate 469 - .chars() 470 - .nth(char_pos.saturating_sub(1)) 471 - .is_some_and(|ch| matches!(ch, '-' | '_' | '.' | ' ')) 472 - } 473 - 474 - fn fuzzy_match(entry: &FilePickerEntry, query: &str) -> Option<(usize, Vec<usize>)> { 475 - if query.is_empty() { 476 - return Some((0, Vec::new())); 477 - } 478 - 479 - let (score, positions) = Self::fuzzy_component_match(entry.file_name_lower(), query)?; 480 - Some(( 481 - score, 482 - positions 483 - .into_iter() 484 - .map(|position| entry.file_name_offset() + position) 485 - .collect(), 486 - )) 487 - } 488 - 489 - fn refresh_file_picker_matches(&mut self) { 490 - if self.is_browser_file_picker() { 491 - self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 492 - self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 493 - self.file_picker.index = self 494 - .file_picker 495 - .index 496 - .min(self.file_picker.filtered.len().saturating_sub(1)); 497 - return; 498 - } 499 - 500 - let query = self.file_picker.query.trim().to_lowercase(); 501 - if query.is_empty() { 502 - self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 503 - self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 504 - self.file_picker.index = self 505 - .file_picker 506 - .index 507 - .min(self.file_picker.filtered.len().saturating_sub(1)); 508 - return; 509 - } 510 - 511 - let mut filtered = self 512 - .file_picker 513 - .entries 514 - .iter() 515 - .enumerate() 516 - .filter_map(|(idx, entry)| { 517 - Self::fuzzy_match(entry, &query).map(|(score, positions)| { 518 - ( 519 - idx, 520 - score, 521 - entry.path_depth(), 522 - entry.file_name_lower(), 523 - entry.label_lower(), 524 - positions, 525 - ) 526 - }) 527 - }) 528 - .collect::<Vec<_>>(); 529 - 530 - filtered.sort_by( 531 - |(left_idx, left_score, left_depth, left_name, left_label, _), 532 - (right_idx, right_score, right_depth, right_name, right_label, _)| { 533 - left_score 534 - .cmp(right_score) 535 - .then_with(|| left_depth.cmp(right_depth)) 536 - .then_with(|| left_name.cmp(right_name)) 537 - .then_with(|| left_label.cmp(right_label)) 538 - .then_with(|| left_idx.cmp(right_idx)) 539 - }, 540 - ); 541 - 542 - self.file_picker.filtered = filtered.iter().map(|(idx, ..)| *idx).collect(); 543 - self.file_picker.match_positions = filtered 544 - .into_iter() 545 - .map(|(_, _, _, _, _, positions)| positions) 546 - .collect(); 547 - if self.file_picker.filtered.is_empty() 548 - || self.file_picker.index >= self.file_picker.filtered.len() 549 - { 550 - self.file_picker.index = 0; 551 - } 552 - } 553 - 554 - fn selected_file_picker_entry(&self) -> Option<&FilePickerEntry> { 555 - let idx = *self.file_picker.filtered.get(self.file_picker.index)?; 556 - self.file_picker.entries.get(idx) 557 - } 558 - 559 - #[cfg(test)] 560 - pub(crate) fn new( 561 - lines: Vec<Line<'static>>, 562 - toc: Vec<TocEntry>, 563 - filename: String, 564 - debug_input: bool, 565 - watch: bool, 566 - filepath: Option<PathBuf>, 567 - last_file_state: Option<FileState>, 568 - ) -> Self { 569 - let source = lines 570 - .iter() 571 - .map(|line| { 572 - line.spans 573 - .iter() 574 - .map(|s| s.content.as_ref()) 575 - .collect::<String>() 576 - }) 577 - .collect::<Vec<_>>() 578 - .join("\n"); 579 - Self::new_with_source( 580 - lines, 581 - toc, 582 - AppConfig { 583 - filename, 584 - source, 585 - debug_input, 586 - watch, 587 - filepath, 588 - last_file_state, 589 - }, 590 - ) 591 - } 592 - 593 - pub(crate) fn new_with_source( 594 - lines: Vec<Line<'static>>, 595 - toc: Vec<TocEntry>, 596 - config: AppConfig, 597 - ) -> Self { 598 - let AppConfig { 599 - filename, 600 - source, 601 - debug_input, 602 - watch, 603 - filepath, 604 - last_file_state, 605 - } = config; 606 - let plain_lines = build_plain_lines(&lines); 607 - let mut app = Self { 608 - lines, 609 - plain_lines, 610 - folded_plain_lines: None, 611 - scroll: 0, 612 - toc, 613 - toc_visible: false, 614 - search: SearchState { 615 - mode: false, 616 - draft: String::new(), 617 - query: String::new(), 618 - matches: vec![], 619 - idx: 0, 620 - }, 621 - debug_input, 622 - filename, 623 - source, 624 - watch, 625 - filepath, 626 - last_file_state, 627 - last_content_hash: 0, 628 - last_hash_check: None, 629 - reload_flash: None, 630 - highlighted_line_cache: None, 631 - toc_display_lines: Vec::new(), 632 - toc_header_line: toc_header_line(), 633 - toc_active_idx: None, 634 - status_line: Line::default(), 635 - status_cache_key: None, 636 - help_open: false, 637 - file_picker: FilePickerState { 638 - open: false, 639 - mode: FilePickerMode::Browser, 640 - dir: PathBuf::from("."), 641 - entries: Vec::new(), 642 - filtered: Vec::new(), 643 - match_positions: Vec::new(), 644 - index: 0, 645 - query: String::new(), 646 - truncation: None, 647 - }, 648 - pending_picker: PendingPicker::None, 649 - picker_load_state: PickerLoadState::Idle, 650 - theme_picker: ThemePickerState { 651 - open: false, 652 - index: theme_preset_index(current_theme_preset()), 653 - original: None, 654 - preview_cache: vec![None; crate::theme::THEME_PRESETS.len()], 655 - }, 656 - render_width: 80, 657 - }; 658 - app.store_current_theme_preview(); 659 - app.refresh_static_caches(); 660 - app 661 - } 662 - 663 - pub(crate) fn set_last_content_hash(&mut self, last_content_hash: u64) { 664 - self.last_content_hash = last_content_hash; 665 - } 666 - 667 - pub(crate) fn is_watch_enabled(&self) -> bool { 668 - self.watch 669 - } 670 - 671 - pub(crate) fn debug_input_enabled(&self) -> bool { 672 - self.debug_input 673 - } 674 - 675 - pub(crate) fn is_toc_visible(&self) -> bool { 676 - self.toc_visible 677 - } 678 - 679 - pub(crate) fn has_toc(&self) -> bool { 680 - !self.toc.is_empty() 681 - } 682 - 683 - pub(crate) fn total(&self) -> usize { 684 - self.lines.len() 685 - } 686 - 687 - pub(crate) fn scroll(&self) -> usize { 688 - self.scroll 689 - } 690 - 691 - pub(crate) fn visible_lines(&self, start: usize, end: usize) -> &[Line<'static>] { 692 - &self.lines[start..end] 693 - } 694 - 695 - pub(crate) fn highlighted_line_cache(&self) -> Option<&(usize, Line<'static>)> { 696 - self.highlighted_line_cache.as_ref() 697 - } 698 - 699 - pub(crate) fn toc_display_lines(&self) -> &[Line<'static>] { 700 - &self.toc_display_lines 701 - } 702 - 703 - pub(crate) fn toc_header_line(&self) -> &Line<'static> { 704 - &self.toc_header_line 705 - } 706 - 707 - pub(crate) fn status_line(&self) -> &Line<'static> { 708 - &self.status_line 709 - } 710 - 711 - pub(crate) fn filename(&self) -> &str { 712 - &self.filename 713 - } 714 - 715 - pub(crate) fn replace_content(&mut self, lines: Vec<Line<'static>>, toc: Vec<TocEntry>) { 716 - self.plain_lines = build_plain_lines(&lines); 717 - self.folded_plain_lines = None; 718 - self.lines = lines; 719 - self.toc = toc; 720 - self.highlighted_line_cache = None; 721 - self.toc_header_line = toc_header_line(); 722 - self.refresh_static_caches(); 723 - } 724 - 725 - pub(crate) fn active_highlight_line(&self) -> Option<usize> { 726 - if self.search.matches.is_empty() { 727 - None 728 - } else { 729 - Some(self.search.matches[self.search.idx]) 730 - } 731 - } 732 - 733 - pub(crate) fn is_search_mode(&self) -> bool { 734 - self.search.mode 735 - } 736 - 737 - pub(crate) fn search_draft(&self) -> &str { 738 - &self.search.draft 739 - } 740 - 741 - pub(crate) fn search_query(&self) -> &str { 742 - &self.search.query 743 - } 744 - 745 - #[cfg(test)] 746 - pub(crate) fn set_search_query(&mut self, query: impl Into<String>) { 747 - self.search.query = query.into(); 748 - } 749 - 750 - pub(crate) fn search_match_count(&self) -> usize { 751 - self.search.matches.len() 752 - } 753 - 754 - pub(crate) fn search_index(&self) -> usize { 755 - self.search.idx 756 - } 757 - 758 - #[cfg(test)] 759 - pub(crate) fn search_matches(&self) -> &[usize] { 760 - &self.search.matches 761 - } 762 - 763 - #[cfg(test)] 764 - pub(crate) fn line(&self, idx: usize) -> Option<&Line<'static>> { 765 - self.lines.get(idx) 766 - } 767 - 768 - #[cfg(test)] 769 - pub(crate) fn set_search_draft(&mut self, draft: impl Into<String>) { 770 - self.search.draft = draft.into(); 771 - } 772 - 773 - pub(crate) fn pop_search_draft(&mut self) { 774 - self.search.draft.pop(); 775 - } 776 - 777 - pub(crate) fn push_search_draft(&mut self, ch: char) { 778 - self.search.draft.push(ch); 779 - } 780 - 781 - pub(crate) fn active_toc_index(&self) -> Option<usize> { 782 - let hide_single_h1 = should_hide_single_h1(&self.toc); 783 - let mut first_visible = None; 784 - let mut active = None; 785 - for (idx, entry) in self 786 - .toc 787 - .iter() 788 - .enumerate() 789 - .filter(|(_, entry)| !(hide_single_h1 && entry.level == 1)) 790 - { 791 - if first_visible.is_none() { 792 - first_visible = Some((idx, entry.line)); 793 - } 794 - if entry.line > self.scroll { 795 - break; 796 - } 797 - active = Some(idx); 798 - } 799 - 800 - let (first_idx, first_line) = first_visible?; 801 - if self.scroll < first_line { 802 - Some(first_idx) 803 - } else { 804 - active.or(Some(first_idx)) 805 - } 806 - } 807 - 808 - pub(crate) fn folded_plain_lines(&mut self) -> &[String] { 809 - if self.folded_plain_lines.is_none() { 810 - self.folded_plain_lines = Some( 811 - self.plain_lines 812 - .iter() 813 - .map(|line| line.to_lowercase()) 814 - .collect(), 815 - ); 816 - } 817 - self.folded_plain_lines.as_deref().unwrap_or(&[]) 818 - } 819 - 820 - pub(crate) fn refresh_highlighted_line_cache(&mut self, line_idx: usize) -> Option<()> { 821 - let needs_refresh = self 822 - .highlighted_line_cache 823 - .as_ref() 824 - .map(|(cached_idx, _)| *cached_idx != line_idx) 825 - .unwrap_or(true); 826 - if needs_refresh { 827 - let line = self.lines.get(line_idx)?; 828 - self.highlighted_line_cache = Some((line_idx, crate::markdown::highlight_line(line))); 829 - } 830 - Some(()) 831 - } 832 - 833 - pub(crate) fn refresh_toc_cache(&mut self) { 834 - let hide_single_h1 = should_hide_single_h1(&self.toc); 835 - let promote_h2_root = should_promote_h2_when_no_h1(&self.toc); 836 - let active_idx = self.active_toc_index(); 837 - if self.toc_active_idx == active_idx && !self.toc_display_lines.is_empty() { 838 - return; 839 - } 840 - 841 - self.toc_active_idx = active_idx; 842 - let mut top_level_index = 0usize; 843 - self.toc_display_lines = self 844 - .toc 845 - .iter() 846 - .enumerate() 847 - .filter(|(_, entry)| !(hide_single_h1 && entry.level == 1)) 848 - .map(|(idx, entry)| { 849 - let display_level = toc_display_level(entry.level, hide_single_h1, promote_h2_root); 850 - let line = build_toc_line_with_index( 851 - entry, 852 - display_level, 853 - (display_level == 1).then_some(top_level_index), 854 - active_idx == Some(idx), 855 - ); 856 - if display_level == 1 { 857 - top_level_index += 1; 858 - } 859 - line 860 - }) 861 - .collect(); 862 - } 863 - 864 - pub(crate) fn refresh_status_cache(&mut self, pct: u16) { 865 - let cache_key = StatusCacheKey { 866 - pct, 867 - search_mode: self.search.mode, 868 - search_draft_hash: hash_str(&self.search.draft), 869 - search_query_hash: hash_str(&self.search.query), 870 - search_draft_len: self.search.draft.len(), 871 - search_query_len: self.search.query.len(), 872 - search_match_count: self.search.matches.len(), 873 - search_idx: self.search.idx, 874 - watch: self.watch, 875 - flash_active: self 876 - .reload_flash 877 - .map(|t| t.elapsed() < Duration::from_millis(1500)) 878 - .unwrap_or(false), 879 - }; 880 - 881 - if self.status_cache_key.as_ref() == Some(&cache_key) { 882 - return; 883 - } 884 - 885 - self.status_line = Line::from(build_status_bar(self, pct)); 886 - self.status_cache_key = Some(cache_key); 887 - } 888 - 889 - pub(crate) fn refresh_static_caches(&mut self) { 890 - self.toc_active_idx = None; 891 - self.toc_display_lines.clear(); 892 - self.refresh_toc_cache(); 893 - self.status_cache_key = None; 894 - } 895 - 896 - pub(crate) fn invalidate_theme_preview_cache(&mut self) { 897 - self.theme_picker.preview_cache.fill(None); 898 - } 899 - 900 - fn store_theme_preview( 901 - &mut self, 902 - preset: ThemePreset, 903 - lines: &[Line<'static>], 904 - toc: &[TocEntry], 905 - ) { 906 - let idx = theme_preset_index(preset); 907 - if let Some(slot) = self.theme_picker.preview_cache.get_mut(idx) { 908 - *slot = Some(ThemePreviewCacheEntry { 909 - lines: lines.to_vec(), 910 - toc: toc.to_vec(), 911 - }); 912 - } 913 - } 914 - 915 - fn store_current_theme_preview(&mut self) { 916 - let preset = current_theme_preset(); 917 - let lines = self.lines.clone(); 918 - let toc = self.toc.clone(); 919 - self.store_theme_preview(preset, &lines, &toc); 920 - } 921 - 922 - pub(crate) fn open_theme_picker(&mut self) { 923 - self.theme_picker.open = true; 924 - let current = current_theme_preset(); 925 - self.theme_picker.index = theme_preset_index(current); 926 - self.theme_picker.original = Some(current); 927 - self.store_current_theme_preview(); 928 - } 929 - 930 - pub(crate) fn close_theme_picker(&mut self) { 931 - self.theme_picker.open = false; 932 - self.theme_picker.original = None; 933 - } 934 - 935 - pub(crate) fn is_theme_picker_open(&self) -> bool { 936 - self.theme_picker.open 937 - } 938 - 939 - pub(crate) fn open_help(&mut self) { 940 - self.help_open = true; 941 - } 942 - 943 - pub(crate) fn close_help(&mut self) { 944 - self.help_open = false; 945 - } 946 - 947 - pub(crate) fn is_help_open(&self) -> bool { 948 - self.help_open 949 - } 950 - 951 - pub(crate) fn open_file_picker(&mut self, dir: PathBuf) -> bool { 952 - self.open_file_picker_with_mode(dir, FilePickerMode::Browser) 953 - } 954 - 955 - #[cfg(test)] 956 - pub(crate) fn open_fuzzy_file_picker(&mut self, dir: PathBuf) -> bool { 957 - self.open_file_picker_with_mode(dir, FilePickerMode::Fuzzy) 958 - } 959 - 960 - pub(crate) fn queue_file_picker(&mut self, dir: PathBuf) { 961 - self.pending_picker = PendingPicker::Browser(dir); 962 - } 963 - 964 - pub(crate) fn queue_fuzzy_file_picker(&mut self, dir: PathBuf) { 965 - self.pending_picker = PendingPicker::Fuzzy(dir); 966 - } 967 - 968 - pub(crate) fn has_pending_picker(&self) -> bool { 969 - !matches!(self.pending_picker, PendingPicker::None) 970 - } 971 - 972 - pub(crate) fn start_pending_picker_loading(&mut self) -> bool { 973 - if !self.has_pending_picker() || !matches!(self.picker_load_state, PickerLoadState::Idle) { 974 - return false; 975 - } 976 - 977 - let pending = std::mem::replace(&mut self.pending_picker, PendingPicker::None); 978 - let (mode, dir) = match pending { 979 - PendingPicker::Browser(dir) => (FilePickerMode::Browser, dir), 980 - PendingPicker::Fuzzy(dir) => (FilePickerMode::Fuzzy, dir), 981 - PendingPicker::None => return false, 982 - }; 983 - 984 - let worker_dir = dir.clone(); 985 - let (tx, rx) = mpsc::channel(); 986 - crate::runtime::debug_log( 987 - self.debug_input, 988 - &format!("picker_loading spawn mode={mode:?} dir={}", dir.display()), 989 - ); 990 - thread::spawn(move || { 991 - let result = match mode { 992 - FilePickerMode::Browser => { 993 - Self::build_file_picker_entries(&worker_dir).map(|entries| PickerIndexResult { 994 - entries, 995 - truncated: None, 996 - }) 997 - } 998 - FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&worker_dir), 999 - }; 1000 - let _ = tx.send(result); 1001 - }); 1002 - 1003 - self.picker_load_state = PickerLoadState::Loading { 1004 - mode, 1005 - dir, 1006 - started_at: Instant::now(), 1007 - receiver: rx, 1008 - pending_result: None, 1009 - }; 1010 - true 1011 - } 1012 - 1013 - pub(crate) fn is_picker_loading(&self) -> bool { 1014 - matches!(self.picker_load_state, PickerLoadState::Loading { .. }) 1015 - } 1016 - 1017 - pub(crate) fn is_picker_load_failed(&self) -> bool { 1018 - matches!(self.picker_load_state, PickerLoadState::Failed { .. }) 1019 - } 1020 - 1021 - pub(crate) fn pending_picker_mode(&self) -> Option<FilePickerMode> { 1022 - match &self.picker_load_state { 1023 - PickerLoadState::Loading { mode, .. } | PickerLoadState::Failed { mode, .. } => { 1024 - Some(*mode) 1025 - } 1026 - PickerLoadState::Idle => match self.pending_picker { 1027 - PendingPicker::Browser(..) => Some(FilePickerMode::Browser), 1028 - PendingPicker::Fuzzy(..) => Some(FilePickerMode::Fuzzy), 1029 - PendingPicker::None => None, 1030 - }, 1031 - } 1032 - } 1033 - 1034 - pub(crate) fn pending_picker_dir(&self) -> Option<&std::path::Path> { 1035 - match &self.picker_load_state { 1036 - PickerLoadState::Loading { dir, .. } | PickerLoadState::Failed { dir, .. } => { 1037 - Some(dir.as_path()) 1038 - } 1039 - PickerLoadState::Idle => match &self.pending_picker { 1040 - PendingPicker::Browser(dir) | PendingPicker::Fuzzy(dir) => Some(dir.as_path()), 1041 - PendingPicker::None => None, 1042 - }, 1043 - } 1044 - } 1045 - 1046 - pub(crate) fn picker_load_error(&self) -> Option<&str> { 1047 - match &self.picker_load_state { 1048 - PickerLoadState::Failed { message, .. } => Some(message.as_str()), 1049 - PickerLoadState::Idle | PickerLoadState::Loading { .. } => None, 1050 - } 1051 - } 1052 - 1053 - fn install_loaded_file_picker( 1054 - &mut self, 1055 - dir: PathBuf, 1056 - mode: FilePickerMode, 1057 - result: PickerIndexResult, 1058 - ) -> bool { 1059 - self.file_picker.open = true; 1060 - self.file_picker.mode = mode; 1061 - self.file_picker.dir = dir; 1062 - self.file_picker.entries = result.entries; 1063 - self.file_picker.query.clear(); 1064 - self.file_picker.index = 0; 1065 - self.file_picker.truncation = if mode == FilePickerMode::Fuzzy { 1066 - result.truncated 1067 - } else { 1068 - None 1069 - }; 1070 - self.refresh_file_picker_matches(); 1071 - true 1072 - } 1073 - 1074 - pub(crate) fn poll_picker_loading(&mut self) -> bool { 1075 - let state = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle); 1076 - match state { 1077 - PickerLoadState::Loading { 1078 - mode, 1079 - dir, 1080 - started_at, 1081 - receiver, 1082 - mut pending_result, 1083 - } => { 1084 - if pending_result.is_none() { 1085 - pending_result = match receiver.try_recv() { 1086 - Ok(result) => { 1087 - crate::runtime::debug_log( 1088 - self.debug_input, 1089 - &format!( 1090 - "picker_loading worker_finished mode={mode:?} dir={}", 1091 - dir.display() 1092 - ), 1093 - ); 1094 - Some(result) 1095 - } 1096 - Err(TryRecvError::Empty) => None, 1097 - Err(TryRecvError::Disconnected) => Some(Err(std::io::Error::other( 1098 - "Picker loading worker disconnected", 1099 - ))), 1100 - }; 1101 - } 1102 - 1103 - if started_at.elapsed() < Self::min_picker_loading_duration() { 1104 - self.picker_load_state = PickerLoadState::Loading { 1105 - mode, 1106 - dir, 1107 - started_at, 1108 - receiver, 1109 - pending_result, 1110 - }; 1111 - return false; 1112 - } 1113 - 1114 - match pending_result { 1115 - Some(Ok(result)) => { 1116 - crate::runtime::debug_log( 1117 - self.debug_input, 1118 - &format!( 1119 - "picker_loading install mode={mode:?} dir={} entries={}", 1120 - dir.display(), 1121 - result.entries.len() 1122 - ), 1123 - ); 1124 - self.install_loaded_file_picker(dir, mode, result) 1125 - } 1126 - Some(Err(err)) => { 1127 - crate::runtime::debug_log( 1128 - self.debug_input, 1129 - &format!( 1130 - "picker_loading failed mode={mode:?} dir={} error={}", 1131 - dir.display(), 1132 - err 1133 - ), 1134 - ); 1135 - self.picker_load_state = PickerLoadState::Failed { 1136 - mode, 1137 - dir, 1138 - message: err.to_string(), 1139 - }; 1140 - true 1141 - } 1142 - None => { 1143 - self.picker_load_state = PickerLoadState::Loading { 1144 - mode, 1145 - dir, 1146 - started_at, 1147 - receiver, 1148 - pending_result: None, 1149 - }; 1150 - false 1151 - } 1152 - } 1153 - } 1154 - PickerLoadState::Failed { .. } => { 1155 - self.picker_load_state = state; 1156 - false 1157 - } 1158 - PickerLoadState::Idle => { 1159 - self.picker_load_state = PickerLoadState::Idle; 1160 - false 1161 - } 1162 - } 1163 - } 1164 - 1165 - #[cfg(test)] 1166 - pub(crate) fn age_picker_loading_by(&mut self, duration: Duration) { 1167 - if let PickerLoadState::Loading { 1168 - mode, 1169 - dir, 1170 - started_at, 1171 - receiver, 1172 - pending_result, 1173 - } = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle) 1174 - { 1175 - let adjusted = started_at.checked_sub(duration).unwrap_or(started_at); 1176 - self.picker_load_state = PickerLoadState::Loading { 1177 - mode, 1178 - dir, 1179 - started_at: adjusted, 1180 - receiver, 1181 - pending_result, 1182 - }; 1183 - } 1184 - } 1185 - 1186 - fn open_file_picker_with_mode(&mut self, dir: PathBuf, mode: FilePickerMode) -> bool { 1187 - let result = match mode { 1188 - FilePickerMode::Browser => { 1189 - Self::build_file_picker_entries(&dir).map(|entries| PickerIndexResult { 1190 - entries, 1191 - truncated: None, 1192 - }) 1193 - } 1194 - FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&dir), 1195 - }; 1196 - 1197 - match result { 1198 - Ok(result) => self.install_loaded_file_picker(dir, mode, result), 1199 - Err(_) => false, 1200 - } 1201 - } 1202 - 1203 - pub(crate) fn is_fuzzy_file_picker(&self) -> bool { 1204 - self.file_picker.mode == FilePickerMode::Fuzzy 1205 - } 1206 - 1207 - pub(crate) fn is_browser_file_picker(&self) -> bool { 1208 - self.file_picker.mode == FilePickerMode::Browser 1209 - } 1210 - 1211 - pub(crate) fn is_file_picker_open(&self) -> bool { 1212 - self.file_picker.open 1213 - } 1214 - 1215 - pub(crate) fn file_picker_dir(&self) -> &std::path::Path { 1216 - &self.file_picker.dir 1217 - } 1218 - 1219 - pub(crate) fn file_picker_entries(&self) -> &[FilePickerEntry] { 1220 - &self.file_picker.entries 1221 - } 1222 - 1223 - pub(crate) fn file_picker_filtered_indices(&self) -> &[usize] { 1224 - &self.file_picker.filtered 1225 - } 1226 - 1227 - pub(crate) fn file_picker_match_positions(&self, filtered_idx: usize) -> &[usize] { 1228 - self.file_picker 1229 - .match_positions 1230 - .get(filtered_idx) 1231 - .map(Vec::as_slice) 1232 - .unwrap_or(&[]) 1233 - } 1234 - 1235 - pub(crate) fn file_picker_index(&self) -> usize { 1236 - self.file_picker.index 1237 - } 1238 - 1239 - pub(crate) fn file_picker_query(&self) -> &str { 1240 - &self.file_picker.query 1241 - } 1242 - 1243 - pub(crate) fn file_picker_truncation(&self) -> Option<PickerIndexTruncation> { 1244 - self.file_picker.truncation 1245 - } 1246 - 1247 - pub(crate) fn move_file_picker_up(&mut self) { 1248 - let total = self.file_picker.filtered.len(); 1249 - if total == 0 { 1250 - return; 1251 - } 1252 - if self.file_picker.index == 0 { 1253 - self.file_picker.index = total - 1; 1254 - } else { 1255 - self.file_picker.index -= 1; 1256 - } 1257 - } 1258 - 1259 - pub(crate) fn move_file_picker_down(&mut self) { 1260 - let total = self.file_picker.filtered.len(); 1261 - if total == 0 { 1262 - return; 1263 - } 1264 - self.file_picker.index = (self.file_picker.index + 1) % total; 1265 - } 1266 - 1267 - pub(crate) fn push_file_picker_query(&mut self, ch: char) { 1268 - if self.is_browser_file_picker() { 1269 - return; 1270 - } 1271 - self.file_picker.query.push(ch); 1272 - self.refresh_file_picker_matches(); 1273 - } 1274 - 1275 - pub(crate) fn pop_file_picker_query(&mut self) { 1276 - if self.is_browser_file_picker() { 1277 - return; 1278 - } 1279 - self.file_picker.query.pop(); 1280 - self.refresh_file_picker_matches(); 1281 - } 1282 - 1283 - pub(crate) fn clear_file_picker_query(&mut self) { 1284 - if self.is_browser_file_picker() { 1285 - return; 1286 - } 1287 - self.file_picker.query.clear(); 1288 - self.refresh_file_picker_matches(); 1289 - } 1290 - 1291 - pub(crate) fn open_file_picker_parent(&mut self) -> bool { 1292 - if self.is_fuzzy_file_picker() { 1293 - return false; 1294 - } 1295 - let Some(parent) = self.file_picker.dir.parent() else { 1296 - return false; 1297 - }; 1298 - self.open_file_picker(parent.to_path_buf()) 1299 - } 1300 - 1301 - pub(crate) fn theme_picker_index(&self) -> usize { 1302 - self.theme_picker.index 1303 - } 1304 - 1305 - #[cfg(test)] 1306 - pub(crate) fn theme_picker_original(&self) -> Option<ThemePreset> { 1307 - self.theme_picker.original 1308 - } 1309 - 1310 - pub(crate) fn clear_reload_flash(&mut self) { 1311 - self.reload_flash = None; 1312 - } 1313 - 1314 - pub(crate) fn reload_flash_started(&self) -> Option<Instant> { 1315 - self.reload_flash 1316 - } 1317 - 1318 - pub(crate) fn set_last_file_state(&mut self, state: FileState) { 1319 - self.last_file_state = Some(state); 1320 - } 1321 - 1322 - pub(crate) fn theme_picker_reference_preset(&self) -> ThemePreset { 1323 - self.theme_picker.original.unwrap_or(current_theme_preset()) 1324 - } 1325 - 1326 - pub(crate) fn move_theme_picker_up(&mut self) { 1327 - let total = THEME_PRESETS.len(); 1328 - if total == 0 { 1329 - return; 1330 - } 1331 - if self.theme_picker.index == 0 { 1332 - self.theme_picker.index = total - 1; 1333 - } else { 1334 - self.theme_picker.index -= 1; 1335 - } 1336 - } 1337 - 1338 - pub(crate) fn move_theme_picker_down(&mut self) { 1339 - let total = THEME_PRESETS.len(); 1340 - if total == 0 { 1341 - return; 1342 - } 1343 - self.theme_picker.index = (self.theme_picker.index + 1) % total; 1344 - } 1345 - 1346 - pub(crate) fn set_theme_picker_index(&mut self, idx: usize) -> bool { 1347 - if idx < THEME_PRESETS.len() { 1348 - self.theme_picker.index = idx; 1349 - true 1350 - } else { 1351 - false 1352 - } 1353 - } 1354 - 1355 - pub(crate) fn selected_theme_preset(&self) -> Option<ThemePreset> { 1356 - THEME_PRESETS.get(self.theme_picker.index).copied() 1357 - } 1358 - 1359 - #[cfg(test)] 1360 - pub(crate) fn has_cached_theme_preview(&self, preset: ThemePreset) -> bool { 1361 - self.theme_picker 1362 - .preview_cache 1363 - .get(theme_preset_index(preset)) 1364 - .and_then(|entry| entry.as_ref()) 1365 - .is_some() 1366 - } 1367 - 1368 - pub(crate) fn preview_theme_preset( 1369 - &mut self, 1370 - preset: ThemePreset, 1371 - ss: &SyntaxSet, 1372 - themes: &ThemeSet, 1373 - ) { 1374 - if current_theme_preset() == preset { 1375 - return; 1376 - } 1377 - set_theme_preset(preset); 1378 - let cached = self 1379 - .theme_picker 1380 - .preview_cache 1381 - .get(theme_preset_index(preset)) 1382 - .and_then(|entry| entry.as_ref()) 1383 - .cloned(); 1384 - if let Some(entry) = cached { 1385 - self.replace_content(entry.lines, entry.toc); 1386 - return; 1387 - } 1388 - 1389 - let theme = current_syntect_theme(themes); 1390 - let (new_lines, new_toc) = 1391 - parse_markdown_with_width(&self.source, ss, theme, self.render_width); 1392 - self.store_theme_preview(preset, &new_lines, &new_toc); 1393 - self.replace_content(new_lines, new_toc); 1394 - } 1395 - 1396 - pub(crate) fn restore_theme_picker_preview(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 1397 - if let Some(original) = self.theme_picker.original { 1398 - self.preview_theme_preset(original, ss, themes); 1399 - } 1400 - self.close_theme_picker(); 1401 - } 1402 - 1403 - pub(crate) fn scroll_down(&mut self, n: usize) { 1404 - self.scroll = (self.scroll + n).min(self.total().saturating_sub(1)); 1405 - } 1406 - 1407 - pub(crate) fn scroll_up(&mut self, n: usize) { 1408 - self.scroll = self.scroll.saturating_sub(n); 1409 - } 1410 - 1411 - pub(crate) fn scroll_top(&mut self) { 1412 - self.scroll = 0; 1413 - } 1414 - 1415 - pub(crate) fn scroll_bottom(&mut self) { 1416 - self.scroll = self.total().saturating_sub(1); 1417 - } 1418 - 1419 - pub(crate) fn toggle_toc(&mut self) { 1420 - self.toc_visible = !self.toc_visible; 1421 - } 1422 - 1423 - pub(crate) fn request_reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 1424 - self.last_file_state = None; 1425 - self.reload(ss, themes) 1426 - } 1427 - 1428 - pub(crate) fn jump_to_toc(&mut self, idx: usize) { 1429 - if let Some(e) = self.toc.get(idx) { 1430 - self.scroll = e.line; 1431 - } 1432 - } 1433 - 1434 - pub(crate) fn run_search(&mut self) { 1435 - let q = self.search.query.to_lowercase(); 1436 - if q.is_empty() { 1437 - return; 1438 - } 1439 - let search_matches = { 1440 - let folded_lines = self.folded_plain_lines(); 1441 - folded_lines 1442 - .iter() 1443 - .enumerate() 1444 - .filter(|(_, line)| line.contains(&q)) 1445 - .map(|(i, _)| i) 1446 - .collect() 1447 - }; 1448 - self.search.matches = search_matches; 1449 - self.search.idx = 0; 1450 - if let Some(&f) = self.search.matches.first() { 1451 - self.scroll = f; 1452 - } 1453 - } 1454 - 1455 - pub(crate) fn begin_search(&mut self) { 1456 - self.search.mode = true; 1457 - self.search.draft = self.search.query.clone(); 1458 - crate::runtime::debug_log( 1459 - self.debug_input, 1460 - &format!( 1461 - "begin_search query={:?} draft={:?} matches={} idx={}", 1462 - self.search.query, 1463 - self.search.draft, 1464 - self.search.matches.len(), 1465 - self.search.idx 1466 - ), 1467 - ); 1468 - } 1469 - 1470 - pub(crate) fn reset_search_state(&mut self) { 1471 - self.search.draft.clear(); 1472 - self.search.query.clear(); 1473 - self.search.matches.clear(); 1474 - self.search.idx = 0; 1475 - } 1476 - 1477 - pub(crate) fn cancel_search(&mut self) { 1478 - self.search.mode = false; 1479 - self.reset_search_state(); 1480 - crate::runtime::debug_log(self.debug_input, "cancel_search cleared query and matches"); 1481 - } 1482 - 1483 - pub(crate) fn confirm_search(&mut self) { 1484 - self.search.mode = false; 1485 - let draft = std::mem::take(&mut self.search.draft); 1486 - self.search.query = draft; 1487 - if self.search.query.is_empty() { 1488 - self.reset_search_state(); 1489 - crate::runtime::debug_log( 1490 - self.debug_input, 1491 - "confirm_search empty query -> cleared matches", 1492 - ); 1493 - return; 1494 - } 1495 - self.run_search(); 1496 - crate::runtime::debug_log( 1497 - self.debug_input, 1498 - &format!( 1499 - "confirm_search query={:?} matches={} idx={} scroll={}", 1500 - self.search.query, 1501 - self.search.matches.len(), 1502 - self.search.idx, 1503 - self.scroll 1504 - ), 1505 - ); 1506 - } 1507 - 1508 - pub(crate) fn clear_active_search(&mut self) { 1509 - self.search.mode = false; 1510 - self.reset_search_state(); 1511 - crate::runtime::debug_log( 1512 - self.debug_input, 1513 - "clear_active_search cleared query and matches", 1514 - ); 1515 - } 1516 - 1517 - pub(crate) fn has_active_search(&self) -> bool { 1518 - !self.search.query.is_empty() || !self.search.matches.is_empty() 1519 - } 1520 - 1521 - pub(crate) fn next_match(&mut self) { 1522 - if self.search.matches.is_empty() { 1523 - return; 1524 - } 1525 - self.search.idx = (self.search.idx + 1) % self.search.matches.len(); 1526 - self.scroll = self.search.matches[self.search.idx]; 1527 - } 1528 - 1529 - pub(crate) fn prev_match(&mut self) { 1530 - if self.search.matches.is_empty() { 1531 - return; 1532 - } 1533 - if self.search.idx == 0 { 1534 - self.search.idx = self.search.matches.len() - 1; 1535 - } else { 1536 - self.search.idx -= 1; 1537 - } 1538 - self.scroll = self.search.matches[self.search.idx]; 1539 - } 1540 - 1541 - pub(crate) fn scroll_percent(&self, vh: usize) -> u16 { 1542 - if self.total() <= vh { 1543 - return 100; 1544 - } 1545 - ((self.scroll * 100) / (self.total() - vh).max(1)) as u16 1546 - } 1547 - 1548 - pub(crate) fn sync_render_width( 1549 - &mut self, 1550 - render_width: usize, 1551 - ss: &SyntaxSet, 1552 - themes: &ThemeSet, 1553 - ) -> bool { 1554 - let next_width = render_width.max(20); 1555 - if self.render_width == next_width { 1556 - return false; 1557 - } 1558 - self.render_width = next_width; 1559 - self.reparse_source(ss, themes); 1560 - true 1561 - } 1562 - 1563 - pub(crate) fn check_modified(&mut self) -> Option<FileChange> { 1564 - const HASH_FALLBACK_INTERVAL: Duration = Duration::from_secs(2); 1565 - 1566 - let path = self.filepath.as_ref()?; 1567 - let state = read_file_state(path)?; 1568 - match self.last_file_state { 1569 - Some(prev) if state.modified != prev.modified || state.len != prev.len => { 1570 - Some(FileChange::Metadata(state)) 1571 - } 1572 - Some(_) => { 1573 - let should_hash = self 1574 - .last_hash_check 1575 - .map(|checked_at| checked_at.elapsed() >= HASH_FALLBACK_INTERVAL) 1576 - .unwrap_or(true); 1577 - if !should_hash { 1578 - return None; 1579 - } 1580 - self.last_hash_check = Some(Instant::now()); 1581 - let current_hash = hash_file_contents(path).ok()?; 1582 - (current_hash != self.last_content_hash).then_some(FileChange::Content(state)) 1583 - } 1584 - None => Some(FileChange::Metadata(state)), 1585 - } 1586 - } 1587 - 1588 - pub(crate) fn reparse_source(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 1589 - let theme = current_syntect_theme(themes); 1590 - let old_total = self.total(); 1591 - let (new_lines, new_toc) = 1592 - parse_markdown_with_width(&self.source, ss, theme, self.render_width); 1593 - let new_total = new_lines.len(); 1594 - 1595 - if old_total > 0 { 1596 - self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize; 1597 - self.scroll = self.scroll.min(new_total.saturating_sub(1)); 1598 - } 1599 - 1600 - self.invalidate_theme_preview_cache(); 1601 - self.store_theme_preview(current_theme_preset(), &new_lines, &new_toc); 1602 - self.replace_content(new_lines, new_toc); 1603 - if !self.search.query.is_empty() && !self.search.mode { 1604 - self.run_search(); 1605 - } 1606 - } 1607 - 1608 - pub(crate) fn load_path(&mut self, path: PathBuf, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 1609 - let src = match std::fs::read_to_string(&path) { 1610 - Ok(src) => src, 1611 - Err(_) => return false, 1612 - }; 1613 - let filename = path 1614 - .file_name() 1615 - .map(|name| name.to_string_lossy().to_string()) 1616 - .unwrap_or_else(|| path.display().to_string()); 1617 - let file_state = read_file_state(&path); 1618 - let content_hash = hash_str(&src); 1619 - let theme = current_syntect_theme(themes); 1620 - let (lines, toc) = parse_markdown_with_width(&src, ss, theme, self.render_width); 1621 - 1622 - self.filename = filename; 1623 - self.source = src; 1624 - self.filepath = Some(path); 1625 - self.last_file_state = file_state; 1626 - self.last_content_hash = content_hash; 1627 - self.last_hash_check = Some(Instant::now()); 1628 - self.reload_flash = None; 1629 - self.scroll = 0; 1630 - self.help_open = false; 1631 - self.file_picker.open = false; 1632 - self.theme_picker.open = false; 1633 - self.search.mode = false; 1634 - self.reset_search_state(); 1635 - self.invalidate_theme_preview_cache(); 1636 - self.store_theme_preview(current_theme_preset(), &lines, &toc); 1637 - self.replace_content(lines, toc); 1638 - true 1639 - } 1640 - 1641 - pub(crate) fn activate_file_picker_selection( 1642 - &mut self, 1643 - ss: &SyntaxSet, 1644 - themes: &ThemeSet, 1645 - ) -> bool { 1646 - let Some(entry) = self.selected_file_picker_entry().cloned() else { 1647 - return false; 1648 - }; 1649 - if self.is_browser_file_picker() && entry.is_dir_like() { 1650 - self.open_file_picker(entry.path) 1651 - } else { 1652 - self.load_path(entry.path, ss, themes) 1653 - } 1654 - } 1655 - 1656 - pub(crate) fn reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 1657 - let path = match &self.filepath { 1658 - Some(p) => p, 1659 - None => return false, 1660 - }; 1661 - let src = match std::fs::read_to_string(path) { 1662 - Ok(s) => s, 1663 - Err(_) => return false, 1664 - }; 1665 - let file_state = read_file_state(path); 1666 - let content_hash = hash_str(&src); 1667 - self.source = src; 1668 - 1669 - self.reparse_source(ss, themes); 1670 - self.last_file_state = file_state; 1671 - self.last_content_hash = content_hash; 1672 - self.last_hash_check = Some(Instant::now()); 1673 - self.reload_flash = Some(Instant::now()); 1674 - true 1675 - } 1676 - } 1677 - 1678 - pub(crate) fn should_hide_single_h1(toc: &[TocEntry]) -> bool { 1679 - let h1_count = toc.iter().filter(|entry| entry.level == 1).count(); 1680 - let has_h2 = toc.iter().any(|entry| entry.level == 2); 1681 - h1_count == 1 && has_h2 1682 - } 1683 - 1684 - pub(crate) fn should_promote_h2_when_no_h1(toc: &[TocEntry]) -> bool { 1685 - !toc.iter().any(|entry| entry.level == 1) && toc.iter().any(|entry| entry.level == 2) 1686 - } 1687 - 1688 - pub(crate) fn toc_display_level(level: u8, hide_single_h1: bool, promote_h2_root: bool) -> u8 { 1689 - if hide_single_h1 || promote_h2_root { 1690 - match level { 1691 - 2 => 1, 1692 - 3 => 2, 1693 - _ => level, 1694 - } 1695 - } else { 1696 - level 1697 - } 1698 - } 1699 - 1700 - pub(crate) fn normalize_toc(mut toc: Vec<TocEntry>) -> Vec<TocEntry> { 1701 - if should_hide_single_h1(&toc) || should_promote_h2_when_no_h1(&toc) { 1702 - toc.retain(|entry| matches!(entry.level, 1..=3)); 1703 - } else { 1704 - toc.retain(|entry| matches!(entry.level, 1..=2)); 1705 - } 1706 - toc 1707 - }
+699
src/app/file_picker.rs
··· 1 + use super::App; 2 + use std::{ 3 + fs, 4 + path::PathBuf, 5 + sync::mpsc::{self, Receiver, TryRecvError}, 6 + thread, 7 + time::{Duration, Instant}, 8 + }; 9 + use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 10 + 11 + const MAX_FUZZY_PICKER_DIRS_VISITED: usize = 5_000; 12 + const MAX_FUZZY_PICKER_FILES_INDEXED: usize = 10_000; 13 + const MAX_FUZZY_PICKER_INDEX_DURATION: Duration = Duration::from_secs(5); 14 + #[derive(Clone, Debug, PartialEq, Eq)] 15 + pub(crate) struct FilePickerEntry { 16 + label: String, 17 + path: PathBuf, 18 + label_lower: String, 19 + file_name: String, 20 + file_name_lower: String, 21 + file_name_offset: usize, 22 + path_depth: usize, 23 + } 24 + 25 + impl FilePickerEntry { 26 + fn new(label: String, path: PathBuf) -> Self { 27 + let file_name = Self::file_name_component(&label).to_string(); 28 + let file_name_offset = label 29 + .rfind(std::path::MAIN_SEPARATOR) 30 + .map(|idx| label[..idx + 1].chars().count()) 31 + .unwrap_or(0); 32 + let path_depth = label.matches(std::path::MAIN_SEPARATOR).count(); 33 + 34 + Self { 35 + label_lower: label.to_lowercase(), 36 + file_name_lower: file_name.to_lowercase(), 37 + label, 38 + path, 39 + file_name, 40 + file_name_offset, 41 + path_depth, 42 + } 43 + } 44 + 45 + pub(crate) fn label(&self) -> &str { 46 + &self.label 47 + } 48 + 49 + pub(super) fn label_lower(&self) -> &str { 50 + &self.label_lower 51 + } 52 + 53 + pub(super) fn file_name_lower(&self) -> &str { 54 + &self.file_name_lower 55 + } 56 + 57 + pub(super) fn file_name_offset(&self) -> usize { 58 + self.file_name_offset 59 + } 60 + 61 + pub(super) fn path_depth(&self) -> usize { 62 + self.path_depth 63 + } 64 + 65 + fn file_name_component(path: &str) -> &str { 66 + path.rsplit(std::path::MAIN_SEPARATOR) 67 + .next() 68 + .unwrap_or(path) 69 + } 70 + 71 + fn is_dir_like(&self) -> bool { 72 + self.label == ".." || self.label.ends_with('/') 73 + } 74 + } 75 + 76 + pub(crate) struct FilePickerState { 77 + pub(super) open: bool, 78 + pub(super) mode: FilePickerMode, 79 + pub(super) dir: PathBuf, 80 + pub(super) entries: Vec<FilePickerEntry>, 81 + pub(super) filtered: Vec<usize>, 82 + pub(super) match_positions: Vec<Vec<usize>>, 83 + pub(super) index: usize, 84 + pub(super) query: String, 85 + pub(super) truncation: Option<PickerIndexTruncation>, 86 + } 87 + 88 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 89 + pub(crate) enum FilePickerMode { 90 + Browser, 91 + Fuzzy, 92 + } 93 + 94 + #[derive(Clone, Debug, PartialEq, Eq)] 95 + pub(crate) enum PendingPicker { 96 + None, 97 + Browser(PathBuf), 98 + Fuzzy(PathBuf), 99 + } 100 + 101 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 102 + pub(crate) enum PickerIndexTruncation { 103 + Directory, 104 + File, 105 + Time, 106 + } 107 + 108 + pub(crate) struct PickerIndexResult { 109 + pub(crate) entries: Vec<FilePickerEntry>, 110 + pub(crate) truncated: Option<PickerIndexTruncation>, 111 + } 112 + 113 + pub(crate) enum PickerLoadState { 114 + Idle, 115 + Loading { 116 + mode: FilePickerMode, 117 + dir: PathBuf, 118 + started_at: Instant, 119 + receiver: Receiver<std::io::Result<PickerIndexResult>>, 120 + pending_result: Option<std::io::Result<PickerIndexResult>>, 121 + }, 122 + Failed { 123 + mode: FilePickerMode, 124 + dir: PathBuf, 125 + message: String, 126 + }, 127 + } 128 + 129 + impl App { 130 + pub(super) fn min_picker_loading_duration() -> Duration { 131 + Duration::from_millis(500) 132 + } 133 + 134 + fn is_markdown_path(path: &std::path::Path) -> bool { 135 + matches!( 136 + path.extension().and_then(|ext| ext.to_str()), 137 + Some("md" | "markdown" | "mdown" | "mkd") 138 + ) 139 + } 140 + 141 + fn build_file_picker_entries(dir: &std::path::Path) -> std::io::Result<Vec<FilePickerEntry>> { 142 + let mut entries = Vec::new(); 143 + 144 + if let Some(parent) = dir.parent() { 145 + entries.push(FilePickerEntry::new("..".to_string(), parent.to_path_buf())); 146 + } 147 + 148 + let mut dirs = Vec::new(); 149 + let mut files = Vec::new(); 150 + for entry in fs::read_dir(dir)? { 151 + let entry = entry?; 152 + let path = entry.path(); 153 + let file_type = match entry.file_type() { 154 + Ok(file_type) => file_type, 155 + Err(_) => continue, 156 + }; 157 + let name = entry.file_name().to_string_lossy().to_string(); 158 + 159 + if file_type.is_dir() { 160 + dirs.push(FilePickerEntry::new(format!("{name}/"), path)); 161 + } else if file_type.is_file() && Self::is_markdown_path(&path) { 162 + files.push(FilePickerEntry::new(name, path)); 163 + } 164 + } 165 + 166 + dirs.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 167 + files.sort_by(|left, right| left.label_lower().cmp(right.label_lower())); 168 + entries.extend(dirs); 169 + entries.extend(files); 170 + Ok(entries) 171 + } 172 + 173 + fn build_fuzzy_file_picker_entries( 174 + dir: &std::path::Path, 175 + ) -> std::io::Result<PickerIndexResult> { 176 + let mut entries = Vec::new(); 177 + let mut stack = vec![dir.to_path_buf()]; 178 + let started_at = Instant::now(); 179 + let mut dirs_visited = 0usize; 180 + let mut files_indexed = 0usize; 181 + let mut truncated = None; 182 + 183 + while let Some(current_dir) = stack.pop() { 184 + if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 185 + truncated = Some(PickerIndexTruncation::Time); 186 + break; 187 + } 188 + if dirs_visited >= MAX_FUZZY_PICKER_DIRS_VISITED { 189 + truncated = Some(PickerIndexTruncation::Directory); 190 + break; 191 + } 192 + dirs_visited += 1; 193 + 194 + let mut dirs = Vec::new(); 195 + let mut files = Vec::new(); 196 + 197 + let read_dir = match fs::read_dir(&current_dir) { 198 + Ok(read_dir) => read_dir, 199 + Err(err) => { 200 + if current_dir == dir { 201 + return Err(err); 202 + } 203 + continue; 204 + } 205 + }; 206 + 207 + for entry in read_dir { 208 + if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 209 + truncated = Some(PickerIndexTruncation::Time); 210 + break; 211 + } 212 + let entry = match entry { 213 + Ok(entry) => entry, 214 + Err(_) => continue, 215 + }; 216 + let path = entry.path(); 217 + let file_type = match entry.file_type() { 218 + Ok(file_type) => file_type, 219 + Err(_) => continue, 220 + }; 221 + 222 + if file_type.is_dir() { 223 + let name = entry.file_name(); 224 + if super::fuzzy::is_ignored_fuzzy_picker_dir_name( 225 + name.to_string_lossy().as_ref(), 226 + ) { 227 + continue; 228 + } 229 + dirs.push(path); 230 + continue; 231 + } 232 + 233 + if file_type.is_file() && Self::is_markdown_path(&path) { 234 + if files_indexed >= MAX_FUZZY_PICKER_FILES_INDEXED { 235 + truncated = Some(PickerIndexTruncation::File); 236 + break; 237 + } 238 + let label = path 239 + .strip_prefix(dir) 240 + .unwrap_or(&path) 241 + .display() 242 + .to_string(); 243 + files.push(FilePickerEntry::new(label, path)); 244 + files_indexed += 1; 245 + } 246 + } 247 + 248 + files.sort_by(|left, right| { 249 + super::fuzzy::fuzzy_entry_sort_key(left) 250 + .cmp(&super::fuzzy::fuzzy_entry_sort_key(right)) 251 + }); 252 + dirs.sort_by_key(|path| super::fuzzy::fuzzy_directory_sort_key(dir, path)); 253 + 254 + entries.extend(files); 255 + if truncated.is_some() { 256 + break; 257 + } 258 + dirs.reverse(); 259 + stack.extend(dirs); 260 + } 261 + 262 + Ok(PickerIndexResult { entries, truncated }) 263 + } 264 + 265 + pub(super) fn refresh_file_picker_matches(&mut self) { 266 + if self.is_browser_file_picker() { 267 + self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 268 + self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 269 + self.file_picker.index = self 270 + .file_picker 271 + .index 272 + .min(self.file_picker.filtered.len().saturating_sub(1)); 273 + return; 274 + } 275 + 276 + let query = self.file_picker.query.trim().to_lowercase(); 277 + if query.is_empty() { 278 + self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 279 + self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 280 + self.file_picker.index = self 281 + .file_picker 282 + .index 283 + .min(self.file_picker.filtered.len().saturating_sub(1)); 284 + return; 285 + } 286 + 287 + let mut filtered = self 288 + .file_picker 289 + .entries 290 + .iter() 291 + .enumerate() 292 + .filter_map(|(idx, entry)| { 293 + super::fuzzy::fuzzy_match(entry, &query).map(|(score, positions)| { 294 + ( 295 + idx, 296 + score, 297 + entry.path_depth(), 298 + entry.file_name_lower(), 299 + entry.label_lower(), 300 + positions, 301 + ) 302 + }) 303 + }) 304 + .collect::<Vec<_>>(); 305 + 306 + filtered.sort_by( 307 + |(left_idx, left_score, left_depth, left_name, left_label, _), 308 + (right_idx, right_score, right_depth, right_name, right_label, _)| { 309 + left_score 310 + .cmp(right_score) 311 + .then_with(|| left_depth.cmp(right_depth)) 312 + .then_with(|| left_name.cmp(right_name)) 313 + .then_with(|| left_label.cmp(right_label)) 314 + .then_with(|| left_idx.cmp(right_idx)) 315 + }, 316 + ); 317 + 318 + self.file_picker.filtered = filtered.iter().map(|(idx, ..)| *idx).collect(); 319 + self.file_picker.match_positions = filtered 320 + .into_iter() 321 + .map(|(_, _, _, _, _, positions)| positions) 322 + .collect(); 323 + if self.file_picker.filtered.is_empty() 324 + || self.file_picker.index >= self.file_picker.filtered.len() 325 + { 326 + self.file_picker.index = 0; 327 + } 328 + } 329 + 330 + fn selected_file_picker_entry(&self) -> Option<&FilePickerEntry> { 331 + let idx = *self.file_picker.filtered.get(self.file_picker.index)?; 332 + self.file_picker.entries.get(idx) 333 + } 334 + 335 + pub(crate) fn open_file_picker(&mut self, dir: PathBuf) -> bool { 336 + self.open_file_picker_with_mode(dir, FilePickerMode::Browser) 337 + } 338 + 339 + #[cfg(test)] 340 + pub(crate) fn open_fuzzy_file_picker(&mut self, dir: PathBuf) -> bool { 341 + self.open_file_picker_with_mode(dir, FilePickerMode::Fuzzy) 342 + } 343 + 344 + pub(crate) fn queue_file_picker(&mut self, dir: PathBuf) { 345 + self.pending_picker = PendingPicker::Browser(dir); 346 + } 347 + 348 + pub(crate) fn queue_fuzzy_file_picker(&mut self, dir: PathBuf) { 349 + self.pending_picker = PendingPicker::Fuzzy(dir); 350 + } 351 + 352 + pub(crate) fn has_pending_picker(&self) -> bool { 353 + !matches!(self.pending_picker, PendingPicker::None) 354 + } 355 + 356 + pub(crate) fn start_pending_picker_loading(&mut self) -> bool { 357 + if !self.has_pending_picker() || !matches!(self.picker_load_state, PickerLoadState::Idle) { 358 + return false; 359 + } 360 + 361 + let pending = std::mem::replace(&mut self.pending_picker, PendingPicker::None); 362 + let (mode, dir) = match pending { 363 + PendingPicker::Browser(dir) => (FilePickerMode::Browser, dir), 364 + PendingPicker::Fuzzy(dir) => (FilePickerMode::Fuzzy, dir), 365 + PendingPicker::None => return false, 366 + }; 367 + 368 + let worker_dir = dir.clone(); 369 + let (tx, rx) = mpsc::channel(); 370 + crate::runtime::debug_log( 371 + self.debug_input, 372 + &format!("picker_loading spawn mode={mode:?} dir={}", dir.display()), 373 + ); 374 + thread::spawn(move || { 375 + let result = match mode { 376 + FilePickerMode::Browser => { 377 + Self::build_file_picker_entries(&worker_dir).map(|entries| PickerIndexResult { 378 + entries, 379 + truncated: None, 380 + }) 381 + } 382 + FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&worker_dir), 383 + }; 384 + let _ = tx.send(result); 385 + }); 386 + 387 + self.picker_load_state = PickerLoadState::Loading { 388 + mode, 389 + dir, 390 + started_at: Instant::now(), 391 + receiver: rx, 392 + pending_result: None, 393 + }; 394 + true 395 + } 396 + 397 + pub(crate) fn is_picker_loading(&self) -> bool { 398 + matches!(self.picker_load_state, PickerLoadState::Loading { .. }) 399 + } 400 + 401 + pub(crate) fn is_picker_load_failed(&self) -> bool { 402 + matches!(self.picker_load_state, PickerLoadState::Failed { .. }) 403 + } 404 + 405 + pub(crate) fn pending_picker_mode(&self) -> Option<FilePickerMode> { 406 + match &self.picker_load_state { 407 + PickerLoadState::Loading { mode, .. } | PickerLoadState::Failed { mode, .. } => { 408 + Some(*mode) 409 + } 410 + PickerLoadState::Idle => match self.pending_picker { 411 + PendingPicker::Browser(..) => Some(FilePickerMode::Browser), 412 + PendingPicker::Fuzzy(..) => Some(FilePickerMode::Fuzzy), 413 + PendingPicker::None => None, 414 + }, 415 + } 416 + } 417 + 418 + pub(crate) fn pending_picker_dir(&self) -> Option<&std::path::Path> { 419 + match &self.picker_load_state { 420 + PickerLoadState::Loading { dir, .. } | PickerLoadState::Failed { dir, .. } => { 421 + Some(dir.as_path()) 422 + } 423 + PickerLoadState::Idle => match &self.pending_picker { 424 + PendingPicker::Browser(dir) | PendingPicker::Fuzzy(dir) => Some(dir.as_path()), 425 + PendingPicker::None => None, 426 + }, 427 + } 428 + } 429 + 430 + pub(crate) fn picker_load_error(&self) -> Option<&str> { 431 + match &self.picker_load_state { 432 + PickerLoadState::Failed { message, .. } => Some(message.as_str()), 433 + PickerLoadState::Idle | PickerLoadState::Loading { .. } => None, 434 + } 435 + } 436 + 437 + fn install_loaded_file_picker( 438 + &mut self, 439 + dir: PathBuf, 440 + mode: FilePickerMode, 441 + result: PickerIndexResult, 442 + ) -> bool { 443 + self.file_picker.open = true; 444 + self.file_picker.mode = mode; 445 + self.file_picker.dir = dir; 446 + self.file_picker.entries = result.entries; 447 + self.file_picker.query.clear(); 448 + self.file_picker.index = 0; 449 + self.file_picker.truncation = if mode == FilePickerMode::Fuzzy { 450 + result.truncated 451 + } else { 452 + None 453 + }; 454 + self.refresh_file_picker_matches(); 455 + true 456 + } 457 + 458 + pub(crate) fn poll_picker_loading(&mut self) -> bool { 459 + let state = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle); 460 + match state { 461 + PickerLoadState::Loading { 462 + mode, 463 + dir, 464 + started_at, 465 + receiver, 466 + mut pending_result, 467 + } => { 468 + if pending_result.is_none() { 469 + pending_result = match receiver.try_recv() { 470 + Ok(result) => { 471 + crate::runtime::debug_log( 472 + self.debug_input, 473 + &format!( 474 + "picker_loading worker_finished mode={mode:?} dir={}", 475 + dir.display() 476 + ), 477 + ); 478 + Some(result) 479 + } 480 + Err(TryRecvError::Empty) => None, 481 + Err(TryRecvError::Disconnected) => Some(Err(std::io::Error::other( 482 + "Picker loading worker disconnected", 483 + ))), 484 + }; 485 + } 486 + 487 + if started_at.elapsed() < Self::min_picker_loading_duration() { 488 + self.picker_load_state = PickerLoadState::Loading { 489 + mode, 490 + dir, 491 + started_at, 492 + receiver, 493 + pending_result, 494 + }; 495 + return false; 496 + } 497 + 498 + match pending_result { 499 + Some(Ok(result)) => { 500 + crate::runtime::debug_log( 501 + self.debug_input, 502 + &format!( 503 + "picker_loading install mode={mode:?} dir={} entries={}", 504 + dir.display(), 505 + result.entries.len() 506 + ), 507 + ); 508 + self.install_loaded_file_picker(dir, mode, result) 509 + } 510 + Some(Err(err)) => { 511 + crate::runtime::debug_log( 512 + self.debug_input, 513 + &format!( 514 + "picker_loading failed mode={mode:?} dir={} error={}", 515 + dir.display(), 516 + err 517 + ), 518 + ); 519 + self.picker_load_state = PickerLoadState::Failed { 520 + mode, 521 + dir, 522 + message: err.to_string(), 523 + }; 524 + true 525 + } 526 + None => { 527 + self.picker_load_state = PickerLoadState::Loading { 528 + mode, 529 + dir, 530 + started_at, 531 + receiver, 532 + pending_result: None, 533 + }; 534 + false 535 + } 536 + } 537 + } 538 + PickerLoadState::Failed { .. } => { 539 + self.picker_load_state = state; 540 + false 541 + } 542 + PickerLoadState::Idle => { 543 + self.picker_load_state = PickerLoadState::Idle; 544 + false 545 + } 546 + } 547 + } 548 + 549 + #[cfg(test)] 550 + pub(crate) fn age_picker_loading_by(&mut self, duration: Duration) { 551 + if let PickerLoadState::Loading { 552 + mode, 553 + dir, 554 + started_at, 555 + receiver, 556 + pending_result, 557 + } = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle) 558 + { 559 + let adjusted = started_at.checked_sub(duration).unwrap_or(started_at); 560 + self.picker_load_state = PickerLoadState::Loading { 561 + mode, 562 + dir, 563 + started_at: adjusted, 564 + receiver, 565 + pending_result, 566 + }; 567 + } 568 + } 569 + 570 + fn open_file_picker_with_mode(&mut self, dir: PathBuf, mode: FilePickerMode) -> bool { 571 + let result = match mode { 572 + FilePickerMode::Browser => { 573 + Self::build_file_picker_entries(&dir).map(|entries| PickerIndexResult { 574 + entries, 575 + truncated: None, 576 + }) 577 + } 578 + FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&dir), 579 + }; 580 + 581 + match result { 582 + Ok(result) => self.install_loaded_file_picker(dir, mode, result), 583 + Err(_) => false, 584 + } 585 + } 586 + 587 + pub(crate) fn is_fuzzy_file_picker(&self) -> bool { 588 + self.file_picker.mode == FilePickerMode::Fuzzy 589 + } 590 + 591 + pub(crate) fn is_browser_file_picker(&self) -> bool { 592 + self.file_picker.mode == FilePickerMode::Browser 593 + } 594 + 595 + pub(crate) fn is_file_picker_open(&self) -> bool { 596 + self.file_picker.open 597 + } 598 + 599 + pub(crate) fn file_picker_dir(&self) -> &std::path::Path { 600 + &self.file_picker.dir 601 + } 602 + 603 + pub(crate) fn file_picker_entries(&self) -> &[FilePickerEntry] { 604 + &self.file_picker.entries 605 + } 606 + 607 + pub(crate) fn file_picker_filtered_indices(&self) -> &[usize] { 608 + &self.file_picker.filtered 609 + } 610 + 611 + pub(crate) fn file_picker_match_positions(&self, filtered_idx: usize) -> &[usize] { 612 + self.file_picker 613 + .match_positions 614 + .get(filtered_idx) 615 + .map(Vec::as_slice) 616 + .unwrap_or(&[]) 617 + } 618 + 619 + pub(crate) fn file_picker_index(&self) -> usize { 620 + self.file_picker.index 621 + } 622 + 623 + pub(crate) fn file_picker_query(&self) -> &str { 624 + &self.file_picker.query 625 + } 626 + 627 + pub(crate) fn file_picker_truncation(&self) -> Option<PickerIndexTruncation> { 628 + self.file_picker.truncation 629 + } 630 + 631 + pub(crate) fn move_file_picker_up(&mut self) { 632 + let total = self.file_picker.filtered.len(); 633 + if total == 0 { 634 + return; 635 + } 636 + if self.file_picker.index == 0 { 637 + self.file_picker.index = total - 1; 638 + } else { 639 + self.file_picker.index -= 1; 640 + } 641 + } 642 + 643 + pub(crate) fn move_file_picker_down(&mut self) { 644 + let total = self.file_picker.filtered.len(); 645 + if total == 0 { 646 + return; 647 + } 648 + self.file_picker.index = (self.file_picker.index + 1) % total; 649 + } 650 + 651 + pub(crate) fn push_file_picker_query(&mut self, ch: char) { 652 + if self.is_browser_file_picker() { 653 + return; 654 + } 655 + self.file_picker.query.push(ch); 656 + self.refresh_file_picker_matches(); 657 + } 658 + 659 + pub(crate) fn pop_file_picker_query(&mut self) { 660 + if self.is_browser_file_picker() { 661 + return; 662 + } 663 + self.file_picker.query.pop(); 664 + self.refresh_file_picker_matches(); 665 + } 666 + 667 + pub(crate) fn clear_file_picker_query(&mut self) { 668 + if self.is_browser_file_picker() { 669 + return; 670 + } 671 + self.file_picker.query.clear(); 672 + self.refresh_file_picker_matches(); 673 + } 674 + 675 + pub(crate) fn open_file_picker_parent(&mut self) -> bool { 676 + if self.is_fuzzy_file_picker() { 677 + return false; 678 + } 679 + let Some(parent) = self.file_picker.dir.parent() else { 680 + return false; 681 + }; 682 + self.open_file_picker(parent.to_path_buf()) 683 + } 684 + 685 + pub(crate) fn activate_file_picker_selection( 686 + &mut self, 687 + ss: &SyntaxSet, 688 + themes: &ThemeSet, 689 + ) -> bool { 690 + let Some(entry) = self.selected_file_picker_entry().cloned() else { 691 + return false; 692 + }; 693 + if self.is_browser_file_picker() && entry.is_dir_like() { 694 + self.open_file_picker(entry.path) 695 + } else { 696 + self.load_path(entry.path, ss, themes) 697 + } 698 + } 699 + }
+126
src/app/fuzzy.rs
··· 1 + use super::file_picker::FilePickerEntry; 2 + 3 + const IGNORED_FUZZY_PICKER_DIRS: &[&str] = &[ 4 + ".git", 5 + "node_modules", 6 + "target", 7 + ".venv", 8 + "venv", 9 + "vendor", 10 + "var", 11 + "dist", 12 + "build", 13 + ".next", 14 + ".cache", 15 + ]; 16 + 17 + pub(super) fn is_ignored_fuzzy_picker_dir_name(name: &str) -> bool { 18 + IGNORED_FUZZY_PICKER_DIRS.contains(&name) 19 + } 20 + 21 + pub(super) fn fuzzy_directory_sort_key( 22 + root: &std::path::Path, 23 + path: &std::path::Path, 24 + ) -> (bool, String) { 25 + let label = path 26 + .strip_prefix(root) 27 + .unwrap_or(path) 28 + .display() 29 + .to_string(); 30 + ( 31 + !label 32 + .split(std::path::MAIN_SEPARATOR) 33 + .next() 34 + .unwrap_or(&label) 35 + .starts_with('.'), 36 + label.to_lowercase(), 37 + ) 38 + } 39 + 40 + pub(super) fn fuzzy_entry_sort_key(entry: &FilePickerEntry) -> (bool, &str) { 41 + let first_component = entry 42 + .label() 43 + .split(std::path::MAIN_SEPARATOR) 44 + .next() 45 + .unwrap_or(entry.label()); 46 + (!first_component.starts_with('.'), entry.label_lower()) 47 + } 48 + 49 + pub(super) fn fuzzy_component_match(candidate: &str, query: &str) -> Option<(usize, Vec<usize>)> { 50 + if let Some(start) = candidate.find(query) { 51 + let start_chars = candidate[..start].chars().count(); 52 + let query_len = query.chars().count(); 53 + let len_diff = candidate.chars().count().saturating_sub(query_len); 54 + let prefix_bonus = usize::from(start_chars == 0).saturating_mul(80); 55 + let boundary_bonus = 56 + usize::from(is_match_boundary(candidate, start_chars)).saturating_mul(40); 57 + let score = start_chars 58 + .saturating_mul(10) 59 + .saturating_add(len_diff) 60 + .saturating_sub(prefix_bonus) 61 + .saturating_sub(boundary_bonus); 62 + let positions = (start_chars..start_chars + query_len).collect::<Vec<_>>(); 63 + return Some((score, positions)); 64 + } 65 + 66 + let mut search_from = 0usize; 67 + let mut positions = Vec::with_capacity(query.len()); 68 + 69 + for needle in query.chars() { 70 + let found = candidate[search_from..] 71 + .char_indices() 72 + .find(|(_, ch)| *ch == needle) 73 + .map(|(idx, _)| search_from + idx)?; 74 + let char_pos = candidate[..found].chars().count(); 75 + positions.push(char_pos); 76 + search_from = found + needle.len_utf8(); 77 + } 78 + 79 + let first = *positions.first()?; 80 + let last = *positions.last()?; 81 + let span = last.saturating_sub(first); 82 + let gaps = positions 83 + .windows(2) 84 + .map(|window| window[1].saturating_sub(window[0]).saturating_sub(1)) 85 + .sum::<usize>(); 86 + let len_diff = candidate 87 + .chars() 88 + .count() 89 + .saturating_sub(query.chars().count()); 90 + let prefix_bonus = usize::from(first == 0).saturating_mul(80); 91 + let boundary_bonus = usize::from(is_match_boundary(candidate, first)).saturating_mul(40); 92 + let score = 1_000usize 93 + .saturating_add(gaps.saturating_mul(120)) 94 + .saturating_add(first.saturating_mul(10)) 95 + .saturating_add(span) 96 + .saturating_add(len_diff) 97 + .saturating_sub(prefix_bonus) 98 + .saturating_sub(boundary_bonus); 99 + Some((score, positions)) 100 + } 101 + 102 + pub(super) fn is_match_boundary(candidate: &str, char_pos: usize) -> bool { 103 + if char_pos == 0 { 104 + return true; 105 + } 106 + 107 + candidate 108 + .chars() 109 + .nth(char_pos.saturating_sub(1)) 110 + .is_some_and(|ch| matches!(ch, '-' | '_' | '.' | ' ')) 111 + } 112 + 113 + pub(super) fn fuzzy_match(entry: &FilePickerEntry, query: &str) -> Option<(usize, Vec<usize>)> { 114 + if query.is_empty() { 115 + return Some((0, Vec::new())); 116 + } 117 + 118 + let (score, positions) = fuzzy_component_match(entry.file_name_lower(), query)?; 119 + Some(( 120 + score, 121 + positions 122 + .into_iter() 123 + .map(|position| entry.file_name_offset() + position) 124 + .collect(), 125 + )) 126 + }
+549
src/app/mod.rs
··· 1 + use crate::{ 2 + markdown::{ 3 + build_plain_lines, hash_file_contents, hash_str, parse_markdown_with_width, 4 + read_file_state, 5 + toc::{should_hide_single_h1, should_promote_h2_when_no_h1, toc_display_level, TocEntry}, 6 + }, 7 + render::{build_status_bar, build_toc_line_with_index, toc_header_line}, 8 + theme::{current_syntect_theme, current_theme_preset, theme_preset_index}, 9 + }; 10 + use ratatui::text::Line; 11 + use std::{ 12 + path::PathBuf, 13 + time::{Duration, Instant, SystemTime}, 14 + }; 15 + use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 16 + 17 + pub(super) mod search; 18 + pub(crate) use search::SearchState; 19 + 20 + pub(crate) mod file_picker; 21 + mod fuzzy; 22 + pub(crate) use file_picker::{FilePickerMode, FilePickerState, PickerIndexTruncation}; 23 + use file_picker::{PendingPicker, PickerLoadState}; 24 + 25 + pub(super) mod theme_picker; 26 + pub(crate) use theme_picker::ThemePickerState; 27 + 28 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 29 + pub(crate) struct FileState { 30 + pub(crate) modified: SystemTime, 31 + pub(crate) len: u64, 32 + } 33 + 34 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 35 + pub(crate) enum FileChange { 36 + Metadata(FileState), 37 + Content(FileState), 38 + } 39 + 40 + #[derive(Clone, Debug, PartialEq, Eq)] 41 + pub(crate) struct StatusCacheKey { 42 + pct: u16, 43 + search_mode: bool, 44 + search_draft_hash: u64, 45 + search_query_hash: u64, 46 + search_draft_len: usize, 47 + search_query_len: usize, 48 + search_match_count: usize, 49 + search_idx: usize, 50 + watch: bool, 51 + flash_active: bool, 52 + } 53 + 54 + pub(crate) struct AppConfig { 55 + pub(crate) filename: String, 56 + pub(crate) source: String, 57 + pub(crate) debug_input: bool, 58 + pub(crate) watch: bool, 59 + pub(crate) filepath: Option<PathBuf>, 60 + pub(crate) last_file_state: Option<FileState>, 61 + } 62 + 63 + pub(crate) struct App { 64 + pub(super) lines: Vec<Line<'static>>, 65 + pub(super) plain_lines: Vec<String>, 66 + pub(super) scroll: usize, 67 + pub(super) toc: Vec<TocEntry>, 68 + toc_visible: bool, 69 + pub(super) search: SearchState, 70 + pub(super) debug_input: bool, 71 + pub(super) filename: String, 72 + pub(super) source: String, 73 + watch: bool, 74 + pub(super) filepath: Option<PathBuf>, 75 + pub(super) last_file_state: Option<FileState>, 76 + pub(super) last_content_hash: u64, 77 + pub(super) last_hash_check: Option<Instant>, 78 + pub(super) reload_flash: Option<Instant>, 79 + highlighted_line_cache: Option<(usize, Line<'static>)>, 80 + toc_display_lines: Vec<Line<'static>>, 81 + toc_header_line: Line<'static>, 82 + toc_active_idx: Option<usize>, 83 + status_line: Line<'static>, 84 + status_cache_key: Option<StatusCacheKey>, 85 + pub(super) help_open: bool, 86 + pub(super) file_picker: FilePickerState, 87 + pub(super) pending_picker: PendingPicker, 88 + pub(super) picker_load_state: PickerLoadState, 89 + pub(super) theme_picker: ThemePickerState, 90 + pub(super) render_width: usize, 91 + } 92 + 93 + impl App { 94 + #[cfg(test)] 95 + pub(crate) fn new( 96 + lines: Vec<Line<'static>>, 97 + toc: Vec<TocEntry>, 98 + filename: String, 99 + debug_input: bool, 100 + watch: bool, 101 + filepath: Option<PathBuf>, 102 + last_file_state: Option<FileState>, 103 + ) -> Self { 104 + let source = lines 105 + .iter() 106 + .map(|line| { 107 + line.spans 108 + .iter() 109 + .map(|s| s.content.as_ref()) 110 + .collect::<String>() 111 + }) 112 + .collect::<Vec<_>>() 113 + .join("\n"); 114 + Self::new_with_source( 115 + lines, 116 + toc, 117 + AppConfig { 118 + filename, 119 + source, 120 + debug_input, 121 + watch, 122 + filepath, 123 + last_file_state, 124 + }, 125 + ) 126 + } 127 + 128 + pub(crate) fn new_with_source( 129 + lines: Vec<Line<'static>>, 130 + toc: Vec<TocEntry>, 131 + config: AppConfig, 132 + ) -> Self { 133 + let AppConfig { 134 + filename, 135 + source, 136 + debug_input, 137 + watch, 138 + filepath, 139 + last_file_state, 140 + } = config; 141 + let plain_lines = build_plain_lines(&lines) 142 + .into_iter() 143 + .map(|line| line.to_lowercase()) 144 + .collect(); 145 + let mut app = Self { 146 + lines, 147 + plain_lines, 148 + scroll: 0, 149 + toc, 150 + toc_visible: false, 151 + search: SearchState { 152 + mode: false, 153 + draft: String::new(), 154 + query: String::new(), 155 + matches: vec![], 156 + idx: 0, 157 + draft_hash: 0, 158 + query_hash: 0, 159 + }, 160 + debug_input, 161 + filename, 162 + source, 163 + watch, 164 + filepath, 165 + last_file_state, 166 + last_content_hash: 0, 167 + last_hash_check: None, 168 + reload_flash: None, 169 + highlighted_line_cache: None, 170 + toc_display_lines: Vec::new(), 171 + toc_header_line: toc_header_line(), 172 + toc_active_idx: None, 173 + status_line: Line::default(), 174 + status_cache_key: None, 175 + help_open: false, 176 + file_picker: FilePickerState { 177 + open: false, 178 + mode: FilePickerMode::Browser, 179 + dir: PathBuf::from("."), 180 + entries: Vec::new(), 181 + filtered: Vec::new(), 182 + match_positions: Vec::new(), 183 + index: 0, 184 + query: String::new(), 185 + truncation: None, 186 + }, 187 + pending_picker: PendingPicker::None, 188 + picker_load_state: PickerLoadState::Idle, 189 + theme_picker: ThemePickerState { 190 + open: false, 191 + index: theme_preset_index(current_theme_preset()), 192 + original: None, 193 + preview_cache: vec![None; crate::theme::THEME_PRESETS.len()], 194 + }, 195 + render_width: 80, 196 + }; 197 + app.store_current_theme_preview(); 198 + app.refresh_static_caches(); 199 + app 200 + } 201 + 202 + pub(crate) fn set_last_content_hash(&mut self, last_content_hash: u64) { 203 + self.last_content_hash = last_content_hash; 204 + } 205 + 206 + pub(crate) fn is_watch_enabled(&self) -> bool { 207 + self.watch 208 + } 209 + 210 + pub(crate) fn debug_input_enabled(&self) -> bool { 211 + self.debug_input 212 + } 213 + 214 + pub(crate) fn is_toc_visible(&self) -> bool { 215 + self.toc_visible 216 + } 217 + 218 + pub(crate) fn has_toc(&self) -> bool { 219 + !self.toc.is_empty() 220 + } 221 + 222 + pub(crate) fn total(&self) -> usize { 223 + self.lines.len() 224 + } 225 + 226 + pub(crate) fn scroll(&self) -> usize { 227 + self.scroll 228 + } 229 + 230 + pub(crate) fn visible_lines(&self, start: usize, end: usize) -> &[Line<'static>] { 231 + &self.lines[start..end] 232 + } 233 + 234 + pub(crate) fn highlighted_line_cache(&self) -> Option<&(usize, Line<'static>)> { 235 + self.highlighted_line_cache.as_ref() 236 + } 237 + 238 + pub(crate) fn toc_display_lines(&self) -> &[Line<'static>] { 239 + &self.toc_display_lines 240 + } 241 + 242 + pub(crate) fn toc_header_line(&self) -> &Line<'static> { 243 + &self.toc_header_line 244 + } 245 + 246 + pub(crate) fn status_line(&self) -> &Line<'static> { 247 + &self.status_line 248 + } 249 + 250 + pub(crate) fn filename(&self) -> &str { 251 + &self.filename 252 + } 253 + 254 + pub(crate) fn replace_content(&mut self, lines: Vec<Line<'static>>, toc: Vec<TocEntry>) { 255 + self.plain_lines = build_plain_lines(&lines) 256 + .into_iter() 257 + .map(|line| line.to_lowercase()) 258 + .collect(); 259 + self.lines = lines; 260 + self.toc = toc; 261 + self.highlighted_line_cache = None; 262 + self.toc_header_line = toc_header_line(); 263 + self.refresh_static_caches(); 264 + } 265 + 266 + #[cfg(test)] 267 + pub(crate) fn line(&self, idx: usize) -> Option<&Line<'static>> { 268 + self.lines.get(idx) 269 + } 270 + 271 + pub(crate) fn active_toc_index(&self) -> Option<usize> { 272 + let hide_single_h1 = should_hide_single_h1(&self.toc); 273 + let mut first_visible = None; 274 + let mut active = None; 275 + for (idx, entry) in self 276 + .toc 277 + .iter() 278 + .enumerate() 279 + .filter(|(_, entry)| !(hide_single_h1 && entry.level == 1)) 280 + { 281 + if first_visible.is_none() { 282 + first_visible = Some((idx, entry.line)); 283 + } 284 + if entry.line > self.scroll { 285 + break; 286 + } 287 + active = Some(idx); 288 + } 289 + 290 + let (first_idx, first_line) = first_visible?; 291 + if self.scroll < first_line { 292 + Some(first_idx) 293 + } else { 294 + active.or(Some(first_idx)) 295 + } 296 + } 297 + 298 + pub(crate) fn refresh_highlighted_line_cache(&mut self, line_idx: usize) -> Option<()> { 299 + let needs_refresh = self 300 + .highlighted_line_cache 301 + .as_ref() 302 + .map(|(cached_idx, _)| *cached_idx != line_idx) 303 + .unwrap_or(true); 304 + if needs_refresh { 305 + let line = self.lines.get(line_idx)?; 306 + self.highlighted_line_cache = Some((line_idx, crate::markdown::highlight_line(line))); 307 + } 308 + Some(()) 309 + } 310 + 311 + pub(crate) fn refresh_toc_cache(&mut self) { 312 + let hide_single_h1 = should_hide_single_h1(&self.toc); 313 + let promote_h2_root = should_promote_h2_when_no_h1(&self.toc); 314 + let active_idx = self.active_toc_index(); 315 + if self.toc_active_idx == active_idx && !self.toc_display_lines.is_empty() { 316 + return; 317 + } 318 + 319 + self.toc_active_idx = active_idx; 320 + let mut top_level_index = 0usize; 321 + self.toc_display_lines = self 322 + .toc 323 + .iter() 324 + .enumerate() 325 + .filter(|(_, entry)| !(hide_single_h1 && entry.level == 1)) 326 + .map(|(idx, entry)| { 327 + let display_level = toc_display_level(entry.level, hide_single_h1, promote_h2_root); 328 + let line = build_toc_line_with_index( 329 + entry, 330 + display_level, 331 + (display_level == 1).then_some(top_level_index), 332 + active_idx == Some(idx), 333 + ); 334 + if display_level == 1 { 335 + top_level_index += 1; 336 + } 337 + line 338 + }) 339 + .collect(); 340 + } 341 + 342 + pub(crate) fn refresh_status_cache(&mut self, pct: u16) { 343 + let cache_key = StatusCacheKey { 344 + pct, 345 + search_mode: self.search.mode, 346 + search_draft_hash: self.search.draft_hash, 347 + search_query_hash: self.search.query_hash, 348 + search_draft_len: self.search.draft.len(), 349 + search_query_len: self.search.query.len(), 350 + search_match_count: self.search.matches.len(), 351 + search_idx: self.search.idx, 352 + watch: self.watch, 353 + flash_active: self 354 + .reload_flash 355 + .map(|t| t.elapsed() < Duration::from_millis(1500)) 356 + .unwrap_or(false), 357 + }; 358 + 359 + if self.status_cache_key.as_ref() == Some(&cache_key) { 360 + return; 361 + } 362 + 363 + self.status_line = Line::from(build_status_bar(self, pct)); 364 + self.status_cache_key = Some(cache_key); 365 + } 366 + 367 + pub(crate) fn refresh_static_caches(&mut self) { 368 + self.toc_active_idx = None; 369 + self.toc_display_lines.clear(); 370 + self.refresh_toc_cache(); 371 + self.status_cache_key = None; 372 + } 373 + 374 + pub(crate) fn open_help(&mut self) { 375 + self.help_open = true; 376 + } 377 + 378 + pub(crate) fn close_help(&mut self) { 379 + self.help_open = false; 380 + } 381 + 382 + pub(crate) fn is_help_open(&self) -> bool { 383 + self.help_open 384 + } 385 + 386 + pub(crate) fn clear_reload_flash(&mut self) { 387 + self.reload_flash = None; 388 + } 389 + 390 + pub(crate) fn reload_flash_started(&self) -> Option<Instant> { 391 + self.reload_flash 392 + } 393 + 394 + pub(crate) fn set_last_file_state(&mut self, state: FileState) { 395 + self.last_file_state = Some(state); 396 + } 397 + 398 + pub(crate) fn scroll_down(&mut self, n: usize) { 399 + self.scroll = (self.scroll + n).min(self.total().saturating_sub(1)); 400 + } 401 + 402 + pub(crate) fn scroll_up(&mut self, n: usize) { 403 + self.scroll = self.scroll.saturating_sub(n); 404 + } 405 + 406 + pub(crate) fn scroll_top(&mut self) { 407 + self.scroll = 0; 408 + } 409 + 410 + pub(crate) fn scroll_bottom(&mut self) { 411 + self.scroll = self.total().saturating_sub(1); 412 + } 413 + 414 + pub(crate) fn toggle_toc(&mut self) { 415 + self.toc_visible = !self.toc_visible; 416 + } 417 + 418 + pub(crate) fn request_reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 419 + self.last_file_state = None; 420 + self.reload(ss, themes) 421 + } 422 + 423 + pub(crate) fn jump_to_toc(&mut self, idx: usize) { 424 + if let Some(e) = self.toc.get(idx) { 425 + self.scroll = e.line; 426 + } 427 + } 428 + 429 + pub(crate) fn scroll_percent(&self, vh: usize) -> u16 { 430 + if self.total() <= vh { 431 + return 100; 432 + } 433 + ((self.scroll * 100) / (self.total() - vh).max(1)) as u16 434 + } 435 + 436 + pub(crate) fn sync_render_width( 437 + &mut self, 438 + render_width: usize, 439 + ss: &SyntaxSet, 440 + themes: &ThemeSet, 441 + ) -> bool { 442 + let next_width = render_width.max(20); 443 + if self.render_width == next_width { 444 + return false; 445 + } 446 + self.render_width = next_width; 447 + self.reparse_source(ss, themes); 448 + true 449 + } 450 + 451 + pub(crate) fn check_modified(&mut self) -> Option<FileChange> { 452 + const HASH_FALLBACK_INTERVAL: Duration = Duration::from_secs(2); 453 + 454 + let path = self.filepath.as_ref()?; 455 + let state = read_file_state(path)?; 456 + match self.last_file_state { 457 + Some(prev) if state.modified != prev.modified || state.len != prev.len => { 458 + Some(FileChange::Metadata(state)) 459 + } 460 + Some(_) => { 461 + let should_hash = self 462 + .last_hash_check 463 + .map(|checked_at| checked_at.elapsed() >= HASH_FALLBACK_INTERVAL) 464 + .unwrap_or(true); 465 + if !should_hash { 466 + return None; 467 + } 468 + self.last_hash_check = Some(Instant::now()); 469 + let current_hash = hash_file_contents(path).ok()?; 470 + (current_hash != self.last_content_hash).then_some(FileChange::Content(state)) 471 + } 472 + None => Some(FileChange::Metadata(state)), 473 + } 474 + } 475 + 476 + pub(crate) fn reparse_source(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 477 + let theme = current_syntect_theme(themes); 478 + let old_total = self.total(); 479 + let (new_lines, new_toc) = 480 + parse_markdown_with_width(&self.source, ss, theme, self.render_width); 481 + let new_total = new_lines.len(); 482 + 483 + if old_total > 0 { 484 + self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize; 485 + self.scroll = self.scroll.min(new_total.saturating_sub(1)); 486 + } 487 + 488 + self.invalidate_theme_preview_cache(); 489 + self.store_theme_preview(current_theme_preset(), &new_lines, &new_toc); 490 + self.replace_content(new_lines, new_toc); 491 + if !self.search.query.is_empty() && !self.search.mode { 492 + self.run_search(); 493 + } 494 + } 495 + 496 + pub(crate) fn load_path(&mut self, path: PathBuf, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 497 + let src = match std::fs::read_to_string(&path) { 498 + Ok(src) => src, 499 + Err(_) => return false, 500 + }; 501 + let filename = path 502 + .file_name() 503 + .map(|name| name.to_string_lossy().to_string()) 504 + .unwrap_or_else(|| path.display().to_string()); 505 + let file_state = read_file_state(&path); 506 + let content_hash = hash_str(&src); 507 + let theme = current_syntect_theme(themes); 508 + let (lines, toc) = parse_markdown_with_width(&src, ss, theme, self.render_width); 509 + 510 + self.filename = filename; 511 + self.source = src; 512 + self.filepath = Some(path); 513 + self.last_file_state = file_state; 514 + self.last_content_hash = content_hash; 515 + self.last_hash_check = Some(Instant::now()); 516 + self.reload_flash = None; 517 + self.scroll = 0; 518 + self.help_open = false; 519 + self.file_picker.open = false; 520 + self.theme_picker.open = false; 521 + self.search.mode = false; 522 + self.reset_search_state(); 523 + self.invalidate_theme_preview_cache(); 524 + self.store_theme_preview(current_theme_preset(), &lines, &toc); 525 + self.replace_content(lines, toc); 526 + true 527 + } 528 + 529 + pub(crate) fn reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool { 530 + let path = match &self.filepath { 531 + Some(p) => p, 532 + None => return false, 533 + }; 534 + let src = match std::fs::read_to_string(path) { 535 + Ok(s) => s, 536 + Err(_) => return false, 537 + }; 538 + let file_state = read_file_state(path); 539 + let content_hash = hash_str(&src); 540 + self.source = src; 541 + 542 + self.reparse_source(ss, themes); 543 + self.last_file_state = file_state; 544 + self.last_content_hash = content_hash; 545 + self.last_hash_check = Some(Instant::now()); 546 + self.reload_flash = Some(Instant::now()); 547 + true 548 + } 549 + }
+180
src/app/search.rs
··· 1 + use super::App; 2 + use crate::markdown::hash_str; 3 + 4 + pub(crate) struct SearchState { 5 + pub(super) mode: bool, 6 + pub(super) draft: String, 7 + pub(super) query: String, 8 + pub(super) matches: Vec<usize>, 9 + pub(super) idx: usize, 10 + pub(super) draft_hash: u64, 11 + pub(super) query_hash: u64, 12 + } 13 + 14 + impl App { 15 + pub(crate) fn active_highlight_line(&self) -> Option<usize> { 16 + if self.search.matches.is_empty() { 17 + None 18 + } else { 19 + Some(self.search.matches[self.search.idx]) 20 + } 21 + } 22 + 23 + pub(crate) fn is_search_mode(&self) -> bool { 24 + self.search.mode 25 + } 26 + 27 + pub(crate) fn search_draft(&self) -> &str { 28 + &self.search.draft 29 + } 30 + 31 + pub(crate) fn search_query(&self) -> &str { 32 + &self.search.query 33 + } 34 + 35 + #[cfg(test)] 36 + pub(crate) fn set_search_query(&mut self, query: impl Into<String>) { 37 + self.search.query = query.into(); 38 + self.search.query_hash = hash_str(&self.search.query); 39 + } 40 + 41 + pub(crate) fn search_match_count(&self) -> usize { 42 + self.search.matches.len() 43 + } 44 + 45 + pub(crate) fn search_index(&self) -> usize { 46 + self.search.idx 47 + } 48 + 49 + #[cfg(test)] 50 + pub(crate) fn search_matches(&self) -> &[usize] { 51 + &self.search.matches 52 + } 53 + 54 + #[cfg(test)] 55 + pub(crate) fn set_search_draft(&mut self, draft: impl Into<String>) { 56 + self.search.draft = draft.into(); 57 + self.search.draft_hash = hash_str(&self.search.draft); 58 + } 59 + 60 + pub(crate) fn pop_search_draft(&mut self) { 61 + self.search.draft.pop(); 62 + self.search.draft_hash = hash_str(&self.search.draft); 63 + } 64 + 65 + pub(crate) fn push_search_draft(&mut self, ch: char) { 66 + self.search.draft.push(ch); 67 + self.search.draft_hash = hash_str(&self.search.draft); 68 + } 69 + 70 + pub(crate) fn run_search(&mut self) { 71 + let q = self.search.query.to_lowercase(); 72 + if q.is_empty() { 73 + return; 74 + } 75 + let search_matches = { 76 + self.plain_lines 77 + .iter() 78 + .enumerate() 79 + .filter(|(_, line)| line.contains(&q)) 80 + .map(|(i, _)| i) 81 + .collect() 82 + }; 83 + self.search.matches = search_matches; 84 + self.search.idx = 0; 85 + if let Some(&f) = self.search.matches.first() { 86 + self.scroll = f; 87 + } 88 + } 89 + 90 + pub(crate) fn begin_search(&mut self) { 91 + self.search.mode = true; 92 + self.search.draft = self.search.query.clone(); 93 + self.search.draft_hash = self.search.query_hash; 94 + crate::runtime::debug_log( 95 + self.debug_input, 96 + &format!( 97 + "begin_search query={:?} draft={:?} matches={} idx={}", 98 + self.search.query, 99 + self.search.draft, 100 + self.search.matches.len(), 101 + self.search.idx 102 + ), 103 + ); 104 + } 105 + 106 + pub(crate) fn reset_search_state(&mut self) { 107 + self.search.draft.clear(); 108 + self.search.query.clear(); 109 + self.search.matches.clear(); 110 + self.search.idx = 0; 111 + self.search.draft_hash = 0; 112 + self.search.query_hash = 0; 113 + } 114 + 115 + pub(crate) fn cancel_search(&mut self) { 116 + self.search.mode = false; 117 + self.reset_search_state(); 118 + crate::runtime::debug_log(self.debug_input, "cancel_search cleared query and matches"); 119 + } 120 + 121 + pub(crate) fn confirm_search(&mut self) { 122 + self.search.mode = false; 123 + let draft = std::mem::take(&mut self.search.draft); 124 + self.search.query = draft; 125 + self.search.query_hash = self.search.draft_hash; 126 + self.search.draft_hash = 0; 127 + if self.search.query.is_empty() { 128 + self.reset_search_state(); 129 + crate::runtime::debug_log( 130 + self.debug_input, 131 + "confirm_search empty query -> cleared matches", 132 + ); 133 + return; 134 + } 135 + self.run_search(); 136 + crate::runtime::debug_log( 137 + self.debug_input, 138 + &format!( 139 + "confirm_search query={:?} matches={} idx={} scroll={}", 140 + self.search.query, 141 + self.search.matches.len(), 142 + self.search.idx, 143 + self.scroll 144 + ), 145 + ); 146 + } 147 + 148 + pub(crate) fn clear_active_search(&mut self) { 149 + self.search.mode = false; 150 + self.reset_search_state(); 151 + crate::runtime::debug_log( 152 + self.debug_input, 153 + "clear_active_search cleared query and matches", 154 + ); 155 + } 156 + 157 + pub(crate) fn has_active_search(&self) -> bool { 158 + !self.search.query.is_empty() || !self.search.matches.is_empty() 159 + } 160 + 161 + pub(crate) fn next_match(&mut self) { 162 + if self.search.matches.is_empty() { 163 + return; 164 + } 165 + self.search.idx = (self.search.idx + 1) % self.search.matches.len(); 166 + self.scroll = self.search.matches[self.search.idx]; 167 + } 168 + 169 + pub(crate) fn prev_match(&mut self) { 170 + if self.search.matches.is_empty() { 171 + return; 172 + } 173 + if self.search.idx == 0 { 174 + self.search.idx = self.search.matches.len() - 1; 175 + } else { 176 + self.search.idx -= 1; 177 + } 178 + self.scroll = self.search.matches[self.search.idx]; 179 + } 180 + }
+163
src/app/theme_picker.rs
··· 1 + use crate::{ 2 + markdown::{parse_markdown_with_width, toc::TocEntry}, 3 + theme::{ 4 + current_syntect_theme, current_theme_preset, set_theme_preset, theme_preset_index, 5 + ThemePreset, THEME_PRESETS, 6 + }, 7 + }; 8 + use ratatui::text::Line; 9 + use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 10 + 11 + use super::App; 12 + 13 + #[derive(Clone)] 14 + pub(crate) struct ThemePreviewCacheEntry { 15 + pub(super) lines: Vec<Line<'static>>, 16 + pub(super) toc: Vec<TocEntry>, 17 + } 18 + 19 + pub(crate) struct ThemePickerState { 20 + pub(super) open: bool, 21 + pub(super) index: usize, 22 + pub(super) original: Option<ThemePreset>, 23 + pub(super) preview_cache: Vec<Option<ThemePreviewCacheEntry>>, 24 + } 25 + 26 + impl App { 27 + pub(crate) fn open_theme_picker(&mut self) { 28 + self.theme_picker.open = true; 29 + let current = current_theme_preset(); 30 + self.theme_picker.index = theme_preset_index(current); 31 + self.theme_picker.original = Some(current); 32 + self.store_current_theme_preview(); 33 + } 34 + 35 + pub(crate) fn close_theme_picker(&mut self) { 36 + self.theme_picker.open = false; 37 + self.theme_picker.original = None; 38 + } 39 + 40 + pub(crate) fn is_theme_picker_open(&self) -> bool { 41 + self.theme_picker.open 42 + } 43 + 44 + pub(crate) fn theme_picker_index(&self) -> usize { 45 + self.theme_picker.index 46 + } 47 + 48 + #[cfg(test)] 49 + pub(crate) fn theme_picker_original(&self) -> Option<ThemePreset> { 50 + self.theme_picker.original 51 + } 52 + 53 + pub(crate) fn theme_picker_reference_preset(&self) -> ThemePreset { 54 + self.theme_picker.original.unwrap_or(current_theme_preset()) 55 + } 56 + 57 + pub(crate) fn move_theme_picker_up(&mut self) { 58 + let total = THEME_PRESETS.len(); 59 + if total == 0 { 60 + return; 61 + } 62 + if self.theme_picker.index == 0 { 63 + self.theme_picker.index = total - 1; 64 + } else { 65 + self.theme_picker.index -= 1; 66 + } 67 + } 68 + 69 + pub(crate) fn move_theme_picker_down(&mut self) { 70 + let total = THEME_PRESETS.len(); 71 + if total == 0 { 72 + return; 73 + } 74 + self.theme_picker.index = (self.theme_picker.index + 1) % total; 75 + } 76 + 77 + pub(crate) fn set_theme_picker_index(&mut self, idx: usize) -> bool { 78 + if idx < THEME_PRESETS.len() { 79 + self.theme_picker.index = idx; 80 + true 81 + } else { 82 + false 83 + } 84 + } 85 + 86 + pub(crate) fn selected_theme_preset(&self) -> Option<ThemePreset> { 87 + THEME_PRESETS.get(self.theme_picker.index).copied() 88 + } 89 + 90 + pub(crate) fn preview_theme_preset( 91 + &mut self, 92 + preset: ThemePreset, 93 + ss: &SyntaxSet, 94 + themes: &ThemeSet, 95 + ) { 96 + if current_theme_preset() == preset { 97 + return; 98 + } 99 + set_theme_preset(preset); 100 + let cached = self 101 + .theme_picker 102 + .preview_cache 103 + .get(theme_preset_index(preset)) 104 + .and_then(|entry| entry.as_ref()) 105 + .cloned(); 106 + if let Some(entry) = cached { 107 + self.replace_content(entry.lines, entry.toc); 108 + return; 109 + } 110 + 111 + let theme = current_syntect_theme(themes); 112 + let (new_lines, new_toc) = 113 + parse_markdown_with_width(&self.source, ss, theme, self.render_width); 114 + self.store_theme_preview(preset, &new_lines, &new_toc); 115 + self.replace_content(new_lines, new_toc); 116 + } 117 + 118 + pub(crate) fn restore_theme_picker_preview(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 119 + if let Some(original) = self.theme_picker.original { 120 + self.preview_theme_preset(original, ss, themes); 121 + } 122 + self.close_theme_picker(); 123 + } 124 + 125 + pub(crate) fn store_theme_preview( 126 + &mut self, 127 + preset: ThemePreset, 128 + lines: &[Line<'static>], 129 + toc: &[TocEntry], 130 + ) { 131 + let idx = theme_preset_index(preset); 132 + if let Some(slot) = self.theme_picker.preview_cache.get_mut(idx) { 133 + *slot = Some(ThemePreviewCacheEntry { 134 + lines: lines.to_vec(), 135 + toc: toc.to_vec(), 136 + }); 137 + } 138 + } 139 + 140 + pub(crate) fn store_current_theme_preview(&mut self) { 141 + let preset = current_theme_preset(); 142 + let idx = theme_preset_index(preset); 143 + if let Some(slot) = self.theme_picker.preview_cache.get_mut(idx) { 144 + *slot = Some(ThemePreviewCacheEntry { 145 + lines: self.lines.clone(), 146 + toc: self.toc.clone(), 147 + }); 148 + } 149 + } 150 + 151 + pub(crate) fn invalidate_theme_preview_cache(&mut self) { 152 + self.theme_picker.preview_cache.fill(None); 153 + } 154 + 155 + #[cfg(test)] 156 + pub(crate) fn has_cached_theme_preview(&self, preset: ThemePreset) -> bool { 157 + self.theme_picker 158 + .preview_cache 159 + .get(theme_preset_index(preset)) 160 + .and_then(|entry| entry.as_ref()) 161 + .is_some() 162 + } 163 + }
+1 -1
src/main.rs
··· 25 25 const MAX_STDIN_BYTES: usize = 8 * 1024 * 1024; 26 26 27 27 #[cfg(test)] 28 - pub(crate) use app::{ 28 + pub(crate) use markdown::toc::{ 29 29 normalize_toc, should_hide_single_h1, should_promote_h2_when_no_h1, toc_display_level, TocEntry, 30 30 }; 31 31 #[cfg(test)]
-1490
src/markdown.rs
··· 1 - use crate::{ 2 - app::{normalize_toc, TocEntry}, 3 - theme::{app_theme, MarkdownTheme}, 4 - }; 5 - use pulldown_cmark::{ 6 - Alignment, CodeBlockKind, Event as MdEvent, HeadingLevel, Options, Parser, Tag, TagEnd, 7 - }; 8 - use ratatui::{ 9 - style::{Color, Modifier, Style}, 10 - text::{Line, Span}, 11 - }; 12 - use std::{ 13 - hash::{Hash, Hasher}, 14 - io, 15 - path::PathBuf, 16 - }; 17 - use syntect::{ 18 - easy::HighlightLines, highlighting::Theme, parsing::SyntaxSet, util::LinesWithEndings, 19 - }; 20 - use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; 21 - 22 - const TAB_STOP: usize = 4; 23 - 24 - #[derive(Clone, Copy)] 25 - enum ListKind { 26 - Unordered, 27 - Ordered(u64), 28 - } 29 - 30 - #[derive(Clone, Copy, PartialEq, Eq)] 31 - enum LastBlock { 32 - Other, 33 - Paragraph, 34 - } 35 - 36 - struct ItemState { 37 - marker_emitted: bool, 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, 47 - } 48 - 49 - struct TableBuf { 50 - alignments: Vec<Alignment>, 51 - rows: Vec<Vec<String>>, 52 - header_count: usize, 53 - current_row: Vec<String>, 54 - current_cell: String, 55 - in_header: bool, 56 - } 57 - 58 - struct TableBorder<'a> { 59 - left: &'a str, 60 - fill: &'a str, 61 - cross: &'a str, 62 - right: &'a str, 63 - } 64 - 65 - struct CodeBlockRenderContext<'a> { 66 - ss: &'a SyntaxSet, 67 - theme: &'a Theme, 68 - render_width: usize, 69 - theme_colors: &'a MarkdownTheme, 70 - blockquote_depth: usize, 71 - list_stack: &'a [ListKind], 72 - } 73 - 74 - pub(crate) fn line_plain_text(line: &Line<'_>) -> String { 75 - line.spans.iter().map(|s| s.content.as_ref()).collect() 76 - } 77 - 78 - pub(crate) fn build_plain_lines(lines: &[Line<'_>]) -> Vec<String> { 79 - lines.iter().map(line_plain_text).collect() 80 - } 81 - 82 - pub(crate) fn hash_str(text: &str) -> u64 { 83 - let mut hasher = std::collections::hash_map::DefaultHasher::new(); 84 - text.hash(&mut hasher); 85 - hasher.finish() 86 - } 87 - 88 - pub(crate) fn read_file_state(path: &PathBuf) -> Option<crate::app::FileState> { 89 - let metadata = std::fs::metadata(path).ok()?; 90 - Some(crate::app::FileState { 91 - modified: metadata.modified().ok()?, 92 - len: metadata.len(), 93 - }) 94 - } 95 - 96 - pub(crate) fn hash_file_contents(path: &PathBuf) -> io::Result<u64> { 97 - std::fs::read_to_string(path).map(|contents| hash_str(&contents)) 98 - } 99 - 100 - pub(crate) fn truncate_display_width(text: &str, max_width: usize) -> String { 101 - if display_width(text) <= max_width { 102 - return text.to_string(); 103 - } 104 - if max_width == 0 { 105 - return String::new(); 106 - } 107 - 108 - let mut out = String::new(); 109 - let mut used = 0; 110 - for ch in text.chars() { 111 - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); 112 - if used + ch_w > max_width.saturating_sub(1) { 113 - break; 114 - } 115 - out.push(ch); 116 - used += ch_w; 117 - } 118 - out.push('…'); 119 - out 120 - } 121 - 122 - pub(crate) fn display_width(text: &str) -> usize { 123 - let mut width = 0; 124 - let mut parts = text.split('\t').peekable(); 125 - while let Some(segment) = parts.next() { 126 - width += UnicodeWidthStr::width(segment); 127 - if parts.peek().is_some() { 128 - width += TAB_STOP - (width % TAB_STOP); 129 - } 130 - } 131 - width 132 - } 133 - 134 - fn expand_tabs(text: &str, start_width: usize) -> String { 135 - if !text.contains('\t') { 136 - return text.to_string(); 137 - } 138 - 139 - let mut out = String::new(); 140 - let mut width = start_width; 141 - let mut parts = text.split('\t').peekable(); 142 - while let Some(segment) = parts.next() { 143 - out.push_str(segment); 144 - width += UnicodeWidthStr::width(segment); 145 - if parts.peek().is_some() { 146 - let spaces = TAB_STOP - (width % TAB_STOP); 147 - out.push_str(&" ".repeat(spaces)); 148 - width += spaces; 149 - } 150 - } 151 - out 152 - } 153 - 154 - pub(crate) fn highlight_line<'a>(line: &Line<'a>) -> Line<'a> { 155 - let theme = &app_theme().markdown; 156 - Line::from( 157 - line.spans 158 - .iter() 159 - .map(|span| { 160 - Span::styled( 161 - span.content.clone(), 162 - span.style.bg(theme.search_highlight_bg), 163 - ) 164 - }) 165 - .collect::<Vec<_>>(), 166 - ) 167 - } 168 - 169 - const DEFAULT_RENDER_WIDTH: usize = 80; 170 - 171 - fn strip_frontmatter(src: &str) -> &str { 172 - let Some(rest) = src.strip_prefix("---\n") else { 173 - return src; 174 - }; 175 - 176 - let mut offset = 4usize; 177 - for line in rest.split_inclusive('\n') { 178 - if line == "---\n" || line == "...\n" || line == "---" || line == "..." { 179 - return &src[offset + line.len()..]; 180 - } 181 - offset += line.len(); 182 - } 183 - 184 - src 185 - } 186 - 187 - fn syntect_to_color(c: syntect::highlighting::Color) -> Color { 188 - Color::Rgb(c.r, c.g, c.b) 189 - } 190 - 191 - pub(crate) fn resolve_syntax<'a>( 192 - lang: &str, 193 - ss: &'a SyntaxSet, 194 - ) -> &'a syntect::parsing::SyntaxReference { 195 - let raw = lang.trim(); 196 - let normalized = raw 197 - .split(|c: char| c.is_whitespace() || c == ',' || c == '{') 198 - .next() 199 - .unwrap_or("") 200 - .trim() 201 - .to_ascii_lowercase(); 202 - 203 - let aliases: &[&str] = match normalized.as_str() { 204 - "ts" | "typescript" => &[ 205 - "JavaScript", 206 - "js", 207 - "javascript", 208 - "TypeScript", 209 - "ts", 210 - "typescript", 211 - ], 212 - "tsx" => &["JSX", "jsx", "JavaScript", "js", "typescriptreact", "tsx"], 213 - "js" | "javascript" => &["JavaScript", "js", "javascript"], 214 - "jsx" => &["JSX", "jsx", "JavaScript React"], 215 - "shell" | "bash" | "sh" | "zsh" => &["Bourne Again Shell (bash)", "bash", "sh"], 216 - "py" | "python" => &["Python", "py", "python"], 217 - "c" => &["C", "c"], 218 - "cpp" | "cxx" | "cc" | "c++" => &["C++", "cpp", "cxx", "cc"], 219 - "json" => &["JSON", "json"], 220 - "toml" => &["TOML", "toml"], 221 - "java" => &["Java", "java"], 222 - "kt" | "kotlin" => &["Kotlin", "kt", "kotlin"], 223 - "ps1" | "powershell" | "pwsh" => &["PowerShell", "ps1", "powershell"], 224 - "docker" | "dockerfile" => &["Dockerfile", "dockerfile"], 225 - "yml" | "yaml" => &["YAML", "yml", "yaml"], 226 - "rs" | "rust" => &["Rust", "rs", "rust"], 227 - _ if normalized.is_empty() => &[], 228 - _ => &[], 229 - }; 230 - 231 - ss.find_syntax_by_token(raw) 232 - .or_else(|| ss.find_syntax_by_extension(raw)) 233 - .or_else(|| ss.find_syntax_by_token(&normalized)) 234 - .or_else(|| ss.find_syntax_by_extension(&normalized)) 235 - .or_else(|| { 236 - aliases.iter().find_map(|alias| { 237 - ss.find_syntax_by_token(alias) 238 - .or_else(|| ss.find_syntax_by_extension(alias)) 239 - .or_else(|| ss.find_syntax_by_name(alias)) 240 - }) 241 - }) 242 - .unwrap_or_else(|| ss.find_syntax_plain_text()) 243 - } 244 - 245 - fn highlight_code( 246 - code: &str, 247 - lang: &str, 248 - ss: &SyntaxSet, 249 - theme: &Theme, 250 - render_width: usize, 251 - ) -> (Vec<Line<'static>>, usize) { 252 - let theme_colors = &app_theme().markdown; 253 - let syntax = resolve_syntax(lang, ss); 254 - let mut hl = HighlightLines::new(syntax, theme); 255 - let gutter = Style::default().fg(theme_colors.code_gutter); 256 - 257 - let mut raw: Vec<(Vec<Span<'static>>, usize)> = Vec::new(); 258 - for line_str in LinesWithEndings::from(code) { 259 - let regions = hl.highlight_line(line_str, ss).unwrap_or_default(); 260 - let mut spans = vec![Span::styled("│ ", gutter)]; 261 - let mut text_width: usize = 0; 262 - for (st, text) in &regions { 263 - let t = expand_tabs(text.trim_end_matches('\n'), text_width); 264 - if t.is_empty() { 265 - continue; 266 - } 267 - text_width += display_width(&t); 268 - let mut rs = Style::default().fg(syntect_to_color(st.foreground)); 269 - if st 270 - .font_style 271 - .contains(syntect::highlighting::FontStyle::BOLD) 272 - { 273 - rs = rs.add_modifier(Modifier::BOLD); 274 - } 275 - if st 276 - .font_style 277 - .contains(syntect::highlighting::FontStyle::ITALIC) 278 - { 279 - rs = rs.add_modifier(Modifier::ITALIC); 280 - } 281 - if st 282 - .font_style 283 - .contains(syntect::highlighting::FontStyle::UNDERLINE) 284 - { 285 - rs = rs.add_modifier(Modifier::UNDERLINED); 286 - } 287 - spans.push(Span::styled(t, rs)); 288 - } 289 - raw.push((spans, text_width)); 290 - } 291 - 292 - let label = if lang.is_empty() { "text" } else { lang }; 293 - let max_text = raw.iter().map(|(_, w)| *w).max().unwrap_or(0); 294 - let max_inner_width = render_width 295 - .saturating_sub(4) 296 - .max(UnicodeWidthStr::width(label) + 3); 297 - let min_inner = (UnicodeWidthStr::width(label) + 3) 298 - .max(44) 299 - .min(max_inner_width); 300 - let inner_width = (max_text + 2).max(min_inner); 301 - 302 - let mut out = Vec::new(); 303 - for (mut spans, text_width) in raw { 304 - let pad = inner_width.saturating_sub(text_width + 1); 305 - spans.push(Span::raw(" ".repeat(pad))); 306 - spans.push(Span::styled("│", gutter)); 307 - out.push(Line::from(spans)); 308 - } 309 - (out, inner_width) 310 - } 311 - 312 - fn block_prefix(in_bq: bool) -> Vec<Span<'static>> { 313 - let theme = &app_theme().markdown; 314 - if in_bq { 315 - vec![Span::styled( 316 - "▏ ", 317 - Style::default().fg(theme.blockquote_marker), 318 - )] 319 - } else { 320 - vec![] 321 - } 322 - } 323 - 324 - fn list_item_prefix( 325 - in_bq: bool, 326 - list_stack: &[ListKind], 327 - item_stack: &mut [ItemState], 328 - ) -> Vec<Span<'static>> { 329 - let theme = &app_theme().markdown; 330 - let mut prefix = block_prefix(in_bq); 331 - let Some(item) = item_stack.last_mut() else { 332 - return prefix; 333 - }; 334 - 335 - if item.marker_emitted { 336 - prefix.push(Span::raw(" ".repeat(item.continuation_indent))); 337 - return prefix; 338 - } 339 - 340 - let depth = list_stack.len(); 341 - prefix.push(Span::raw(" ".repeat(depth.saturating_sub(1)))); 342 - 343 - let marker = match list_stack.last().copied().unwrap_or(ListKind::Unordered) { 344 - ListKind::Unordered => match depth { 345 - 1 => "• ".to_string(), 346 - 2 => "◦ ".to_string(), 347 - _ => "▸ ".to_string(), 348 - }, 349 - ListKind::Ordered(n) => format!("{n}. "), 350 - }; 351 - item.continuation_indent = " ".repeat(depth.saturating_sub(1)).len() + display_width(&marker); 352 - item.marker_emitted = true; 353 - 354 - let marker_style = match list_stack.last().copied().unwrap_or(ListKind::Unordered) { 355 - ListKind::Unordered => match depth { 356 - 1 => Style::default().fg(theme.list_level_1), 357 - 2 => Style::default().fg(theme.list_level_2), 358 - _ => Style::default().fg(theme.list_level_3), 359 - }, 360 - ListKind::Ordered(_) => Style::default().fg(theme.ordered_list), 361 - }; 362 - prefix.push(Span::styled(marker, marker_style)); 363 - prefix 364 - } 365 - 366 - fn push_wrapped_prefixed_lines( 367 - lines: &mut Vec<Line<'static>>, 368 - body_spans: &mut Vec<Span<'static>>, 369 - first_prefix: Vec<Span<'static>>, 370 - continuation_prefix: Vec<Span<'static>>, 371 - render_width: usize, 372 - ) { 373 - if body_spans.is_empty() { 374 - return; 375 - } 376 - 377 - let first_prefix_width: usize = first_prefix 378 - .iter() 379 - .map(|span| display_width(span.content.as_ref())) 380 - .sum(); 381 - let continuation_prefix_width: usize = continuation_prefix 382 - .iter() 383 - .map(|span| display_width(span.content.as_ref())) 384 - .sum(); 385 - let max_width = render_width 386 - .saturating_sub(first_prefix_width.max(continuation_prefix_width)) 387 - .max(8); 388 - 389 - let mut current_prefix = first_prefix.clone(); 390 - let mut next_prefix = continuation_prefix.clone(); 391 - let mut current_width = 0usize; 392 - let mut body_started = false; 393 - 394 - let push_current = |lines: &mut Vec<Line<'static>>, 395 - current_prefix: &mut Vec<Span<'static>>, 396 - next_prefix: &mut Vec<Span<'static>>, 397 - body_started: &mut bool, 398 - current_width: &mut usize| { 399 - if *body_started { 400 - lines.push(Line::from(std::mem::take(current_prefix))); 401 - *current_prefix = next_prefix.clone(); 402 - *body_started = false; 403 - *current_width = 0; 404 - } 405 - }; 406 - 407 - for span in body_spans.drain(..) { 408 - let style = span.style; 409 - let mut token = String::new(); 410 - let mut token_is_space = false; 411 - 412 - let mut flush_token = |token: &mut String, 413 - token_is_space: bool, 414 - lines: &mut Vec<Line<'static>>, 415 - current_prefix: &mut Vec<Span<'static>>, 416 - body_started: &mut bool, 417 - current_width: &mut usize| { 418 - if token.is_empty() { 419 - return; 420 - } 421 - 422 - let token_width = display_width(token); 423 - if token_is_space { 424 - let keep_styled_padding = style.bg.is_some(); 425 - if (*body_started || keep_styled_padding) 426 - && *current_width + token_width <= max_width 427 - { 428 - current_prefix.push(Span::styled(std::mem::take(token), style)); 429 - *current_width += token_width; 430 - *body_started = true; 431 - } else { 432 - token.clear(); 433 - } 434 - return; 435 - } 436 - 437 - if *body_started && *current_width + token_width > max_width { 438 - push_current( 439 - lines, 440 - current_prefix, 441 - &mut next_prefix, 442 - body_started, 443 - current_width, 444 - ); 445 - } 446 - 447 - if token_width <= max_width { 448 - current_prefix.push(Span::styled(std::mem::take(token), style)); 449 - *current_width += token_width; 450 - *body_started = true; 451 - return; 452 - } 453 - 454 - let mut chunk = String::new(); 455 - let mut chunk_width = 0usize; 456 - for ch in token.chars() { 457 - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); 458 - let would_overflow = if *body_started { 459 - *current_width + chunk_width + ch_width > max_width 460 - } else { 461 - chunk_width + ch_width > max_width 462 - }; 463 - if would_overflow { 464 - if !chunk.is_empty() { 465 - current_prefix.push(Span::styled(std::mem::take(&mut chunk), style)); 466 - *body_started = true; 467 - } 468 - push_current( 469 - lines, 470 - current_prefix, 471 - &mut next_prefix, 472 - body_started, 473 - current_width, 474 - ); 475 - chunk_width = 0; 476 - } 477 - 478 - chunk.push(ch); 479 - chunk_width += ch_width; 480 - } 481 - 482 - if !chunk.is_empty() { 483 - current_prefix.push(Span::styled(chunk, style)); 484 - *current_width += chunk_width; 485 - *body_started = true; 486 - } 487 - token.clear(); 488 - }; 489 - 490 - for ch in span.content.chars() { 491 - let is_space = ch.is_whitespace(); 492 - if token.is_empty() { 493 - token_is_space = is_space; 494 - } else if token_is_space != is_space { 495 - flush_token( 496 - &mut token, 497 - token_is_space, 498 - lines, 499 - &mut current_prefix, 500 - &mut body_started, 501 - &mut current_width, 502 - ); 503 - token_is_space = is_space; 504 - } 505 - token.push(ch); 506 - } 507 - 508 - flush_token( 509 - &mut token, 510 - token_is_space, 511 - lines, 512 - &mut current_prefix, 513 - &mut body_started, 514 - &mut current_width, 515 - ); 516 - } 517 - 518 - if body_started { 519 - lines.push(Line::from(current_prefix)); 520 - } 521 - } 522 - 523 - fn push_wrapped_blockquote_lines( 524 - lines: &mut Vec<Line<'static>>, 525 - body_spans: &mut Vec<Span<'static>>, 526 - render_width: usize, 527 - ) { 528 - let prefix = block_prefix(true); 529 - push_wrapped_prefixed_lines(lines, body_spans, prefix.clone(), prefix, render_width); 530 - } 531 - 532 - fn flush_wrapped_spans( 533 - lines: &mut Vec<Line<'static>>, 534 - spans: &mut Vec<Span<'static>>, 535 - blockquote_depth: usize, 536 - list_stack: &[ListKind], 537 - item_stack: &mut [ItemState], 538 - render_width: usize, 539 - ) { 540 - if blockquote_depth > 0 && item_stack.is_empty() { 541 - push_wrapped_blockquote_lines(lines, spans, render_width); 542 - } else if !item_stack.is_empty() { 543 - let first_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 544 - let continuation_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 545 - push_wrapped_prefixed_lines( 546 - lines, 547 - spans, 548 - first_prefix, 549 - continuation_prefix, 550 - render_width, 551 - ); 552 - } else if !spans.is_empty() { 553 - let mut all = block_prefix(false); 554 - all.append(spans); 555 - lines.push(Line::from(all)); 556 - } 557 - } 558 - 559 - fn trim_paragraph_gap_before_block(lines: &mut Vec<Line<'static>>, last_block: LastBlock) { 560 - if last_block == LastBlock::Paragraph 561 - && lines 562 - .last() 563 - .is_some_and(|line| line_plain_text(line).is_empty()) 564 - { 565 - lines.pop(); 566 - } 567 - } 568 - 569 - fn push_heading_lines( 570 - lines: &mut Vec<Line<'static>>, 571 - toc: &mut Vec<TocEntry>, 572 - spans: &mut Vec<Span<'static>>, 573 - level: u8, 574 - render_width: usize, 575 - theme: &MarkdownTheme, 576 - ) { 577 - let color: Color = match level { 578 - 1 => theme.heading_1, 579 - 2 => theme.heading_2, 580 - 3 => theme.heading_3, 581 - _ => theme.heading_other, 582 - }; 583 - let style = Style::default().fg(color).add_modifier(match level { 584 - 1..=3 => Modifier::BOLD, 585 - _ => Modifier::empty(), 586 - }); 587 - let title: String = spans.iter().map(|s| s.content.as_ref()).collect(); 588 - let rendered_title = if level == 3 { 589 - format!("{title} ") 590 - } else { 591 - title.clone() 592 - }; 593 - toc.push(TocEntry { 594 - level, 595 - title: title.clone(), 596 - line: lines.len(), 597 - }); 598 - spans.clear(); 599 - lines.push(Line::from(vec![Span::styled(rendered_title, style)])); 600 - 601 - match level { 602 - 1 => lines.push(Line::from(Span::styled( 603 - "═".repeat(display_width(&title).min(rule_width(render_width, 0))), 604 - Style::default().fg(theme.heading_underline), 605 - ))), 606 - 2 => lines.push(Line::from(Span::styled( 607 - "─".repeat(display_width(&title).min(rule_width(render_width, 0))), 608 - Style::default().fg(theme.heading_underline), 609 - ))), 610 - _ => {} 611 - } 612 - } 613 - 614 - fn push_code_block_lines( 615 - lines: &mut Vec<Line<'static>>, 616 - code_buf: &mut String, 617 - code_lang: &mut String, 618 - ctx: CodeBlockRenderContext<'_>, 619 - item_stack: &mut [ItemState], 620 - ) { 621 - let prefix = if !item_stack.is_empty() { 622 - list_item_prefix(ctx.blockquote_depth > 0, ctx.list_stack, item_stack) 623 - } else if ctx.blockquote_depth > 0 { 624 - block_prefix(true) 625 - } else { 626 - Vec::new() 627 - }; 628 - let prefix_width: usize = prefix 629 - .iter() 630 - .map(|span| display_width(span.content.as_ref())) 631 - .sum(); 632 - let label = if code_lang.is_empty() { 633 - "text".to_string() 634 - } else { 635 - code_lang.clone() 636 - }; 637 - let available_width = ctx.render_width.saturating_sub(prefix_width); 638 - let (code_lines, inner_width) = 639 - highlight_code(code_buf, code_lang, ctx.ss, ctx.theme, available_width); 640 - let header_width = UnicodeWidthStr::width(label.as_str()) + 3; 641 - let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 642 - let mut header = prefix.clone(); 643 - header.extend([ 644 - Span::styled( 645 - "┌─ ".to_string(), 646 - Style::default().fg(ctx.theme_colors.code_frame), 647 - ), 648 - Span::styled( 649 - format!("{label} "), 650 - Style::default().fg(ctx.theme_colors.code_label), 651 - ), 652 - Span::styled( 653 - format!("{top_bar}┐"), 654 - Style::default().fg(ctx.theme_colors.code_frame), 655 - ), 656 - ]); 657 - lines.push(Line::from(header)); 658 - lines.extend(code_lines.into_iter().map(|line| { 659 - let mut spans = prefix.clone(); 660 - spans.extend(line.spans); 661 - Line::from(spans) 662 - })); 663 - let mut footer = prefix; 664 - footer.push(Span::styled( 665 - format!("└{}┘", "─".repeat(inner_width)), 666 - Style::default().fg(ctx.theme_colors.code_frame), 667 - )); 668 - lines.push(Line::from(footer)); 669 - lines.push(Line::from("")); 670 - code_lang.clear(); 671 - code_buf.clear(); 672 - } 673 - 674 - fn inline_text_style( 675 - theme: &MarkdownTheme, 676 - blockquote_depth: usize, 677 - inline: InlineStyleState, 678 - ) -> Style { 679 - let mut style = if blockquote_depth > 0 { 680 - Style::default() 681 - .fg(theme.blockquote_text) 682 - .add_modifier(Modifier::ITALIC) 683 - } else if inline.in_link { 684 - Style::default().fg(theme.link_text) 685 - } else { 686 - Style::default().fg(theme.text) 687 - }; 688 - 689 - if inline.in_strong { 690 - style = style.fg(theme.strong_text).add_modifier(Modifier::BOLD); 691 - } 692 - if inline.in_em { 693 - style = style.add_modifier(Modifier::ITALIC); 694 - } 695 - if inline.in_strike { 696 - style = style.add_modifier(Modifier::CROSSED_OUT); 697 - } 698 - 699 - style 700 - } 701 - 702 - fn flush_list_item_spans( 703 - lines: &mut Vec<Line<'static>>, 704 - spans: &mut Vec<Span<'static>>, 705 - list_stack: &[ListKind], 706 - item_stack: &mut [ItemState], 707 - blockquote_depth: usize, 708 - render_width: usize, 709 - ) { 710 - if spans.is_empty() { 711 - return; 712 - } 713 - 714 - let first_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 715 - let continuation_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 716 - push_wrapped_prefixed_lines( 717 - lines, 718 - spans, 719 - first_prefix, 720 - continuation_prefix, 721 - render_width, 722 - ); 723 - } 724 - 725 - fn handle_table_event( 726 - table: &mut Option<TableBuf>, 727 - ev: &MdEvent<'_>, 728 - lines: &mut Vec<Line<'static>>, 729 - render_width: usize, 730 - ) -> bool { 731 - let Some(tb) = table.as_mut() else { 732 - return false; 733 - }; 734 - 735 - match ev { 736 - MdEvent::Text(t) | MdEvent::Code(t) => { 737 - tb.push_text(t.as_ref()); 738 - true 739 - } 740 - MdEvent::Start(Tag::TableCell) => true, 741 - MdEvent::End(TagEnd::TableCell) => { 742 - tb.end_cell(); 743 - true 744 - } 745 - MdEvent::Start(Tag::TableRow) => true, 746 - MdEvent::End(TagEnd::TableRow) => { 747 - tb.end_row(); 748 - true 749 - } 750 - MdEvent::Start(Tag::TableHead) => { 751 - tb.in_header = true; 752 - true 753 - } 754 - MdEvent::End(TagEnd::TableHead) => { 755 - tb.end_header(); 756 - true 757 - } 758 - MdEvent::Start(Tag::Strong) 759 - | MdEvent::End(TagEnd::Strong) 760 - | MdEvent::Start(Tag::Emphasis) 761 - | MdEvent::End(TagEnd::Emphasis) 762 - | MdEvent::Start(Tag::Link { .. }) 763 - | MdEvent::End(TagEnd::Link) => true, 764 - MdEvent::End(TagEnd::Table) => { 765 - let rendered = tb.render(render_width); 766 - lines.extend(rendered); 767 - *table = None; 768 - true 769 - } 770 - _ => true, 771 - } 772 - } 773 - 774 - fn start_list( 775 - lines: &mut Vec<Line<'static>>, 776 - last_block: LastBlock, 777 - list_stack: &mut Vec<ListKind>, 778 - start: Option<u64>, 779 - ) { 780 - trim_paragraph_gap_before_block(lines, last_block); 781 - list_stack.push(match start { 782 - Some(n) => ListKind::Ordered(n), 783 - None => ListKind::Unordered, 784 - }); 785 - } 786 - 787 - fn start_table(table: &mut Option<TableBuf>, aligns: &[Alignment]) { 788 - *table = Some(TableBuf::new(aligns.to_vec())); 789 - } 790 - 791 - fn end_list(lines: &mut Vec<Line<'static>>, list_stack: &mut Vec<ListKind>) { 792 - list_stack.pop(); 793 - if list_stack.is_empty() { 794 - lines.push(Line::from("")); 795 - } 796 - } 797 - 798 - fn start_item(item_stack: &mut Vec<ItemState>) { 799 - item_stack.push(ItemState { 800 - marker_emitted: false, 801 - continuation_indent: 0, 802 - }); 803 - } 804 - 805 - fn end_item( 806 - lines: &mut Vec<Line<'static>>, 807 - spans: &mut Vec<Span<'static>>, 808 - list_stack: &mut [ListKind], 809 - item_stack: &mut Vec<ItemState>, 810 - blockquote_depth: usize, 811 - render_width: usize, 812 - ) { 813 - flush_list_item_spans( 814 - lines, 815 - spans, 816 - list_stack, 817 - item_stack, 818 - blockquote_depth, 819 - render_width, 820 - ); 821 - item_stack.pop(); 822 - if let Some(ListKind::Ordered(next)) = list_stack.last_mut() { 823 - *next += 1; 824 - } 825 - } 826 - 827 - fn end_blockquote( 828 - lines: &mut Vec<Line<'static>>, 829 - spans: &mut Vec<Span<'static>>, 830 - blockquote_depth: &mut usize, 831 - theme: &MarkdownTheme, 832 - ) { 833 - if !spans.is_empty() { 834 - let mut all = vec![Span::styled( 835 - "▏ ", 836 - Style::default().fg(theme.blockquote_marker), 837 - )]; 838 - all.append(spans); 839 - lines.push(Line::from(all)); 840 - } 841 - *blockquote_depth = blockquote_depth.saturating_sub(1); 842 - lines.push(Line::from("")); 843 - } 844 - 845 - fn push_rule_line(lines: &mut Vec<Line<'static>>, render_width: usize, theme: &MarkdownTheme) { 846 - lines.push(Line::from(Span::styled( 847 - "─".repeat(rule_width(render_width, 0)), 848 - Style::default().fg(theme.rule), 849 - ))); 850 - lines.push(Line::from("")); 851 - } 852 - 853 - fn push_inline_code_span(spans: &mut Vec<Span<'static>>, text: &str, theme: &MarkdownTheme) { 854 - spans.push(Span::styled( 855 - format!(" {} ", text), 856 - Style::default() 857 - .fg(theme.inline_code_fg) 858 - .bg(theme.inline_code_bg), 859 - )); 860 - } 861 - 862 - fn push_link_marker(spans: &mut Vec<Span<'static>>, theme: &MarkdownTheme) { 863 - spans.push(Span::styled("⌗", Style::default().fg(theme.link_icon))); 864 - } 865 - 866 - fn handle_inline_style_event( 867 - ev: &MdEvent<'_>, 868 - inline: &mut InlineStyleState, 869 - spans: &mut Vec<Span<'static>>, 870 - theme: &MarkdownTheme, 871 - ) -> bool { 872 - match ev { 873 - MdEvent::Start(Tag::Strong) => { 874 - inline.in_strong = true; 875 - true 876 - } 877 - MdEvent::End(TagEnd::Strong) => { 878 - inline.in_strong = false; 879 - true 880 - } 881 - MdEvent::Start(Tag::Emphasis) => { 882 - inline.in_em = true; 883 - true 884 - } 885 - MdEvent::End(TagEnd::Emphasis) => { 886 - inline.in_em = false; 887 - true 888 - } 889 - MdEvent::Start(Tag::Strikethrough) => { 890 - inline.in_strike = true; 891 - true 892 - } 893 - MdEvent::End(TagEnd::Strikethrough) => { 894 - inline.in_strike = false; 895 - true 896 - } 897 - MdEvent::Start(Tag::Link { .. }) => { 898 - inline.in_link = true; 899 - push_link_marker(spans, theme); 900 - true 901 - } 902 - MdEvent::End(TagEnd::Link) => { 903 - inline.in_link = false; 904 - true 905 - } 906 - _ => false, 907 - } 908 - } 909 - 910 - fn heading_level(level: HeadingLevel) -> u8 { 911 - match level { 912 - HeadingLevel::H1 => 1, 913 - HeadingLevel::H2 => 2, 914 - HeadingLevel::H3 => 3, 915 - _ => 4, 916 - } 917 - } 918 - 919 - fn start_heading(in_heading: &mut Option<u8>, level: HeadingLevel) { 920 - *in_heading = Some(heading_level(level)); 921 - } 922 - 923 - fn end_heading( 924 - lines: &mut Vec<Line<'static>>, 925 - toc: &mut Vec<TocEntry>, 926 - spans: &mut Vec<Span<'static>>, 927 - in_heading: &mut Option<u8>, 928 - render_width: usize, 929 - theme: &MarkdownTheme, 930 - ) { 931 - push_heading_lines( 932 - lines, 933 - toc, 934 - spans, 935 - in_heading.unwrap_or(1), 936 - render_width, 937 - theme, 938 - ); 939 - *in_heading = None; 940 - } 941 - 942 - fn start_code_block( 943 - lines: &mut Vec<Line<'static>>, 944 - last_block: LastBlock, 945 - in_code: &mut bool, 946 - code_buf: &mut String, 947 - code_lang: &mut String, 948 - kind: &CodeBlockKind<'_>, 949 - ) { 950 - trim_paragraph_gap_before_block(lines, last_block); 951 - *in_code = true; 952 - code_buf.clear(); 953 - *code_lang = match kind { 954 - CodeBlockKind::Fenced(lang) => lang.to_string(), 955 - CodeBlockKind::Indented => String::new(), 956 - }; 957 - } 958 - 959 - fn end_line_break( 960 - lines: &mut Vec<Line<'static>>, 961 - spans: &mut Vec<Span<'static>>, 962 - in_code: bool, 963 - blockquote_depth: usize, 964 - list_stack: &[ListKind], 965 - item_stack: &mut [ItemState], 966 - render_width: usize, 967 - ) { 968 - if !in_code { 969 - flush_wrapped_spans( 970 - lines, 971 - spans, 972 - blockquote_depth, 973 - list_stack, 974 - item_stack, 975 - render_width, 976 - ); 977 - } 978 - } 979 - 980 - fn end_paragraph( 981 - lines: &mut Vec<Line<'static>>, 982 - spans: &mut Vec<Span<'static>>, 983 - blockquote_depth: usize, 984 - list_stack: &[ListKind], 985 - item_stack: &mut [ItemState], 986 - render_width: usize, 987 - ) { 988 - flush_wrapped_spans( 989 - lines, 990 - spans, 991 - blockquote_depth, 992 - list_stack, 993 - item_stack, 994 - render_width, 995 - ); 996 - lines.push(Line::from("")); 997 - } 998 - 999 - fn push_text_event( 1000 - spans: &mut Vec<Span<'static>>, 1001 - code_buf: &mut String, 1002 - text: &str, 1003 - in_code: bool, 1004 - theme: &MarkdownTheme, 1005 - blockquote_depth: usize, 1006 - inline: InlineStyleState, 1007 - ) { 1008 - if in_code { 1009 - code_buf.push_str(text); 1010 - } else { 1011 - spans.push(Span::styled( 1012 - text.to_string(), 1013 - inline_text_style(theme, blockquote_depth, inline), 1014 - )); 1015 - } 1016 - } 1017 - 1018 - impl TableBuf { 1019 - fn new(alignments: Vec<Alignment>) -> Self { 1020 - Self { 1021 - alignments, 1022 - rows: vec![], 1023 - header_count: 0, 1024 - current_row: vec![], 1025 - current_cell: String::new(), 1026 - in_header: false, 1027 - } 1028 - } 1029 - fn push_text(&mut self, t: &str) { 1030 - self.current_cell.push_str(t); 1031 - } 1032 - fn end_cell(&mut self) { 1033 - let cell = std::mem::take(&mut self.current_cell).trim().to_string(); 1034 - self.current_row.push(cell); 1035 - } 1036 - fn end_row(&mut self) { 1037 - let row = std::mem::take(&mut self.current_row); 1038 - if !row.is_empty() { 1039 - self.rows.push(row); 1040 - } 1041 - } 1042 - fn end_header(&mut self) { 1043 - self.end_row(); 1044 - self.header_count = self.rows.len(); 1045 - self.in_header = false; 1046 - } 1047 - 1048 - fn render(&self, render_width: usize) -> Vec<Line<'static>> { 1049 - let theme = &app_theme().markdown; 1050 - if self.rows.is_empty() { 1051 - return vec![]; 1052 - } 1053 - let col_count = self.rows.iter().map(|r| r.len()).max().unwrap_or(0); 1054 - if col_count == 0 { 1055 - return vec![]; 1056 - } 1057 - 1058 - let mut col_widths: Vec<usize> = vec![1; col_count]; 1059 - let mut min_widths: Vec<usize> = vec![4; col_count]; 1060 - for row in &self.rows { 1061 - for (ci, cell) in row.iter().enumerate() { 1062 - if ci < col_count { 1063 - col_widths[ci] = col_widths[ci].max(display_width(cell)); 1064 - min_widths[ci] = min_widths[ci].max(min_table_cell_width(cell)); 1065 - } 1066 - } 1067 - } 1068 - 1069 - fit_table_widths(&mut col_widths, &min_widths, render_width); 1070 - 1071 - let border = Style::default().fg(theme.table_border); 1072 - let sep = Style::default().fg(theme.table_separator); 1073 - let header = Style::default() 1074 - .fg(theme.table_header) 1075 - .add_modifier(Modifier::BOLD); 1076 - let cell = Style::default().fg(theme.table_cell); 1077 - let ind = ""; 1078 - 1079 - let mut out: Vec<Line<'static>> = Vec::new(); 1080 - out.push(self.hline( 1081 - ind, 1082 - TableBorder { 1083 - left: "┌", 1084 - fill: "─", 1085 - cross: "┬", 1086 - right: "┐", 1087 - }, 1088 - &col_widths, 1089 - border, 1090 - )); 1091 - 1092 - for (ri, row) in self.rows.iter().enumerate() { 1093 - let is_hdr = ri < self.header_count; 1094 - let wrapped_cells: Vec<Vec<String>> = col_widths 1095 - .iter() 1096 - .copied() 1097 - .enumerate() 1098 - .take(col_count) 1099 - .map(|(ci, width)| { 1100 - wrap_table_cell(row.get(ci).map(|s| s.as_str()).unwrap_or(""), width) 1101 - }) 1102 - .collect(); 1103 - let row_height = wrapped_cells 1104 - .iter() 1105 - .map(|lines| lines.len()) 1106 - .max() 1107 - .unwrap_or(1); 1108 - 1109 - for line_idx in 0..row_height { 1110 - let mut spans = vec![Span::raw(ind), Span::styled("│", border)]; 1111 - for (ci, width) in col_widths.iter().copied().enumerate().take(col_count) { 1112 - let txt = wrapped_cells[ci] 1113 - .get(line_idx) 1114 - .map(|s| s.as_str()) 1115 - .unwrap_or(""); 1116 - let align = self.alignments.get(ci).copied().unwrap_or(Alignment::None); 1117 - let pad = align_cell(txt, width, align); 1118 - let st = if is_hdr { header } else { cell }; 1119 - spans.push(Span::raw(" ")); 1120 - spans.push(Span::styled(pad, st)); 1121 - spans.push(Span::raw(" ")); 1122 - spans.push(Span::styled("│", border)); 1123 - } 1124 - out.push(Line::from(spans)); 1125 - } 1126 - 1127 - if is_hdr && ri == self.header_count - 1 { 1128 - out.push(self.hline( 1129 - ind, 1130 - TableBorder { 1131 - left: "╞", 1132 - fill: "═", 1133 - cross: "╪", 1134 - right: "╡", 1135 - }, 1136 - &col_widths, 1137 - sep, 1138 - )); 1139 - } else if !is_hdr && ri < self.rows.len() - 1 { 1140 - out.push(self.hline( 1141 - ind, 1142 - TableBorder { 1143 - left: "├", 1144 - fill: "─", 1145 - cross: "┼", 1146 - right: "┤", 1147 - }, 1148 - &col_widths, 1149 - border, 1150 - )); 1151 - } 1152 - } 1153 - 1154 - out.push(self.hline( 1155 - ind, 1156 - TableBorder { 1157 - left: "└", 1158 - fill: "─", 1159 - cross: "┴", 1160 - right: "┘", 1161 - }, 1162 - &col_widths, 1163 - border, 1164 - )); 1165 - out.push(Line::from("")); 1166 - out 1167 - } 1168 - 1169 - fn hline( 1170 - &self, 1171 - indent: &str, 1172 - border: TableBorder<'_>, 1173 - col_widths: &[usize], 1174 - style: Style, 1175 - ) -> Line<'static> { 1176 - let mut spans = vec![ 1177 - Span::raw(indent.to_string()), 1178 - Span::styled(border.left.to_string(), style), 1179 - ]; 1180 - for (ci, &w) in col_widths.iter().enumerate() { 1181 - spans.push(Span::styled(border.fill.repeat(w + 2), style)); 1182 - if ci < col_widths.len() - 1 { 1183 - spans.push(Span::styled(border.cross.to_string(), style)); 1184 - } 1185 - } 1186 - spans.push(Span::styled(border.right.to_string(), style)); 1187 - Line::from(spans) 1188 - } 1189 - } 1190 - 1191 - fn min_table_cell_width(text: &str) -> usize { 1192 - let max_word = text 1193 - .split_whitespace() 1194 - .map(display_width) 1195 - .max() 1196 - .unwrap_or(0) 1197 - .min(12); 1198 - max_word.max(4) 1199 - } 1200 - 1201 - fn fit_table_widths(col_widths: &mut [usize], min_widths: &[usize], render_width: usize) { 1202 - if col_widths.is_empty() { 1203 - return; 1204 - } 1205 - 1206 - let col_count = col_widths.len(); 1207 - let border_width = 3 * col_count + 1; 1208 - let available = render_width.saturating_sub(border_width).max(col_count); 1209 - let min_total: usize = min_widths.iter().sum(); 1210 - 1211 - if min_total >= available { 1212 - let mut widths = vec![1; col_count]; 1213 - let mut remaining = available.saturating_sub(col_count); 1214 - let mut order: Vec<usize> = (0..col_count).collect(); 1215 - order.sort_by_key(|&idx| std::cmp::Reverse(min_widths[idx])); 1216 - for idx in order { 1217 - if remaining == 0 { 1218 - break; 1219 - } 1220 - let extra = (min_widths[idx].saturating_sub(1)).min(remaining); 1221 - widths[idx] += extra; 1222 - remaining -= extra; 1223 - } 1224 - col_widths.copy_from_slice(&widths); 1225 - return; 1226 - } 1227 - 1228 - while col_widths.iter().sum::<usize>() > available { 1229 - let Some((idx, _)) = col_widths 1230 - .iter() 1231 - .enumerate() 1232 - .filter(|(idx, width)| **width > min_widths[*idx]) 1233 - .max_by_key(|(_, width)| **width) 1234 - else { 1235 - break; 1236 - }; 1237 - col_widths[idx] -= 1; 1238 - } 1239 - } 1240 - 1241 - fn wrap_table_cell(text: &str, width: usize) -> Vec<String> { 1242 - if width == 0 { 1243 - return vec![String::new()]; 1244 - } 1245 - let expanded = expand_tabs(text, 0); 1246 - if expanded.is_empty() { 1247 - return vec![String::new()]; 1248 - } 1249 - 1250 - let mut lines = Vec::new(); 1251 - let mut current = String::new(); 1252 - let mut current_width = 0usize; 1253 - 1254 - for word in expanded.split_whitespace() { 1255 - let word_width = display_width(word); 1256 - 1257 - if word_width > width { 1258 - if !current.is_empty() { 1259 - lines.push(std::mem::take(&mut current)); 1260 - current_width = 0; 1261 - } 1262 - let mut chunk = String::new(); 1263 - let mut chunk_width = 0usize; 1264 - for ch in word.chars() { 1265 - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); 1266 - if chunk_width + ch_width > width && !chunk.is_empty() { 1267 - lines.push(std::mem::take(&mut chunk)); 1268 - chunk_width = 0; 1269 - } 1270 - chunk.push(ch); 1271 - chunk_width += ch_width; 1272 - } 1273 - if !chunk.is_empty() { 1274 - current = chunk; 1275 - current_width = chunk_width; 1276 - } 1277 - continue; 1278 - } 1279 - 1280 - let sep = if current.is_empty() { 0 } else { 1 }; 1281 - if current_width + sep + word_width > width && !current.is_empty() { 1282 - lines.push(std::mem::take(&mut current)); 1283 - current_width = 0; 1284 - } 1285 - if !current.is_empty() { 1286 - current.push(' '); 1287 - current_width += 1; 1288 - } 1289 - current.push_str(word); 1290 - current_width += word_width; 1291 - } 1292 - 1293 - if !current.is_empty() { 1294 - lines.push(current); 1295 - } 1296 - if lines.is_empty() { 1297 - lines.push(String::new()); 1298 - } 1299 - lines 1300 - } 1301 - 1302 - fn align_cell(text: &str, width: usize, align: Alignment) -> String { 1303 - let text = expand_tabs(text, 0); 1304 - let len = display_width(&text); 1305 - if len >= width { 1306 - return text; 1307 - } 1308 - let pad = width - len; 1309 - match align { 1310 - Alignment::Right => format!("{}{}", " ".repeat(pad), text), 1311 - Alignment::Center => { 1312 - let l = pad / 2; 1313 - format!("{}{}{}", " ".repeat(l), text, " ".repeat(pad - l)) 1314 - } 1315 - _ => format!("{}{}", text, " ".repeat(pad)), 1316 - } 1317 - } 1318 - 1319 - pub(crate) fn parse_markdown( 1320 - src: &str, 1321 - ss: &SyntaxSet, 1322 - theme: &Theme, 1323 - ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 1324 - parse_markdown_with_width(src, ss, theme, DEFAULT_RENDER_WIDTH) 1325 - } 1326 - 1327 - fn rule_width(render_width: usize, indent: usize) -> usize { 1328 - render_width.saturating_sub(indent).max(8) 1329 - } 1330 - 1331 - pub(crate) fn parse_markdown_with_width( 1332 - src: &str, 1333 - ss: &SyntaxSet, 1334 - theme: &Theme, 1335 - render_width: usize, 1336 - ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 1337 - let theme_colors = &app_theme().markdown; 1338 - let src = strip_frontmatter(src); 1339 - let mut lines: Vec<Line<'static>> = Vec::new(); 1340 - let mut toc: Vec<TocEntry> = Vec::new(); 1341 - 1342 - let mut spans: Vec<Span<'static>> = Vec::new(); 1343 - let mut in_heading: Option<u8> = None; 1344 - let mut in_code = false; 1345 - let mut code_lang = String::new(); 1346 - let mut code_buf = String::new(); 1347 - let mut blockquote_depth = 0usize; 1348 - let mut inline = InlineStyleState::default(); 1349 - let mut list_stack: Vec<ListKind> = Vec::new(); 1350 - let mut item_stack: Vec<ItemState> = Vec::new(); 1351 - let mut table: Option<TableBuf> = None; 1352 - let mut last_block = LastBlock::Other; 1353 - 1354 - for ev in Parser::new_ext(src, Options::all()) { 1355 - if table.is_some() && handle_table_event(&mut table, &ev, &mut lines, render_width) { 1356 - continue; 1357 - } 1358 - if handle_inline_style_event(&ev, &mut inline, &mut spans, theme_colors) { 1359 - continue; 1360 - } 1361 - 1362 - match ev { 1363 - MdEvent::Start(Tag::Table(aligns)) => { 1364 - start_table(&mut table, &aligns); 1365 - } 1366 - MdEvent::Start(Tag::Heading { level, .. }) => { 1367 - start_heading(&mut in_heading, level); 1368 - } 1369 - MdEvent::End(TagEnd::Heading(_)) => { 1370 - end_heading( 1371 - &mut lines, 1372 - &mut toc, 1373 - &mut spans, 1374 - &mut in_heading, 1375 - render_width, 1376 - theme_colors, 1377 - ); 1378 - last_block = LastBlock::Other; 1379 - } 1380 - MdEvent::Start(Tag::Paragraph) => {} 1381 - MdEvent::End(TagEnd::Paragraph) => { 1382 - end_paragraph( 1383 - &mut lines, 1384 - &mut spans, 1385 - blockquote_depth, 1386 - &list_stack, 1387 - &mut item_stack, 1388 - render_width, 1389 - ); 1390 - last_block = LastBlock::Paragraph; 1391 - } 1392 - MdEvent::Start(Tag::CodeBlock(kind)) => { 1393 - start_code_block( 1394 - &mut lines, 1395 - last_block, 1396 - &mut in_code, 1397 - &mut code_buf, 1398 - &mut code_lang, 1399 - &kind, 1400 - ); 1401 - last_block = LastBlock::Other; 1402 - } 1403 - MdEvent::End(TagEnd::CodeBlock) => { 1404 - in_code = false; 1405 - push_code_block_lines( 1406 - &mut lines, 1407 - &mut code_buf, 1408 - &mut code_lang, 1409 - CodeBlockRenderContext { 1410 - ss, 1411 - theme, 1412 - render_width, 1413 - theme_colors, 1414 - blockquote_depth, 1415 - list_stack: &list_stack, 1416 - }, 1417 - &mut item_stack, 1418 - ); 1419 - last_block = LastBlock::Other; 1420 - } 1421 - MdEvent::Code(text) => { 1422 - push_inline_code_span(&mut spans, text.as_ref(), theme_colors); 1423 - } 1424 - MdEvent::Start(Tag::BlockQuote(_)) => { 1425 - blockquote_depth += 1; 1426 - } 1427 - MdEvent::End(TagEnd::BlockQuote(_)) => { 1428 - end_blockquote(&mut lines, &mut spans, &mut blockquote_depth, theme_colors); 1429 - last_block = LastBlock::Other; 1430 - } 1431 - MdEvent::Start(Tag::List(start)) => { 1432 - start_list(&mut lines, last_block, &mut list_stack, start); 1433 - last_block = LastBlock::Other; 1434 - } 1435 - MdEvent::End(TagEnd::List(_)) => { 1436 - end_list(&mut lines, &mut list_stack); 1437 - last_block = LastBlock::Other; 1438 - } 1439 - MdEvent::Start(Tag::Item) => { 1440 - start_item(&mut item_stack); 1441 - } 1442 - MdEvent::End(TagEnd::Item) => { 1443 - end_item( 1444 - &mut lines, 1445 - &mut spans, 1446 - &mut list_stack, 1447 - &mut item_stack, 1448 - blockquote_depth, 1449 - render_width, 1450 - ); 1451 - last_block = LastBlock::Other; 1452 - } 1453 - MdEvent::Rule => { 1454 - push_rule_line(&mut lines, render_width, theme_colors); 1455 - last_block = LastBlock::Other; 1456 - } 1457 - MdEvent::Text(text) => { 1458 - push_text_event( 1459 - &mut spans, 1460 - &mut code_buf, 1461 - text.as_ref(), 1462 - in_code, 1463 - theme_colors, 1464 - blockquote_depth, 1465 - inline, 1466 - ); 1467 - } 1468 - MdEvent::SoftBreak | MdEvent::HardBreak => { 1469 - end_line_break( 1470 - &mut lines, 1471 - &mut spans, 1472 - in_code, 1473 - blockquote_depth, 1474 - &list_stack, 1475 - &mut item_stack, 1476 - render_width, 1477 - ); 1478 - } 1479 - _ => {} 1480 - } 1481 - } 1482 - 1483 - if !spans.is_empty() { 1484 - lines.push(Line::from(spans)); 1485 - } 1486 - for _ in 0..5 { 1487 - lines.push(Line::from("")); 1488 - } 1489 - (lines, normalize_toc(toc)) 1490 - }
+905
src/markdown/mod.rs
··· 1 + mod tables; 2 + pub(crate) mod toc; 3 + pub(crate) mod width; 4 + mod wrapping; 5 + 6 + use tables::{handle_table_event, start_table, TableBuf}; 7 + pub(crate) use width::{build_plain_lines, display_width, line_plain_text, truncate_display_width}; 8 + 9 + use crate::theme::{app_theme, MarkdownTheme}; 10 + use pulldown_cmark::{CodeBlockKind, Event as MdEvent, HeadingLevel, Options, Parser, Tag, TagEnd}; 11 + use ratatui::{ 12 + style::{Color, Modifier, Style}, 13 + text::{Line, Span}, 14 + }; 15 + use std::{ 16 + hash::{Hash, Hasher}, 17 + io, 18 + path::PathBuf, 19 + }; 20 + use syntect::{ 21 + easy::HighlightLines, highlighting::Theme, parsing::SyntaxSet, util::LinesWithEndings, 22 + }; 23 + use toc::{normalize_toc, TocEntry}; 24 + use unicode_width::UnicodeWidthStr; 25 + use width::expand_tabs; 26 + use wrapping::push_wrapped_prefixed_lines; 27 + 28 + #[derive(Clone, Copy)] 29 + enum ListKind { 30 + Unordered, 31 + Ordered(u64), 32 + } 33 + 34 + #[derive(Clone, Copy, PartialEq, Eq)] 35 + enum LastBlock { 36 + Other, 37 + Paragraph, 38 + } 39 + 40 + struct ItemState { 41 + marker_emitted: bool, 42 + continuation_indent: usize, 43 + } 44 + 45 + #[derive(Clone, Copy, Default)] 46 + struct InlineStyleState { 47 + in_strong: bool, 48 + in_em: bool, 49 + in_strike: bool, 50 + in_link: bool, 51 + } 52 + 53 + struct CodeBlockRenderContext<'a> { 54 + ss: &'a SyntaxSet, 55 + theme: &'a Theme, 56 + render_width: usize, 57 + theme_colors: &'a MarkdownTheme, 58 + blockquote_depth: usize, 59 + list_stack: &'a [ListKind], 60 + } 61 + 62 + pub(crate) fn hash_str(text: &str) -> u64 { 63 + let mut hasher = std::collections::hash_map::DefaultHasher::new(); 64 + text.hash(&mut hasher); 65 + hasher.finish() 66 + } 67 + 68 + pub(crate) fn read_file_state(path: &PathBuf) -> Option<crate::app::FileState> { 69 + let metadata = std::fs::metadata(path).ok()?; 70 + Some(crate::app::FileState { 71 + modified: metadata.modified().ok()?, 72 + len: metadata.len(), 73 + }) 74 + } 75 + 76 + pub(crate) fn hash_file_contents(path: &PathBuf) -> io::Result<u64> { 77 + std::fs::read_to_string(path).map(|contents| hash_str(&contents)) 78 + } 79 + 80 + pub(crate) fn highlight_line<'a>(line: &Line<'a>) -> Line<'a> { 81 + let theme = &app_theme().markdown; 82 + Line::from( 83 + line.spans 84 + .iter() 85 + .map(|span| { 86 + Span::styled( 87 + span.content.clone(), 88 + span.style.bg(theme.search_highlight_bg), 89 + ) 90 + }) 91 + .collect::<Vec<_>>(), 92 + ) 93 + } 94 + 95 + const DEFAULT_RENDER_WIDTH: usize = 80; 96 + 97 + fn strip_frontmatter(src: &str) -> &str { 98 + let Some(rest) = src.strip_prefix("---\n") else { 99 + return src; 100 + }; 101 + 102 + let mut offset = 4usize; 103 + for line in rest.split_inclusive('\n') { 104 + if line == "---\n" || line == "...\n" || line == "---" || line == "..." { 105 + return &src[offset + line.len()..]; 106 + } 107 + offset += line.len(); 108 + } 109 + 110 + src 111 + } 112 + 113 + fn syntect_to_color(c: syntect::highlighting::Color) -> Color { 114 + Color::Rgb(c.r, c.g, c.b) 115 + } 116 + 117 + pub(crate) fn resolve_syntax<'a>( 118 + lang: &str, 119 + ss: &'a SyntaxSet, 120 + ) -> &'a syntect::parsing::SyntaxReference { 121 + let raw = lang.trim(); 122 + let normalized = raw 123 + .split(|c: char| c.is_whitespace() || c == ',' || c == '{') 124 + .next() 125 + .unwrap_or("") 126 + .trim() 127 + .to_ascii_lowercase(); 128 + 129 + let aliases: &[&str] = match normalized.as_str() { 130 + "ts" | "typescript" => &[ 131 + "JavaScript", 132 + "js", 133 + "javascript", 134 + "TypeScript", 135 + "ts", 136 + "typescript", 137 + ], 138 + "tsx" => &["JSX", "jsx", "JavaScript", "js", "typescriptreact", "tsx"], 139 + "js" | "javascript" => &["JavaScript", "js", "javascript"], 140 + "jsx" => &["JSX", "jsx", "JavaScript React"], 141 + "shell" | "bash" | "sh" | "zsh" => &["Bourne Again Shell (bash)", "bash", "sh"], 142 + "py" | "python" => &["Python", "py", "python"], 143 + "c" => &["C", "c"], 144 + "cpp" | "cxx" | "cc" | "c++" => &["C++", "cpp", "cxx", "cc"], 145 + "json" => &["JSON", "json"], 146 + "toml" => &["TOML", "toml"], 147 + "java" => &["Java", "java"], 148 + "kt" | "kotlin" => &["Kotlin", "kt", "kotlin"], 149 + "ps1" | "powershell" | "pwsh" => &["PowerShell", "ps1", "powershell"], 150 + "docker" | "dockerfile" => &["Dockerfile", "dockerfile"], 151 + "yml" | "yaml" => &["YAML", "yml", "yaml"], 152 + "rs" | "rust" => &["Rust", "rs", "rust"], 153 + _ if normalized.is_empty() => &[], 154 + _ => &[], 155 + }; 156 + 157 + ss.find_syntax_by_token(raw) 158 + .or_else(|| ss.find_syntax_by_extension(raw)) 159 + .or_else(|| ss.find_syntax_by_token(&normalized)) 160 + .or_else(|| ss.find_syntax_by_extension(&normalized)) 161 + .or_else(|| { 162 + aliases.iter().find_map(|alias| { 163 + ss.find_syntax_by_token(alias) 164 + .or_else(|| ss.find_syntax_by_extension(alias)) 165 + .or_else(|| ss.find_syntax_by_name(alias)) 166 + }) 167 + }) 168 + .unwrap_or_else(|| ss.find_syntax_plain_text()) 169 + } 170 + 171 + fn highlight_code( 172 + code: &str, 173 + lang: &str, 174 + ss: &SyntaxSet, 175 + theme: &Theme, 176 + render_width: usize, 177 + ) -> (Vec<Line<'static>>, usize) { 178 + let theme_colors = &app_theme().markdown; 179 + let syntax = resolve_syntax(lang, ss); 180 + let mut hl = HighlightLines::new(syntax, theme); 181 + let gutter = Style::default().fg(theme_colors.code_gutter); 182 + 183 + let mut raw: Vec<(Vec<Span<'static>>, usize)> = Vec::new(); 184 + for line_str in LinesWithEndings::from(code) { 185 + let regions = hl.highlight_line(line_str, ss).unwrap_or_default(); 186 + let mut spans = vec![Span::styled("│ ", gutter)]; 187 + let mut text_width: usize = 0; 188 + for (st, text) in &regions { 189 + let t = expand_tabs(text.trim_end_matches('\n'), text_width); 190 + if t.is_empty() { 191 + continue; 192 + } 193 + text_width += display_width(&t); 194 + let mut rs = Style::default().fg(syntect_to_color(st.foreground)); 195 + if st 196 + .font_style 197 + .contains(syntect::highlighting::FontStyle::BOLD) 198 + { 199 + rs = rs.add_modifier(Modifier::BOLD); 200 + } 201 + if st 202 + .font_style 203 + .contains(syntect::highlighting::FontStyle::ITALIC) 204 + { 205 + rs = rs.add_modifier(Modifier::ITALIC); 206 + } 207 + if st 208 + .font_style 209 + .contains(syntect::highlighting::FontStyle::UNDERLINE) 210 + { 211 + rs = rs.add_modifier(Modifier::UNDERLINED); 212 + } 213 + spans.push(Span::styled(t, rs)); 214 + } 215 + raw.push((spans, text_width)); 216 + } 217 + 218 + let label = if lang.is_empty() { "text" } else { lang }; 219 + let max_text = raw.iter().map(|(_, w)| *w).max().unwrap_or(0); 220 + let max_inner_width = render_width 221 + .saturating_sub(4) 222 + .max(UnicodeWidthStr::width(label) + 3); 223 + let min_inner = (UnicodeWidthStr::width(label) + 3) 224 + .max(44) 225 + .min(max_inner_width); 226 + let inner_width = (max_text + 2).max(min_inner); 227 + 228 + let mut out = Vec::new(); 229 + for (mut spans, text_width) in raw { 230 + let pad = inner_width.saturating_sub(text_width + 1); 231 + spans.push(Span::raw(" ".repeat(pad))); 232 + spans.push(Span::styled("│", gutter)); 233 + out.push(Line::from(spans)); 234 + } 235 + (out, inner_width) 236 + } 237 + 238 + fn block_prefix(in_bq: bool) -> Vec<Span<'static>> { 239 + let theme = &app_theme().markdown; 240 + if in_bq { 241 + vec![Span::styled( 242 + "▏ ", 243 + Style::default().fg(theme.blockquote_marker), 244 + )] 245 + } else { 246 + vec![] 247 + } 248 + } 249 + 250 + fn list_item_prefix( 251 + in_bq: bool, 252 + list_stack: &[ListKind], 253 + item_stack: &mut [ItemState], 254 + ) -> Vec<Span<'static>> { 255 + let theme = &app_theme().markdown; 256 + let mut prefix = block_prefix(in_bq); 257 + let Some(item) = item_stack.last_mut() else { 258 + return prefix; 259 + }; 260 + 261 + if item.marker_emitted { 262 + prefix.push(Span::raw(" ".repeat(item.continuation_indent))); 263 + return prefix; 264 + } 265 + 266 + let depth = list_stack.len(); 267 + prefix.push(Span::raw(" ".repeat(depth.saturating_sub(1)))); 268 + 269 + let marker = match list_stack.last().copied().unwrap_or(ListKind::Unordered) { 270 + ListKind::Unordered => match depth { 271 + 1 => "• ".to_string(), 272 + 2 => "◦ ".to_string(), 273 + _ => "▸ ".to_string(), 274 + }, 275 + ListKind::Ordered(n) => format!("{n}. "), 276 + }; 277 + item.continuation_indent = " ".repeat(depth.saturating_sub(1)).len() + display_width(&marker); 278 + item.marker_emitted = true; 279 + 280 + let marker_style = match list_stack.last().copied().unwrap_or(ListKind::Unordered) { 281 + ListKind::Unordered => match depth { 282 + 1 => Style::default().fg(theme.list_level_1), 283 + 2 => Style::default().fg(theme.list_level_2), 284 + _ => Style::default().fg(theme.list_level_3), 285 + }, 286 + ListKind::Ordered(_) => Style::default().fg(theme.ordered_list), 287 + }; 288 + prefix.push(Span::styled(marker, marker_style)); 289 + prefix 290 + } 291 + 292 + fn push_wrapped_blockquote_lines( 293 + lines: &mut Vec<Line<'static>>, 294 + body_spans: &mut Vec<Span<'static>>, 295 + render_width: usize, 296 + ) { 297 + let prefix = block_prefix(true); 298 + push_wrapped_prefixed_lines(lines, body_spans, prefix.clone(), prefix, render_width); 299 + } 300 + 301 + fn flush_wrapped_spans( 302 + lines: &mut Vec<Line<'static>>, 303 + spans: &mut Vec<Span<'static>>, 304 + blockquote_depth: usize, 305 + list_stack: &[ListKind], 306 + item_stack: &mut [ItemState], 307 + render_width: usize, 308 + ) { 309 + if blockquote_depth > 0 && item_stack.is_empty() { 310 + push_wrapped_blockquote_lines(lines, spans, render_width); 311 + } else if !item_stack.is_empty() { 312 + let first_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 313 + let continuation_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 314 + push_wrapped_prefixed_lines( 315 + lines, 316 + spans, 317 + first_prefix, 318 + continuation_prefix, 319 + render_width, 320 + ); 321 + } else if !spans.is_empty() { 322 + let mut all = block_prefix(false); 323 + all.append(spans); 324 + lines.push(Line::from(all)); 325 + } 326 + } 327 + 328 + fn trim_paragraph_gap_before_block(lines: &mut Vec<Line<'static>>, last_block: LastBlock) { 329 + if last_block == LastBlock::Paragraph 330 + && lines 331 + .last() 332 + .is_some_and(|line| line_plain_text(line).is_empty()) 333 + { 334 + lines.pop(); 335 + } 336 + } 337 + 338 + fn push_heading_lines( 339 + lines: &mut Vec<Line<'static>>, 340 + toc: &mut Vec<TocEntry>, 341 + spans: &mut Vec<Span<'static>>, 342 + level: u8, 343 + render_width: usize, 344 + theme: &MarkdownTheme, 345 + ) { 346 + let color: Color = match level { 347 + 1 => theme.heading_1, 348 + 2 => theme.heading_2, 349 + 3 => theme.heading_3, 350 + _ => theme.heading_other, 351 + }; 352 + let style = Style::default().fg(color).add_modifier(match level { 353 + 1..=3 => Modifier::BOLD, 354 + _ => Modifier::empty(), 355 + }); 356 + let title: String = spans.iter().map(|s| s.content.as_ref()).collect(); 357 + let rendered_title = if level == 3 { 358 + format!("{title} ") 359 + } else { 360 + title.clone() 361 + }; 362 + toc.push(TocEntry { 363 + level, 364 + title: title.clone(), 365 + line: lines.len(), 366 + }); 367 + spans.clear(); 368 + lines.push(Line::from(vec![Span::styled(rendered_title, style)])); 369 + 370 + match level { 371 + 1 => lines.push(Line::from(Span::styled( 372 + "═".repeat(display_width(&title).min(rule_width(render_width, 0))), 373 + Style::default().fg(theme.heading_underline), 374 + ))), 375 + 2 => lines.push(Line::from(Span::styled( 376 + "─".repeat(display_width(&title).min(rule_width(render_width, 0))), 377 + Style::default().fg(theme.heading_underline), 378 + ))), 379 + _ => {} 380 + } 381 + } 382 + 383 + fn push_code_block_lines( 384 + lines: &mut Vec<Line<'static>>, 385 + code_buf: &mut String, 386 + code_lang: &mut String, 387 + ctx: CodeBlockRenderContext<'_>, 388 + item_stack: &mut [ItemState], 389 + ) { 390 + let prefix = if !item_stack.is_empty() { 391 + list_item_prefix(ctx.blockquote_depth > 0, ctx.list_stack, item_stack) 392 + } else if ctx.blockquote_depth > 0 { 393 + block_prefix(true) 394 + } else { 395 + Vec::new() 396 + }; 397 + let prefix_width: usize = prefix 398 + .iter() 399 + .map(|span| display_width(span.content.as_ref())) 400 + .sum(); 401 + let label = if code_lang.is_empty() { 402 + "text".to_string() 403 + } else { 404 + code_lang.clone() 405 + }; 406 + let available_width = ctx.render_width.saturating_sub(prefix_width); 407 + let (code_lines, inner_width) = 408 + highlight_code(code_buf, code_lang, ctx.ss, ctx.theme, available_width); 409 + let header_width = UnicodeWidthStr::width(label.as_str()) + 3; 410 + let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 411 + let mut header = prefix.clone(); 412 + header.extend([ 413 + Span::styled( 414 + "┌─ ".to_string(), 415 + Style::default().fg(ctx.theme_colors.code_frame), 416 + ), 417 + Span::styled( 418 + format!("{label} "), 419 + Style::default().fg(ctx.theme_colors.code_label), 420 + ), 421 + Span::styled( 422 + format!("{top_bar}┐"), 423 + Style::default().fg(ctx.theme_colors.code_frame), 424 + ), 425 + ]); 426 + lines.push(Line::from(header)); 427 + lines.extend(code_lines.into_iter().map(|line| { 428 + let mut spans = prefix.clone(); 429 + spans.extend(line.spans); 430 + Line::from(spans) 431 + })); 432 + let mut footer = prefix; 433 + footer.push(Span::styled( 434 + format!("└{}┘", "─".repeat(inner_width)), 435 + Style::default().fg(ctx.theme_colors.code_frame), 436 + )); 437 + lines.push(Line::from(footer)); 438 + lines.push(Line::from("")); 439 + code_lang.clear(); 440 + code_buf.clear(); 441 + } 442 + 443 + fn inline_text_style( 444 + theme: &MarkdownTheme, 445 + blockquote_depth: usize, 446 + inline: InlineStyleState, 447 + ) -> Style { 448 + let mut style = if blockquote_depth > 0 { 449 + Style::default() 450 + .fg(theme.blockquote_text) 451 + .add_modifier(Modifier::ITALIC) 452 + } else if inline.in_link { 453 + Style::default().fg(theme.link_text) 454 + } else { 455 + Style::default().fg(theme.text) 456 + }; 457 + 458 + if inline.in_strong { 459 + style = style.fg(theme.strong_text).add_modifier(Modifier::BOLD); 460 + } 461 + if inline.in_em { 462 + style = style.add_modifier(Modifier::ITALIC); 463 + } 464 + if inline.in_strike { 465 + style = style.add_modifier(Modifier::CROSSED_OUT); 466 + } 467 + 468 + style 469 + } 470 + 471 + fn flush_list_item_spans( 472 + lines: &mut Vec<Line<'static>>, 473 + spans: &mut Vec<Span<'static>>, 474 + list_stack: &[ListKind], 475 + item_stack: &mut [ItemState], 476 + blockquote_depth: usize, 477 + render_width: usize, 478 + ) { 479 + if spans.is_empty() { 480 + return; 481 + } 482 + 483 + let first_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 484 + let continuation_prefix = list_item_prefix(blockquote_depth > 0, list_stack, item_stack); 485 + push_wrapped_prefixed_lines( 486 + lines, 487 + spans, 488 + first_prefix, 489 + continuation_prefix, 490 + render_width, 491 + ); 492 + } 493 + 494 + fn start_list( 495 + lines: &mut Vec<Line<'static>>, 496 + last_block: LastBlock, 497 + list_stack: &mut Vec<ListKind>, 498 + start: Option<u64>, 499 + ) { 500 + trim_paragraph_gap_before_block(lines, last_block); 501 + list_stack.push(match start { 502 + Some(n) => ListKind::Ordered(n), 503 + None => ListKind::Unordered, 504 + }); 505 + } 506 + 507 + fn end_list(lines: &mut Vec<Line<'static>>, list_stack: &mut Vec<ListKind>) { 508 + list_stack.pop(); 509 + if list_stack.is_empty() { 510 + lines.push(Line::from("")); 511 + } 512 + } 513 + 514 + fn start_item(item_stack: &mut Vec<ItemState>) { 515 + item_stack.push(ItemState { 516 + marker_emitted: false, 517 + continuation_indent: 0, 518 + }); 519 + } 520 + 521 + fn end_item( 522 + lines: &mut Vec<Line<'static>>, 523 + spans: &mut Vec<Span<'static>>, 524 + list_stack: &mut [ListKind], 525 + item_stack: &mut Vec<ItemState>, 526 + blockquote_depth: usize, 527 + render_width: usize, 528 + ) { 529 + flush_list_item_spans( 530 + lines, 531 + spans, 532 + list_stack, 533 + item_stack, 534 + blockquote_depth, 535 + render_width, 536 + ); 537 + item_stack.pop(); 538 + if let Some(ListKind::Ordered(next)) = list_stack.last_mut() { 539 + *next += 1; 540 + } 541 + } 542 + 543 + fn end_blockquote( 544 + lines: &mut Vec<Line<'static>>, 545 + spans: &mut Vec<Span<'static>>, 546 + blockquote_depth: &mut usize, 547 + theme: &MarkdownTheme, 548 + ) { 549 + if !spans.is_empty() { 550 + let mut all = vec![Span::styled( 551 + "▏ ", 552 + Style::default().fg(theme.blockquote_marker), 553 + )]; 554 + all.append(spans); 555 + lines.push(Line::from(all)); 556 + } 557 + *blockquote_depth = blockquote_depth.saturating_sub(1); 558 + lines.push(Line::from("")); 559 + } 560 + 561 + fn push_rule_line(lines: &mut Vec<Line<'static>>, render_width: usize, theme: &MarkdownTheme) { 562 + lines.push(Line::from(Span::styled( 563 + "─".repeat(rule_width(render_width, 0)), 564 + Style::default().fg(theme.rule), 565 + ))); 566 + lines.push(Line::from("")); 567 + } 568 + 569 + fn push_inline_code_span(spans: &mut Vec<Span<'static>>, text: &str, theme: &MarkdownTheme) { 570 + spans.push(Span::styled( 571 + format!(" {} ", text), 572 + Style::default() 573 + .fg(theme.inline_code_fg) 574 + .bg(theme.inline_code_bg), 575 + )); 576 + } 577 + 578 + fn push_link_marker(spans: &mut Vec<Span<'static>>, theme: &MarkdownTheme) { 579 + spans.push(Span::styled("⌗", Style::default().fg(theme.link_icon))); 580 + } 581 + 582 + fn handle_inline_style_event( 583 + ev: &MdEvent<'_>, 584 + inline: &mut InlineStyleState, 585 + spans: &mut Vec<Span<'static>>, 586 + theme: &MarkdownTheme, 587 + ) -> bool { 588 + match ev { 589 + MdEvent::Start(Tag::Strong) => { 590 + inline.in_strong = true; 591 + true 592 + } 593 + MdEvent::End(TagEnd::Strong) => { 594 + inline.in_strong = false; 595 + true 596 + } 597 + MdEvent::Start(Tag::Emphasis) => { 598 + inline.in_em = true; 599 + true 600 + } 601 + MdEvent::End(TagEnd::Emphasis) => { 602 + inline.in_em = false; 603 + true 604 + } 605 + MdEvent::Start(Tag::Strikethrough) => { 606 + inline.in_strike = true; 607 + true 608 + } 609 + MdEvent::End(TagEnd::Strikethrough) => { 610 + inline.in_strike = false; 611 + true 612 + } 613 + MdEvent::Start(Tag::Link { .. }) => { 614 + inline.in_link = true; 615 + push_link_marker(spans, theme); 616 + true 617 + } 618 + MdEvent::End(TagEnd::Link) => { 619 + inline.in_link = false; 620 + true 621 + } 622 + _ => false, 623 + } 624 + } 625 + 626 + fn heading_level(level: HeadingLevel) -> u8 { 627 + match level { 628 + HeadingLevel::H1 => 1, 629 + HeadingLevel::H2 => 2, 630 + HeadingLevel::H3 => 3, 631 + _ => 4, 632 + } 633 + } 634 + 635 + fn start_heading(in_heading: &mut Option<u8>, level: HeadingLevel) { 636 + *in_heading = Some(heading_level(level)); 637 + } 638 + 639 + fn end_heading( 640 + lines: &mut Vec<Line<'static>>, 641 + toc: &mut Vec<TocEntry>, 642 + spans: &mut Vec<Span<'static>>, 643 + in_heading: &mut Option<u8>, 644 + render_width: usize, 645 + theme: &MarkdownTheme, 646 + ) { 647 + push_heading_lines( 648 + lines, 649 + toc, 650 + spans, 651 + in_heading.unwrap_or(1), 652 + render_width, 653 + theme, 654 + ); 655 + *in_heading = None; 656 + } 657 + 658 + fn start_code_block( 659 + lines: &mut Vec<Line<'static>>, 660 + last_block: LastBlock, 661 + in_code: &mut bool, 662 + code_buf: &mut String, 663 + code_lang: &mut String, 664 + kind: &CodeBlockKind<'_>, 665 + ) { 666 + trim_paragraph_gap_before_block(lines, last_block); 667 + *in_code = true; 668 + code_buf.clear(); 669 + *code_lang = match kind { 670 + CodeBlockKind::Fenced(lang) => lang.to_string(), 671 + CodeBlockKind::Indented => String::new(), 672 + }; 673 + } 674 + 675 + fn end_line_break( 676 + lines: &mut Vec<Line<'static>>, 677 + spans: &mut Vec<Span<'static>>, 678 + in_code: bool, 679 + blockquote_depth: usize, 680 + list_stack: &[ListKind], 681 + item_stack: &mut [ItemState], 682 + render_width: usize, 683 + ) { 684 + if !in_code { 685 + flush_wrapped_spans( 686 + lines, 687 + spans, 688 + blockquote_depth, 689 + list_stack, 690 + item_stack, 691 + render_width, 692 + ); 693 + } 694 + } 695 + 696 + fn end_paragraph( 697 + lines: &mut Vec<Line<'static>>, 698 + spans: &mut Vec<Span<'static>>, 699 + blockquote_depth: usize, 700 + list_stack: &[ListKind], 701 + item_stack: &mut [ItemState], 702 + render_width: usize, 703 + ) { 704 + flush_wrapped_spans( 705 + lines, 706 + spans, 707 + blockquote_depth, 708 + list_stack, 709 + item_stack, 710 + render_width, 711 + ); 712 + lines.push(Line::from("")); 713 + } 714 + 715 + fn push_text_event( 716 + spans: &mut Vec<Span<'static>>, 717 + code_buf: &mut String, 718 + text: &str, 719 + in_code: bool, 720 + theme: &MarkdownTheme, 721 + blockquote_depth: usize, 722 + inline: InlineStyleState, 723 + ) { 724 + if in_code { 725 + code_buf.push_str(text); 726 + } else { 727 + spans.push(Span::styled( 728 + text.to_string(), 729 + inline_text_style(theme, blockquote_depth, inline), 730 + )); 731 + } 732 + } 733 + 734 + pub(crate) fn parse_markdown( 735 + src: &str, 736 + ss: &SyntaxSet, 737 + theme: &Theme, 738 + ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 739 + parse_markdown_with_width(src, ss, theme, DEFAULT_RENDER_WIDTH) 740 + } 741 + 742 + fn rule_width(render_width: usize, indent: usize) -> usize { 743 + render_width.saturating_sub(indent).max(8) 744 + } 745 + 746 + pub(crate) fn parse_markdown_with_width( 747 + src: &str, 748 + ss: &SyntaxSet, 749 + theme: &Theme, 750 + render_width: usize, 751 + ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 752 + let theme_colors = &app_theme().markdown; 753 + let src = strip_frontmatter(src); 754 + let mut lines: Vec<Line<'static>> = Vec::new(); 755 + let mut toc: Vec<TocEntry> = Vec::new(); 756 + 757 + let mut spans: Vec<Span<'static>> = Vec::new(); 758 + let mut in_heading: Option<u8> = None; 759 + let mut in_code = false; 760 + let mut code_lang = String::new(); 761 + let mut code_buf = String::new(); 762 + let mut blockquote_depth = 0usize; 763 + let mut inline = InlineStyleState::default(); 764 + let mut list_stack: Vec<ListKind> = Vec::new(); 765 + let mut item_stack: Vec<ItemState> = Vec::new(); 766 + let mut table: Option<TableBuf> = None; 767 + let mut last_block = LastBlock::Other; 768 + 769 + for ev in Parser::new_ext(src, Options::all()) { 770 + if table.is_some() && handle_table_event(&mut table, &ev, &mut lines, render_width) { 771 + continue; 772 + } 773 + if handle_inline_style_event(&ev, &mut inline, &mut spans, theme_colors) { 774 + continue; 775 + } 776 + 777 + match ev { 778 + MdEvent::Start(Tag::Table(aligns)) => { 779 + start_table(&mut table, &aligns); 780 + } 781 + MdEvent::Start(Tag::Heading { level, .. }) => { 782 + start_heading(&mut in_heading, level); 783 + } 784 + MdEvent::End(TagEnd::Heading(_)) => { 785 + end_heading( 786 + &mut lines, 787 + &mut toc, 788 + &mut spans, 789 + &mut in_heading, 790 + render_width, 791 + theme_colors, 792 + ); 793 + last_block = LastBlock::Other; 794 + } 795 + MdEvent::Start(Tag::Paragraph) => {} 796 + MdEvent::End(TagEnd::Paragraph) => { 797 + end_paragraph( 798 + &mut lines, 799 + &mut spans, 800 + blockquote_depth, 801 + &list_stack, 802 + &mut item_stack, 803 + render_width, 804 + ); 805 + last_block = LastBlock::Paragraph; 806 + } 807 + MdEvent::Start(Tag::CodeBlock(kind)) => { 808 + start_code_block( 809 + &mut lines, 810 + last_block, 811 + &mut in_code, 812 + &mut code_buf, 813 + &mut code_lang, 814 + &kind, 815 + ); 816 + last_block = LastBlock::Other; 817 + } 818 + MdEvent::End(TagEnd::CodeBlock) => { 819 + in_code = false; 820 + push_code_block_lines( 821 + &mut lines, 822 + &mut code_buf, 823 + &mut code_lang, 824 + CodeBlockRenderContext { 825 + ss, 826 + theme, 827 + render_width, 828 + theme_colors, 829 + blockquote_depth, 830 + list_stack: &list_stack, 831 + }, 832 + &mut item_stack, 833 + ); 834 + last_block = LastBlock::Other; 835 + } 836 + MdEvent::Code(text) => { 837 + push_inline_code_span(&mut spans, text.as_ref(), theme_colors); 838 + } 839 + MdEvent::Start(Tag::BlockQuote(_)) => { 840 + blockquote_depth += 1; 841 + } 842 + MdEvent::End(TagEnd::BlockQuote(_)) => { 843 + end_blockquote(&mut lines, &mut spans, &mut blockquote_depth, theme_colors); 844 + last_block = LastBlock::Other; 845 + } 846 + MdEvent::Start(Tag::List(start)) => { 847 + start_list(&mut lines, last_block, &mut list_stack, start); 848 + last_block = LastBlock::Other; 849 + } 850 + MdEvent::End(TagEnd::List(_)) => { 851 + end_list(&mut lines, &mut list_stack); 852 + last_block = LastBlock::Other; 853 + } 854 + MdEvent::Start(Tag::Item) => { 855 + start_item(&mut item_stack); 856 + } 857 + MdEvent::End(TagEnd::Item) => { 858 + end_item( 859 + &mut lines, 860 + &mut spans, 861 + &mut list_stack, 862 + &mut item_stack, 863 + blockquote_depth, 864 + render_width, 865 + ); 866 + last_block = LastBlock::Other; 867 + } 868 + MdEvent::Rule => { 869 + push_rule_line(&mut lines, render_width, theme_colors); 870 + last_block = LastBlock::Other; 871 + } 872 + MdEvent::Text(text) => { 873 + push_text_event( 874 + &mut spans, 875 + &mut code_buf, 876 + text.as_ref(), 877 + in_code, 878 + theme_colors, 879 + blockquote_depth, 880 + inline, 881 + ); 882 + } 883 + MdEvent::SoftBreak | MdEvent::HardBreak => { 884 + end_line_break( 885 + &mut lines, 886 + &mut spans, 887 + in_code, 888 + blockquote_depth, 889 + &list_stack, 890 + &mut item_stack, 891 + render_width, 892 + ); 893 + } 894 + _ => {} 895 + } 896 + } 897 + 898 + if !spans.is_empty() { 899 + lines.push(Line::from(spans)); 900 + } 901 + for _ in 0..5 { 902 + lines.push(Line::from("")); 903 + } 904 + (lines, normalize_toc(toc)) 905 + }
+379
src/markdown/tables.rs
··· 1 + use crate::theme::app_theme; 2 + use pulldown_cmark::{Alignment, Event as MdEvent, Tag, TagEnd}; 3 + use ratatui::{ 4 + style::{Modifier, Style}, 5 + text::{Line, Span}, 6 + }; 7 + use unicode_width::UnicodeWidthChar; 8 + 9 + use super::width::{display_width, expand_tabs}; 10 + 11 + pub(super) struct TableBuf { 12 + pub(super) alignments: Vec<Alignment>, 13 + rows: Vec<Vec<String>>, 14 + header_count: usize, 15 + current_row: Vec<String>, 16 + current_cell: String, 17 + pub(super) in_header: bool, 18 + } 19 + 20 + struct TableBorder<'a> { 21 + left: &'a str, 22 + fill: &'a str, 23 + cross: &'a str, 24 + right: &'a str, 25 + } 26 + 27 + pub(super) fn handle_table_event( 28 + table: &mut Option<TableBuf>, 29 + ev: &MdEvent<'_>, 30 + lines: &mut Vec<Line<'static>>, 31 + render_width: usize, 32 + ) -> bool { 33 + let Some(tb) = table.as_mut() else { 34 + return false; 35 + }; 36 + 37 + match ev { 38 + MdEvent::Text(t) | MdEvent::Code(t) => { 39 + tb.push_text(t.as_ref()); 40 + true 41 + } 42 + MdEvent::Start(Tag::TableCell) => true, 43 + MdEvent::End(TagEnd::TableCell) => { 44 + tb.end_cell(); 45 + true 46 + } 47 + MdEvent::Start(Tag::TableRow) => true, 48 + MdEvent::End(TagEnd::TableRow) => { 49 + tb.end_row(); 50 + true 51 + } 52 + MdEvent::Start(Tag::TableHead) => { 53 + tb.in_header = true; 54 + true 55 + } 56 + MdEvent::End(TagEnd::TableHead) => { 57 + tb.end_header(); 58 + true 59 + } 60 + MdEvent::Start(Tag::Strong) 61 + | MdEvent::End(TagEnd::Strong) 62 + | MdEvent::Start(Tag::Emphasis) 63 + | MdEvent::End(TagEnd::Emphasis) 64 + | MdEvent::Start(Tag::Link { .. }) 65 + | MdEvent::End(TagEnd::Link) => true, 66 + MdEvent::End(TagEnd::Table) => { 67 + let rendered = tb.render(render_width); 68 + lines.extend(rendered); 69 + *table = None; 70 + true 71 + } 72 + _ => true, 73 + } 74 + } 75 + 76 + pub(super) fn start_table(table: &mut Option<TableBuf>, aligns: &[Alignment]) { 77 + *table = Some(TableBuf::new(aligns.to_vec())); 78 + } 79 + 80 + impl TableBuf { 81 + fn new(alignments: Vec<Alignment>) -> Self { 82 + Self { 83 + alignments, 84 + rows: vec![], 85 + header_count: 0, 86 + current_row: vec![], 87 + current_cell: String::new(), 88 + in_header: false, 89 + } 90 + } 91 + fn push_text(&mut self, t: &str) { 92 + self.current_cell.push_str(t); 93 + } 94 + fn end_cell(&mut self) { 95 + let cell = std::mem::take(&mut self.current_cell).trim().to_string(); 96 + self.current_row.push(cell); 97 + } 98 + fn end_row(&mut self) { 99 + let row = std::mem::take(&mut self.current_row); 100 + if !row.is_empty() { 101 + self.rows.push(row); 102 + } 103 + } 104 + fn end_header(&mut self) { 105 + self.end_row(); 106 + self.header_count = self.rows.len(); 107 + self.in_header = false; 108 + } 109 + 110 + fn render(&self, render_width: usize) -> Vec<Line<'static>> { 111 + let theme = &app_theme().markdown; 112 + if self.rows.is_empty() { 113 + return vec![]; 114 + } 115 + let col_count = self.rows.iter().map(|r| r.len()).max().unwrap_or(0); 116 + if col_count == 0 { 117 + return vec![]; 118 + } 119 + 120 + let mut col_widths: Vec<usize> = vec![1; col_count]; 121 + let mut min_widths: Vec<usize> = vec![4; col_count]; 122 + for row in &self.rows { 123 + for (ci, cell) in row.iter().enumerate() { 124 + if ci < col_count { 125 + col_widths[ci] = col_widths[ci].max(display_width(cell)); 126 + min_widths[ci] = min_widths[ci].max(min_table_cell_width(cell)); 127 + } 128 + } 129 + } 130 + 131 + fit_table_widths(&mut col_widths, &min_widths, render_width); 132 + 133 + let border = Style::default().fg(theme.table_border); 134 + let sep = Style::default().fg(theme.table_separator); 135 + let header = Style::default() 136 + .fg(theme.table_header) 137 + .add_modifier(Modifier::BOLD); 138 + let cell = Style::default().fg(theme.table_cell); 139 + let ind = ""; 140 + 141 + let mut out: Vec<Line<'static>> = Vec::new(); 142 + out.push(self.hline( 143 + ind, 144 + TableBorder { 145 + left: "┌", 146 + fill: "─", 147 + cross: "┬", 148 + right: "┐", 149 + }, 150 + &col_widths, 151 + border, 152 + )); 153 + 154 + for (ri, row) in self.rows.iter().enumerate() { 155 + let is_hdr = ri < self.header_count; 156 + let wrapped_cells: Vec<Vec<String>> = col_widths 157 + .iter() 158 + .copied() 159 + .enumerate() 160 + .take(col_count) 161 + .map(|(ci, width)| { 162 + wrap_table_cell(row.get(ci).map(|s| s.as_str()).unwrap_or(""), width) 163 + }) 164 + .collect(); 165 + let row_height = wrapped_cells 166 + .iter() 167 + .map(|lines| lines.len()) 168 + .max() 169 + .unwrap_or(1); 170 + 171 + for line_idx in 0..row_height { 172 + let mut spans = vec![Span::raw(ind), Span::styled("│", border)]; 173 + for (ci, width) in col_widths.iter().copied().enumerate().take(col_count) { 174 + let txt = wrapped_cells[ci] 175 + .get(line_idx) 176 + .map(|s| s.as_str()) 177 + .unwrap_or(""); 178 + let align = self.alignments.get(ci).copied().unwrap_or(Alignment::None); 179 + let pad = align_cell(txt, width, align); 180 + let st = if is_hdr { header } else { cell }; 181 + spans.push(Span::raw(" ")); 182 + spans.push(Span::styled(pad, st)); 183 + spans.push(Span::raw(" ")); 184 + spans.push(Span::styled("│", border)); 185 + } 186 + out.push(Line::from(spans)); 187 + } 188 + 189 + if is_hdr && ri == self.header_count - 1 { 190 + out.push(self.hline( 191 + ind, 192 + TableBorder { 193 + left: "╞", 194 + fill: "═", 195 + cross: "╪", 196 + right: "╡", 197 + }, 198 + &col_widths, 199 + sep, 200 + )); 201 + } else if !is_hdr && ri < self.rows.len() - 1 { 202 + out.push(self.hline( 203 + ind, 204 + TableBorder { 205 + left: "├", 206 + fill: "─", 207 + cross: "┼", 208 + right: "┤", 209 + }, 210 + &col_widths, 211 + border, 212 + )); 213 + } 214 + } 215 + 216 + out.push(self.hline( 217 + ind, 218 + TableBorder { 219 + left: "└", 220 + fill: "─", 221 + cross: "┴", 222 + right: "┘", 223 + }, 224 + &col_widths, 225 + border, 226 + )); 227 + out.push(Line::from("")); 228 + out 229 + } 230 + 231 + fn hline( 232 + &self, 233 + indent: &str, 234 + border: TableBorder<'_>, 235 + col_widths: &[usize], 236 + style: Style, 237 + ) -> Line<'static> { 238 + let mut spans = vec![ 239 + Span::raw(indent.to_string()), 240 + Span::styled(border.left.to_string(), style), 241 + ]; 242 + for (ci, &w) in col_widths.iter().enumerate() { 243 + spans.push(Span::styled(border.fill.repeat(w + 2), style)); 244 + if ci < col_widths.len() - 1 { 245 + spans.push(Span::styled(border.cross.to_string(), style)); 246 + } 247 + } 248 + spans.push(Span::styled(border.right.to_string(), style)); 249 + Line::from(spans) 250 + } 251 + } 252 + 253 + fn min_table_cell_width(text: &str) -> usize { 254 + let max_word = text 255 + .split_whitespace() 256 + .map(display_width) 257 + .max() 258 + .unwrap_or(0) 259 + .min(12); 260 + max_word.max(4) 261 + } 262 + 263 + fn fit_table_widths(col_widths: &mut [usize], min_widths: &[usize], render_width: usize) { 264 + if col_widths.is_empty() { 265 + return; 266 + } 267 + 268 + let col_count = col_widths.len(); 269 + let border_width = 3 * col_count + 1; 270 + let available = render_width.saturating_sub(border_width).max(col_count); 271 + let min_total: usize = min_widths.iter().sum(); 272 + 273 + if min_total >= available { 274 + let mut widths = vec![1; col_count]; 275 + let mut remaining = available.saturating_sub(col_count); 276 + let mut order: Vec<usize> = (0..col_count).collect(); 277 + order.sort_by_key(|&idx| std::cmp::Reverse(min_widths[idx])); 278 + for idx in order { 279 + if remaining == 0 { 280 + break; 281 + } 282 + let extra = (min_widths[idx].saturating_sub(1)).min(remaining); 283 + widths[idx] += extra; 284 + remaining -= extra; 285 + } 286 + col_widths.copy_from_slice(&widths); 287 + return; 288 + } 289 + 290 + while col_widths.iter().sum::<usize>() > available { 291 + let Some((idx, _)) = col_widths 292 + .iter() 293 + .enumerate() 294 + .filter(|(idx, width)| **width > min_widths[*idx]) 295 + .max_by_key(|(_, width)| **width) 296 + else { 297 + break; 298 + }; 299 + col_widths[idx] -= 1; 300 + } 301 + } 302 + 303 + fn wrap_table_cell(text: &str, width: usize) -> Vec<String> { 304 + if width == 0 { 305 + return vec![String::new()]; 306 + } 307 + let expanded = expand_tabs(text, 0); 308 + if expanded.is_empty() { 309 + return vec![String::new()]; 310 + } 311 + 312 + let mut lines = Vec::new(); 313 + let mut current = String::new(); 314 + let mut current_width = 0usize; 315 + 316 + for word in expanded.split_whitespace() { 317 + let word_width = display_width(word); 318 + 319 + if word_width > width { 320 + if !current.is_empty() { 321 + lines.push(std::mem::take(&mut current)); 322 + current_width = 0; 323 + } 324 + let mut chunk = String::new(); 325 + let mut chunk_width = 0usize; 326 + for ch in word.chars() { 327 + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); 328 + if chunk_width + ch_width > width && !chunk.is_empty() { 329 + lines.push(std::mem::take(&mut chunk)); 330 + chunk_width = 0; 331 + } 332 + chunk.push(ch); 333 + chunk_width += ch_width; 334 + } 335 + if !chunk.is_empty() { 336 + current = chunk; 337 + current_width = chunk_width; 338 + } 339 + continue; 340 + } 341 + 342 + let sep = if current.is_empty() { 0 } else { 1 }; 343 + if current_width + sep + word_width > width && !current.is_empty() { 344 + lines.push(std::mem::take(&mut current)); 345 + current_width = 0; 346 + } 347 + if !current.is_empty() { 348 + current.push(' '); 349 + current_width += 1; 350 + } 351 + current.push_str(word); 352 + current_width += word_width; 353 + } 354 + 355 + if !current.is_empty() { 356 + lines.push(current); 357 + } 358 + if lines.is_empty() { 359 + lines.push(String::new()); 360 + } 361 + lines 362 + } 363 + 364 + fn align_cell(text: &str, width: usize, align: Alignment) -> String { 365 + let text = expand_tabs(text, 0); 366 + let len = display_width(&text); 367 + if len >= width { 368 + return text; 369 + } 370 + let pad = width - len; 371 + match align { 372 + Alignment::Right => format!("{}{}", " ".repeat(pad), text), 373 + Alignment::Center => { 374 + let l = pad / 2; 375 + format!("{}{}{}", " ".repeat(l), text, " ".repeat(pad - l)) 376 + } 377 + _ => format!("{}{}", text, " ".repeat(pad)), 378 + } 379 + }
+37
src/markdown/toc.rs
··· 1 + #[derive(Clone)] 2 + pub(crate) struct TocEntry { 3 + pub(crate) level: u8, 4 + pub(crate) title: String, 5 + pub(crate) line: usize, 6 + } 7 + 8 + pub(crate) fn should_hide_single_h1(toc: &[TocEntry]) -> bool { 9 + let h1_count = toc.iter().filter(|entry| entry.level == 1).count(); 10 + let has_h2 = toc.iter().any(|entry| entry.level == 2); 11 + h1_count == 1 && has_h2 12 + } 13 + 14 + pub(crate) fn should_promote_h2_when_no_h1(toc: &[TocEntry]) -> bool { 15 + !toc.iter().any(|entry| entry.level == 1) && toc.iter().any(|entry| entry.level == 2) 16 + } 17 + 18 + pub(crate) fn toc_display_level(level: u8, hide_single_h1: bool, promote_h2_root: bool) -> u8 { 19 + if hide_single_h1 || promote_h2_root { 20 + match level { 21 + 2 => 1, 22 + 3 => 2, 23 + _ => level, 24 + } 25 + } else { 26 + level 27 + } 28 + } 29 + 30 + pub(crate) fn normalize_toc(mut toc: Vec<TocEntry>) -> Vec<TocEntry> { 31 + if should_hide_single_h1(&toc) || should_promote_h2_when_no_h1(&toc) { 32 + toc.retain(|entry| matches!(entry.level, 1..=3)); 33 + } else { 34 + toc.retain(|entry| matches!(entry.level, 1..=2)); 35 + } 36 + toc 37 + }
+66
src/markdown/width.rs
··· 1 + use ratatui::text::Line; 2 + use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; 3 + 4 + pub(super) const TAB_STOP: usize = 4; 5 + 6 + pub(crate) fn line_plain_text(line: &Line<'_>) -> String { 7 + line.spans.iter().map(|s| s.content.as_ref()).collect() 8 + } 9 + 10 + pub(crate) fn build_plain_lines(lines: &[Line<'_>]) -> Vec<String> { 11 + lines.iter().map(line_plain_text).collect() 12 + } 13 + 14 + pub(crate) fn truncate_display_width(text: &str, max_width: usize) -> String { 15 + if display_width(text) <= max_width { 16 + return text.to_string(); 17 + } 18 + if max_width == 0 { 19 + return String::new(); 20 + } 21 + 22 + let mut out = String::new(); 23 + let mut used = 0; 24 + for ch in text.chars() { 25 + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); 26 + if used + ch_w > max_width.saturating_sub(1) { 27 + break; 28 + } 29 + out.push(ch); 30 + used += ch_w; 31 + } 32 + out.push('\u{2026}'); 33 + out 34 + } 35 + 36 + pub(crate) fn display_width(text: &str) -> usize { 37 + let mut width = 0; 38 + let mut parts = text.split('\t').peekable(); 39 + while let Some(segment) = parts.next() { 40 + width += UnicodeWidthStr::width(segment); 41 + if parts.peek().is_some() { 42 + width += TAB_STOP - (width % TAB_STOP); 43 + } 44 + } 45 + width 46 + } 47 + 48 + pub(super) fn expand_tabs(text: &str, start_width: usize) -> String { 49 + if !text.contains('\t') { 50 + return text.to_string(); 51 + } 52 + 53 + let mut out = String::new(); 54 + let mut width = start_width; 55 + let mut parts = text.split('\t').peekable(); 56 + while let Some(segment) = parts.next() { 57 + out.push_str(segment); 58 + width += UnicodeWidthStr::width(segment); 59 + if parts.peek().is_some() { 60 + let spaces = TAB_STOP - (width % TAB_STOP); 61 + out.push_str(&" ".repeat(spaces)); 62 + width += spaces; 63 + } 64 + } 65 + out 66 + }
+160
src/markdown/wrapping.rs
··· 1 + use super::width::display_width; 2 + use ratatui::text::{Line, Span}; 3 + use unicode_width::UnicodeWidthChar; 4 + 5 + pub(super) fn push_wrapped_prefixed_lines( 6 + lines: &mut Vec<Line<'static>>, 7 + body_spans: &mut Vec<Span<'static>>, 8 + first_prefix: Vec<Span<'static>>, 9 + continuation_prefix: Vec<Span<'static>>, 10 + render_width: usize, 11 + ) { 12 + if body_spans.is_empty() { 13 + return; 14 + } 15 + 16 + let first_prefix_width: usize = first_prefix 17 + .iter() 18 + .map(|span| display_width(span.content.as_ref())) 19 + .sum(); 20 + let continuation_prefix_width: usize = continuation_prefix 21 + .iter() 22 + .map(|span| display_width(span.content.as_ref())) 23 + .sum(); 24 + let max_width = render_width 25 + .saturating_sub(first_prefix_width.max(continuation_prefix_width)) 26 + .max(8); 27 + 28 + let mut current_prefix = first_prefix.clone(); 29 + let mut next_prefix = continuation_prefix.clone(); 30 + let mut current_width = 0usize; 31 + let mut body_started = false; 32 + 33 + let push_current = |lines: &mut Vec<Line<'static>>, 34 + current_prefix: &mut Vec<Span<'static>>, 35 + next_prefix: &mut Vec<Span<'static>>, 36 + body_started: &mut bool, 37 + current_width: &mut usize| { 38 + if *body_started { 39 + lines.push(Line::from(std::mem::take(current_prefix))); 40 + *current_prefix = next_prefix.clone(); 41 + *body_started = false; 42 + *current_width = 0; 43 + } 44 + }; 45 + 46 + for span in body_spans.drain(..) { 47 + let style = span.style; 48 + let mut token = String::new(); 49 + let mut token_is_space = false; 50 + 51 + let mut flush_token = |token: &mut String, 52 + token_is_space: bool, 53 + lines: &mut Vec<Line<'static>>, 54 + current_prefix: &mut Vec<Span<'static>>, 55 + body_started: &mut bool, 56 + current_width: &mut usize| { 57 + if token.is_empty() { 58 + return; 59 + } 60 + 61 + let token_width = display_width(token); 62 + if token_is_space { 63 + let keep_styled_padding = style.bg.is_some(); 64 + if (*body_started || keep_styled_padding) 65 + && *current_width + token_width <= max_width 66 + { 67 + current_prefix.push(Span::styled(std::mem::take(token), style)); 68 + *current_width += token_width; 69 + *body_started = true; 70 + } else { 71 + token.clear(); 72 + } 73 + return; 74 + } 75 + 76 + if *body_started && *current_width + token_width > max_width { 77 + push_current( 78 + lines, 79 + current_prefix, 80 + &mut next_prefix, 81 + body_started, 82 + current_width, 83 + ); 84 + } 85 + 86 + if token_width <= max_width { 87 + current_prefix.push(Span::styled(std::mem::take(token), style)); 88 + *current_width += token_width; 89 + *body_started = true; 90 + return; 91 + } 92 + 93 + let mut chunk = String::new(); 94 + let mut chunk_width = 0usize; 95 + for ch in token.chars() { 96 + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); 97 + let would_overflow = if *body_started { 98 + *current_width + chunk_width + ch_width > max_width 99 + } else { 100 + chunk_width + ch_width > max_width 101 + }; 102 + if would_overflow { 103 + if !chunk.is_empty() { 104 + current_prefix.push(Span::styled(std::mem::take(&mut chunk), style)); 105 + *body_started = true; 106 + } 107 + push_current( 108 + lines, 109 + current_prefix, 110 + &mut next_prefix, 111 + body_started, 112 + current_width, 113 + ); 114 + chunk_width = 0; 115 + } 116 + 117 + chunk.push(ch); 118 + chunk_width += ch_width; 119 + } 120 + 121 + if !chunk.is_empty() { 122 + current_prefix.push(Span::styled(chunk, style)); 123 + *current_width += chunk_width; 124 + *body_started = true; 125 + } 126 + token.clear(); 127 + }; 128 + 129 + for ch in span.content.chars() { 130 + let is_space = ch.is_whitespace(); 131 + if token.is_empty() { 132 + token_is_space = is_space; 133 + } else if token_is_space != is_space { 134 + flush_token( 135 + &mut token, 136 + token_is_space, 137 + lines, 138 + &mut current_prefix, 139 + &mut body_started, 140 + &mut current_width, 141 + ); 142 + token_is_space = is_space; 143 + } 144 + token.push(ch); 145 + } 146 + 147 + flush_token( 148 + &mut token, 149 + token_is_space, 150 + lines, 151 + &mut current_prefix, 152 + &mut body_started, 153 + &mut current_width, 154 + ); 155 + } 156 + 157 + if body_started { 158 + lines.push(Line::from(current_prefix)); 159 + } 160 + }
-945
src/render.rs
··· 1 - use crate::{ 2 - app::App, 3 - cli::version_text, 4 - theme::{app_theme, theme_preset_label, THEME_PRESETS}, 5 - }; 6 - use ratatui::{ 7 - layout::{Constraint, Direction, Layout, Rect}, 8 - style::{Color, Modifier, Style}, 9 - text::{Line, Span}, 10 - widgets::{ 11 - Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, 12 - Wrap, 13 - }, 14 - Frame, 15 - }; 16 - 17 - pub(crate) const CONTENT_HORIZONTAL_PADDING: u16 = 1; 18 - pub(crate) const SCROLLBAR_WIDTH: u16 = 1; 19 - 20 - pub(crate) fn ui(f: &mut Frame, app: &mut App) { 21 - let area = f.area(); 22 - let root = Layout::default() 23 - .direction(Direction::Vertical) 24 - .constraints([Constraint::Min(0), Constraint::Length(1)]) 25 - .split(area); 26 - 27 - let (toc_area, content_area): (Option<Rect>, Rect) = if app.is_toc_visible() && app.has_toc() { 28 - let cols = Layout::default() 29 - .direction(Direction::Horizontal) 30 - .constraints([Constraint::Length(30), Constraint::Min(0)]) 31 - .split(root[0]); 32 - (Some(cols[0]), cols[1]) 33 - } else { 34 - (None, root[0]) 35 - }; 36 - 37 - if let Some(ta) = toc_area { 38 - render_toc_panel(f, app, ta); 39 - } 40 - 41 - let viewport_height = content_area.height as usize; 42 - render_content_panel(f, app, content_area, viewport_height); 43 - render_status_bar(f, app, root[1], viewport_height); 44 - 45 - if app.is_help_open() { 46 - render_help_popup(f); 47 - } else if app.is_picker_loading() || app.is_picker_load_failed() { 48 - render_picker_loading(f, app); 49 - } else if app.is_file_picker_open() { 50 - render_file_picker(f, app); 51 - } else if app.is_theme_picker_open() { 52 - render_theme_picker(f, app); 53 - } 54 - } 55 - 56 - fn render_toc_panel(f: &mut Frame, app: &mut App, area: Rect) { 57 - let theme = app_theme(); 58 - app.refresh_toc_cache(); 59 - let toc_chunks = Layout::default() 60 - .direction(Direction::Vertical) 61 - .constraints([Constraint::Length(3), Constraint::Min(0)]) 62 - .split(area); 63 - 64 - f.render_widget( 65 - Paragraph::new("") 66 - .style(Style::default().bg(theme.ui.toc_bg)) 67 - .block( 68 - Block::default() 69 - .borders(Borders::RIGHT | Borders::BOTTOM) 70 - .border_style(Style::default().fg(theme.ui.toc_border)) 71 - .style(Style::default().bg(theme.ui.toc_bg)), 72 - ), 73 - toc_chunks[0], 74 - ); 75 - f.render_widget( 76 - Paragraph::new(app.toc_display_lines().to_vec()) 77 - .style(Style::default().bg(theme.ui.toc_bg)) 78 - .block( 79 - Block::default() 80 - .borders(Borders::RIGHT) 81 - .border_style(Style::default().fg(theme.ui.toc_border)) 82 - .style(Style::default().bg(theme.ui.toc_bg)), 83 - ), 84 - toc_chunks[1], 85 - ); 86 - f.render_widget( 87 - Paragraph::new(vec![app.toc_header_line().clone()]) 88 - .style(Style::default().bg(theme.ui.toc_bg)), 89 - Rect { 90 - x: toc_chunks[0].x, 91 - y: toc_chunks[0].y.saturating_add(1), 92 - width: toc_chunks[0].width.saturating_sub(1), 93 - height: 1, 94 - }, 95 - ); 96 - } 97 - 98 - fn render_content_panel(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 99 - let theme = app_theme(); 100 - f.render_widget( 101 - Paragraph::new("").style(Style::default().bg(theme.ui.content_bg)), 102 - area, 103 - ); 104 - let content_area = inner_content_area(area); 105 - let scroll = app.scroll(); 106 - let active_highlight_line = app.active_highlight_line(); 107 - if let Some(line_idx) = active_highlight_line { 108 - let _ = app.refresh_highlighted_line_cache(line_idx); 109 - } 110 - 111 - let visible_end = (scroll + viewport_height).min(app.total()); 112 - let mut visible_lines = app.visible_lines(scroll, visible_end).to_vec(); 113 - 114 - if let Some(line_idx) = active_highlight_line { 115 - if (scroll..visible_end).contains(&line_idx) { 116 - if let Some((_, highlighted_line)) = app.highlighted_line_cache() { 117 - visible_lines[line_idx - scroll] = highlighted_line.clone(); 118 - } 119 - } 120 - } 121 - 122 - f.render_widget( 123 - Paragraph::new(visible_lines) 124 - .style(Style::default().bg(theme.ui.content_bg)) 125 - .wrap(Wrap { trim: false }), 126 - content_area, 127 - ); 128 - 129 - let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll()); 130 - f.render_stateful_widget( 131 - Scrollbar::new(ScrollbarOrientation::VerticalRight) 132 - .begin_symbol(None) 133 - .end_symbol(None) 134 - .track_symbol(Some("│")) 135 - .thumb_symbol("█"), 136 - area, 137 - &mut scrollbar_state, 138 - ); 139 - } 140 - 141 - fn inner_content_area(area: Rect) -> Rect { 142 - Rect { 143 - x: area.x.saturating_add(CONTENT_HORIZONTAL_PADDING), 144 - y: area.y, 145 - width: area 146 - .width 147 - .saturating_sub(CONTENT_HORIZONTAL_PADDING.saturating_mul(2)) 148 - .saturating_sub(SCROLLBAR_WIDTH), 149 - height: area.height, 150 - } 151 - } 152 - 153 - fn render_status_bar(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 154 - let pct = app.scroll_percent(viewport_height); 155 - let bar_bg = status_bar_bg(); 156 - app.refresh_status_cache(pct); 157 - 158 - f.render_widget( 159 - Paragraph::new(vec![app.status_line().clone()]).style(Style::default().bg(bar_bg)), 160 - area, 161 - ); 162 - } 163 - 164 - pub(crate) fn status_bar_bg() -> Color { 165 - app_theme().ui.status_bg 166 - } 167 - 168 - pub(crate) fn status_separator_style(bar_bg: Color) -> Style { 169 - Style::default() 170 - .fg(app_theme().ui.status_separator) 171 - .bg(bar_bg) 172 - } 173 - 174 - pub(crate) fn join_span_sections( 175 - sections: Vec<Vec<Span<'static>>>, 176 - separator: Span<'static>, 177 - ) -> Vec<Span<'static>> { 178 - let mut joined = Vec::new(); 179 - for (idx, section) in sections.into_iter().enumerate() { 180 - if idx > 0 { 181 - joined.push(separator.clone()); 182 - } 183 - joined.extend(section); 184 - } 185 - joined 186 - } 187 - 188 - pub(crate) fn status_brand_section() -> Vec<Span<'static>> { 189 - let theme = app_theme(); 190 - vec![Span::styled( 191 - " leaf ", 192 - Style::default() 193 - .fg(theme.ui.status_brand_fg) 194 - .bg(theme.ui.status_brand_bg) 195 - .add_modifier(Modifier::BOLD), 196 - )] 197 - } 198 - 199 - pub(crate) fn status_filename_section(filename: &str) -> Vec<Span<'static>> { 200 - let theme = app_theme(); 201 - vec![Span::styled( 202 - format!(" {} ", filename), 203 - Style::default() 204 - .fg(theme.ui.status_filename_fg) 205 - .bg(theme.ui.status_filename_bg), 206 - )] 207 - } 208 - 209 - pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 210 - let theme = app_theme(); 211 - if !app.is_watch_enabled() { 212 - return None; 213 - } 214 - 215 - let flash_active = app 216 - .reload_flash_started() 217 - .map(|t| t.elapsed() < std::time::Duration::from_millis(1500)) 218 - .unwrap_or(false); 219 - let span = if flash_active { 220 - Span::styled( 221 - " ⟳ reloaded ", 222 - Style::default() 223 - .fg(theme.ui.status_reloaded_fg) 224 - .bg(theme.ui.status_reloaded_bg) 225 - .add_modifier(Modifier::BOLD), 226 - ) 227 - } else { 228 - Span::styled( 229 - " ⟳ watch ", 230 - Style::default() 231 - .fg(theme.ui.status_watch_fg) 232 - .bg(theme.ui.status_watch_bg), 233 - ) 234 - }; 235 - Some(vec![span]) 236 - } 237 - 238 - pub(crate) fn status_search_section(app: &App) -> Option<Vec<Span<'static>>> { 239 - let theme = app_theme(); 240 - if app.is_search_mode() { 241 - return Some(vec![Span::styled( 242 - format!(" /{} ", app.search_draft()), 243 - Style::default() 244 - .fg(theme.ui.status_search_fg) 245 - .bg(theme.ui.status_search_bg), 246 - )]); 247 - } 248 - 249 - if app.search_query().is_empty() { 250 - return None; 251 - } 252 - 253 - let span = if app.search_match_count() == 0 { 254 - Span::styled( 255 - format!(" ✗ {} ", app.search_query()), 256 - Style::default() 257 - .fg(theme.ui.status_search_error_fg) 258 - .bg(theme.ui.status_search_bg), 259 - ) 260 - } else { 261 - Span::styled( 262 - format!(" {}/{} ", app.search_index() + 1, app.search_match_count()), 263 - Style::default() 264 - .fg(theme.ui.status_search_match_fg) 265 - .bg(theme.ui.status_search_bg), 266 - ) 267 - }; 268 - Some(vec![span]) 269 - } 270 - 271 - pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { 272 - if app.is_search_mode() { 273 - &["enter confirm", "esc cancel"] 274 - } else if app.is_file_picker_open() { 275 - if app.is_fuzzy_file_picker() { 276 - &["↑/↓ move", "enter open", "backspace delete", "ctrl+c quit"] 277 - } else { 278 - &["j/k move", "enter open", "backspace up", "ctrl+c quit"] 279 - } 280 - } else if app.is_theme_picker_open() { 281 - &["j/k preview", "enter keep", "esc restore"] 282 - } else if app.is_help_open() { 283 - &["esc close", "? close"] 284 - } else if app.has_active_search() { 285 - &[ 286 - "enter next", 287 - "n/N next/prev", 288 - "/ search", 289 - "? help", 290 - "T theme", 291 - "esc clear", 292 - "q quit", 293 - ] 294 - } else { 295 - &[ 296 - "j/k scroll", 297 - "g/G top/bot", 298 - "t toc", 299 - "T theme", 300 - "/ search", 301 - "? help", 302 - "n/N next/prev", 303 - "q quit", 304 - ] 305 - } 306 - } 307 - 308 - fn render_help_popup(f: &mut Frame) { 309 - let theme = app_theme(); 310 - let area = centered_rect(56, 16, f.area()); 311 - let section_style = Style::default() 312 - .fg(theme.ui.toc_primary_active) 313 - .add_modifier(Modifier::BOLD); 314 - let key_style = Style::default() 315 - .fg(theme.ui.toc_accent) 316 - .add_modifier(Modifier::BOLD); 317 - let text_style = Style::default().fg(theme.ui.toc_primary_inactive); 318 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 319 - let title_style = Style::default() 320 - .fg(theme.markdown.heading_2) 321 - .add_modifier(Modifier::BOLD); 322 - let lines = vec![ 323 - Line::from(vec![Span::styled(version_text().to_string(), title_style)]), 324 - Line::from(vec![Span::styled( 325 - "Keyboard shortcuts", 326 - Style::default().fg(theme.ui.status_shortcut_fg), 327 - )]), 328 - Line::from(""), 329 - Line::from(vec![Span::styled( 330 - "Navigation Search", 331 - section_style, 332 - )]), 333 - Line::from(vec![ 334 - Span::styled("j/k, ↑/↓ ", key_style), 335 - Span::styled("scroll", text_style), 336 - Span::raw(" "), 337 - Span::styled("/, Ctrl+F ", key_style), 338 - Span::styled("search", text_style), 339 - ]), 340 - Line::from(vec![ 341 - Span::styled("PgUp/PgDn ", key_style), 342 - Span::styled("page", text_style), 343 - Span::raw(" "), 344 - Span::styled("n/N ", key_style), 345 - Span::styled("next/prev", text_style), 346 - ]), 347 - Line::from(vec![ 348 - Span::styled("g/G ", key_style), 349 - Span::styled("top/bottom", text_style), 350 - ]), 351 - Line::from(""), 352 - Line::from(vec![Span::styled("Actions", section_style)]), 353 - Line::from(vec![ 354 - Span::styled("r ", key_style), 355 - Span::styled("reload (watch)", text_style), 356 - Span::raw(" "), 357 - Span::styled("? ", key_style), 358 - Span::styled("show help", text_style), 359 - ]), 360 - Line::from(vec![ 361 - Span::styled("t ", key_style), 362 - Span::styled("toggle toc", text_style), 363 - Span::raw(" "), 364 - Span::styled("q ", key_style), 365 - Span::styled("quit", text_style), 366 - ]), 367 - Line::from(vec![ 368 - Span::styled("T ", key_style), 369 - Span::styled("theme picker", text_style), 370 - ]), 371 - Line::from(""), 372 - Line::from(vec![Span::styled("Esc or ? to close", footer_style)]), 373 - ]; 374 - 375 - f.render_widget(Clear, area); 376 - f.render_widget( 377 - Paragraph::new(lines).block( 378 - Block::default() 379 - .title("─ Help ") 380 - .borders(Borders::ALL) 381 - .border_style(Style::default().fg(theme.ui.toc_border)) 382 - .style(Style::default().bg(theme.ui.toc_bg)) 383 - .padding(Padding::new(1, 1, 0, 0)), 384 - ), 385 - area, 386 - ); 387 - } 388 - 389 - fn render_theme_picker(f: &mut Frame, app: &App) { 390 - let theme = app_theme(); 391 - let area = centered_rect(38, 10, f.area()); 392 - let active = app.theme_picker_reference_preset(); 393 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 394 - 395 - let mut lines = vec![ 396 - Line::from(vec![Span::styled( 397 - "Choose a theme", 398 - Style::default().fg(theme.ui.status_shortcut_fg), 399 - )]), 400 - Line::from(""), 401 - ]; 402 - for (idx, preset) in THEME_PRESETS.iter().enumerate() { 403 - let selected = idx == app.theme_picker_index(); 404 - let is_active = *preset == active; 405 - let bg = if selected { 406 - theme.ui.toc_active_bg 407 - } else { 408 - theme.ui.toc_bg 409 - }; 410 - let marker = if selected { "▸ " } else { " " }; 411 - let name = if is_active { 412 - format!("{} ✓", theme_preset_label(*preset)) 413 - } else { 414 - theme_preset_label(*preset).to_string() 415 - }; 416 - lines.push(Line::from(vec![ 417 - Span::styled( 418 - marker, 419 - Style::default() 420 - .fg(theme.ui.toc_accent) 421 - .bg(bg) 422 - .add_modifier(if selected { 423 - Modifier::BOLD 424 - } else { 425 - Modifier::empty() 426 - }), 427 - ), 428 - Span::styled( 429 - name, 430 - Style::default() 431 - .fg(if selected { 432 - theme.ui.toc_primary_active 433 - } else { 434 - theme.ui.toc_primary_inactive 435 - }) 436 - .bg(bg) 437 - .add_modifier(if is_active || selected { 438 - Modifier::BOLD 439 - } else { 440 - Modifier::empty() 441 - }), 442 - ), 443 - ])); 444 - } 445 - lines.push(Line::from("")); 446 - lines.push(Line::from(vec![Span::styled( 447 - "Enter keep • Esc restore", 448 - footer_style.bg(theme.ui.toc_bg), 449 - )])); 450 - 451 - f.render_widget(Clear, area); 452 - f.render_widget( 453 - Paragraph::new(lines).block( 454 - Block::default() 455 - .title("─ Theme ") 456 - .borders(Borders::ALL) 457 - .border_style(Style::default().fg(theme.ui.toc_border)) 458 - .style(Style::default().bg(theme.ui.toc_bg)) 459 - .padding(Padding::new(1, 1, 0, 0)), 460 - ), 461 - area, 462 - ); 463 - } 464 - 465 - fn render_file_picker(f: &mut Frame, app: &App) { 466 - let theme = app_theme(); 467 - let area = centered_rect(78, 20, f.area()); 468 - let title_style = Style::default() 469 - .fg(theme.markdown.heading_2) 470 - .add_modifier(Modifier::BOLD); 471 - let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 472 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 473 - let inner_height = area.height.saturating_sub(2) as usize; 474 - let header_lines = if app.is_fuzzy_file_picker() { 4 } else { 3 }; 475 - let total = app.file_picker_filtered_indices().len(); 476 - let truncation_message = picker_truncation_message(app.file_picker_truncation()); 477 - let max_visible_slots = if app.is_fuzzy_file_picker() { 478 - if truncation_message.is_some() { 479 - 11 480 - } else { 481 - 12 482 - } 483 - } else { 484 - 13 485 - }; 486 - let reserved_footer_lines = if truncation_message.is_some() { 3 } else { 2 }; 487 - let visible_slots = inner_height 488 - .saturating_sub(header_lines + reserved_footer_lines) 489 - .min(max_visible_slots); 490 - let start = if visible_slots == 0 || app.file_picker_index() < visible_slots { 491 - 0 492 - } else { 493 - app.file_picker_index() + 1 - visible_slots 494 - }; 495 - let end = (start + visible_slots).min(total); 496 - 497 - let mut lines = vec![ 498 - Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 499 - Line::from(vec![ 500 - Span::styled("Dir: ", section_style), 501 - Span::styled( 502 - app.file_picker_dir().display().to_string(), 503 - Style::default().fg(theme.ui.toc_primary_inactive), 504 - ), 505 - ]), 506 - ]; 507 - 508 - if app.is_fuzzy_file_picker() { 509 - lines.push(Line::from(vec![ 510 - Span::styled("Query: ", section_style), 511 - Span::styled( 512 - if app.file_picker_query().is_empty() { 513 - " type to filter ".to_string() 514 - } else { 515 - format!(" {} ", app.file_picker_query()) 516 - }, 517 - Style::default() 518 - .fg(if app.file_picker_query().is_empty() { 519 - theme.ui.toc_primary_inactive 520 - } else { 521 - theme.ui.toc_primary_active 522 - }) 523 - .bg(theme.markdown.inline_code_bg), 524 - ), 525 - ])); 526 - } 527 - 528 - lines.push(Line::from("")); 529 - 530 - if app.file_picker_entries().is_empty() { 531 - lines.push(Line::from(vec![Span::styled( 532 - if app.is_fuzzy_file_picker() { 533 - "No Markdown file found in this directory or its subdirectories" 534 - } else { 535 - "No folders or Markdown files here" 536 - }, 537 - Style::default().fg(theme.ui.toc_primary_inactive), 538 - )])); 539 - } else if total == 0 { 540 - lines.push(Line::from(vec![Span::styled( 541 - "No match for the current query", 542 - Style::default().fg(theme.ui.toc_primary_inactive), 543 - )])); 544 - } else { 545 - for (idx, entry_idx) in app.file_picker_filtered_indices()[start..end] 546 - .iter() 547 - .enumerate() 548 - { 549 - let actual_idx = start + idx; 550 - let selected = actual_idx == app.file_picker_index(); 551 - let entry = &app.file_picker_entries()[*entry_idx]; 552 - let bg = if selected { 553 - theme.ui.toc_active_bg 554 - } else { 555 - theme.ui.toc_bg 556 - }; 557 - let marker = if selected { "▸ " } else { " " }; 558 - let label_spans = if app.is_fuzzy_file_picker() { 559 - highlighted_picker_label( 560 - entry.label(), 561 - app.file_picker_match_positions(actual_idx), 562 - bg, 563 - selected, 564 - ) 565 - } else { 566 - vec![Span::styled( 567 - entry.label().to_string(), 568 - Style::default() 569 - .fg(theme.ui.toc_primary_inactive) 570 - .bg(bg) 571 - .add_modifier(if selected { 572 - Modifier::BOLD 573 - } else { 574 - Modifier::empty() 575 - }), 576 - )] 577 - }; 578 - let mut spans = vec![Span::styled( 579 - marker, 580 - Style::default() 581 - .fg(theme.ui.toc_accent) 582 - .bg(bg) 583 - .add_modifier(if selected { 584 - Modifier::BOLD 585 - } else { 586 - Modifier::empty() 587 - }), 588 - )]; 589 - spans.extend(label_spans); 590 - lines.push(Line::from(spans)); 591 - } 592 - } 593 - 594 - while lines.len() < inner_height.saturating_sub(reserved_footer_lines) { 595 - lines.push(Line::from("")); 596 - } 597 - 598 - if let Some(message) = truncation_message { 599 - lines.push(Line::from(vec![Span::styled( 600 - "", 601 - Style::default().fg(theme.ui.toc_primary_inactive), 602 - )])); 603 - lines.push(Line::from(vec![Span::styled( 604 - message, 605 - Style::default().fg(theme.markdown.heading_3), 606 - )])); 607 - } else { 608 - lines.push(Line::from("")); 609 - } 610 - 611 - lines.push(Line::from(vec![Span::styled( 612 - if app.is_fuzzy_file_picker() { 613 - "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 614 - } else { 615 - "enter open • backspace up • ctrl+c quit" 616 - }, 617 - footer_style.bg(theme.ui.toc_bg), 618 - )])); 619 - 620 - f.render_widget(Clear, area); 621 - f.render_widget( 622 - Paragraph::new(lines).block( 623 - Block::default() 624 - .title("─ Files ") 625 - .borders(Borders::ALL) 626 - .border_style(Style::default().fg(theme.ui.toc_border)) 627 - .style(Style::default().bg(theme.ui.toc_bg)) 628 - .padding(Padding::new(1, 1, 0, 0)), 629 - ), 630 - area, 631 - ); 632 - } 633 - 634 - fn render_picker_loading(f: &mut Frame, app: &App) { 635 - let theme = app_theme(); 636 - let area = centered_rect(78, 20, f.area()); 637 - let title_style = Style::default() 638 - .fg(theme.markdown.heading_2) 639 - .add_modifier(Modifier::BOLD); 640 - let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 641 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 642 - let is_failed = app.is_picker_load_failed(); 643 - let is_fuzzy = matches!( 644 - app.pending_picker_mode(), 645 - Some(crate::app::FilePickerMode::Fuzzy) 646 - ); 647 - let inner_height = area.height.saturating_sub(2) as usize; 648 - let message = if is_failed { 649 - app.picker_load_error().unwrap_or("Failed to load files") 650 - } else { 651 - "Indexing markdown files..." 652 - }; 653 - 654 - let mut lines = vec![ 655 - Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 656 - Line::from(vec![ 657 - Span::styled("Dir: ", section_style), 658 - Span::styled( 659 - app.pending_picker_dir() 660 - .map(|dir| dir.display().to_string()) 661 - .unwrap_or_else(|| ".".to_string()), 662 - Style::default().fg(theme.ui.toc_primary_inactive), 663 - ), 664 - ]), 665 - ]; 666 - 667 - if is_fuzzy { 668 - lines.push(Line::from(vec![ 669 - Span::styled("Query: ", section_style), 670 - Span::styled( 671 - " type to filter ".to_string(), 672 - Style::default() 673 - .fg(theme.ui.toc_primary_inactive) 674 - .bg(theme.markdown.inline_code_bg), 675 - ), 676 - ])); 677 - } 678 - 679 - lines.push(Line::from("")); 680 - lines.push(Line::from(vec![Span::styled( 681 - message, 682 - Style::default().fg(theme.ui.toc_primary_inactive), 683 - )])); 684 - 685 - while lines.len() < inner_height.saturating_sub(2) { 686 - lines.push(Line::from("")); 687 - } 688 - 689 - lines.push(Line::from("")); 690 - lines.push(Line::from(vec![Span::styled( 691 - if is_fuzzy { 692 - "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 693 - } else { 694 - "enter open • backspace up • ctrl+c quit" 695 - }, 696 - footer_style.bg(theme.ui.toc_bg), 697 - )])); 698 - 699 - f.render_widget(Clear, area); 700 - f.render_widget( 701 - Paragraph::new(lines).block( 702 - Block::default() 703 - .title("─ Files ") 704 - .borders(Borders::ALL) 705 - .border_style(Style::default().fg(theme.ui.toc_border)) 706 - .style(Style::default().bg(theme.ui.toc_bg)) 707 - .padding(Padding::new(1, 1, 0, 0)), 708 - ), 709 - area, 710 - ); 711 - } 712 - 713 - fn picker_truncation_message( 714 - truncation: Option<crate::app::PickerIndexTruncation>, 715 - ) -> Option<&'static str> { 716 - match truncation { 717 - Some(crate::app::PickerIndexTruncation::Directory) => { 718 - Some("Indexing limited: directory limit reached") 719 - } 720 - Some(crate::app::PickerIndexTruncation::File) => { 721 - Some("Indexing limited: file limit reached") 722 - } 723 - Some(crate::app::PickerIndexTruncation::Time) => { 724 - Some("Indexing limited: time limit reached") 725 - } 726 - None => None, 727 - } 728 - } 729 - 730 - fn highlighted_picker_label( 731 - label: &str, 732 - match_positions: &[usize], 733 - bg: Color, 734 - selected: bool, 735 - ) -> Vec<Span<'static>> { 736 - let theme = app_theme(); 737 - let default_style = Style::default() 738 - .fg(theme.ui.toc_primary_inactive) 739 - .bg(bg) 740 - .add_modifier(if selected { 741 - Modifier::BOLD 742 - } else { 743 - Modifier::empty() 744 - }); 745 - let matched_style = Style::default() 746 - .fg(theme.ui.toc_accent) 747 - .bg(bg) 748 - .add_modifier(if selected { 749 - Modifier::BOLD 750 - } else { 751 - Modifier::empty() 752 - }); 753 - 754 - if match_positions.is_empty() { 755 - return vec![Span::styled(label.to_string(), default_style)]; 756 - } 757 - 758 - let match_set = match_positions 759 - .iter() 760 - .copied() 761 - .collect::<std::collections::BTreeSet<_>>(); 762 - let mut spans = Vec::new(); 763 - let mut buffer = String::new(); 764 - let mut current_matched = None; 765 - 766 - for (idx, ch) in label.chars().enumerate() { 767 - let is_matched = match_set.contains(&idx); 768 - if current_matched == Some(is_matched) || current_matched.is_none() { 769 - buffer.push(ch); 770 - current_matched = Some(is_matched); 771 - continue; 772 - } 773 - 774 - spans.push(Span::styled( 775 - std::mem::take(&mut buffer), 776 - if current_matched == Some(true) { 777 - matched_style 778 - } else { 779 - default_style 780 - }, 781 - )); 782 - buffer.push(ch); 783 - current_matched = Some(is_matched); 784 - } 785 - 786 - if !buffer.is_empty() { 787 - spans.push(Span::styled( 788 - buffer, 789 - if current_matched == Some(true) { 790 - matched_style 791 - } else { 792 - default_style 793 - }, 794 - )); 795 - } 796 - 797 - spans 798 - } 799 - 800 - fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { 801 - let popup_width = width.min(area.width.saturating_sub(2)).max(1); 802 - let popup_height = height.min(area.height.saturating_sub(2)).max(1); 803 - Rect { 804 - x: area.x + area.width.saturating_sub(popup_width) / 2, 805 - y: area.y + area.height.saturating_sub(popup_height) / 2, 806 - width: popup_width, 807 - height: popup_height, 808 - } 809 - } 810 - 811 - pub(crate) fn status_shortcuts_section(app: &App, bar_bg: Color) -> Vec<Span<'static>> { 812 - let theme = app_theme(); 813 - let separator = Span::styled(" · ", status_separator_style(bar_bg)); 814 - let sections = status_hint_segments(app) 815 - .iter() 816 - .map(|segment| { 817 - vec![Span::styled( 818 - (*segment).to_string(), 819 - Style::default().fg(theme.ui.status_shortcut_fg).bg(bar_bg), 820 - )] 821 - }) 822 - .collect(); 823 - join_span_sections(sections, separator) 824 - } 825 - 826 - pub(crate) fn status_percent_section(pct: u16, bar_bg: Color) -> Vec<Span<'static>> { 827 - let theme = app_theme(); 828 - vec![Span::styled( 829 - format!("{:>3}% ", pct), 830 - Style::default().fg(theme.ui.status_percent_fg).bg(bar_bg), 831 - )] 832 - } 833 - 834 - pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec<Span<'static>> { 835 - let bar_bg = status_bar_bg(); 836 - let outer_separator = Span::raw(" "); 837 - 838 - let mut left_section = status_brand_section(); 839 - left_section.extend(status_filename_section(app.filename())); 840 - 841 - if let Some(section) = status_search_section(app) { 842 - left_section.extend(section); 843 - } 844 - 845 - if let Some(section) = status_watch_section(app) { 846 - left_section.extend(section); 847 - } 848 - 849 - let mut sections = vec![left_section, status_shortcuts_section(app, bar_bg)]; 850 - if !app.is_file_picker_open() && !app.is_picker_loading() { 851 - sections.push(status_percent_section(pct, bar_bg)); 852 - } 853 - 854 - join_span_sections(sections, outer_separator) 855 - } 856 - 857 - pub(crate) fn toc_header_line() -> Line<'static> { 858 - let theme = app_theme(); 859 - Line::from(vec![Span::styled( 860 - " TABLE OF CONTENTS", 861 - Style::default() 862 - .fg(theme.ui.toc_header_fg) 863 - .bg(theme.ui.toc_bg) 864 - .add_modifier(Modifier::BOLD), 865 - )]) 866 - } 867 - 868 - pub(crate) fn build_toc_line_with_index( 869 - entry: &crate::app::TocEntry, 870 - display_level: u8, 871 - top_level_index: Option<usize>, 872 - active: bool, 873 - ) -> Line<'static> { 874 - let theme = app_theme(); 875 - let active_bg = theme.ui.toc_active_bg; 876 - let inactive_bg = theme.ui.toc_inactive_bg; 877 - 878 - match display_level { 879 - 1 => { 880 - let index = top_level_index.unwrap_or(0) + 1; 881 - let title = crate::markdown::truncate_display_width(&entry.title, 18); 882 - let bg = if active { active_bg } else { inactive_bg }; 883 - Line::from(vec![ 884 - Span::styled( 885 - if active { "▎" } else { " " }, 886 - Style::default().fg(theme.ui.toc_accent).bg(bg), 887 - ), 888 - Span::styled(" ", Style::default().bg(bg)), 889 - Span::styled( 890 - format!("{index:02}"), 891 - Style::default() 892 - .fg(if active { 893 - theme.ui.toc_accent 894 - } else { 895 - theme.ui.toc_index_inactive 896 - }) 897 - .bg(bg) 898 - .add_modifier(Modifier::BOLD), 899 - ), 900 - Span::styled(" ", Style::default().bg(bg)), 901 - Span::styled( 902 - title, 903 - Style::default() 904 - .fg(if active { 905 - theme.ui.toc_primary_active 906 - } else { 907 - theme.ui.toc_primary_inactive 908 - }) 909 - .bg(bg) 910 - .add_modifier(Modifier::BOLD), 911 - ), 912 - ]) 913 - } 914 - _ => Line::from(vec![ 915 - Span::styled( 916 - if active { "▎" } else { " " }, 917 - Style::default().fg(theme.ui.toc_accent), 918 - ), 919 - Span::raw(" "), 920 - Span::styled( 921 - "•", 922 - Style::default().fg(if active { 923 - theme.ui.toc_accent 924 - } else { 925 - theme.ui.toc_secondary_inactive 926 - }), 927 - ), 928 - Span::raw(" "), 929 - Span::styled( 930 - crate::markdown::truncate_display_width(&entry.title, 18), 931 - Style::default() 932 - .fg(if active { 933 - theme.ui.toc_secondary_text_active 934 - } else { 935 - theme.ui.toc_secondary_text_inactive 936 - }) 937 - .add_modifier(if active { 938 - Modifier::BOLD 939 - } else { 940 - Modifier::empty() 941 - }), 942 - ), 943 - ]), 944 - } 945 - }
+80
src/render/content.rs
··· 1 + use crate::{app::App, theme::app_theme}; 2 + use ratatui::{ 3 + layout::Rect, 4 + style::Style, 5 + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, 6 + Frame, 7 + }; 8 + 9 + use super::{CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH}; 10 + 11 + pub(super) fn render_content_panel( 12 + f: &mut Frame, 13 + app: &mut App, 14 + area: Rect, 15 + viewport_height: usize, 16 + ) { 17 + let theme = app_theme(); 18 + f.render_widget( 19 + Paragraph::new("").style(Style::default().bg(theme.ui.content_bg)), 20 + area, 21 + ); 22 + let content_area = inner_content_area(area); 23 + let scroll = app.scroll(); 24 + let active_highlight_line = app.active_highlight_line(); 25 + if let Some(line_idx) = active_highlight_line { 26 + let _ = app.refresh_highlighted_line_cache(line_idx); 27 + } 28 + 29 + let visible_end = (scroll + viewport_height).min(app.total()); 30 + let mut visible_lines = app.visible_lines(scroll, visible_end).to_vec(); 31 + 32 + if let Some(line_idx) = active_highlight_line { 33 + if (scroll..visible_end).contains(&line_idx) { 34 + if let Some((_, highlighted_line)) = app.highlighted_line_cache() { 35 + visible_lines[line_idx - scroll] = highlighted_line.clone(); 36 + } 37 + } 38 + } 39 + 40 + f.render_widget( 41 + Paragraph::new(visible_lines) 42 + .style(Style::default().bg(theme.ui.content_bg)) 43 + .wrap(Wrap { trim: false }), 44 + content_area, 45 + ); 46 + 47 + let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll()); 48 + f.render_stateful_widget( 49 + Scrollbar::new(ScrollbarOrientation::VerticalRight) 50 + .begin_symbol(None) 51 + .end_symbol(None) 52 + .track_symbol(Some("│")) 53 + .thumb_symbol("█"), 54 + area, 55 + &mut scrollbar_state, 56 + ); 57 + } 58 + 59 + fn inner_content_area(area: Rect) -> Rect { 60 + Rect { 61 + x: area.x.saturating_add(CONTENT_HORIZONTAL_PADDING), 62 + y: area.y, 63 + width: area 64 + .width 65 + .saturating_sub(CONTENT_HORIZONTAL_PADDING.saturating_mul(2)) 66 + .saturating_sub(SCROLLBAR_WIDTH), 67 + height: area.height, 68 + } 69 + } 70 + 71 + pub(super) fn render_status_bar(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 72 + let pct = app.scroll_percent(viewport_height); 73 + let bar_bg = super::status::status_bar_bg(); 74 + app.refresh_status_cache(pct); 75 + 76 + f.render_widget( 77 + Paragraph::new(vec![app.status_line().clone()]).style(Style::default().bg(bar_bg)), 78 + area, 79 + ); 80 + }
+63
src/render/mod.rs
··· 1 + mod content; 2 + mod modal; 3 + mod status; 4 + mod toc; 5 + 6 + use crate::app::App; 7 + use ratatui::{ 8 + layout::{Constraint, Direction, Layout, Rect}, 9 + Frame, 10 + }; 11 + 12 + pub(crate) use status::build_status_bar; 13 + pub(crate) use toc::{build_toc_line_with_index, toc_header_line}; 14 + 15 + pub(crate) const CONTENT_HORIZONTAL_PADDING: u16 = 1; 16 + pub(crate) const SCROLLBAR_WIDTH: u16 = 1; 17 + 18 + pub(crate) fn ui(f: &mut Frame, app: &mut App) { 19 + let area = f.area(); 20 + let root = Layout::default() 21 + .direction(Direction::Vertical) 22 + .constraints([Constraint::Min(0), Constraint::Length(1)]) 23 + .split(area); 24 + 25 + let (toc_area, content_area): (Option<Rect>, Rect) = if app.is_toc_visible() && app.has_toc() { 26 + let cols = Layout::default() 27 + .direction(Direction::Horizontal) 28 + .constraints([Constraint::Length(30), Constraint::Min(0)]) 29 + .split(root[0]); 30 + (Some(cols[0]), cols[1]) 31 + } else { 32 + (None, root[0]) 33 + }; 34 + 35 + if let Some(ta) = toc_area { 36 + toc::render_toc_panel(f, app, ta); 37 + } 38 + 39 + let viewport_height = content_area.height as usize; 40 + content::render_content_panel(f, app, content_area, viewport_height); 41 + content::render_status_bar(f, app, root[1], viewport_height); 42 + 43 + if app.is_help_open() { 44 + modal::render_help_popup(f); 45 + } else if app.is_picker_loading() || app.is_picker_load_failed() { 46 + modal::render_picker_loading(f, app); 47 + } else if app.is_file_picker_open() { 48 + modal::render_file_picker(f, app); 49 + } else if app.is_theme_picker_open() { 50 + modal::render_theme_picker(f, app); 51 + } 52 + } 53 + 54 + pub(super) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { 55 + let popup_width = width.min(area.width.saturating_sub(2)).max(1); 56 + let popup_height = height.min(area.height.saturating_sub(2)).max(1); 57 + Rect { 58 + x: area.x + area.width.saturating_sub(popup_width) / 2, 59 + y: area.y + area.height.saturating_sub(popup_height) / 2, 60 + width: popup_width, 61 + height: popup_height, 62 + } 63 + }
+505
src/render/modal.rs
··· 1 + use crate::{ 2 + app::App, 3 + cli::version_text, 4 + theme::{app_theme, theme_preset_label, THEME_PRESETS}, 5 + }; 6 + use ratatui::{ 7 + style::{Color, Modifier, Style}, 8 + text::{Line, Span}, 9 + widgets::{Block, Borders, Clear, Padding, Paragraph}, 10 + Frame, 11 + }; 12 + 13 + use super::centered_rect; 14 + 15 + pub(super) fn render_help_popup(f: &mut Frame) { 16 + let theme = app_theme(); 17 + let area = centered_rect(56, 16, f.area()); 18 + let section_style = Style::default() 19 + .fg(theme.ui.toc_primary_active) 20 + .add_modifier(Modifier::BOLD); 21 + let key_style = Style::default() 22 + .fg(theme.ui.toc_accent) 23 + .add_modifier(Modifier::BOLD); 24 + let text_style = Style::default().fg(theme.ui.toc_primary_inactive); 25 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 26 + let title_style = Style::default() 27 + .fg(theme.markdown.heading_2) 28 + .add_modifier(Modifier::BOLD); 29 + let lines = vec![ 30 + Line::from(vec![Span::styled(version_text().to_string(), title_style)]), 31 + Line::from(vec![Span::styled( 32 + "Keyboard shortcuts", 33 + Style::default().fg(theme.ui.status_shortcut_fg), 34 + )]), 35 + Line::from(""), 36 + Line::from(vec![Span::styled( 37 + "Navigation Search", 38 + section_style, 39 + )]), 40 + Line::from(vec![ 41 + Span::styled("j/k, ↑/↓ ", key_style), 42 + Span::styled("scroll", text_style), 43 + Span::raw(" "), 44 + Span::styled("/, Ctrl+F ", key_style), 45 + Span::styled("search", text_style), 46 + ]), 47 + Line::from(vec![ 48 + Span::styled("PgUp/PgDn ", key_style), 49 + Span::styled("page", text_style), 50 + Span::raw(" "), 51 + Span::styled("n/N ", key_style), 52 + Span::styled("next/prev", text_style), 53 + ]), 54 + Line::from(vec![ 55 + Span::styled("g/G ", key_style), 56 + Span::styled("top/bottom", text_style), 57 + ]), 58 + Line::from(""), 59 + Line::from(vec![Span::styled("Actions", section_style)]), 60 + Line::from(vec![ 61 + Span::styled("r ", key_style), 62 + Span::styled("reload (watch)", text_style), 63 + Span::raw(" "), 64 + Span::styled("? ", key_style), 65 + Span::styled("show help", text_style), 66 + ]), 67 + Line::from(vec![ 68 + Span::styled("t ", key_style), 69 + Span::styled("toggle toc", text_style), 70 + Span::raw(" "), 71 + Span::styled("q ", key_style), 72 + Span::styled("quit", text_style), 73 + ]), 74 + Line::from(vec![ 75 + Span::styled("T ", key_style), 76 + Span::styled("theme picker", text_style), 77 + ]), 78 + Line::from(""), 79 + Line::from(vec![Span::styled("Esc or ? to close", footer_style)]), 80 + ]; 81 + 82 + f.render_widget(Clear, area); 83 + f.render_widget( 84 + Paragraph::new(lines).block( 85 + Block::default() 86 + .title("─ Help ") 87 + .borders(Borders::ALL) 88 + .border_style(Style::default().fg(theme.ui.toc_border)) 89 + .style(Style::default().bg(theme.ui.toc_bg)) 90 + .padding(Padding::new(1, 1, 0, 0)), 91 + ), 92 + area, 93 + ); 94 + } 95 + 96 + pub(super) fn render_theme_picker(f: &mut Frame, app: &App) { 97 + let theme = app_theme(); 98 + let area = centered_rect(38, 10, f.area()); 99 + let active = app.theme_picker_reference_preset(); 100 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 101 + 102 + let mut lines = vec![ 103 + Line::from(vec![Span::styled( 104 + "Choose a theme", 105 + Style::default().fg(theme.ui.status_shortcut_fg), 106 + )]), 107 + Line::from(""), 108 + ]; 109 + for (idx, preset) in THEME_PRESETS.iter().enumerate() { 110 + let selected = idx == app.theme_picker_index(); 111 + let is_active = *preset == active; 112 + let bg = if selected { 113 + theme.ui.toc_active_bg 114 + } else { 115 + theme.ui.toc_bg 116 + }; 117 + let marker = if selected { "▸ " } else { " " }; 118 + let name = if is_active { 119 + format!("{} ✓", theme_preset_label(*preset)) 120 + } else { 121 + theme_preset_label(*preset).to_string() 122 + }; 123 + lines.push(Line::from(vec![ 124 + Span::styled( 125 + marker, 126 + Style::default() 127 + .fg(theme.ui.toc_accent) 128 + .bg(bg) 129 + .add_modifier(if selected { 130 + Modifier::BOLD 131 + } else { 132 + Modifier::empty() 133 + }), 134 + ), 135 + Span::styled( 136 + name, 137 + Style::default() 138 + .fg(if selected { 139 + theme.ui.toc_primary_active 140 + } else { 141 + theme.ui.toc_primary_inactive 142 + }) 143 + .bg(bg) 144 + .add_modifier(if is_active || selected { 145 + Modifier::BOLD 146 + } else { 147 + Modifier::empty() 148 + }), 149 + ), 150 + ])); 151 + } 152 + lines.push(Line::from("")); 153 + lines.push(Line::from(vec![Span::styled( 154 + "Enter keep • Esc restore", 155 + footer_style.bg(theme.ui.toc_bg), 156 + )])); 157 + 158 + f.render_widget(Clear, area); 159 + f.render_widget( 160 + Paragraph::new(lines).block( 161 + Block::default() 162 + .title("─ Theme ") 163 + .borders(Borders::ALL) 164 + .border_style(Style::default().fg(theme.ui.toc_border)) 165 + .style(Style::default().bg(theme.ui.toc_bg)) 166 + .padding(Padding::new(1, 1, 0, 0)), 167 + ), 168 + area, 169 + ); 170 + } 171 + 172 + pub(super) fn render_file_picker(f: &mut Frame, app: &App) { 173 + let theme = app_theme(); 174 + let area = centered_rect(78, 20, f.area()); 175 + let title_style = Style::default() 176 + .fg(theme.markdown.heading_2) 177 + .add_modifier(Modifier::BOLD); 178 + let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 179 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 180 + let inner_height = area.height.saturating_sub(2) as usize; 181 + let header_lines = if app.is_fuzzy_file_picker() { 4 } else { 3 }; 182 + let total = app.file_picker_filtered_indices().len(); 183 + let truncation_message = picker_truncation_message(app.file_picker_truncation()); 184 + let max_visible_slots = if app.is_fuzzy_file_picker() { 185 + if truncation_message.is_some() { 186 + 11 187 + } else { 188 + 12 189 + } 190 + } else { 191 + 13 192 + }; 193 + let reserved_footer_lines = if truncation_message.is_some() { 3 } else { 2 }; 194 + let visible_slots = inner_height 195 + .saturating_sub(header_lines + reserved_footer_lines) 196 + .min(max_visible_slots); 197 + let start = if visible_slots == 0 || app.file_picker_index() < visible_slots { 198 + 0 199 + } else { 200 + app.file_picker_index() + 1 - visible_slots 201 + }; 202 + let end = (start + visible_slots).min(total); 203 + 204 + let mut lines = vec![ 205 + Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 206 + Line::from(vec![ 207 + Span::styled("Dir: ", section_style), 208 + Span::styled( 209 + app.file_picker_dir().display().to_string(), 210 + Style::default().fg(theme.ui.toc_primary_inactive), 211 + ), 212 + ]), 213 + ]; 214 + 215 + if app.is_fuzzy_file_picker() { 216 + lines.push(Line::from(vec![ 217 + Span::styled("Query: ", section_style), 218 + Span::styled( 219 + if app.file_picker_query().is_empty() { 220 + " type to filter ".to_string() 221 + } else { 222 + format!(" {} ", app.file_picker_query()) 223 + }, 224 + Style::default() 225 + .fg(if app.file_picker_query().is_empty() { 226 + theme.ui.toc_primary_inactive 227 + } else { 228 + theme.ui.toc_primary_active 229 + }) 230 + .bg(theme.markdown.inline_code_bg), 231 + ), 232 + ])); 233 + } 234 + 235 + lines.push(Line::from("")); 236 + 237 + if app.file_picker_entries().is_empty() { 238 + lines.push(Line::from(vec![Span::styled( 239 + if app.is_fuzzy_file_picker() { 240 + "No Markdown file found in this directory or its subdirectories" 241 + } else { 242 + "No folders or Markdown files here" 243 + }, 244 + Style::default().fg(theme.ui.toc_primary_inactive), 245 + )])); 246 + } else if total == 0 { 247 + lines.push(Line::from(vec![Span::styled( 248 + "No match for the current query", 249 + Style::default().fg(theme.ui.toc_primary_inactive), 250 + )])); 251 + } else { 252 + for (idx, entry_idx) in app.file_picker_filtered_indices()[start..end] 253 + .iter() 254 + .enumerate() 255 + { 256 + let actual_idx = start + idx; 257 + let selected = actual_idx == app.file_picker_index(); 258 + let entry = &app.file_picker_entries()[*entry_idx]; 259 + let bg = if selected { 260 + theme.ui.toc_active_bg 261 + } else { 262 + theme.ui.toc_bg 263 + }; 264 + let marker = if selected { "▸ " } else { " " }; 265 + let label_spans = if app.is_fuzzy_file_picker() { 266 + highlighted_picker_label( 267 + entry.label(), 268 + app.file_picker_match_positions(actual_idx), 269 + bg, 270 + selected, 271 + ) 272 + } else { 273 + vec![Span::styled( 274 + entry.label().to_string(), 275 + Style::default() 276 + .fg(theme.ui.toc_primary_inactive) 277 + .bg(bg) 278 + .add_modifier(if selected { 279 + Modifier::BOLD 280 + } else { 281 + Modifier::empty() 282 + }), 283 + )] 284 + }; 285 + let mut spans = vec![Span::styled( 286 + marker, 287 + Style::default() 288 + .fg(theme.ui.toc_accent) 289 + .bg(bg) 290 + .add_modifier(if selected { 291 + Modifier::BOLD 292 + } else { 293 + Modifier::empty() 294 + }), 295 + )]; 296 + spans.extend(label_spans); 297 + lines.push(Line::from(spans)); 298 + } 299 + } 300 + 301 + while lines.len() < inner_height.saturating_sub(reserved_footer_lines) { 302 + lines.push(Line::from("")); 303 + } 304 + 305 + if let Some(message) = truncation_message { 306 + lines.push(Line::from(vec![Span::styled( 307 + "", 308 + Style::default().fg(theme.ui.toc_primary_inactive), 309 + )])); 310 + lines.push(Line::from(vec![Span::styled( 311 + message, 312 + Style::default().fg(theme.markdown.heading_3), 313 + )])); 314 + } else { 315 + lines.push(Line::from("")); 316 + } 317 + 318 + lines.push(Line::from(vec![Span::styled( 319 + if app.is_fuzzy_file_picker() { 320 + "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 321 + } else { 322 + "enter open • backspace up • ctrl+c quit" 323 + }, 324 + footer_style.bg(theme.ui.toc_bg), 325 + )])); 326 + 327 + f.render_widget(Clear, area); 328 + f.render_widget( 329 + Paragraph::new(lines).block( 330 + Block::default() 331 + .title("─ Files ") 332 + .borders(Borders::ALL) 333 + .border_style(Style::default().fg(theme.ui.toc_border)) 334 + .style(Style::default().bg(theme.ui.toc_bg)) 335 + .padding(Padding::new(1, 1, 0, 0)), 336 + ), 337 + area, 338 + ); 339 + } 340 + 341 + pub(super) fn render_picker_loading(f: &mut Frame, app: &App) { 342 + let theme = app_theme(); 343 + let area = centered_rect(78, 20, f.area()); 344 + let title_style = Style::default() 345 + .fg(theme.markdown.heading_2) 346 + .add_modifier(Modifier::BOLD); 347 + let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 348 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 349 + let is_failed = app.is_picker_load_failed(); 350 + let is_fuzzy = matches!( 351 + app.pending_picker_mode(), 352 + Some(crate::app::FilePickerMode::Fuzzy) 353 + ); 354 + let inner_height = area.height.saturating_sub(2) as usize; 355 + let message = if is_failed { 356 + app.picker_load_error().unwrap_or("Failed to load files") 357 + } else { 358 + "Indexing markdown files..." 359 + }; 360 + 361 + let mut lines = vec![ 362 + Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 363 + Line::from(vec![ 364 + Span::styled("Dir: ", section_style), 365 + Span::styled( 366 + app.pending_picker_dir() 367 + .map(|dir| dir.display().to_string()) 368 + .unwrap_or_else(|| ".".to_string()), 369 + Style::default().fg(theme.ui.toc_primary_inactive), 370 + ), 371 + ]), 372 + ]; 373 + 374 + if is_fuzzy { 375 + lines.push(Line::from(vec![ 376 + Span::styled("Query: ", section_style), 377 + Span::styled( 378 + " type to filter ".to_string(), 379 + Style::default() 380 + .fg(theme.ui.toc_primary_inactive) 381 + .bg(theme.markdown.inline_code_bg), 382 + ), 383 + ])); 384 + } 385 + 386 + lines.push(Line::from("")); 387 + lines.push(Line::from(vec![Span::styled( 388 + message, 389 + Style::default().fg(theme.ui.toc_primary_inactive), 390 + )])); 391 + 392 + while lines.len() < inner_height.saturating_sub(2) { 393 + lines.push(Line::from("")); 394 + } 395 + 396 + lines.push(Line::from("")); 397 + lines.push(Line::from(vec![Span::styled( 398 + if is_fuzzy { 399 + "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 400 + } else { 401 + "enter open • backspace up • ctrl+c quit" 402 + }, 403 + footer_style.bg(theme.ui.toc_bg), 404 + )])); 405 + 406 + f.render_widget(Clear, area); 407 + f.render_widget( 408 + Paragraph::new(lines).block( 409 + Block::default() 410 + .title("─ Files ") 411 + .borders(Borders::ALL) 412 + .border_style(Style::default().fg(theme.ui.toc_border)) 413 + .style(Style::default().bg(theme.ui.toc_bg)) 414 + .padding(Padding::new(1, 1, 0, 0)), 415 + ), 416 + area, 417 + ); 418 + } 419 + 420 + fn picker_truncation_message( 421 + truncation: Option<crate::app::PickerIndexTruncation>, 422 + ) -> Option<&'static str> { 423 + match truncation { 424 + Some(crate::app::PickerIndexTruncation::Directory) => { 425 + Some("Indexing limited: directory limit reached") 426 + } 427 + Some(crate::app::PickerIndexTruncation::File) => { 428 + Some("Indexing limited: file limit reached") 429 + } 430 + Some(crate::app::PickerIndexTruncation::Time) => { 431 + Some("Indexing limited: time limit reached") 432 + } 433 + None => None, 434 + } 435 + } 436 + 437 + fn highlighted_picker_label( 438 + label: &str, 439 + match_positions: &[usize], 440 + bg: Color, 441 + selected: bool, 442 + ) -> Vec<Span<'static>> { 443 + let theme = app_theme(); 444 + let default_style = Style::default() 445 + .fg(theme.ui.toc_primary_inactive) 446 + .bg(bg) 447 + .add_modifier(if selected { 448 + Modifier::BOLD 449 + } else { 450 + Modifier::empty() 451 + }); 452 + let matched_style = Style::default() 453 + .fg(theme.ui.toc_accent) 454 + .bg(bg) 455 + .add_modifier(if selected { 456 + Modifier::BOLD 457 + } else { 458 + Modifier::empty() 459 + }); 460 + 461 + if match_positions.is_empty() { 462 + return vec![Span::styled(label.to_string(), default_style)]; 463 + } 464 + 465 + let match_set = match_positions 466 + .iter() 467 + .copied() 468 + .collect::<std::collections::BTreeSet<_>>(); 469 + let mut spans = Vec::new(); 470 + let mut buffer = String::new(); 471 + let mut current_matched = None; 472 + 473 + for (idx, ch) in label.chars().enumerate() { 474 + let is_matched = match_set.contains(&idx); 475 + if current_matched == Some(is_matched) || current_matched.is_none() { 476 + buffer.push(ch); 477 + current_matched = Some(is_matched); 478 + continue; 479 + } 480 + 481 + spans.push(Span::styled( 482 + std::mem::take(&mut buffer), 483 + if current_matched == Some(true) { 484 + matched_style 485 + } else { 486 + default_style 487 + }, 488 + )); 489 + buffer.push(ch); 490 + current_matched = Some(is_matched); 491 + } 492 + 493 + if !buffer.is_empty() { 494 + spans.push(Span::styled( 495 + buffer, 496 + if current_matched == Some(true) { 497 + matched_style 498 + } else { 499 + default_style 500 + }, 501 + )); 502 + } 503 + 504 + spans 505 + }
+195
src/render/status.rs
··· 1 + use crate::{app::App, theme::app_theme}; 2 + use ratatui::{ 3 + style::{Color, Modifier, Style}, 4 + text::Span, 5 + }; 6 + 7 + pub(crate) fn status_bar_bg() -> Color { 8 + app_theme().ui.status_bg 9 + } 10 + 11 + pub(crate) fn status_separator_style(bar_bg: Color) -> Style { 12 + Style::default() 13 + .fg(app_theme().ui.status_separator) 14 + .bg(bar_bg) 15 + } 16 + 17 + pub(crate) fn join_span_sections( 18 + sections: Vec<Vec<Span<'static>>>, 19 + separator: Span<'static>, 20 + ) -> Vec<Span<'static>> { 21 + let mut joined = Vec::new(); 22 + for (idx, section) in sections.into_iter().enumerate() { 23 + if idx > 0 { 24 + joined.push(separator.clone()); 25 + } 26 + joined.extend(section); 27 + } 28 + joined 29 + } 30 + 31 + pub(crate) fn status_brand_section() -> Vec<Span<'static>> { 32 + let theme = app_theme(); 33 + vec![Span::styled( 34 + " leaf ", 35 + Style::default() 36 + .fg(theme.ui.status_brand_fg) 37 + .bg(theme.ui.status_brand_bg) 38 + .add_modifier(Modifier::BOLD), 39 + )] 40 + } 41 + 42 + pub(crate) fn status_filename_section(filename: &str) -> Vec<Span<'static>> { 43 + let theme = app_theme(); 44 + vec![Span::styled( 45 + format!(" {} ", filename), 46 + Style::default() 47 + .fg(theme.ui.status_filename_fg) 48 + .bg(theme.ui.status_filename_bg), 49 + )] 50 + } 51 + 52 + pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 53 + let theme = app_theme(); 54 + if !app.is_watch_enabled() { 55 + return None; 56 + } 57 + 58 + let flash_active = app 59 + .reload_flash_started() 60 + .map(|t| t.elapsed() < std::time::Duration::from_millis(1500)) 61 + .unwrap_or(false); 62 + let span = if flash_active { 63 + Span::styled( 64 + " ⟳ reloaded ", 65 + Style::default() 66 + .fg(theme.ui.status_reloaded_fg) 67 + .bg(theme.ui.status_reloaded_bg) 68 + .add_modifier(Modifier::BOLD), 69 + ) 70 + } else { 71 + Span::styled( 72 + " ⟳ watch ", 73 + Style::default() 74 + .fg(theme.ui.status_watch_fg) 75 + .bg(theme.ui.status_watch_bg), 76 + ) 77 + }; 78 + Some(vec![span]) 79 + } 80 + 81 + pub(crate) fn status_search_section(app: &App) -> Option<Vec<Span<'static>>> { 82 + let theme = app_theme(); 83 + if app.is_search_mode() { 84 + return Some(vec![Span::styled( 85 + format!(" /{} ", app.search_draft()), 86 + Style::default() 87 + .fg(theme.ui.status_search_fg) 88 + .bg(theme.ui.status_search_bg), 89 + )]); 90 + } 91 + 92 + if app.search_query().is_empty() { 93 + return None; 94 + } 95 + 96 + let span = if app.search_match_count() == 0 { 97 + Span::styled( 98 + format!(" ✗ {} ", app.search_query()), 99 + Style::default() 100 + .fg(theme.ui.status_search_error_fg) 101 + .bg(theme.ui.status_search_bg), 102 + ) 103 + } else { 104 + Span::styled( 105 + format!(" {}/{} ", app.search_index() + 1, app.search_match_count()), 106 + Style::default() 107 + .fg(theme.ui.status_search_match_fg) 108 + .bg(theme.ui.status_search_bg), 109 + ) 110 + }; 111 + Some(vec![span]) 112 + } 113 + 114 + pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { 115 + if app.is_search_mode() { 116 + &["enter confirm", "esc cancel"] 117 + } else if app.is_file_picker_open() { 118 + if app.is_fuzzy_file_picker() { 119 + &["↑/↓ move", "enter open", "backspace delete", "ctrl+c quit"] 120 + } else { 121 + &["j/k move", "enter open", "backspace up", "ctrl+c quit"] 122 + } 123 + } else if app.is_theme_picker_open() { 124 + &["j/k preview", "enter keep", "esc restore"] 125 + } else if app.is_help_open() { 126 + &["esc close", "? close"] 127 + } else if app.has_active_search() { 128 + &[ 129 + "enter next", 130 + "n/N next/prev", 131 + "/ search", 132 + "? help", 133 + "T theme", 134 + "esc clear", 135 + "q quit", 136 + ] 137 + } else { 138 + &[ 139 + "j/k scroll", 140 + "g/G top/bot", 141 + "t toc", 142 + "T theme", 143 + "/ search", 144 + "? help", 145 + "n/N next/prev", 146 + "q quit", 147 + ] 148 + } 149 + } 150 + 151 + pub(crate) fn status_shortcuts_section(app: &App, bar_bg: Color) -> Vec<Span<'static>> { 152 + let theme = app_theme(); 153 + let separator = Span::styled(" · ", status_separator_style(bar_bg)); 154 + let sections = status_hint_segments(app) 155 + .iter() 156 + .map(|segment| { 157 + vec![Span::styled( 158 + (*segment).to_string(), 159 + Style::default().fg(theme.ui.status_shortcut_fg).bg(bar_bg), 160 + )] 161 + }) 162 + .collect(); 163 + join_span_sections(sections, separator) 164 + } 165 + 166 + pub(crate) fn status_percent_section(pct: u16, bar_bg: Color) -> Vec<Span<'static>> { 167 + let theme = app_theme(); 168 + vec![Span::styled( 169 + format!("{:>3}% ", pct), 170 + Style::default().fg(theme.ui.status_percent_fg).bg(bar_bg), 171 + )] 172 + } 173 + 174 + pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec<Span<'static>> { 175 + let bar_bg = status_bar_bg(); 176 + let outer_separator = Span::raw(" "); 177 + 178 + let mut left_section = status_brand_section(); 179 + left_section.extend(status_filename_section(app.filename())); 180 + 181 + if let Some(section) = status_search_section(app) { 182 + left_section.extend(section); 183 + } 184 + 185 + if let Some(section) = status_watch_section(app) { 186 + left_section.extend(section); 187 + } 188 + 189 + let mut sections = vec![left_section, status_shortcuts_section(app, bar_bg)]; 190 + if !app.is_file_picker_open() && !app.is_picker_loading() { 191 + sections.push(status_percent_section(pct, bar_bg)); 192 + } 193 + 194 + join_span_sections(sections, outer_separator) 195 + }
+140
src/render/toc.rs
··· 1 + use crate::{app::App, theme::app_theme}; 2 + use ratatui::{ 3 + layout::{Constraint, Direction, Layout, Rect}, 4 + style::{Modifier, Style}, 5 + text::{Line, Span}, 6 + widgets::{Block, Borders, Paragraph}, 7 + Frame, 8 + }; 9 + 10 + pub(super) fn render_toc_panel(f: &mut Frame, app: &mut App, area: Rect) { 11 + let theme = app_theme(); 12 + app.refresh_toc_cache(); 13 + let toc_chunks = Layout::default() 14 + .direction(Direction::Vertical) 15 + .constraints([Constraint::Length(3), Constraint::Min(0)]) 16 + .split(area); 17 + 18 + f.render_widget( 19 + Paragraph::new("") 20 + .style(Style::default().bg(theme.ui.toc_bg)) 21 + .block( 22 + Block::default() 23 + .borders(Borders::RIGHT | Borders::BOTTOM) 24 + .border_style(Style::default().fg(theme.ui.toc_border)) 25 + .style(Style::default().bg(theme.ui.toc_bg)), 26 + ), 27 + toc_chunks[0], 28 + ); 29 + f.render_widget( 30 + Paragraph::new(app.toc_display_lines().to_vec()) 31 + .style(Style::default().bg(theme.ui.toc_bg)) 32 + .block( 33 + Block::default() 34 + .borders(Borders::RIGHT) 35 + .border_style(Style::default().fg(theme.ui.toc_border)) 36 + .style(Style::default().bg(theme.ui.toc_bg)), 37 + ), 38 + toc_chunks[1], 39 + ); 40 + f.render_widget( 41 + Paragraph::new(vec![app.toc_header_line().clone()]) 42 + .style(Style::default().bg(theme.ui.toc_bg)), 43 + Rect { 44 + x: toc_chunks[0].x, 45 + y: toc_chunks[0].y.saturating_add(1), 46 + width: toc_chunks[0].width.saturating_sub(1), 47 + height: 1, 48 + }, 49 + ); 50 + } 51 + 52 + pub(crate) fn toc_header_line() -> Line<'static> { 53 + let theme = app_theme(); 54 + Line::from(vec![Span::styled( 55 + " TABLE OF CONTENTS", 56 + Style::default() 57 + .fg(theme.ui.toc_header_fg) 58 + .bg(theme.ui.toc_bg) 59 + .add_modifier(Modifier::BOLD), 60 + )]) 61 + } 62 + 63 + pub(crate) fn build_toc_line_with_index( 64 + entry: &crate::markdown::toc::TocEntry, 65 + display_level: u8, 66 + top_level_index: Option<usize>, 67 + active: bool, 68 + ) -> Line<'static> { 69 + let theme = app_theme(); 70 + let active_bg = theme.ui.toc_active_bg; 71 + let inactive_bg = theme.ui.toc_inactive_bg; 72 + 73 + match display_level { 74 + 1 => { 75 + let index = top_level_index.unwrap_or(0) + 1; 76 + let title = crate::markdown::truncate_display_width(&entry.title, 18); 77 + let bg = if active { active_bg } else { inactive_bg }; 78 + Line::from(vec![ 79 + Span::styled( 80 + if active { "▎" } else { " " }, 81 + Style::default().fg(theme.ui.toc_accent).bg(bg), 82 + ), 83 + Span::styled(" ", Style::default().bg(bg)), 84 + Span::styled( 85 + format!("{index:02}"), 86 + Style::default() 87 + .fg(if active { 88 + theme.ui.toc_accent 89 + } else { 90 + theme.ui.toc_index_inactive 91 + }) 92 + .bg(bg) 93 + .add_modifier(Modifier::BOLD), 94 + ), 95 + Span::styled(" ", Style::default().bg(bg)), 96 + Span::styled( 97 + title, 98 + Style::default() 99 + .fg(if active { 100 + theme.ui.toc_primary_active 101 + } else { 102 + theme.ui.toc_primary_inactive 103 + }) 104 + .bg(bg) 105 + .add_modifier(Modifier::BOLD), 106 + ), 107 + ]) 108 + } 109 + _ => Line::from(vec![ 110 + Span::styled( 111 + if active { "▎" } else { " " }, 112 + Style::default().fg(theme.ui.toc_accent), 113 + ), 114 + Span::raw(" "), 115 + Span::styled( 116 + "•", 117 + Style::default().fg(if active { 118 + theme.ui.toc_accent 119 + } else { 120 + theme.ui.toc_secondary_inactive 121 + }), 122 + ), 123 + Span::raw(" "), 124 + Span::styled( 125 + crate::markdown::truncate_display_width(&entry.title, 18), 126 + Style::default() 127 + .fg(if active { 128 + theme.ui.toc_secondary_text_active 129 + } else { 130 + theme.ui.toc_secondary_text_inactive 131 + }) 132 + .add_modifier(if active { 133 + Modifier::BOLD 134 + } else { 135 + Modifier::empty() 136 + }), 137 + ), 138 + ]), 139 + } 140 + }
-1834
src/tests.rs
··· 1 - use crate::app::FileChange; 2 - use crate::markdown::{ 3 - hash_str, parse_markdown, parse_markdown_with_width, read_file_state, resolve_syntax, 4 - }; 5 - use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 6 - use crate::update::TestAsset; 7 - use crate::*; 8 - use crossterm::event::KeyEventKind; 9 - use ratatui::backend::TestBackend; 10 - use ratatui::{text::Line, widgets::Paragraph, Terminal}; 11 - use std::{ 12 - fs, 13 - path::PathBuf, 14 - sync::{Mutex, MutexGuard}, 15 - time::{SystemTime, UNIX_EPOCH}, 16 - }; 17 - use syntect::{ 18 - highlighting::{Theme, ThemeSet}, 19 - parsing::SyntaxSet, 20 - }; 21 - 22 - static THEME_TEST_MUTEX: Mutex<()> = Mutex::new(()); 23 - 24 - fn test_assets() -> (SyntaxSet, Theme) { 25 - let ss = SyntaxSet::load_defaults_newlines(); 26 - let ts = ThemeSet::load_defaults(); 27 - let theme = ts.themes["base16-ocean.dark"].clone(); 28 - (ss, theme) 29 - } 30 - 31 - fn render_buffer(lines: &[Line<'static>]) -> ratatui::buffer::Buffer { 32 - let width = lines 33 - .iter() 34 - .map(|line| line.width()) 35 - .max() 36 - .unwrap_or(1) 37 - .max(1) as u16; 38 - let height = lines.len().max(1) as u16; 39 - let backend = TestBackend::new(width, height); 40 - let mut terminal = Terminal::new(backend).unwrap(); 41 - terminal 42 - .draw(|f| { 43 - f.render_widget(Paragraph::new(lines.to_vec()), f.area()); 44 - }) 45 - .unwrap(); 46 - terminal.backend().buffer().clone() 47 - } 48 - 49 - fn find_symbol(buffer: &ratatui::buffer::Buffer, symbol: &str) -> Option<(u16, u16)> { 50 - for y in 0..buffer.area.height { 51 - for x in 0..buffer.area.width { 52 - if buffer 53 - .cell((x, y)) 54 - .is_some_and(|cell| cell.symbol() == symbol) 55 - { 56 - return Some((x, y)); 57 - } 58 - } 59 - } 60 - None 61 - } 62 - 63 - fn rendered_non_empty_lines(lines: &[Line<'static>]) -> Vec<String> { 64 - lines 65 - .iter() 66 - .map(line_plain_text) 67 - .filter(|line| !line.is_empty()) 68 - .collect() 69 - } 70 - 71 - fn lock_theme_test_state() -> MutexGuard<'static, ()> { 72 - THEME_TEST_MUTEX.lock().unwrap() 73 - } 74 - 75 - fn unique_temp_dir(prefix: &str) -> PathBuf { 76 - let unique = SystemTime::now() 77 - .duration_since(UNIX_EPOCH) 78 - .unwrap() 79 - .as_nanos(); 80 - std::env::temp_dir().join(format!("{prefix}-{unique}")) 81 - } 82 - 83 - #[test] 84 - fn search_matches_across_span_boundaries() { 85 - let (ss, theme) = test_assets(); 86 - let (lines, toc) = parse_markdown("hello **world**", &ss, &theme); 87 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 88 - 89 - app.set_search_query("hello world"); 90 - app.run_search(); 91 - 92 - assert_eq!(app.search_match_count(), 1); 93 - assert!(line_plain_text(app.line(app.search_matches()[0]).unwrap()).contains("hello world")); 94 - } 95 - 96 - #[test] 97 - fn key_release_events_are_ignored() { 98 - assert!(should_handle_key(KeyEventKind::Press)); 99 - assert!(should_handle_key(KeyEventKind::Repeat)); 100 - assert!(!should_handle_key(KeyEventKind::Release)); 101 - } 102 - 103 - #[test] 104 - fn stdin_read_is_rejected_when_over_limit() { 105 - let mut cursor = std::io::Cursor::new(vec![b'a'; 5]); 106 - let err = read_stdin_with_limit(&mut cursor, 4).unwrap_err(); 107 - assert!(err 108 - .to_string() 109 - .contains("stdin exceeds the maximum supported size")); 110 - } 111 - 112 - #[test] 113 - fn parse_cli_accepts_update_on_its_own() { 114 - let args = vec!["leaf".to_string(), "--update".to_string()]; 115 - let options = parse_cli(&args).unwrap(); 116 - 117 - assert!(options.update); 118 - assert!(!options.watch); 119 - assert_eq!(options.file_arg, None); 120 - } 121 - 122 - #[test] 123 - fn parse_cli_rejects_update_with_other_flags() { 124 - let args = vec![ 125 - "leaf".to_string(), 126 - "--update".to_string(), 127 - "--watch".to_string(), 128 - ]; 129 - 130 - let err = parse_cli(&args).unwrap_err(); 131 - assert!(err.to_string().contains("--update must be used on its own")); 132 - } 133 - 134 - #[test] 135 - fn parse_cli_accepts_picker_on_its_own() { 136 - let args = vec!["leaf".to_string(), "--picker".to_string()]; 137 - let options = parse_cli(&args).unwrap(); 138 - 139 - assert!(options.picker); 140 - assert!(!options.watch); 141 - assert_eq!(options.file_arg, None); 142 - } 143 - 144 - #[test] 145 - fn parse_cli_accepts_picker_with_watch() { 146 - let args = vec![ 147 - "leaf".to_string(), 148 - "--picker".to_string(), 149 - "--watch".to_string(), 150 - ]; 151 - 152 - let options = parse_cli(&args).unwrap(); 153 - assert!(options.picker); 154 - assert!(options.watch); 155 - assert_eq!(options.file_arg, None); 156 - } 157 - 158 - #[test] 159 - fn asset_name_matches_supported_release_targets() { 160 - assert_eq!( 161 - asset_name_for_target("macos", "x86_64"), 162 - Some("leaf-macos-x86_64") 163 - ); 164 - assert_eq!( 165 - asset_name_for_target("macos", "aarch64"), 166 - Some("leaf-macos-arm64") 167 - ); 168 - assert_eq!( 169 - asset_name_for_target("linux", "x86_64"), 170 - Some("leaf-linux-x86_64") 171 - ); 172 - assert_eq!( 173 - asset_name_for_target("linux", "aarch64"), 174 - Some("leaf-linux-arm64") 175 - ); 176 - assert_eq!( 177 - asset_name_for_target("android", "aarch64"), 178 - Some("leaf-android-arm64") 179 - ); 180 - assert_eq!( 181 - asset_name_for_target("windows", "x86_64"), 182 - Some("leaf-windows-x86_64.exe") 183 - ); 184 - assert_eq!(asset_name_for_target("linux", "arm"), None); 185 - } 186 - 187 - #[test] 188 - fn newer_version_comparison_accepts_optional_v_prefix() { 189 - assert!(is_newer_version("1.4.2", "v1.4.3").unwrap()); 190 - assert!(!is_newer_version("1.4.2", "1.4.2").unwrap()); 191 - assert!(!is_newer_version("1.4.2", "1.4.1").unwrap()); 192 - } 193 - 194 - #[test] 195 - fn expected_asset_download_url_selects_matching_asset() { 196 - let assets = vec![ 197 - TestAsset { 198 - name: "leaf-linux-x86_64", 199 - download_url: "https://example.test/linux", 200 - }, 201 - TestAsset { 202 - name: "leaf-windows-x86_64.exe", 203 - download_url: "https://example.test/windows", 204 - }, 205 - ]; 206 - 207 - let url = expected_asset_download_url("1.4.3", &assets, "leaf-linux-x86_64").unwrap(); 208 - assert_eq!(url, "https://example.test/linux"); 209 - } 210 - 211 - #[test] 212 - fn expected_asset_download_url_errors_when_asset_is_missing() { 213 - let assets = vec![TestAsset { 214 - name: "leaf-linux-x86_64", 215 - download_url: "https://example.test/linux", 216 - }]; 217 - 218 - let err = expected_asset_download_url("1.4.3", &assets, "leaf-macos-arm64").unwrap_err(); 219 - assert!(err.to_string().contains("does not contain asset")); 220 - } 221 - 222 - #[test] 223 - fn validate_download_size_accepts_matching_non_zero_sizes() { 224 - assert!(validate_download_size(Some(42), 42).is_ok()); 225 - assert!(validate_download_size(None, 42).is_ok()); 226 - } 227 - 228 - #[test] 229 - fn validate_download_size_rejects_zero_or_mismatched_sizes() { 230 - let empty_err = validate_download_size(None, 0).unwrap_err(); 231 - assert!(empty_err.to_string().contains("is empty")); 232 - 233 - let mismatch_err = validate_download_size(Some(42), 41).unwrap_err(); 234 - assert!(mismatch_err.to_string().contains("size mismatch")); 235 - } 236 - 237 - #[test] 238 - fn find_expected_checksum_extracts_matching_asset_checksum() { 239 - let checksums = "\ 240 - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa leaf-linux-x86_64 241 - bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb leaf-windows-x86_64.exe 242 - "; 243 - 244 - let checksum = find_expected_checksum(checksums, "leaf-windows-x86_64.exe").unwrap(); 245 - assert_eq!( 246 - checksum, 247 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 248 - ); 249 - } 250 - 251 - #[test] 252 - fn find_expected_checksum_rejects_missing_or_invalid_entries() { 253 - let missing = 254 - find_expected_checksum("abcd leaf-linux-x86_64\n", "leaf-macos-arm64").unwrap_err(); 255 - assert!(missing.to_string().contains("does not contain")); 256 - 257 - let invalid = 258 - find_expected_checksum("xyz leaf-linux-x86_64\n", "leaf-linux-x86_64").unwrap_err(); 259 - assert!(invalid 260 - .to_string() 261 - .contains("Invalid SHA256 checksum format")); 262 - } 263 - 264 - #[test] 265 - fn validate_sha256_hex_accepts_expected_format() { 266 - assert!(validate_sha256_hex( 267 - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 268 - ) 269 - .is_ok()); 270 - } 271 - 272 - #[test] 273 - fn cancelling_search_clears_query_and_matches() { 274 - let (ss, theme) = test_assets(); 275 - let (lines, toc) = parse_markdown("alpha\nbeta\nalpha beta\n", &ss, &theme); 276 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 277 - 278 - app.set_search_query("alpha"); 279 - app.run_search(); 280 - 281 - app.begin_search(); 282 - app.set_search_draft("alpha gamma"); 283 - app.cancel_search(); 284 - 285 - assert!(!app.is_search_mode()); 286 - assert!(app.search_draft().is_empty()); 287 - assert!(app.search_query().is_empty()); 288 - assert!(app.search_matches().is_empty()); 289 - assert_eq!(app.search_index(), 0); 290 - } 291 - 292 - #[test] 293 - fn confirm_search_uses_draft_and_updates_matches() { 294 - let (ss, theme) = test_assets(); 295 - let (lines, toc) = parse_markdown("alpha\nbeta\nbeta\n", &ss, &theme); 296 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 297 - 298 - app.begin_search(); 299 - app.set_search_draft("beta"); 300 - app.confirm_search(); 301 - 302 - assert!(!app.is_search_mode()); 303 - assert!(app.search_draft().is_empty()); 304 - assert_eq!(app.search_query(), "beta"); 305 - assert_eq!(app.search_match_count(), 2); 306 - } 307 - 308 - #[test] 309 - fn confirm_search_with_new_query_restarts_from_first_match() { 310 - let (ss, theme) = test_assets(); 311 - let (lines, toc) = parse_markdown("alpha\nbeta\nbeta again\n", &ss, &theme); 312 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 313 - 314 - app.set_search_query("alpha"); 315 - app.run_search(); 316 - 317 - app.begin_search(); 318 - app.set_search_draft("beta"); 319 - app.confirm_search(); 320 - 321 - assert_eq!(app.search_query(), "beta"); 322 - assert_eq!(app.search_index(), 0); 323 - assert_eq!(app.scroll(), app.search_matches()[0]); 324 - assert_eq!(app.search_match_count(), 2); 325 - } 326 - 327 - #[test] 328 - fn enter_in_normal_mode_advances_active_search() { 329 - let (ss, theme) = test_assets(); 330 - let (lines, toc) = parse_markdown("alpha\nbeta alpha\nalpha again\n", &ss, &theme); 331 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 332 - 333 - app.set_search_query("alpha"); 334 - app.run_search(); 335 - let second_match = app.search_matches()[1]; 336 - 337 - app.next_match(); 338 - 339 - assert_eq!(app.search_index(), 1); 340 - assert_eq!(app.scroll(), second_match); 341 - } 342 - 343 - #[test] 344 - fn ctrl_c_cancels_search_prompt_and_clears_active_query() { 345 - let (ss, theme) = test_assets(); 346 - let (lines, toc) = parse_markdown("alpha\nbeta\n", &ss, &theme); 347 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 348 - 349 - app.set_search_query("alpha"); 350 - app.run_search(); 351 - 352 - app.begin_search(); 353 - app.push_search_draft('z'); 354 - app.cancel_search(); 355 - 356 - assert!(!app.is_search_mode()); 357 - assert!(app.search_query().is_empty()); 358 - assert!(app.search_matches().is_empty()); 359 - assert_eq!(app.search_index(), 0); 360 - } 361 - 362 - #[test] 363 - fn esc_clears_active_search_from_normal_mode() { 364 - let (ss, theme) = test_assets(); 365 - let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 366 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 367 - 368 - app.set_search_query("alpha"); 369 - app.run_search(); 370 - app.clear_active_search(); 371 - 372 - assert!(!app.is_search_mode()); 373 - assert!(app.search_draft().is_empty()); 374 - assert!(app.search_query().is_empty()); 375 - assert!(app.search_matches().is_empty()); 376 - assert_eq!(app.search_index(), 0); 377 - } 378 - 379 - #[test] 380 - fn ctrl_c_clears_active_search_before_exit() { 381 - let (ss, theme) = test_assets(); 382 - let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 383 - let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 384 - 385 - app.set_search_query("alpha"); 386 - app.run_search(); 387 - app.clear_active_search(); 388 - 389 - assert!(!app.has_active_search()); 390 - assert!(app.search_query().is_empty()); 391 - assert!(app.search_matches().is_empty()); 392 - } 393 - 394 - #[test] 395 - fn active_highlight_line_is_none_without_search_matches() { 396 - let (ss, theme) = test_assets(); 397 - let (lines, toc) = parse_markdown("alpha\nbeta\n", &ss, &theme); 398 - let app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 399 - 400 - assert_eq!(app.active_highlight_line(), None); 401 - } 402 - 403 - #[test] 404 - fn code_block_box_renders_right_border_in_one_column() { 405 - let (ss, theme) = test_assets(); 406 - let md = "```ts\nconst city = \"東京\";\n\tconsole.log(city)\n```"; 407 - let (lines, _) = parse_markdown(md, &ss, &theme); 408 - let buffer = render_buffer(&lines); 409 - 410 - let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 411 - let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 412 - 413 - for y in start_y + 1..end_y { 414 - assert_eq!( 415 - buffer.cell((right_x, y)).unwrap().symbol(), 416 - "│", 417 - "missing code block right border at row {y}" 418 - ); 419 - } 420 - } 421 - 422 - #[test] 423 - fn table_render_right_border_stays_aligned() { 424 - let (ss, theme) = test_assets(); 425 - let md = "| Name | Value |\n| --- | --- |\n| 東京 | 12 |\n| tab\tcell | ok |"; 426 - let (lines, _) = parse_markdown(md, &ss, &theme); 427 - let buffer = render_buffer(&lines); 428 - 429 - let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 430 - let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 431 - 432 - for y in start_y + 1..end_y { 433 - let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 434 - assert!( 435 - matches!(symbol, "│" | "┤" | "╡"), 436 - "unexpected table edge symbol {symbol:?} at row {y}" 437 - ); 438 - } 439 - } 440 - 441 - #[test] 442 - fn table_render_right_border_stays_aligned_with_emoji_cells() { 443 - let (ss, theme) = test_assets(); 444 - let md = "| Critère | Note |\n| --- | --- |\n| Tests | ✅ Bonne couverture |\n| Sécurité | ⚠ Quelques points |\n"; 445 - let (lines, _) = parse_markdown(md, &ss, &theme); 446 - let buffer = render_buffer(&lines); 447 - 448 - let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 449 - let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 450 - 451 - for y in start_y + 1..end_y { 452 - let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 453 - assert!( 454 - matches!(symbol, "│" | "┤" | "╡"), 455 - "unexpected emoji-table edge symbol {symbol:?} at row {y}" 456 - ); 457 - } 458 - } 459 - 460 - #[test] 461 - fn narrow_tables_fit_render_width_and_wrap_cells() { 462 - let (ss, theme) = test_assets(); 463 - let md = "| Column | Description | Value |\n| --- | --- | ---: |\n| Width | Terminal-dependent layout behavior | 80 |\n"; 464 - let (lines, _) = parse_markdown_with_width(md, &ss, &theme, 36); 465 - let rendered = rendered_non_empty_lines(&lines); 466 - 467 - assert!(rendered.len() >= 6); 468 - assert!(rendered.iter().all(|line| display_width(line) <= 36)); 469 - } 470 - 471 - #[test] 472 - fn h1_headings_render_double_rule_without_bottom_spacing() { 473 - let (ss, theme) = test_assets(); 474 - let (lines, _) = parse_markdown("# 東京\n", &ss, &theme); 475 - let rendered = rendered_non_empty_lines(&lines); 476 - 477 - assert_eq!(rendered[0], "東京"); 478 - assert_eq!(rendered[1], "═".repeat(display_width("東京"))); 479 - } 480 - 481 - #[test] 482 - fn loose_list_items_keep_their_markers() { 483 - let (ss, theme) = test_assets(); 484 - let (lines, _) = parse_markdown("- first\n\n- second\n", &ss, &theme); 485 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 486 - 487 - assert!(rendered.iter().any(|line| line.contains("• first"))); 488 - assert!(rendered.iter().any(|line| line.contains("• second"))); 489 - } 490 - 491 - #[test] 492 - fn ordered_lists_render_numeric_markers() { 493 - let (ss, theme) = test_assets(); 494 - let (lines, _) = parse_markdown("3. third\n4. fourth\n", &ss, &theme); 495 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 496 - 497 - assert!(rendered.iter().any(|line| line.contains("3. third"))); 498 - assert!(rendered.iter().any(|line| line.contains("4. fourth"))); 499 - } 500 - 501 - #[test] 502 - fn multiline_list_items_keep_marker_only_on_first_line() { 503 - let (ss, theme) = test_assets(); 504 - let (lines, _) = parse_markdown("- first line\n second line\n", &ss, &theme); 505 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 506 - 507 - let first = rendered 508 - .iter() 509 - .find(|line| line.contains("first line")) 510 - .unwrap(); 511 - let second = rendered 512 - .iter() 513 - .find(|line| line.contains("second line")) 514 - .unwrap(); 515 - 516 - assert!(first.contains("• first line")); 517 - assert!(!second.contains('•')); 518 - assert!(second.starts_with(" ")); 519 - } 520 - 521 - #[test] 522 - fn ordered_lists_preserve_non_default_start_numbers() { 523 - let (ss, theme) = test_assets(); 524 - let (lines, _) = parse_markdown("7. seven\n8. eight\n", &ss, &theme); 525 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 526 - 527 - assert!(rendered.iter().any(|line| line.contains("7. seven"))); 528 - assert!(rendered.iter().any(|line| line.contains("8. eight"))); 529 - } 530 - 531 - #[test] 532 - fn loose_list_items_render_expected_lines() { 533 - let (ss, theme) = test_assets(); 534 - let src = "- first loose item\n\n- second loose item after a blank line\n\n- third loose item\n\n continuation paragraph\n"; 535 - let (lines, _) = parse_markdown(src, &ss, &theme); 536 - let rendered = rendered_non_empty_lines(&lines); 537 - 538 - assert_eq!( 539 - rendered, 540 - vec![ 541 - "• first loose item", 542 - "• second loose item after a blank line", 543 - "• third loose item", 544 - " continuation paragraph", 545 - ] 546 - ); 547 - } 548 - 549 - #[test] 550 - fn ordered_loose_lists_render_expected_lines() { 551 - let (ss, theme) = test_assets(); 552 - let src = "7. seventh item\n\n8. eighth item\n\n continuation paragraph\n"; 553 - let (lines, _) = parse_markdown(src, &ss, &theme); 554 - let rendered = rendered_non_empty_lines(&lines); 555 - 556 - assert_eq!( 557 - rendered, 558 - vec![ 559 - "7. seventh item", 560 - "8. eighth item", 561 - " continuation paragraph", 562 - ] 563 - ); 564 - } 565 - 566 - #[test] 567 - fn ordered_lists_render_expected_lines() { 568 - let (ss, theme) = test_assets(); 569 - let (lines, _) = parse_markdown("3. third item\n4. fourth item\n", &ss, &theme); 570 - let rendered = rendered_non_empty_lines(&lines); 571 - 572 - assert_eq!(rendered, vec!["3. third item", "4. fourth item"]); 573 - } 574 - 575 - #[test] 576 - fn paragraph_and_following_list_have_no_blank_gap() { 577 - let (ss, theme) = test_assets(); 578 - let (lines, _) = parse_markdown("Intro paragraph\n\n- first\n- second\n", &ss, &theme); 579 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 580 - let intro_idx = rendered 581 - .iter() 582 - .position(|line| line == "Intro paragraph") 583 - .unwrap(); 584 - 585 - assert_eq!(rendered[intro_idx + 1], "• first"); 586 - } 587 - 588 - #[test] 589 - fn wrapped_list_items_align_continuation_under_text() { 590 - let (ss, theme) = test_assets(); 591 - let src = "- First item with enough text to wrap when the terminal is narrow and show continuation alignment.\n8. Eighth item with enough text to wrap and keep numeric alignment readable.\n"; 592 - let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 36); 593 - let rendered = rendered_non_empty_lines(&lines); 594 - 595 - assert!(rendered.iter().any(|line| line.starts_with("• First item"))); 596 - assert!(rendered 597 - .iter() 598 - .any(|line| line.starts_with(" ") && line.contains("terminal is narrow"))); 599 - assert!(rendered 600 - .iter() 601 - .any(|line| line.starts_with("8. Eighth item"))); 602 - assert!(rendered 603 - .iter() 604 - .any(|line| line.starts_with(" ") && !line.starts_with("8. "))); 605 - } 606 - 607 - #[test] 608 - fn paragraph_and_following_code_block_have_no_blank_gap() { 609 - let (ss, theme) = test_assets(); 610 - let src = "Intro paragraph\n\n```rs\nfn main() {}\n```\n"; 611 - let (lines, _) = parse_markdown(src, &ss, &theme); 612 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 613 - let intro_idx = rendered 614 - .iter() 615 - .position(|line| line == "Intro paragraph") 616 - .unwrap(); 617 - 618 - assert!(rendered[intro_idx + 1].starts_with("┌─ rs ")); 619 - } 620 - 621 - #[test] 622 - fn nested_blockquotes_keep_quote_prefix_after_inner_quote_ends() { 623 - let (ss, theme) = test_assets(); 624 - let src = "> outer\n> > inner\n> outer again\n"; 625 - let (lines, _) = parse_markdown(src, &ss, &theme); 626 - let rendered = rendered_non_empty_lines(&lines); 627 - 628 - assert!(rendered.iter().any(|line| line == "▏ outer")); 629 - assert!(rendered.iter().any(|line| line == "▏ inner")); 630 - assert!(rendered.iter().any(|line| line == "▏ outer again")); 631 - } 632 - 633 - #[test] 634 - fn long_blockquotes_wrap_into_multiple_prefixed_lines() { 635 - let (ss, theme) = test_assets(); 636 - let src = "> This is a long blockquote line that should wrap into multiple quoted lines at narrow widths.\n"; 637 - let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 28); 638 - let rendered = rendered_non_empty_lines(&lines); 639 - let quoted: Vec<_> = rendered 640 - .into_iter() 641 - .filter(|line| line.starts_with('▏')) 642 - .collect(); 643 - 644 - assert!(quoted.len() >= 2); 645 - assert!(quoted.iter().all(|line| line.starts_with("▏ "))); 646 - } 647 - 648 - #[test] 649 - fn toc_only_includes_first_two_heading_levels() { 650 - let (ss, theme) = test_assets(); 651 - let (_, toc) = parse_markdown("# One\n## Two\n### Three\n#### Four\n", &ss, &theme); 652 - 653 - assert_eq!(toc.len(), 3); 654 - assert_eq!(toc[0].level, 1); 655 - assert_eq!(toc[1].level, 2); 656 - assert_eq!(toc[2].level, 3); 657 - } 658 - 659 - #[test] 660 - fn frontmatter_is_ignored_in_preview_and_toc() { 661 - let (ss, theme) = test_assets(); 662 - let src = "---\ntitle: Demo\nowner: me\n---\n# Visible\nBody\n"; 663 - let (lines, toc) = parse_markdown(src, &ss, &theme); 664 - let rendered = rendered_non_empty_lines(&lines); 665 - 666 - assert!(!rendered.iter().any(|line| line.contains("title: Demo"))); 667 - assert!(rendered.iter().any(|line| line.contains("Visible"))); 668 - assert_eq!(toc.len(), 1); 669 - assert_eq!(toc[0].title, "Visible"); 670 - } 671 - 672 - #[test] 673 - fn h2_headings_are_underlined_and_compact() { 674 - let (ss, theme) = test_assets(); 675 - let (lines, _) = parse_markdown_with_width("Intro\n\n## Section\nBody\n", &ss, &theme, 40); 676 - let rendered = rendered_non_empty_lines(&lines); 677 - 678 - assert!(rendered.iter().any(|line| line.contains("Section"))); 679 - assert!(rendered.iter().any(|line| line.contains("────"))); 680 - } 681 - 682 - #[test] 683 - fn rules_use_render_width_without_extra_blank_after() { 684 - let (ss, theme) = test_assets(); 685 - let (lines, _) = parse_markdown_with_width("Alpha\n\n---\nBeta\n", &ss, &theme, 24); 686 - let rendered = rendered_non_empty_lines(&lines); 687 - let rule = rendered 688 - .iter() 689 - .find(|line| line.trim_start().starts_with('─')) 690 - .unwrap(); 691 - 692 - assert_eq!(display_width(rule.trim_start()), 24); 693 - let rule_idx = rendered.iter().position(|line| line == rule).unwrap(); 694 - assert_eq!(rendered[rule_idx + 1], "Beta"); 695 - } 696 - 697 - #[test] 698 - fn toc_hides_single_h1_when_h2_entries_exist() { 699 - let toc = vec![ 700 - TocEntry { 701 - level: 1, 702 - title: "Doc Title".to_string(), 703 - line: 0, 704 - }, 705 - TocEntry { 706 - level: 2, 707 - title: "Install".to_string(), 708 - line: 10, 709 - }, 710 - ]; 711 - 712 - assert!(should_hide_single_h1(&toc)); 713 - assert_eq!(toc_display_level(2, true, false), 1); 714 - assert_eq!(toc_display_level(3, true, false), 2); 715 - } 716 - 717 - #[test] 718 - fn toc_keeps_single_h1_when_no_h2_entries_exist() { 719 - let toc = vec![TocEntry { 720 - level: 1, 721 - title: "Doc Title".to_string(), 722 - line: 0, 723 - }]; 724 - 725 - assert!(!should_hide_single_h1(&toc)); 726 - } 727 - 728 - #[test] 729 - fn toc_promotes_h2_when_document_has_no_h1() { 730 - let toc = vec![ 731 - TocEntry { 732 - level: 2, 733 - title: "Build & install".to_string(), 734 - line: 0, 735 - }, 736 - TocEntry { 737 - level: 3, 738 - title: "Android".to_string(), 739 - line: 4, 740 - }, 741 - ]; 742 - 743 - assert!(should_promote_h2_when_no_h1(&toc)); 744 - assert_eq!(toc_display_level(2, false, true), 1); 745 - assert_eq!(toc_display_level(3, false, true), 2); 746 - let normalized = normalize_toc(toc); 747 - assert_eq!(normalized.len(), 2); 748 - assert_eq!(normalized[0].level, 2); 749 - assert_eq!(normalized[1].level, 3); 750 - } 751 - 752 - #[test] 753 - fn parse_theme_preset_supports_ocean_and_forest() { 754 - assert_eq!(parse_theme_preset("arctic"), Some(ThemePreset::Arctic)); 755 - assert_eq!(parse_theme_preset("ocean"), Some(ThemePreset::OceanDark)); 756 - assert_eq!(parse_theme_preset("forest"), Some(ThemePreset::Forest)); 757 - assert_eq!( 758 - parse_theme_preset("solarized-dark"), 759 - Some(ThemePreset::SolarizedDark) 760 - ); 761 - assert_eq!(parse_theme_preset("nope"), None); 762 - } 763 - 764 - #[test] 765 - fn resolve_syntax_supports_common_language_aliases() { 766 - let ss = SyntaxSet::load_defaults_newlines(); 767 - 768 - assert_eq!( 769 - resolve_syntax("py", &ss).name, 770 - resolve_syntax("python", &ss).name 771 - ); 772 - assert_eq!( 773 - resolve_syntax("cpp", &ss).name, 774 - resolve_syntax("c++", &ss).name 775 - ); 776 - assert_eq!(resolve_syntax("json", &ss).name, "JSON"); 777 - assert_eq!( 778 - resolve_syntax("ps1", &ss).name, 779 - resolve_syntax("powershell", &ss).name 780 - ); 781 - } 782 - 783 - #[test] 784 - fn theme_presets_are_in_alphabetical_order() { 785 - let labels: Vec<_> = THEME_PRESETS 786 - .iter() 787 - .map(|preset| theme_preset_label(*preset)) 788 - .collect(); 789 - let mut sorted = labels.clone(); 790 - sorted.sort(); 791 - assert_eq!(labels, sorted); 792 - } 793 - 794 - #[test] 795 - fn theme_picker_restores_original_preset_on_escape() { 796 - let _guard = lock_theme_test_state(); 797 - let (ss, theme) = test_assets(); 798 - let ts = ThemeSet::load_defaults(); 799 - let (lines, toc) = parse_markdown("# Demo\n", &ss, &theme); 800 - let mut app = App::new_with_source( 801 - lines, 802 - toc, 803 - AppConfig { 804 - filename: "stdin".to_string(), 805 - source: "# Demo\n".to_string(), 806 - debug_input: false, 807 - watch: false, 808 - filepath: None, 809 - last_file_state: None, 810 - }, 811 - ); 812 - 813 - let original = current_theme_preset(); 814 - set_theme_preset(ThemePreset::OceanDark); 815 - app.open_theme_picker(); 816 - assert!(app.set_theme_picker_index(theme_preset_index(ThemePreset::Forest))); 817 - app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 818 - 819 - assert_eq!(current_theme_preset(), ThemePreset::Forest); 820 - 821 - app.restore_theme_picker_preview(&ss, &ts); 822 - 823 - assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 824 - assert!(!app.is_theme_picker_open()); 825 - assert_eq!(app.theme_picker_original(), None); 826 - set_theme_preset(original); 827 - } 828 - 829 - #[test] 830 - fn theme_picker_caches_previewed_themes_for_reuse() { 831 - let _guard = lock_theme_test_state(); 832 - let (ss, theme) = test_assets(); 833 - let ts = ThemeSet::load_defaults(); 834 - let (lines, toc) = parse_markdown("# Demo\n\n```rs\nfn main() {}\n```\n", &ss, &theme); 835 - let mut app = App::new_with_source( 836 - lines, 837 - toc, 838 - AppConfig { 839 - filename: "stdin".to_string(), 840 - source: "# Demo\n\n```rs\nfn main() {}\n```\n".to_string(), 841 - debug_input: false, 842 - watch: false, 843 - filepath: None, 844 - last_file_state: None, 845 - }, 846 - ); 847 - 848 - let original = current_theme_preset(); 849 - set_theme_preset(ThemePreset::OceanDark); 850 - app.open_theme_picker(); 851 - app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 852 - 853 - assert!(app.has_cached_theme_preview(ThemePreset::Forest)); 854 - assert_eq!(current_theme_preset(), ThemePreset::Forest); 855 - 856 - app.preview_theme_preset(ThemePreset::OceanDark, &ss, &ts); 857 - assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 858 - assert!(app.has_cached_theme_preview(ThemePreset::OceanDark)); 859 - set_theme_preset(original); 860 - } 861 - 862 - #[test] 863 - fn file_picker_lists_dirs_then_markdown_files_only() { 864 - let unique = SystemTime::now() 865 - .duration_since(UNIX_EPOCH) 866 - .unwrap() 867 - .as_nanos(); 868 - let root = std::env::temp_dir().join(format!("leaf-picker-test-{unique}")); 869 - let _ = fs::remove_dir_all(&root); 870 - fs::create_dir_all(root.join("notes")).unwrap(); 871 - fs::write(root.join("README.md"), "# Demo\n").unwrap(); 872 - fs::write(root.join("draft.markdown"), "# Draft\n").unwrap(); 873 - fs::write(root.join("ignore.txt"), "nope\n").unwrap(); 874 - 875 - let mut app = App::new_with_source( 876 - Vec::new(), 877 - Vec::new(), 878 - AppConfig { 879 - filename: "picker".to_string(), 880 - source: String::new(), 881 - debug_input: false, 882 - watch: false, 883 - filepath: None, 884 - last_file_state: None, 885 - }, 886 - ); 887 - 888 - assert!(app.open_file_picker(root.clone())); 889 - 890 - let labels: Vec<_> = app 891 - .file_picker_entries() 892 - .iter() 893 - .map(|entry| entry.label()) 894 - .collect(); 895 - assert!(labels.contains(&"notes/")); 896 - assert!(labels.contains(&"README.md")); 897 - assert!(labels.contains(&"draft.markdown")); 898 - assert!(!labels.contains(&"ignore.txt")); 899 - 900 - let notes_idx = labels.iter().position(|label| *label == "notes/").unwrap(); 901 - let readme_idx = labels 902 - .iter() 903 - .position(|label| *label == "README.md") 904 - .unwrap(); 905 - assert!(notes_idx < readme_idx); 906 - 907 - let _ = fs::remove_dir_all(root); 908 - } 909 - 910 - #[test] 911 - fn fuzzy_file_picker_lists_markdown_files_from_subdirectories() { 912 - let unique = SystemTime::now() 913 - .duration_since(UNIX_EPOCH) 914 - .unwrap() 915 - .as_nanos(); 916 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-test-{unique}")); 917 - let _ = fs::remove_dir_all(&root); 918 - fs::create_dir_all(root.join("docs/nested")).unwrap(); 919 - fs::write(root.join("README.md"), "# Demo\n").unwrap(); 920 - fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 921 - fs::write(root.join("docs/nested/deep.markdown"), "# Deep\n").unwrap(); 922 - fs::write(root.join("ignore.txt"), "nope\n").unwrap(); 923 - 924 - let mut app = App::new_with_source( 925 - Vec::new(), 926 - Vec::new(), 927 - AppConfig { 928 - filename: "picker".to_string(), 929 - source: String::new(), 930 - debug_input: false, 931 - watch: false, 932 - filepath: None, 933 - last_file_state: None, 934 - }, 935 - ); 936 - 937 - assert!(app.open_fuzzy_file_picker(root.clone())); 938 - assert!(app.is_fuzzy_file_picker()); 939 - 940 - let labels: Vec<_> = app 941 - .file_picker_filtered_indices() 942 - .iter() 943 - .map(|idx| app.file_picker_entries()[*idx].label()) 944 - .collect(); 945 - assert!(labels.contains(&"README.md")); 946 - assert!(labels.contains(&"docs/guide.md")); 947 - assert!(labels.contains(&"docs/nested/deep.markdown")); 948 - assert!(!labels.contains(&"ignore.txt")); 949 - 950 - let _ = fs::remove_dir_all(root); 951 - } 952 - 953 - #[test] 954 - fn queued_fuzzy_picker_transitions_from_pending_to_loading_to_open() { 955 - let unique = SystemTime::now() 956 - .duration_since(UNIX_EPOCH) 957 - .unwrap() 958 - .as_nanos(); 959 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-queued-{unique}")); 960 - let _ = fs::remove_dir_all(&root); 961 - fs::create_dir_all(root.join("docs")).unwrap(); 962 - fs::write(root.join("README.md"), "# Demo\n").unwrap(); 963 - fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 964 - 965 - let mut app = App::new_with_source( 966 - Vec::new(), 967 - Vec::new(), 968 - AppConfig { 969 - filename: "picker".to_string(), 970 - source: String::new(), 971 - debug_input: false, 972 - watch: false, 973 - filepath: None, 974 - last_file_state: None, 975 - }, 976 - ); 977 - 978 - app.queue_fuzzy_file_picker(root.clone()); 979 - assert!(app.has_pending_picker()); 980 - assert_eq!( 981 - app.pending_picker_mode(), 982 - Some(crate::app::FilePickerMode::Fuzzy) 983 - ); 984 - assert_eq!(app.pending_picker_dir(), Some(root.as_path())); 985 - assert!(!app.is_picker_loading()); 986 - assert!(app.start_pending_picker_loading()); 987 - assert!(app.is_picker_loading()); 988 - app.age_picker_loading_by(std::time::Duration::from_secs(1)); 989 - let mut opened = false; 990 - for _ in 0..50 { 991 - if app.poll_picker_loading() { 992 - opened = app.is_file_picker_open(); 993 - break; 994 - } 995 - std::thread::sleep(std::time::Duration::from_millis(10)); 996 - } 997 - assert!(opened); 998 - assert!(app.is_file_picker_open()); 999 - assert!(app.is_fuzzy_file_picker()); 1000 - assert!(!app.has_pending_picker()); 1001 - assert!(!app.is_picker_loading()); 1002 - 1003 - let labels: Vec<_> = app 1004 - .file_picker_filtered_indices() 1005 - .iter() 1006 - .map(|idx| app.file_picker_entries()[*idx].label()) 1007 - .collect(); 1008 - assert!(labels.contains(&"README.md")); 1009 - assert!(labels.contains(&"docs/guide.md")); 1010 - 1011 - let _ = fs::remove_dir_all(root); 1012 - } 1013 - 1014 - #[test] 1015 - fn fuzzy_file_picker_uses_depth_first_order_with_hidden_first() { 1016 - let unique = SystemTime::now() 1017 - .duration_since(UNIX_EPOCH) 1018 - .unwrap() 1019 - .as_nanos(); 1020 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-order-{unique}")); 1021 - let _ = fs::remove_dir_all(&root); 1022 - fs::create_dir_all(root.join(".private")).unwrap(); 1023 - fs::create_dir_all(root.join("docs")).unwrap(); 1024 - fs::write(root.join(".draft.md"), "# Hidden\n").unwrap(); 1025 - fs::write(root.join(".private/alpha.md"), "# Private\n").unwrap(); 1026 - fs::write(root.join("README.md"), "# Demo\n").unwrap(); 1027 - fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 1028 - 1029 - let mut app = App::new_with_source( 1030 - Vec::new(), 1031 - Vec::new(), 1032 - AppConfig { 1033 - filename: "picker".to_string(), 1034 - source: String::new(), 1035 - debug_input: false, 1036 - watch: false, 1037 - filepath: None, 1038 - last_file_state: None, 1039 - }, 1040 - ); 1041 - 1042 - assert!(app.open_fuzzy_file_picker(root.clone())); 1043 - 1044 - let labels: Vec<_> = app 1045 - .file_picker_filtered_indices() 1046 - .iter() 1047 - .map(|idx| app.file_picker_entries()[*idx].label()) 1048 - .collect(); 1049 - assert_eq!( 1050 - labels, 1051 - vec![ 1052 - ".draft.md", 1053 - "README.md", 1054 - ".private/alpha.md", 1055 - "docs/guide.md", 1056 - ] 1057 - ); 1058 - 1059 - let _ = fs::remove_dir_all(root); 1060 - } 1061 - 1062 - #[test] 1063 - fn fuzzy_file_picker_uses_depth_first_file_order() { 1064 - let unique = SystemTime::now() 1065 - .duration_since(UNIX_EPOCH) 1066 - .unwrap() 1067 - .as_nanos(); 1068 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-bfs-{unique}")); 1069 - let _ = fs::remove_dir_all(&root); 1070 - fs::create_dir_all(root.join("a/deep")).unwrap(); 1071 - fs::create_dir_all(root.join("b")).unwrap(); 1072 - fs::write(root.join("z-root.md"), "# Root\n").unwrap(); 1073 - fs::write(root.join("a/a-child.md"), "# Child A\n").unwrap(); 1074 - fs::write(root.join("b/b-child.md"), "# Child B\n").unwrap(); 1075 - fs::write(root.join("a/deep/a-deep.md"), "# Deep\n").unwrap(); 1076 - 1077 - let mut app = App::new_with_source( 1078 - Vec::new(), 1079 - Vec::new(), 1080 - AppConfig { 1081 - filename: "picker".to_string(), 1082 - source: String::new(), 1083 - debug_input: false, 1084 - watch: false, 1085 - filepath: None, 1086 - last_file_state: None, 1087 - }, 1088 - ); 1089 - 1090 - assert!(app.open_fuzzy_file_picker(root.clone())); 1091 - 1092 - let labels: Vec<_> = app 1093 - .file_picker_filtered_indices() 1094 - .iter() 1095 - .map(|idx| app.file_picker_entries()[*idx].label()) 1096 - .collect(); 1097 - assert_eq!( 1098 - labels, 1099 - vec![ 1100 - "z-root.md", 1101 - "a/a-child.md", 1102 - "a/deep/a-deep.md", 1103 - "b/b-child.md" 1104 - ] 1105 - ); 1106 - 1107 - let _ = fs::remove_dir_all(root); 1108 - } 1109 - 1110 - #[test] 1111 - fn fuzzy_file_picker_keeps_depth_first_order_when_query_is_empty() { 1112 - let unique = SystemTime::now() 1113 - .duration_since(UNIX_EPOCH) 1114 - .unwrap() 1115 - .as_nanos(); 1116 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-empty-query-{unique}")); 1117 - let _ = fs::remove_dir_all(&root); 1118 - fs::create_dir_all(root.join(".nvm")).unwrap(); 1119 - fs::create_dir_all(root.join("projects")).unwrap(); 1120 - fs::write(root.join(".nvm/README.md"), "# Hidden Readme\n").unwrap(); 1121 - fs::write(root.join(".nvm/ROADMAP.md"), "# Hidden Roadmap\n").unwrap(); 1122 - fs::write(root.join("projects/README.md"), "# Project Readme\n").unwrap(); 1123 - 1124 - let mut app = App::new_with_source( 1125 - Vec::new(), 1126 - Vec::new(), 1127 - AppConfig { 1128 - filename: "picker".to_string(), 1129 - source: String::new(), 1130 - debug_input: false, 1131 - watch: false, 1132 - filepath: None, 1133 - last_file_state: None, 1134 - }, 1135 - ); 1136 - 1137 - assert!(app.open_fuzzy_file_picker(root.clone())); 1138 - 1139 - let labels: Vec<_> = app 1140 - .file_picker_filtered_indices() 1141 - .iter() 1142 - .map(|idx| app.file_picker_entries()[*idx].label()) 1143 - .collect(); 1144 - assert_eq!( 1145 - labels, 1146 - vec![".nvm/README.md", ".nvm/ROADMAP.md", "projects/README.md"] 1147 - ); 1148 - 1149 - let _ = fs::remove_dir_all(root); 1150 - } 1151 - 1152 - #[test] 1153 - fn fuzzy_file_picker_filters_entries_by_query() { 1154 - let unique = SystemTime::now() 1155 - .duration_since(UNIX_EPOCH) 1156 - .unwrap() 1157 - .as_nanos(); 1158 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-query-{unique}")); 1159 - let _ = fs::remove_dir_all(&root); 1160 - fs::create_dir_all(root.join("docs")).unwrap(); 1161 - fs::write(root.join("README.md"), "# Demo\n").unwrap(); 1162 - fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 1163 - 1164 - let mut app = App::new_with_source( 1165 - Vec::new(), 1166 - Vec::new(), 1167 - AppConfig { 1168 - filename: "picker".to_string(), 1169 - source: String::new(), 1170 - debug_input: false, 1171 - watch: false, 1172 - filepath: None, 1173 - last_file_state: None, 1174 - }, 1175 - ); 1176 - 1177 - assert!(app.open_fuzzy_file_picker(root.clone())); 1178 - app.push_file_picker_query('g'); 1179 - app.push_file_picker_query('u'); 1180 - 1181 - let labels: Vec<_> = app 1182 - .file_picker_filtered_indices() 1183 - .iter() 1184 - .map(|idx| app.file_picker_entries()[*idx].label()) 1185 - .collect(); 1186 - assert_eq!(labels, vec!["docs/guide.md"]); 1187 - 1188 - let _ = fs::remove_dir_all(root); 1189 - } 1190 - 1191 - #[test] 1192 - fn fuzzy_file_picker_does_not_match_directory_segments() { 1193 - let unique = SystemTime::now() 1194 - .duration_since(UNIX_EPOCH) 1195 - .unwrap() 1196 - .as_nanos(); 1197 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-cla-{unique}")); 1198 - let _ = fs::remove_dir_all(&root); 1199 - fs::create_dir_all(root.join(".notes/backup")).unwrap(); 1200 - fs::write(root.join(".notes/backup/PLAN.md"), "# Plan\n").unwrap(); 1201 - fs::write(root.join("claude.md"), "# Claude\n").unwrap(); 1202 - 1203 - let mut app = App::new_with_source( 1204 - Vec::new(), 1205 - Vec::new(), 1206 - AppConfig { 1207 - filename: "picker".to_string(), 1208 - source: String::new(), 1209 - debug_input: false, 1210 - watch: false, 1211 - filepath: None, 1212 - last_file_state: None, 1213 - }, 1214 - ); 1215 - 1216 - assert!(app.open_fuzzy_file_picker(root.clone())); 1217 - app.push_file_picker_query('c'); 1218 - app.push_file_picker_query('l'); 1219 - app.push_file_picker_query('a'); 1220 - 1221 - let labels: Vec<_> = app 1222 - .file_picker_filtered_indices() 1223 - .iter() 1224 - .map(|idx| app.file_picker_entries()[*idx].label()) 1225 - .collect(); 1226 - assert!(labels.contains(&"claude.md")); 1227 - assert!(!labels.contains(&".notes/backup/PLAN.md")); 1228 - 1229 - let _ = fs::remove_dir_all(root); 1230 - } 1231 - 1232 - #[test] 1233 - fn fuzzy_file_picker_tracks_match_positions_for_highlighting() { 1234 - let unique = SystemTime::now() 1235 - .duration_since(UNIX_EPOCH) 1236 - .unwrap() 1237 - .as_nanos(); 1238 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-highlight-{unique}")); 1239 - let _ = fs::remove_dir_all(&root); 1240 - fs::create_dir_all(&root).unwrap(); 1241 - fs::write(root.join("claude.md"), "# Claude\n").unwrap(); 1242 - 1243 - let mut app = App::new_with_source( 1244 - Vec::new(), 1245 - Vec::new(), 1246 - AppConfig { 1247 - filename: "picker".to_string(), 1248 - source: String::new(), 1249 - debug_input: false, 1250 - watch: false, 1251 - filepath: None, 1252 - last_file_state: None, 1253 - }, 1254 - ); 1255 - 1256 - assert!(app.open_fuzzy_file_picker(root.clone())); 1257 - app.push_file_picker_query('c'); 1258 - app.push_file_picker_query('l'); 1259 - app.push_file_picker_query('a'); 1260 - 1261 - let labels: Vec<_> = app 1262 - .file_picker_filtered_indices() 1263 - .iter() 1264 - .map(|idx| app.file_picker_entries()[*idx].label()) 1265 - .collect(); 1266 - assert_eq!(labels, vec!["claude.md"]); 1267 - assert_eq!(app.file_picker_match_positions(0), &[0, 1, 2]); 1268 - 1269 - let _ = fs::remove_dir_all(root); 1270 - } 1271 - 1272 - #[test] 1273 - fn fuzzy_file_picker_prefers_compact_matches() { 1274 - let unique = SystemTime::now() 1275 - .duration_since(UNIX_EPOCH) 1276 - .unwrap() 1277 - .as_nanos(); 1278 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-compact-{unique}")); 1279 - let _ = fs::remove_dir_all(&root); 1280 - fs::create_dir_all(&root).unwrap(); 1281 - fs::write(root.join("case.md"), "# Case\n").unwrap(); 1282 - fs::write(root.join("ciase.md"), "# Ciase\n").unwrap(); 1283 - 1284 - let mut app = App::new_with_source( 1285 - Vec::new(), 1286 - Vec::new(), 1287 - AppConfig { 1288 - filename: "picker".to_string(), 1289 - source: String::new(), 1290 - debug_input: false, 1291 - watch: false, 1292 - filepath: None, 1293 - last_file_state: None, 1294 - }, 1295 - ); 1296 - 1297 - assert!(app.open_fuzzy_file_picker(root.clone())); 1298 - app.push_file_picker_query('c'); 1299 - app.push_file_picker_query('a'); 1300 - 1301 - let labels: Vec<_> = app 1302 - .file_picker_filtered_indices() 1303 - .iter() 1304 - .map(|idx| app.file_picker_entries()[*idx].label()) 1305 - .collect(); 1306 - assert_eq!(labels, vec!["case.md", "ciase.md"]); 1307 - 1308 - let _ = fs::remove_dir_all(root); 1309 - } 1310 - 1311 - #[test] 1312 - fn fuzzy_file_picker_prefers_contiguous_matches_over_earlier_scattered_matches() { 1313 - let unique = SystemTime::now() 1314 - .duration_since(UNIX_EPOCH) 1315 - .unwrap() 1316 - .as_nanos(); 1317 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-contiguous-{unique}")); 1318 - let _ = fs::remove_dir_all(&root); 1319 - fs::create_dir_all(root.join(".notes/todo")).unwrap(); 1320 - fs::create_dir_all(root.join(".notes/tests")).unwrap(); 1321 - fs::write(root.join(".notes/todo/review-chatgpt.md"), "# ChatGPT\n").unwrap(); 1322 - fs::write(root.join(".notes/tests/themes-showcase.md"), "# Showcase\n").unwrap(); 1323 - 1324 - let mut app = App::new_with_source( 1325 - Vec::new(), 1326 - Vec::new(), 1327 - AppConfig { 1328 - filename: "picker".to_string(), 1329 - source: String::new(), 1330 - debug_input: false, 1331 - watch: false, 1332 - filepath: None, 1333 - last_file_state: None, 1334 - }, 1335 - ); 1336 - 1337 - assert!(app.open_fuzzy_file_picker(root.clone())); 1338 - app.push_file_picker_query('c'); 1339 - app.push_file_picker_query('a'); 1340 - 1341 - let labels: Vec<_> = app 1342 - .file_picker_filtered_indices() 1343 - .iter() 1344 - .map(|idx| app.file_picker_entries()[*idx].label()) 1345 - .collect(); 1346 - let showcase_idx = labels 1347 - .iter() 1348 - .position(|label| *label == ".notes/tests/themes-showcase.md") 1349 - .unwrap(); 1350 - let chatgpt_idx = labels 1351 - .iter() 1352 - .position(|label| *label == ".notes/todo/review-chatgpt.md") 1353 - .unwrap(); 1354 - assert!(showcase_idx < chatgpt_idx); 1355 - 1356 - let _ = fs::remove_dir_all(root); 1357 - } 1358 - 1359 - #[test] 1360 - fn fuzzy_file_picker_prefers_filename_prefix_matches() { 1361 - let unique = SystemTime::now() 1362 - .duration_since(UNIX_EPOCH) 1363 - .unwrap() 1364 - .as_nanos(); 1365 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-prefix-{unique}")); 1366 - let _ = fs::remove_dir_all(&root); 1367 - fs::create_dir_all(&root).unwrap(); 1368 - fs::write(root.join("todo-case.md"), "# Todo\n").unwrap(); 1369 - fs::write(root.join("case-study.md"), "# Case\n").unwrap(); 1370 - 1371 - let mut app = App::new_with_source( 1372 - Vec::new(), 1373 - Vec::new(), 1374 - AppConfig { 1375 - filename: "picker".to_string(), 1376 - source: String::new(), 1377 - debug_input: false, 1378 - watch: false, 1379 - filepath: None, 1380 - last_file_state: None, 1381 - }, 1382 - ); 1383 - 1384 - assert!(app.open_fuzzy_file_picker(root.clone())); 1385 - app.push_file_picker_query('c'); 1386 - app.push_file_picker_query('a'); 1387 - 1388 - let labels: Vec<_> = app 1389 - .file_picker_filtered_indices() 1390 - .iter() 1391 - .map(|idx| app.file_picker_entries()[*idx].label()) 1392 - .collect(); 1393 - assert_eq!(labels, vec!["case-study.md", "todo-case.md"]); 1394 - 1395 - let _ = fs::remove_dir_all(root); 1396 - } 1397 - 1398 - #[test] 1399 - fn fuzzy_file_picker_prefers_token_boundary_matches() { 1400 - let unique = SystemTime::now() 1401 - .duration_since(UNIX_EPOCH) 1402 - .unwrap() 1403 - .as_nanos(); 1404 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-boundary-{unique}")); 1405 - let _ = fs::remove_dir_all(&root); 1406 - fs::create_dir_all(&root).unwrap(); 1407 - fs::write(root.join("alpha-case.md"), "# Boundary\n").unwrap(); 1408 - fs::write(root.join("alphacase.md"), "# Plain\n").unwrap(); 1409 - 1410 - let mut app = App::new_with_source( 1411 - Vec::new(), 1412 - Vec::new(), 1413 - AppConfig { 1414 - filename: "picker".to_string(), 1415 - source: String::new(), 1416 - debug_input: false, 1417 - watch: false, 1418 - filepath: None, 1419 - last_file_state: None, 1420 - }, 1421 - ); 1422 - 1423 - assert!(app.open_fuzzy_file_picker(root.clone())); 1424 - app.push_file_picker_query('c'); 1425 - app.push_file_picker_query('a'); 1426 - 1427 - let labels: Vec<_> = app 1428 - .file_picker_filtered_indices() 1429 - .iter() 1430 - .map(|idx| app.file_picker_entries()[*idx].label()) 1431 - .collect(); 1432 - assert_eq!(labels, vec!["alpha-case.md", "alphacase.md"]); 1433 - 1434 - let _ = fs::remove_dir_all(root); 1435 - } 1436 - 1437 - #[test] 1438 - fn fuzzy_file_picker_prefers_shallower_paths_on_equal_scores() { 1439 - let unique = SystemTime::now() 1440 - .duration_since(UNIX_EPOCH) 1441 - .unwrap() 1442 - .as_nanos(); 1443 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-depth-{unique}")); 1444 - let _ = fs::remove_dir_all(&root); 1445 - fs::create_dir_all(root.join("nested/deeper")).unwrap(); 1446 - fs::write(root.join("case.md"), "# Root\n").unwrap(); 1447 - fs::write(root.join("nested/deeper/case.md"), "# Nested\n").unwrap(); 1448 - 1449 - let mut app = App::new_with_source( 1450 - Vec::new(), 1451 - Vec::new(), 1452 - AppConfig { 1453 - filename: "picker".to_string(), 1454 - source: String::new(), 1455 - debug_input: false, 1456 - watch: false, 1457 - filepath: None, 1458 - last_file_state: None, 1459 - }, 1460 - ); 1461 - 1462 - assert!(app.open_fuzzy_file_picker(root.clone())); 1463 - app.push_file_picker_query('c'); 1464 - app.push_file_picker_query('a'); 1465 - app.push_file_picker_query('s'); 1466 - app.push_file_picker_query('e'); 1467 - 1468 - let labels: Vec<_> = app 1469 - .file_picker_filtered_indices() 1470 - .iter() 1471 - .map(|idx| app.file_picker_entries()[*idx].label()) 1472 - .collect(); 1473 - assert_eq!(labels, vec!["case.md", "nested/deeper/case.md"]); 1474 - 1475 - let _ = fs::remove_dir_all(root); 1476 - } 1477 - 1478 - #[test] 1479 - fn fuzzy_file_picker_allows_q_in_query() { 1480 - let unique = SystemTime::now() 1481 - .duration_since(UNIX_EPOCH) 1482 - .unwrap() 1483 - .as_nanos(); 1484 - let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-q-{unique}")); 1485 - let _ = fs::remove_dir_all(&root); 1486 - fs::create_dir_all(&root).unwrap(); 1487 - fs::write(root.join("query.md"), "# Query\n").unwrap(); 1488 - 1489 - let mut app = App::new_with_source( 1490 - Vec::new(), 1491 - Vec::new(), 1492 - AppConfig { 1493 - filename: "picker".to_string(), 1494 - source: String::new(), 1495 - debug_input: false, 1496 - watch: false, 1497 - filepath: None, 1498 - last_file_state: None, 1499 - }, 1500 - ); 1501 - 1502 - assert!(app.open_fuzzy_file_picker(root.clone())); 1503 - app.push_file_picker_query('q'); 1504 - assert_eq!(app.file_picker_query(), "q"); 1505 - 1506 - let labels: Vec<_> = app 1507 - .file_picker_filtered_indices() 1508 - .iter() 1509 - .map(|idx| app.file_picker_entries()[*idx].label()) 1510 - .collect(); 1511 - assert_eq!(labels, vec!["query.md"]); 1512 - 1513 - let _ = fs::remove_dir_all(root); 1514 - } 1515 - 1516 - #[test] 1517 - fn fuzzy_file_picker_skips_ignored_technical_directories() { 1518 - let root = unique_temp_dir("leaf-fuzzy-picker-ignore"); 1519 - let _ = fs::remove_dir_all(&root); 1520 - fs::create_dir_all(root.join(".git")).unwrap(); 1521 - fs::create_dir_all(root.join("target")).unwrap(); 1522 - fs::create_dir_all(root.join("vendor")).unwrap(); 1523 - fs::create_dir_all(root.join("var")).unwrap(); 1524 - fs::create_dir_all(root.join(".notes")).unwrap(); 1525 - fs::write(root.join(".git/ignored.md"), "# Ignored\n").unwrap(); 1526 - fs::write(root.join("target/ignored.md"), "# Ignored\n").unwrap(); 1527 - fs::write(root.join("vendor/ignored.md"), "# Ignored\n").unwrap(); 1528 - fs::write(root.join("var/ignored.md"), "# Ignored\n").unwrap(); 1529 - fs::write(root.join(".notes/kept.md"), "# Kept\n").unwrap(); 1530 - 1531 - let mut app = App::new_with_source( 1532 - Vec::new(), 1533 - Vec::new(), 1534 - AppConfig { 1535 - filename: "picker".to_string(), 1536 - source: String::new(), 1537 - debug_input: false, 1538 - watch: false, 1539 - filepath: None, 1540 - last_file_state: None, 1541 - }, 1542 - ); 1543 - 1544 - assert!(app.open_fuzzy_file_picker(root.clone())); 1545 - 1546 - let labels: Vec<_> = app 1547 - .file_picker_filtered_indices() 1548 - .iter() 1549 - .map(|idx| app.file_picker_entries()[*idx].label()) 1550 - .collect(); 1551 - assert_eq!(labels, vec![".notes/kept.md"]); 1552 - assert_eq!(app.file_picker_truncation(), None); 1553 - 1554 - let _ = fs::remove_dir_all(root); 1555 - } 1556 - 1557 - #[test] 1558 - fn fuzzy_file_picker_reports_directory_limit_truncation() { 1559 - let root = unique_temp_dir("leaf-fuzzy-picker-dir-limit"); 1560 - let _ = fs::remove_dir_all(&root); 1561 - for idx in 0..5_050usize { 1562 - let dir = root.join(format!("nested-{idx:04}")); 1563 - fs::create_dir_all(&dir).unwrap(); 1564 - fs::write(dir.join(format!("file-{idx:04}.md")), "# File\n").unwrap(); 1565 - } 1566 - 1567 - let mut app = App::new_with_source( 1568 - Vec::new(), 1569 - Vec::new(), 1570 - AppConfig { 1571 - filename: "picker".to_string(), 1572 - source: String::new(), 1573 - debug_input: false, 1574 - watch: false, 1575 - filepath: None, 1576 - last_file_state: None, 1577 - }, 1578 - ); 1579 - 1580 - assert!(app.open_fuzzy_file_picker(root.clone())); 1581 - assert_eq!( 1582 - app.file_picker_truncation(), 1583 - Some(crate::app::PickerIndexTruncation::Directory) 1584 - ); 1585 - assert!(!app.file_picker_entries().is_empty()); 1586 - 1587 - let _ = fs::remove_dir_all(root); 1588 - } 1589 - 1590 - #[test] 1591 - fn fuzzy_file_picker_reports_file_limit_truncation() { 1592 - let root = unique_temp_dir("leaf-fuzzy-picker-file-limit"); 1593 - let _ = fs::remove_dir_all(&root); 1594 - fs::create_dir_all(&root).unwrap(); 1595 - for idx in 0..10_050usize { 1596 - fs::write(root.join(format!("file-{idx:05}.md")), "# File\n").unwrap(); 1597 - } 1598 - 1599 - let mut app = App::new_with_source( 1600 - Vec::new(), 1601 - Vec::new(), 1602 - AppConfig { 1603 - filename: "picker".to_string(), 1604 - source: String::new(), 1605 - debug_input: false, 1606 - watch: false, 1607 - filepath: None, 1608 - last_file_state: None, 1609 - }, 1610 - ); 1611 - 1612 - assert!(app.open_fuzzy_file_picker(root.clone())); 1613 - assert_eq!( 1614 - app.file_picker_truncation(), 1615 - Some(crate::app::PickerIndexTruncation::File) 1616 - ); 1617 - assert_eq!(app.file_picker_entries().len(), 10_000); 1618 - 1619 - let _ = fs::remove_dir_all(root); 1620 - } 1621 - 1622 - #[test] 1623 - fn check_modified_detects_file_metadata_change() { 1624 - let (ss, theme) = test_assets(); 1625 - let unique = SystemTime::now() 1626 - .duration_since(UNIX_EPOCH) 1627 - .unwrap() 1628 - .as_nanos(); 1629 - let path = std::env::temp_dir().join(format!("leaf-check-modified-{unique}.md")); 1630 - fs::write(&path, "# Before\n").unwrap(); 1631 - 1632 - let src = fs::read_to_string(&path).unwrap(); 1633 - let (lines, toc) = parse_markdown(&src, &ss, &theme); 1634 - let state = read_file_state(&path).unwrap(); 1635 - let mut app = App::new_with_source( 1636 - lines, 1637 - toc, 1638 - AppConfig { 1639 - filename: path.file_name().unwrap().to_string_lossy().to_string(), 1640 - source: src.clone(), 1641 - debug_input: false, 1642 - watch: true, 1643 - filepath: Some(path.clone()), 1644 - last_file_state: Some(state), 1645 - }, 1646 - ); 1647 - app.set_last_content_hash(hash_str(&src)); 1648 - 1649 - std::thread::sleep(std::time::Duration::from_millis(10)); 1650 - fs::write(&path, "# After\nextra\n").unwrap(); 1651 - 1652 - let change = app.check_modified(); 1653 - assert!(matches!( 1654 - change, 1655 - Some(FileChange::Metadata(_)) | Some(FileChange::Content(_)) 1656 - )); 1657 - 1658 - let _ = fs::remove_file(path); 1659 - } 1660 - 1661 - #[test] 1662 - fn reload_returns_false_when_file_cannot_be_read() { 1663 - let (ss, _theme) = test_assets(); 1664 - let ts = ThemeSet::load_defaults(); 1665 - let unique = SystemTime::now() 1666 - .duration_since(UNIX_EPOCH) 1667 - .unwrap() 1668 - .as_nanos(); 1669 - let path = std::env::temp_dir().join(format!("leaf-reload-fail-{unique}.md")); 1670 - fs::write(&path, "# Demo\n").unwrap(); 1671 - 1672 - let mut app = App::new_with_source( 1673 - Vec::new(), 1674 - Vec::new(), 1675 - AppConfig { 1676 - filename: "picker".to_string(), 1677 - source: String::new(), 1678 - debug_input: false, 1679 - watch: true, 1680 - filepath: None, 1681 - last_file_state: None, 1682 - }, 1683 - ); 1684 - assert!(app.load_path(path.clone(), &ss, &ts)); 1685 - 1686 - fs::remove_file(&path).unwrap(); 1687 - assert!(!app.reload(&ss, &ts)); 1688 - } 1689 - 1690 - #[test] 1691 - fn sync_render_width_preserves_scroll_proportion() { 1692 - let (ss, theme) = test_assets(); 1693 - let ts = ThemeSet::load_defaults(); 1694 - let source = (0..12) 1695 - .map(|idx| { 1696 - format!( 1697 - "Paragraph {idx} has enough repeated content to wrap differently when the render width changes significantly across reparses." 1698 - ) 1699 - }) 1700 - .collect::<Vec<_>>() 1701 - .join("\n\n"); 1702 - let (lines, toc) = parse_markdown_with_width(&source, &ss, &theme, 80); 1703 - let mut app = App::new_with_source( 1704 - lines, 1705 - toc, 1706 - AppConfig { 1707 - filename: "stdin".to_string(), 1708 - source, 1709 - debug_input: false, 1710 - watch: false, 1711 - filepath: None, 1712 - last_file_state: None, 1713 - }, 1714 - ); 1715 - 1716 - app.scroll_down(8); 1717 - let old_scroll = app.scroll(); 1718 - let old_total = app.total(); 1719 - assert!(app.sync_render_width(24, &ss, &ts)); 1720 - 1721 - let new_total = app.total(); 1722 - let expected = ((old_scroll as f64 / old_total as f64) * new_total as f64) as usize; 1723 - assert_eq!(app.scroll(), expected.min(new_total.saturating_sub(1))); 1724 - } 1725 - 1726 - #[test] 1727 - fn check_modified_reports_metadata_when_no_previous_file_state() { 1728 - let (ss, theme) = test_assets(); 1729 - let unique = SystemTime::now() 1730 - .duration_since(UNIX_EPOCH) 1731 - .unwrap() 1732 - .as_nanos(); 1733 - let path = std::env::temp_dir().join(format!("leaf-check-modified-initial-{unique}.md")); 1734 - fs::write(&path, "# Initial\n").unwrap(); 1735 - 1736 - let src = fs::read_to_string(&path).unwrap(); 1737 - let (lines, toc) = parse_markdown(&src, &ss, &theme); 1738 - let mut app = App::new_with_source( 1739 - lines, 1740 - toc, 1741 - AppConfig { 1742 - filename: path.file_name().unwrap().to_string_lossy().to_string(), 1743 - source: src.clone(), 1744 - debug_input: false, 1745 - watch: true, 1746 - filepath: Some(path.clone()), 1747 - last_file_state: None, 1748 - }, 1749 - ); 1750 - app.set_last_content_hash(hash_str(&src)); 1751 - 1752 - assert!(matches!( 1753 - app.check_modified(), 1754 - Some(FileChange::Metadata(_)) 1755 - )); 1756 - 1757 - let _ = fs::remove_file(path); 1758 - } 1759 - 1760 - #[test] 1761 - fn sync_render_width_returns_false_when_clamped_width_is_unchanged() { 1762 - let (ss, theme) = test_assets(); 1763 - let ts = ThemeSet::load_defaults(); 1764 - let source = "One paragraph that does not matter much for this width clamp test."; 1765 - let (lines, toc) = parse_markdown_with_width(source, &ss, &theme, 20); 1766 - let mut app = App::new_with_source( 1767 - lines, 1768 - toc, 1769 - AppConfig { 1770 - filename: "stdin".to_string(), 1771 - source: source.to_string(), 1772 - debug_input: false, 1773 - watch: false, 1774 - filepath: None, 1775 - last_file_state: None, 1776 - }, 1777 - ); 1778 - 1779 - assert!(app.sync_render_width(10, &ss, &ts)); 1780 - assert!(!app.sync_render_width(10, &ss, &ts)); 1781 - assert_eq!( 1782 - app.total(), 1783 - parse_markdown_with_width(source, &ss, &theme, 20).0.len() 1784 - ); 1785 - } 1786 - 1787 - #[test] 1788 - fn wrapped_list_inline_code_keeps_left_padding_in_rendered_line() { 1789 - let (ss, theme) = test_assets(); 1790 - let source = "- `leaf --theme ocean README.md` exercises wrapping inside a list item.\n"; 1791 - let (lines, _) = parse_markdown_with_width(source, &ss, &theme, 22); 1792 - 1793 - let target = lines 1794 - .iter() 1795 - .find(|line| line_plain_text(line).contains("leaf --theme")) 1796 - .expect("expected wrapped inline-code line"); 1797 - 1798 - assert!( 1799 - target 1800 - .spans 1801 - .iter() 1802 - .any(|span| span.style.bg.is_some() && span.content.starts_with(' ')), 1803 - "expected a background-styled span with left padding" 1804 - ); 1805 - } 1806 - 1807 - #[test] 1808 - fn code_block_inside_list_item_is_indented_and_has_no_blank_gap_before() { 1809 - let (ss, theme) = test_assets(); 1810 - let md = "To put a code block within a list item, the code block needs\nto be indented *twice* -- 8 spaces or two tabs:\n\n* A list item with a code block:\n\n <code goes here>\n"; 1811 - let (lines, _) = parse_markdown(md, &ss, &theme); 1812 - let rendered = rendered_non_empty_lines(&lines); 1813 - 1814 - let item_idx = rendered 1815 - .iter() 1816 - .position(|line| line.contains("A list item with a code block:")) 1817 - .expect("missing list item line"); 1818 - let header_idx = rendered 1819 - .iter() 1820 - .position(|line| line.contains("┌─ text")) 1821 - .expect("missing code block header"); 1822 - let code_idx = rendered 1823 - .iter() 1824 - .position(|line| line.contains("<code goes here>")) 1825 - .expect("missing code line"); 1826 - 1827 - assert_eq!( 1828 - header_idx, 1829 - item_idx + 1, 1830 - "expected no blank gap before code block" 1831 - ); 1832 - assert!(rendered[header_idx].starts_with(" ")); 1833 - assert!(rendered[code_idx].starts_with(" ")); 1834 - }
+382
src/tests/app.rs
··· 1 + use super::test_assets; 2 + use crate::app::{App, AppConfig, FileChange}; 3 + use crate::cli::parse_cli; 4 + use crate::markdown::{hash_str, parse_markdown, parse_markdown_with_width, read_file_state}; 5 + use crate::*; 6 + use crossterm::event::KeyEventKind; 7 + use std::{ 8 + fs, 9 + time::{SystemTime, UNIX_EPOCH}, 10 + }; 11 + use syntect::highlighting::ThemeSet; 12 + 13 + #[test] 14 + fn search_matches_across_span_boundaries() { 15 + let (ss, theme) = test_assets(); 16 + let (lines, toc) = parse_markdown("hello **world**", &ss, &theme); 17 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 18 + 19 + app.set_search_query("hello world"); 20 + app.run_search(); 21 + 22 + assert_eq!(app.search_match_count(), 1); 23 + assert!(line_plain_text(app.line(app.search_matches()[0]).unwrap()).contains("hello world")); 24 + } 25 + 26 + #[test] 27 + fn key_release_events_are_ignored() { 28 + assert!(should_handle_key(KeyEventKind::Press)); 29 + assert!(should_handle_key(KeyEventKind::Repeat)); 30 + assert!(!should_handle_key(KeyEventKind::Release)); 31 + } 32 + 33 + #[test] 34 + fn stdin_read_is_rejected_when_over_limit() { 35 + let mut cursor = std::io::Cursor::new(vec![b'a'; 5]); 36 + let err = read_stdin_with_limit(&mut cursor, 4).unwrap_err(); 37 + assert!(err 38 + .to_string() 39 + .contains("stdin exceeds the maximum supported size")); 40 + } 41 + 42 + #[test] 43 + fn parse_cli_accepts_update_on_its_own() { 44 + let args = vec!["leaf".to_string(), "--update".to_string()]; 45 + let options = parse_cli(&args).unwrap(); 46 + 47 + assert!(options.update); 48 + assert!(!options.watch); 49 + assert_eq!(options.file_arg, None); 50 + } 51 + 52 + #[test] 53 + fn parse_cli_rejects_update_with_other_flags() { 54 + let args = vec![ 55 + "leaf".to_string(), 56 + "--update".to_string(), 57 + "--watch".to_string(), 58 + ]; 59 + 60 + let err = parse_cli(&args).unwrap_err(); 61 + assert!(err.to_string().contains("--update must be used on its own")); 62 + } 63 + 64 + #[test] 65 + fn parse_cli_accepts_picker_on_its_own() { 66 + let args = vec!["leaf".to_string(), "--picker".to_string()]; 67 + let options = parse_cli(&args).unwrap(); 68 + 69 + assert!(options.picker); 70 + assert!(!options.watch); 71 + assert_eq!(options.file_arg, None); 72 + } 73 + 74 + #[test] 75 + fn parse_cli_accepts_picker_with_watch() { 76 + let args = vec![ 77 + "leaf".to_string(), 78 + "--picker".to_string(), 79 + "--watch".to_string(), 80 + ]; 81 + 82 + let options = parse_cli(&args).unwrap(); 83 + assert!(options.picker); 84 + assert!(options.watch); 85 + assert_eq!(options.file_arg, None); 86 + } 87 + 88 + #[test] 89 + fn cancelling_search_clears_query_and_matches() { 90 + let (ss, theme) = test_assets(); 91 + let (lines, toc) = parse_markdown("alpha\nbeta\nalpha beta\n", &ss, &theme); 92 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 93 + 94 + app.set_search_query("alpha"); 95 + app.run_search(); 96 + 97 + app.begin_search(); 98 + app.set_search_draft("alpha gamma"); 99 + app.cancel_search(); 100 + 101 + assert!(!app.is_search_mode()); 102 + assert!(app.search_draft().is_empty()); 103 + assert!(app.search_query().is_empty()); 104 + assert!(app.search_matches().is_empty()); 105 + assert_eq!(app.search_index(), 0); 106 + } 107 + 108 + #[test] 109 + fn confirm_search_uses_draft_and_updates_matches() { 110 + let (ss, theme) = test_assets(); 111 + let (lines, toc) = parse_markdown("alpha\nbeta\nbeta\n", &ss, &theme); 112 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 113 + 114 + app.begin_search(); 115 + app.set_search_draft("beta"); 116 + app.confirm_search(); 117 + 118 + assert!(!app.is_search_mode()); 119 + assert!(app.search_draft().is_empty()); 120 + assert_eq!(app.search_query(), "beta"); 121 + assert_eq!(app.search_match_count(), 2); 122 + } 123 + 124 + #[test] 125 + fn confirm_search_with_new_query_restarts_from_first_match() { 126 + let (ss, theme) = test_assets(); 127 + let (lines, toc) = parse_markdown("alpha\nbeta\nbeta again\n", &ss, &theme); 128 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 129 + 130 + app.set_search_query("alpha"); 131 + app.run_search(); 132 + 133 + app.begin_search(); 134 + app.set_search_draft("beta"); 135 + app.confirm_search(); 136 + 137 + assert_eq!(app.search_query(), "beta"); 138 + assert_eq!(app.search_index(), 0); 139 + assert_eq!(app.scroll(), app.search_matches()[0]); 140 + assert_eq!(app.search_match_count(), 2); 141 + } 142 + 143 + #[test] 144 + fn enter_in_normal_mode_advances_active_search() { 145 + let (ss, theme) = test_assets(); 146 + let (lines, toc) = parse_markdown("alpha\nbeta alpha\nalpha again\n", &ss, &theme); 147 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 148 + 149 + app.set_search_query("alpha"); 150 + app.run_search(); 151 + let second_match = app.search_matches()[1]; 152 + 153 + app.next_match(); 154 + 155 + assert_eq!(app.search_index(), 1); 156 + assert_eq!(app.scroll(), second_match); 157 + } 158 + 159 + #[test] 160 + fn ctrl_c_cancels_search_prompt_and_clears_active_query() { 161 + let (ss, theme) = test_assets(); 162 + let (lines, toc) = parse_markdown("alpha\nbeta\n", &ss, &theme); 163 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 164 + 165 + app.set_search_query("alpha"); 166 + app.run_search(); 167 + 168 + app.begin_search(); 169 + app.push_search_draft('z'); 170 + app.cancel_search(); 171 + 172 + assert!(!app.is_search_mode()); 173 + assert!(app.search_query().is_empty()); 174 + assert!(app.search_matches().is_empty()); 175 + assert_eq!(app.search_index(), 0); 176 + } 177 + 178 + #[test] 179 + fn esc_clears_active_search_from_normal_mode() { 180 + let (ss, theme) = test_assets(); 181 + let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 182 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 183 + 184 + app.set_search_query("alpha"); 185 + app.run_search(); 186 + app.clear_active_search(); 187 + 188 + assert!(!app.is_search_mode()); 189 + assert!(app.search_draft().is_empty()); 190 + assert!(app.search_query().is_empty()); 191 + assert!(app.search_matches().is_empty()); 192 + assert_eq!(app.search_index(), 0); 193 + } 194 + 195 + #[test] 196 + fn ctrl_c_clears_active_search_before_exit() { 197 + let (ss, theme) = test_assets(); 198 + let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 199 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 200 + 201 + app.set_search_query("alpha"); 202 + app.run_search(); 203 + app.clear_active_search(); 204 + 205 + assert!(!app.has_active_search()); 206 + assert!(app.search_query().is_empty()); 207 + assert!(app.search_matches().is_empty()); 208 + } 209 + 210 + #[test] 211 + fn active_highlight_line_is_none_without_search_matches() { 212 + let (ss, theme) = test_assets(); 213 + let (lines, toc) = parse_markdown("alpha\nbeta\n", &ss, &theme); 214 + let app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 215 + 216 + assert_eq!(app.active_highlight_line(), None); 217 + } 218 + 219 + #[test] 220 + fn check_modified_detects_file_metadata_change() { 221 + let (ss, theme) = test_assets(); 222 + let unique = SystemTime::now() 223 + .duration_since(UNIX_EPOCH) 224 + .unwrap() 225 + .as_nanos(); 226 + let path = std::env::temp_dir().join(format!("leaf-check-modified-{unique}.md")); 227 + fs::write(&path, "# Before\n").unwrap(); 228 + 229 + let src = fs::read_to_string(&path).unwrap(); 230 + let (lines, toc) = parse_markdown(&src, &ss, &theme); 231 + let state = read_file_state(&path).unwrap(); 232 + let mut app = App::new_with_source( 233 + lines, 234 + toc, 235 + AppConfig { 236 + filename: path.file_name().unwrap().to_string_lossy().to_string(), 237 + source: src.clone(), 238 + debug_input: false, 239 + watch: true, 240 + filepath: Some(path.clone()), 241 + last_file_state: Some(state), 242 + }, 243 + ); 244 + app.set_last_content_hash(hash_str(&src)); 245 + 246 + std::thread::sleep(std::time::Duration::from_millis(10)); 247 + fs::write(&path, "# After\nextra\n").unwrap(); 248 + 249 + let change = app.check_modified(); 250 + assert!(matches!( 251 + change, 252 + Some(FileChange::Metadata(_)) | Some(FileChange::Content(_)) 253 + )); 254 + 255 + let _ = fs::remove_file(path); 256 + } 257 + 258 + #[test] 259 + fn reload_returns_false_when_file_cannot_be_read() { 260 + let (ss, _theme) = test_assets(); 261 + let ts = ThemeSet::load_defaults(); 262 + let unique = SystemTime::now() 263 + .duration_since(UNIX_EPOCH) 264 + .unwrap() 265 + .as_nanos(); 266 + let path = std::env::temp_dir().join(format!("leaf-reload-fail-{unique}.md")); 267 + fs::write(&path, "# Demo\n").unwrap(); 268 + 269 + let mut app = App::new_with_source( 270 + Vec::new(), 271 + Vec::new(), 272 + AppConfig { 273 + filename: "picker".to_string(), 274 + source: String::new(), 275 + debug_input: false, 276 + watch: true, 277 + filepath: None, 278 + last_file_state: None, 279 + }, 280 + ); 281 + assert!(app.load_path(path.clone(), &ss, &ts)); 282 + 283 + fs::remove_file(&path).unwrap(); 284 + assert!(!app.reload(&ss, &ts)); 285 + } 286 + 287 + #[test] 288 + fn sync_render_width_preserves_scroll_proportion() { 289 + let (ss, theme) = test_assets(); 290 + let ts = ThemeSet::load_defaults(); 291 + let source = (0..12) 292 + .map(|idx| { 293 + format!( 294 + "Paragraph {idx} has enough repeated content to wrap differently when the render width changes significantly across reparses." 295 + ) 296 + }) 297 + .collect::<Vec<_>>() 298 + .join("\n\n"); 299 + let (lines, toc) = parse_markdown_with_width(&source, &ss, &theme, 80); 300 + let mut app = App::new_with_source( 301 + lines, 302 + toc, 303 + AppConfig { 304 + filename: "stdin".to_string(), 305 + source, 306 + debug_input: false, 307 + watch: false, 308 + filepath: None, 309 + last_file_state: None, 310 + }, 311 + ); 312 + 313 + app.scroll_down(8); 314 + let old_scroll = app.scroll(); 315 + let old_total = app.total(); 316 + assert!(app.sync_render_width(24, &ss, &ts)); 317 + 318 + let new_total = app.total(); 319 + let expected = ((old_scroll as f64 / old_total as f64) * new_total as f64) as usize; 320 + assert_eq!(app.scroll(), expected.min(new_total.saturating_sub(1))); 321 + } 322 + 323 + #[test] 324 + fn check_modified_reports_metadata_when_no_previous_file_state() { 325 + let (ss, theme) = test_assets(); 326 + let unique = SystemTime::now() 327 + .duration_since(UNIX_EPOCH) 328 + .unwrap() 329 + .as_nanos(); 330 + let path = std::env::temp_dir().join(format!("leaf-check-modified-initial-{unique}.md")); 331 + fs::write(&path, "# Initial\n").unwrap(); 332 + 333 + let src = fs::read_to_string(&path).unwrap(); 334 + let (lines, toc) = parse_markdown(&src, &ss, &theme); 335 + let mut app = App::new_with_source( 336 + lines, 337 + toc, 338 + AppConfig { 339 + filename: path.file_name().unwrap().to_string_lossy().to_string(), 340 + source: src.clone(), 341 + debug_input: false, 342 + watch: true, 343 + filepath: Some(path.clone()), 344 + last_file_state: None, 345 + }, 346 + ); 347 + app.set_last_content_hash(hash_str(&src)); 348 + 349 + assert!(matches!( 350 + app.check_modified(), 351 + Some(FileChange::Metadata(_)) 352 + )); 353 + 354 + let _ = fs::remove_file(path); 355 + } 356 + 357 + #[test] 358 + fn sync_render_width_returns_false_when_clamped_width_is_unchanged() { 359 + let (ss, theme) = test_assets(); 360 + let ts = ThemeSet::load_defaults(); 361 + let source = "One paragraph that does not matter much for this width clamp test."; 362 + let (lines, toc) = parse_markdown_with_width(source, &ss, &theme, 20); 363 + let mut app = App::new_with_source( 364 + lines, 365 + toc, 366 + AppConfig { 367 + filename: "stdin".to_string(), 368 + source: source.to_string(), 369 + debug_input: false, 370 + watch: false, 371 + filepath: None, 372 + last_file_state: None, 373 + }, 374 + ); 375 + 376 + assert!(app.sync_render_width(10, &ss, &ts)); 377 + assert!(!app.sync_render_width(10, &ss, &ts)); 378 + assert_eq!( 379 + app.total(), 380 + parse_markdown_with_width(source, &ss, &theme, 20).0.len() 381 + ); 382 + }
+766
src/tests/file_picker.rs
··· 1 + use super::unique_temp_dir; 2 + use crate::app::{App, AppConfig}; 3 + use std::{ 4 + fs, 5 + time::{SystemTime, UNIX_EPOCH}, 6 + }; 7 + 8 + #[test] 9 + fn file_picker_lists_dirs_then_markdown_files_only() { 10 + let unique = SystemTime::now() 11 + .duration_since(UNIX_EPOCH) 12 + .unwrap() 13 + .as_nanos(); 14 + let root = std::env::temp_dir().join(format!("leaf-picker-test-{unique}")); 15 + let _ = fs::remove_dir_all(&root); 16 + fs::create_dir_all(root.join("notes")).unwrap(); 17 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 18 + fs::write(root.join("draft.markdown"), "# Draft\n").unwrap(); 19 + fs::write(root.join("ignore.txt"), "nope\n").unwrap(); 20 + 21 + let mut app = App::new_with_source( 22 + Vec::new(), 23 + Vec::new(), 24 + AppConfig { 25 + filename: "picker".to_string(), 26 + source: String::new(), 27 + debug_input: false, 28 + watch: false, 29 + filepath: None, 30 + last_file_state: None, 31 + }, 32 + ); 33 + 34 + assert!(app.open_file_picker(root.clone())); 35 + 36 + let labels: Vec<_> = app 37 + .file_picker_entries() 38 + .iter() 39 + .map(|entry| entry.label()) 40 + .collect(); 41 + assert!(labels.contains(&"notes/")); 42 + assert!(labels.contains(&"README.md")); 43 + assert!(labels.contains(&"draft.markdown")); 44 + assert!(!labels.contains(&"ignore.txt")); 45 + 46 + let notes_idx = labels.iter().position(|label| *label == "notes/").unwrap(); 47 + let readme_idx = labels 48 + .iter() 49 + .position(|label| *label == "README.md") 50 + .unwrap(); 51 + assert!(notes_idx < readme_idx); 52 + 53 + let _ = fs::remove_dir_all(root); 54 + } 55 + 56 + #[test] 57 + fn fuzzy_file_picker_lists_markdown_files_from_subdirectories() { 58 + let unique = SystemTime::now() 59 + .duration_since(UNIX_EPOCH) 60 + .unwrap() 61 + .as_nanos(); 62 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-test-{unique}")); 63 + let _ = fs::remove_dir_all(&root); 64 + fs::create_dir_all(root.join("docs/nested")).unwrap(); 65 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 66 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 67 + fs::write(root.join("docs/nested/deep.markdown"), "# Deep\n").unwrap(); 68 + fs::write(root.join("ignore.txt"), "nope\n").unwrap(); 69 + 70 + let mut app = App::new_with_source( 71 + Vec::new(), 72 + Vec::new(), 73 + AppConfig { 74 + filename: "picker".to_string(), 75 + source: String::new(), 76 + debug_input: false, 77 + watch: false, 78 + filepath: None, 79 + last_file_state: None, 80 + }, 81 + ); 82 + 83 + assert!(app.open_fuzzy_file_picker(root.clone())); 84 + assert!(app.is_fuzzy_file_picker()); 85 + 86 + let labels: Vec<_> = app 87 + .file_picker_filtered_indices() 88 + .iter() 89 + .map(|idx| app.file_picker_entries()[*idx].label()) 90 + .collect(); 91 + assert!(labels.contains(&"README.md")); 92 + assert!(labels.contains(&"docs/guide.md")); 93 + assert!(labels.contains(&"docs/nested/deep.markdown")); 94 + assert!(!labels.contains(&"ignore.txt")); 95 + 96 + let _ = fs::remove_dir_all(root); 97 + } 98 + 99 + #[test] 100 + fn queued_fuzzy_picker_transitions_from_pending_to_loading_to_open() { 101 + let unique = SystemTime::now() 102 + .duration_since(UNIX_EPOCH) 103 + .unwrap() 104 + .as_nanos(); 105 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-queued-{unique}")); 106 + let _ = fs::remove_dir_all(&root); 107 + fs::create_dir_all(root.join("docs")).unwrap(); 108 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 109 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 110 + 111 + let mut app = App::new_with_source( 112 + Vec::new(), 113 + Vec::new(), 114 + AppConfig { 115 + filename: "picker".to_string(), 116 + source: String::new(), 117 + debug_input: false, 118 + watch: false, 119 + filepath: None, 120 + last_file_state: None, 121 + }, 122 + ); 123 + 124 + app.queue_fuzzy_file_picker(root.clone()); 125 + assert!(app.has_pending_picker()); 126 + assert_eq!( 127 + app.pending_picker_mode(), 128 + Some(crate::app::FilePickerMode::Fuzzy) 129 + ); 130 + assert_eq!(app.pending_picker_dir(), Some(root.as_path())); 131 + assert!(!app.is_picker_loading()); 132 + assert!(app.start_pending_picker_loading()); 133 + assert!(app.is_picker_loading()); 134 + app.age_picker_loading_by(std::time::Duration::from_secs(1)); 135 + let mut opened = false; 136 + for _ in 0..50 { 137 + if app.poll_picker_loading() { 138 + opened = app.is_file_picker_open(); 139 + break; 140 + } 141 + std::thread::sleep(std::time::Duration::from_millis(10)); 142 + } 143 + assert!(opened); 144 + assert!(app.is_file_picker_open()); 145 + assert!(app.is_fuzzy_file_picker()); 146 + assert!(!app.has_pending_picker()); 147 + assert!(!app.is_picker_loading()); 148 + 149 + let labels: Vec<_> = app 150 + .file_picker_filtered_indices() 151 + .iter() 152 + .map(|idx| app.file_picker_entries()[*idx].label()) 153 + .collect(); 154 + assert!(labels.contains(&"README.md")); 155 + assert!(labels.contains(&"docs/guide.md")); 156 + 157 + let _ = fs::remove_dir_all(root); 158 + } 159 + 160 + #[test] 161 + fn fuzzy_file_picker_uses_depth_first_order_with_hidden_first() { 162 + let unique = SystemTime::now() 163 + .duration_since(UNIX_EPOCH) 164 + .unwrap() 165 + .as_nanos(); 166 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-order-{unique}")); 167 + let _ = fs::remove_dir_all(&root); 168 + fs::create_dir_all(root.join(".private")).unwrap(); 169 + fs::create_dir_all(root.join("docs")).unwrap(); 170 + fs::write(root.join(".draft.md"), "# Hidden\n").unwrap(); 171 + fs::write(root.join(".private/alpha.md"), "# Private\n").unwrap(); 172 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 173 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 174 + 175 + let mut app = App::new_with_source( 176 + Vec::new(), 177 + Vec::new(), 178 + AppConfig { 179 + filename: "picker".to_string(), 180 + source: String::new(), 181 + debug_input: false, 182 + watch: false, 183 + filepath: None, 184 + last_file_state: None, 185 + }, 186 + ); 187 + 188 + assert!(app.open_fuzzy_file_picker(root.clone())); 189 + 190 + let labels: Vec<_> = app 191 + .file_picker_filtered_indices() 192 + .iter() 193 + .map(|idx| app.file_picker_entries()[*idx].label()) 194 + .collect(); 195 + assert_eq!( 196 + labels, 197 + vec![ 198 + ".draft.md", 199 + "README.md", 200 + ".private/alpha.md", 201 + "docs/guide.md", 202 + ] 203 + ); 204 + 205 + let _ = fs::remove_dir_all(root); 206 + } 207 + 208 + #[test] 209 + fn fuzzy_file_picker_uses_depth_first_file_order() { 210 + let unique = SystemTime::now() 211 + .duration_since(UNIX_EPOCH) 212 + .unwrap() 213 + .as_nanos(); 214 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-bfs-{unique}")); 215 + let _ = fs::remove_dir_all(&root); 216 + fs::create_dir_all(root.join("a/deep")).unwrap(); 217 + fs::create_dir_all(root.join("b")).unwrap(); 218 + fs::write(root.join("z-root.md"), "# Root\n").unwrap(); 219 + fs::write(root.join("a/a-child.md"), "# Child A\n").unwrap(); 220 + fs::write(root.join("b/b-child.md"), "# Child B\n").unwrap(); 221 + fs::write(root.join("a/deep/a-deep.md"), "# Deep\n").unwrap(); 222 + 223 + let mut app = App::new_with_source( 224 + Vec::new(), 225 + Vec::new(), 226 + AppConfig { 227 + filename: "picker".to_string(), 228 + source: String::new(), 229 + debug_input: false, 230 + watch: false, 231 + filepath: None, 232 + last_file_state: None, 233 + }, 234 + ); 235 + 236 + assert!(app.open_fuzzy_file_picker(root.clone())); 237 + 238 + let labels: Vec<_> = app 239 + .file_picker_filtered_indices() 240 + .iter() 241 + .map(|idx| app.file_picker_entries()[*idx].label()) 242 + .collect(); 243 + assert_eq!( 244 + labels, 245 + vec![ 246 + "z-root.md", 247 + "a/a-child.md", 248 + "a/deep/a-deep.md", 249 + "b/b-child.md" 250 + ] 251 + ); 252 + 253 + let _ = fs::remove_dir_all(root); 254 + } 255 + 256 + #[test] 257 + fn fuzzy_file_picker_keeps_depth_first_order_when_query_is_empty() { 258 + let unique = SystemTime::now() 259 + .duration_since(UNIX_EPOCH) 260 + .unwrap() 261 + .as_nanos(); 262 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-empty-query-{unique}")); 263 + let _ = fs::remove_dir_all(&root); 264 + fs::create_dir_all(root.join(".nvm")).unwrap(); 265 + fs::create_dir_all(root.join("projects")).unwrap(); 266 + fs::write(root.join(".nvm/README.md"), "# Hidden Readme\n").unwrap(); 267 + fs::write(root.join(".nvm/ROADMAP.md"), "# Hidden Roadmap\n").unwrap(); 268 + fs::write(root.join("projects/README.md"), "# Project Readme\n").unwrap(); 269 + 270 + let mut app = App::new_with_source( 271 + Vec::new(), 272 + Vec::new(), 273 + AppConfig { 274 + filename: "picker".to_string(), 275 + source: String::new(), 276 + debug_input: false, 277 + watch: false, 278 + filepath: None, 279 + last_file_state: None, 280 + }, 281 + ); 282 + 283 + assert!(app.open_fuzzy_file_picker(root.clone())); 284 + 285 + let labels: Vec<_> = app 286 + .file_picker_filtered_indices() 287 + .iter() 288 + .map(|idx| app.file_picker_entries()[*idx].label()) 289 + .collect(); 290 + assert_eq!( 291 + labels, 292 + vec![".nvm/README.md", ".nvm/ROADMAP.md", "projects/README.md"] 293 + ); 294 + 295 + let _ = fs::remove_dir_all(root); 296 + } 297 + 298 + #[test] 299 + fn fuzzy_file_picker_filters_entries_by_query() { 300 + let unique = SystemTime::now() 301 + .duration_since(UNIX_EPOCH) 302 + .unwrap() 303 + .as_nanos(); 304 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-query-{unique}")); 305 + let _ = fs::remove_dir_all(&root); 306 + fs::create_dir_all(root.join("docs")).unwrap(); 307 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 308 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 309 + 310 + let mut app = App::new_with_source( 311 + Vec::new(), 312 + Vec::new(), 313 + AppConfig { 314 + filename: "picker".to_string(), 315 + source: String::new(), 316 + debug_input: false, 317 + watch: false, 318 + filepath: None, 319 + last_file_state: None, 320 + }, 321 + ); 322 + 323 + assert!(app.open_fuzzy_file_picker(root.clone())); 324 + app.push_file_picker_query('g'); 325 + app.push_file_picker_query('u'); 326 + 327 + let labels: Vec<_> = app 328 + .file_picker_filtered_indices() 329 + .iter() 330 + .map(|idx| app.file_picker_entries()[*idx].label()) 331 + .collect(); 332 + assert_eq!(labels, vec!["docs/guide.md"]); 333 + 334 + let _ = fs::remove_dir_all(root); 335 + } 336 + 337 + #[test] 338 + fn fuzzy_file_picker_does_not_match_directory_segments() { 339 + let unique = SystemTime::now() 340 + .duration_since(UNIX_EPOCH) 341 + .unwrap() 342 + .as_nanos(); 343 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-cla-{unique}")); 344 + let _ = fs::remove_dir_all(&root); 345 + fs::create_dir_all(root.join(".notes/backup")).unwrap(); 346 + fs::write(root.join(".notes/backup/PLAN.md"), "# Plan\n").unwrap(); 347 + fs::write(root.join("claude.md"), "# Claude\n").unwrap(); 348 + 349 + let mut app = App::new_with_source( 350 + Vec::new(), 351 + Vec::new(), 352 + AppConfig { 353 + filename: "picker".to_string(), 354 + source: String::new(), 355 + debug_input: false, 356 + watch: false, 357 + filepath: None, 358 + last_file_state: None, 359 + }, 360 + ); 361 + 362 + assert!(app.open_fuzzy_file_picker(root.clone())); 363 + app.push_file_picker_query('c'); 364 + app.push_file_picker_query('l'); 365 + app.push_file_picker_query('a'); 366 + 367 + let labels: Vec<_> = app 368 + .file_picker_filtered_indices() 369 + .iter() 370 + .map(|idx| app.file_picker_entries()[*idx].label()) 371 + .collect(); 372 + assert!(labels.contains(&"claude.md")); 373 + assert!(!labels.contains(&".notes/backup/PLAN.md")); 374 + 375 + let _ = fs::remove_dir_all(root); 376 + } 377 + 378 + #[test] 379 + fn fuzzy_file_picker_tracks_match_positions_for_highlighting() { 380 + let unique = SystemTime::now() 381 + .duration_since(UNIX_EPOCH) 382 + .unwrap() 383 + .as_nanos(); 384 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-highlight-{unique}")); 385 + let _ = fs::remove_dir_all(&root); 386 + fs::create_dir_all(&root).unwrap(); 387 + fs::write(root.join("claude.md"), "# Claude\n").unwrap(); 388 + 389 + let mut app = App::new_with_source( 390 + Vec::new(), 391 + Vec::new(), 392 + AppConfig { 393 + filename: "picker".to_string(), 394 + source: String::new(), 395 + debug_input: false, 396 + watch: false, 397 + filepath: None, 398 + last_file_state: None, 399 + }, 400 + ); 401 + 402 + assert!(app.open_fuzzy_file_picker(root.clone())); 403 + app.push_file_picker_query('c'); 404 + app.push_file_picker_query('l'); 405 + app.push_file_picker_query('a'); 406 + 407 + let labels: Vec<_> = app 408 + .file_picker_filtered_indices() 409 + .iter() 410 + .map(|idx| app.file_picker_entries()[*idx].label()) 411 + .collect(); 412 + assert_eq!(labels, vec!["claude.md"]); 413 + assert_eq!(app.file_picker_match_positions(0), &[0, 1, 2]); 414 + 415 + let _ = fs::remove_dir_all(root); 416 + } 417 + 418 + #[test] 419 + fn fuzzy_file_picker_prefers_compact_matches() { 420 + let unique = SystemTime::now() 421 + .duration_since(UNIX_EPOCH) 422 + .unwrap() 423 + .as_nanos(); 424 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-compact-{unique}")); 425 + let _ = fs::remove_dir_all(&root); 426 + fs::create_dir_all(&root).unwrap(); 427 + fs::write(root.join("case.md"), "# Case\n").unwrap(); 428 + fs::write(root.join("ciase.md"), "# Ciase\n").unwrap(); 429 + 430 + let mut app = App::new_with_source( 431 + Vec::new(), 432 + Vec::new(), 433 + AppConfig { 434 + filename: "picker".to_string(), 435 + source: String::new(), 436 + debug_input: false, 437 + watch: false, 438 + filepath: None, 439 + last_file_state: None, 440 + }, 441 + ); 442 + 443 + assert!(app.open_fuzzy_file_picker(root.clone())); 444 + app.push_file_picker_query('c'); 445 + app.push_file_picker_query('a'); 446 + 447 + let labels: Vec<_> = app 448 + .file_picker_filtered_indices() 449 + .iter() 450 + .map(|idx| app.file_picker_entries()[*idx].label()) 451 + .collect(); 452 + assert_eq!(labels, vec!["case.md", "ciase.md"]); 453 + 454 + let _ = fs::remove_dir_all(root); 455 + } 456 + 457 + #[test] 458 + fn fuzzy_file_picker_prefers_contiguous_matches_over_earlier_scattered_matches() { 459 + let unique = SystemTime::now() 460 + .duration_since(UNIX_EPOCH) 461 + .unwrap() 462 + .as_nanos(); 463 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-contiguous-{unique}")); 464 + let _ = fs::remove_dir_all(&root); 465 + fs::create_dir_all(root.join(".notes/todo")).unwrap(); 466 + fs::create_dir_all(root.join(".notes/tests")).unwrap(); 467 + fs::write(root.join(".notes/todo/review-chatgpt.md"), "# ChatGPT\n").unwrap(); 468 + fs::write(root.join(".notes/tests/themes-showcase.md"), "# Showcase\n").unwrap(); 469 + 470 + let mut app = App::new_with_source( 471 + Vec::new(), 472 + Vec::new(), 473 + AppConfig { 474 + filename: "picker".to_string(), 475 + source: String::new(), 476 + debug_input: false, 477 + watch: false, 478 + filepath: None, 479 + last_file_state: None, 480 + }, 481 + ); 482 + 483 + assert!(app.open_fuzzy_file_picker(root.clone())); 484 + app.push_file_picker_query('c'); 485 + app.push_file_picker_query('a'); 486 + 487 + let labels: Vec<_> = app 488 + .file_picker_filtered_indices() 489 + .iter() 490 + .map(|idx| app.file_picker_entries()[*idx].label()) 491 + .collect(); 492 + let showcase_idx = labels 493 + .iter() 494 + .position(|label| *label == ".notes/tests/themes-showcase.md") 495 + .unwrap(); 496 + let chatgpt_idx = labels 497 + .iter() 498 + .position(|label| *label == ".notes/todo/review-chatgpt.md") 499 + .unwrap(); 500 + assert!(showcase_idx < chatgpt_idx); 501 + 502 + let _ = fs::remove_dir_all(root); 503 + } 504 + 505 + #[test] 506 + fn fuzzy_file_picker_prefers_filename_prefix_matches() { 507 + let unique = SystemTime::now() 508 + .duration_since(UNIX_EPOCH) 509 + .unwrap() 510 + .as_nanos(); 511 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-prefix-{unique}")); 512 + let _ = fs::remove_dir_all(&root); 513 + fs::create_dir_all(&root).unwrap(); 514 + fs::write(root.join("todo-case.md"), "# Todo\n").unwrap(); 515 + fs::write(root.join("case-study.md"), "# Case\n").unwrap(); 516 + 517 + let mut app = App::new_with_source( 518 + Vec::new(), 519 + Vec::new(), 520 + AppConfig { 521 + filename: "picker".to_string(), 522 + source: String::new(), 523 + debug_input: false, 524 + watch: false, 525 + filepath: None, 526 + last_file_state: None, 527 + }, 528 + ); 529 + 530 + assert!(app.open_fuzzy_file_picker(root.clone())); 531 + app.push_file_picker_query('c'); 532 + app.push_file_picker_query('a'); 533 + 534 + let labels: Vec<_> = app 535 + .file_picker_filtered_indices() 536 + .iter() 537 + .map(|idx| app.file_picker_entries()[*idx].label()) 538 + .collect(); 539 + assert_eq!(labels, vec!["case-study.md", "todo-case.md"]); 540 + 541 + let _ = fs::remove_dir_all(root); 542 + } 543 + 544 + #[test] 545 + fn fuzzy_file_picker_prefers_token_boundary_matches() { 546 + let unique = SystemTime::now() 547 + .duration_since(UNIX_EPOCH) 548 + .unwrap() 549 + .as_nanos(); 550 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-boundary-{unique}")); 551 + let _ = fs::remove_dir_all(&root); 552 + fs::create_dir_all(&root).unwrap(); 553 + fs::write(root.join("alpha-case.md"), "# Boundary\n").unwrap(); 554 + fs::write(root.join("alphacase.md"), "# Plain\n").unwrap(); 555 + 556 + let mut app = App::new_with_source( 557 + Vec::new(), 558 + Vec::new(), 559 + AppConfig { 560 + filename: "picker".to_string(), 561 + source: String::new(), 562 + debug_input: false, 563 + watch: false, 564 + filepath: None, 565 + last_file_state: None, 566 + }, 567 + ); 568 + 569 + assert!(app.open_fuzzy_file_picker(root.clone())); 570 + app.push_file_picker_query('c'); 571 + app.push_file_picker_query('a'); 572 + 573 + let labels: Vec<_> = app 574 + .file_picker_filtered_indices() 575 + .iter() 576 + .map(|idx| app.file_picker_entries()[*idx].label()) 577 + .collect(); 578 + assert_eq!(labels, vec!["alpha-case.md", "alphacase.md"]); 579 + 580 + let _ = fs::remove_dir_all(root); 581 + } 582 + 583 + #[test] 584 + fn fuzzy_file_picker_prefers_shallower_paths_on_equal_scores() { 585 + let unique = SystemTime::now() 586 + .duration_since(UNIX_EPOCH) 587 + .unwrap() 588 + .as_nanos(); 589 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-depth-{unique}")); 590 + let _ = fs::remove_dir_all(&root); 591 + fs::create_dir_all(root.join("nested/deeper")).unwrap(); 592 + fs::write(root.join("case.md"), "# Root\n").unwrap(); 593 + fs::write(root.join("nested/deeper/case.md"), "# Nested\n").unwrap(); 594 + 595 + let mut app = App::new_with_source( 596 + Vec::new(), 597 + Vec::new(), 598 + AppConfig { 599 + filename: "picker".to_string(), 600 + source: String::new(), 601 + debug_input: false, 602 + watch: false, 603 + filepath: None, 604 + last_file_state: None, 605 + }, 606 + ); 607 + 608 + assert!(app.open_fuzzy_file_picker(root.clone())); 609 + app.push_file_picker_query('c'); 610 + app.push_file_picker_query('a'); 611 + app.push_file_picker_query('s'); 612 + app.push_file_picker_query('e'); 613 + 614 + let labels: Vec<_> = app 615 + .file_picker_filtered_indices() 616 + .iter() 617 + .map(|idx| app.file_picker_entries()[*idx].label()) 618 + .collect(); 619 + assert_eq!(labels, vec!["case.md", "nested/deeper/case.md"]); 620 + 621 + let _ = fs::remove_dir_all(root); 622 + } 623 + 624 + #[test] 625 + fn fuzzy_file_picker_allows_q_in_query() { 626 + let unique = SystemTime::now() 627 + .duration_since(UNIX_EPOCH) 628 + .unwrap() 629 + .as_nanos(); 630 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-q-{unique}")); 631 + let _ = fs::remove_dir_all(&root); 632 + fs::create_dir_all(&root).unwrap(); 633 + fs::write(root.join("query.md"), "# Query\n").unwrap(); 634 + 635 + let mut app = App::new_with_source( 636 + Vec::new(), 637 + Vec::new(), 638 + AppConfig { 639 + filename: "picker".to_string(), 640 + source: String::new(), 641 + debug_input: false, 642 + watch: false, 643 + filepath: None, 644 + last_file_state: None, 645 + }, 646 + ); 647 + 648 + assert!(app.open_fuzzy_file_picker(root.clone())); 649 + app.push_file_picker_query('q'); 650 + assert_eq!(app.file_picker_query(), "q"); 651 + 652 + let labels: Vec<_> = app 653 + .file_picker_filtered_indices() 654 + .iter() 655 + .map(|idx| app.file_picker_entries()[*idx].label()) 656 + .collect(); 657 + assert_eq!(labels, vec!["query.md"]); 658 + 659 + let _ = fs::remove_dir_all(root); 660 + } 661 + 662 + #[test] 663 + fn fuzzy_file_picker_skips_ignored_technical_directories() { 664 + let root = unique_temp_dir("leaf-fuzzy-picker-ignore"); 665 + let _ = fs::remove_dir_all(&root); 666 + fs::create_dir_all(root.join(".git")).unwrap(); 667 + fs::create_dir_all(root.join("target")).unwrap(); 668 + fs::create_dir_all(root.join("vendor")).unwrap(); 669 + fs::create_dir_all(root.join("var")).unwrap(); 670 + fs::create_dir_all(root.join(".notes")).unwrap(); 671 + fs::write(root.join(".git/ignored.md"), "# Ignored\n").unwrap(); 672 + fs::write(root.join("target/ignored.md"), "# Ignored\n").unwrap(); 673 + fs::write(root.join("vendor/ignored.md"), "# Ignored\n").unwrap(); 674 + fs::write(root.join("var/ignored.md"), "# Ignored\n").unwrap(); 675 + fs::write(root.join(".notes/kept.md"), "# Kept\n").unwrap(); 676 + 677 + let mut app = App::new_with_source( 678 + Vec::new(), 679 + Vec::new(), 680 + AppConfig { 681 + filename: "picker".to_string(), 682 + source: String::new(), 683 + debug_input: false, 684 + watch: false, 685 + filepath: None, 686 + last_file_state: None, 687 + }, 688 + ); 689 + 690 + assert!(app.open_fuzzy_file_picker(root.clone())); 691 + 692 + let labels: Vec<_> = app 693 + .file_picker_filtered_indices() 694 + .iter() 695 + .map(|idx| app.file_picker_entries()[*idx].label()) 696 + .collect(); 697 + assert_eq!(labels, vec![".notes/kept.md"]); 698 + assert_eq!(app.file_picker_truncation(), None); 699 + 700 + let _ = fs::remove_dir_all(root); 701 + } 702 + 703 + #[test] 704 + fn fuzzy_file_picker_reports_directory_limit_truncation() { 705 + let root = unique_temp_dir("leaf-fuzzy-picker-dir-limit"); 706 + let _ = fs::remove_dir_all(&root); 707 + for idx in 0..5_050usize { 708 + let dir = root.join(format!("nested-{idx:04}")); 709 + fs::create_dir_all(&dir).unwrap(); 710 + fs::write(dir.join(format!("file-{idx:04}.md")), "# File\n").unwrap(); 711 + } 712 + 713 + let mut app = App::new_with_source( 714 + Vec::new(), 715 + Vec::new(), 716 + AppConfig { 717 + filename: "picker".to_string(), 718 + source: String::new(), 719 + debug_input: false, 720 + watch: false, 721 + filepath: None, 722 + last_file_state: None, 723 + }, 724 + ); 725 + 726 + assert!(app.open_fuzzy_file_picker(root.clone())); 727 + assert_eq!( 728 + app.file_picker_truncation(), 729 + Some(crate::app::PickerIndexTruncation::Directory) 730 + ); 731 + assert!(!app.file_picker_entries().is_empty()); 732 + 733 + let _ = fs::remove_dir_all(root); 734 + } 735 + 736 + #[test] 737 + fn fuzzy_file_picker_reports_file_limit_truncation() { 738 + let root = unique_temp_dir("leaf-fuzzy-picker-file-limit"); 739 + let _ = fs::remove_dir_all(&root); 740 + fs::create_dir_all(&root).unwrap(); 741 + for idx in 0..10_050usize { 742 + fs::write(root.join(format!("file-{idx:05}.md")), "# File\n").unwrap(); 743 + } 744 + 745 + let mut app = App::new_with_source( 746 + Vec::new(), 747 + Vec::new(), 748 + AppConfig { 749 + filename: "picker".to_string(), 750 + source: String::new(), 751 + debug_input: false, 752 + watch: false, 753 + filepath: None, 754 + last_file_state: None, 755 + }, 756 + ); 757 + 758 + assert!(app.open_fuzzy_file_picker(root.clone())); 759 + assert_eq!( 760 + app.file_picker_truncation(), 761 + Some(crate::app::PickerIndexTruncation::File) 762 + ); 763 + assert_eq!(app.file_picker_entries().len(), 10_000); 764 + 765 + let _ = fs::remove_dir_all(root); 766 + }
+364
src/tests/markdown.rs
··· 1 + use super::{rendered_non_empty_lines, test_assets}; 2 + use crate::markdown::{parse_markdown, parse_markdown_with_width, resolve_syntax}; 3 + use crate::*; 4 + use syntect::parsing::SyntaxSet; 5 + 6 + #[test] 7 + fn h1_headings_render_double_rule_without_bottom_spacing() { 8 + let (ss, theme) = test_assets(); 9 + let (lines, _) = parse_markdown("# 東京\n", &ss, &theme); 10 + let rendered = rendered_non_empty_lines(&lines); 11 + 12 + assert_eq!(rendered[0], "東京"); 13 + assert_eq!(rendered[1], "═".repeat(display_width("東京"))); 14 + } 15 + 16 + #[test] 17 + fn loose_list_items_keep_their_markers() { 18 + let (ss, theme) = test_assets(); 19 + let (lines, _) = parse_markdown("- first\n\n- second\n", &ss, &theme); 20 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 21 + 22 + assert!(rendered.iter().any(|line| line.contains("• first"))); 23 + assert!(rendered.iter().any(|line| line.contains("• second"))); 24 + } 25 + 26 + #[test] 27 + fn ordered_lists_render_numeric_markers() { 28 + let (ss, theme) = test_assets(); 29 + let (lines, _) = parse_markdown("3. third\n4. fourth\n", &ss, &theme); 30 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 31 + 32 + assert!(rendered.iter().any(|line| line.contains("3. third"))); 33 + assert!(rendered.iter().any(|line| line.contains("4. fourth"))); 34 + } 35 + 36 + #[test] 37 + fn multiline_list_items_keep_marker_only_on_first_line() { 38 + let (ss, theme) = test_assets(); 39 + let (lines, _) = parse_markdown("- first line\n second line\n", &ss, &theme); 40 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 41 + 42 + let first = rendered 43 + .iter() 44 + .find(|line| line.contains("first line")) 45 + .unwrap(); 46 + let second = rendered 47 + .iter() 48 + .find(|line| line.contains("second line")) 49 + .unwrap(); 50 + 51 + assert!(first.contains("• first line")); 52 + assert!(!second.contains('•')); 53 + assert!(second.starts_with(" ")); 54 + } 55 + 56 + #[test] 57 + fn ordered_lists_preserve_non_default_start_numbers() { 58 + let (ss, theme) = test_assets(); 59 + let (lines, _) = parse_markdown("7. seven\n8. eight\n", &ss, &theme); 60 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 61 + 62 + assert!(rendered.iter().any(|line| line.contains("7. seven"))); 63 + assert!(rendered.iter().any(|line| line.contains("8. eight"))); 64 + } 65 + 66 + #[test] 67 + fn loose_list_items_render_expected_lines() { 68 + let (ss, theme) = test_assets(); 69 + let src = "- first loose item\n\n- second loose item after a blank line\n\n- third loose item\n\n continuation paragraph\n"; 70 + let (lines, _) = parse_markdown(src, &ss, &theme); 71 + let rendered = rendered_non_empty_lines(&lines); 72 + 73 + assert_eq!( 74 + rendered, 75 + vec![ 76 + "• first loose item", 77 + "• second loose item after a blank line", 78 + "• third loose item", 79 + " continuation paragraph", 80 + ] 81 + ); 82 + } 83 + 84 + #[test] 85 + fn ordered_loose_lists_render_expected_lines() { 86 + let (ss, theme) = test_assets(); 87 + let src = "7. seventh item\n\n8. eighth item\n\n continuation paragraph\n"; 88 + let (lines, _) = parse_markdown(src, &ss, &theme); 89 + let rendered = rendered_non_empty_lines(&lines); 90 + 91 + assert_eq!( 92 + rendered, 93 + vec![ 94 + "7. seventh item", 95 + "8. eighth item", 96 + " continuation paragraph", 97 + ] 98 + ); 99 + } 100 + 101 + #[test] 102 + fn ordered_lists_render_expected_lines() { 103 + let (ss, theme) = test_assets(); 104 + let (lines, _) = parse_markdown("3. third item\n4. fourth item\n", &ss, &theme); 105 + let rendered = rendered_non_empty_lines(&lines); 106 + 107 + assert_eq!(rendered, vec!["3. third item", "4. fourth item"]); 108 + } 109 + 110 + #[test] 111 + fn paragraph_and_following_list_have_no_blank_gap() { 112 + let (ss, theme) = test_assets(); 113 + let (lines, _) = parse_markdown("Intro paragraph\n\n- first\n- second\n", &ss, &theme); 114 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 115 + let intro_idx = rendered 116 + .iter() 117 + .position(|line| line == "Intro paragraph") 118 + .unwrap(); 119 + 120 + assert_eq!(rendered[intro_idx + 1], "• first"); 121 + } 122 + 123 + #[test] 124 + fn wrapped_list_items_align_continuation_under_text() { 125 + let (ss, theme) = test_assets(); 126 + let src = "- First item with enough text to wrap when the terminal is narrow and show continuation alignment.\n8. Eighth item with enough text to wrap and keep numeric alignment readable.\n"; 127 + let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 36); 128 + let rendered = rendered_non_empty_lines(&lines); 129 + 130 + assert!(rendered.iter().any(|line| line.starts_with("• First item"))); 131 + assert!(rendered 132 + .iter() 133 + .any(|line| line.starts_with(" ") && line.contains("terminal is narrow"))); 134 + assert!(rendered 135 + .iter() 136 + .any(|line| line.starts_with("8. Eighth item"))); 137 + assert!(rendered 138 + .iter() 139 + .any(|line| line.starts_with(" ") && !line.starts_with("8. "))); 140 + } 141 + 142 + #[test] 143 + fn paragraph_and_following_code_block_have_no_blank_gap() { 144 + let (ss, theme) = test_assets(); 145 + let src = "Intro paragraph\n\n```rs\nfn main() {}\n```\n"; 146 + let (lines, _) = parse_markdown(src, &ss, &theme); 147 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 148 + let intro_idx = rendered 149 + .iter() 150 + .position(|line| line == "Intro paragraph") 151 + .unwrap(); 152 + 153 + assert!(rendered[intro_idx + 1].starts_with("┌─ rs ")); 154 + } 155 + 156 + #[test] 157 + fn nested_blockquotes_keep_quote_prefix_after_inner_quote_ends() { 158 + let (ss, theme) = test_assets(); 159 + let src = "> outer\n> > inner\n> outer again\n"; 160 + let (lines, _) = parse_markdown(src, &ss, &theme); 161 + let rendered = rendered_non_empty_lines(&lines); 162 + 163 + assert!(rendered.iter().any(|line| line == "▏ outer")); 164 + assert!(rendered.iter().any(|line| line == "▏ inner")); 165 + assert!(rendered.iter().any(|line| line == "▏ outer again")); 166 + } 167 + 168 + #[test] 169 + fn long_blockquotes_wrap_into_multiple_prefixed_lines() { 170 + let (ss, theme) = test_assets(); 171 + let src = "> This is a long blockquote line that should wrap into multiple quoted lines at narrow widths.\n"; 172 + let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 28); 173 + let rendered = rendered_non_empty_lines(&lines); 174 + let quoted: Vec<_> = rendered 175 + .into_iter() 176 + .filter(|line| line.starts_with('▏')) 177 + .collect(); 178 + 179 + assert!(quoted.len() >= 2); 180 + assert!(quoted.iter().all(|line| line.starts_with("▏ "))); 181 + } 182 + 183 + #[test] 184 + fn toc_only_includes_first_two_heading_levels() { 185 + let (ss, theme) = test_assets(); 186 + let (_, toc) = parse_markdown("# One\n## Two\n### Three\n#### Four\n", &ss, &theme); 187 + 188 + assert_eq!(toc.len(), 3); 189 + assert_eq!(toc[0].level, 1); 190 + assert_eq!(toc[1].level, 2); 191 + assert_eq!(toc[2].level, 3); 192 + } 193 + 194 + #[test] 195 + fn frontmatter_is_ignored_in_preview_and_toc() { 196 + let (ss, theme) = test_assets(); 197 + let src = "---\ntitle: Demo\nowner: me\n---\n# Visible\nBody\n"; 198 + let (lines, toc) = parse_markdown(src, &ss, &theme); 199 + let rendered = rendered_non_empty_lines(&lines); 200 + 201 + assert!(!rendered.iter().any(|line| line.contains("title: Demo"))); 202 + assert!(rendered.iter().any(|line| line.contains("Visible"))); 203 + assert_eq!(toc.len(), 1); 204 + assert_eq!(toc[0].title, "Visible"); 205 + } 206 + 207 + #[test] 208 + fn h2_headings_are_underlined_and_compact() { 209 + let (ss, theme) = test_assets(); 210 + let (lines, _) = parse_markdown_with_width("Intro\n\n## Section\nBody\n", &ss, &theme, 40); 211 + let rendered = rendered_non_empty_lines(&lines); 212 + 213 + assert!(rendered.iter().any(|line| line.contains("Section"))); 214 + assert!(rendered.iter().any(|line| line.contains("────"))); 215 + } 216 + 217 + #[test] 218 + fn rules_use_render_width_without_extra_blank_after() { 219 + let (ss, theme) = test_assets(); 220 + let (lines, _) = parse_markdown_with_width("Alpha\n\n---\nBeta\n", &ss, &theme, 24); 221 + let rendered = rendered_non_empty_lines(&lines); 222 + let rule = rendered 223 + .iter() 224 + .find(|line| line.trim_start().starts_with('─')) 225 + .unwrap(); 226 + 227 + assert_eq!(display_width(rule.trim_start()), 24); 228 + let rule_idx = rendered.iter().position(|line| line == rule).unwrap(); 229 + assert_eq!(rendered[rule_idx + 1], "Beta"); 230 + } 231 + 232 + #[test] 233 + fn toc_hides_single_h1_when_h2_entries_exist() { 234 + let toc = vec![ 235 + TocEntry { 236 + level: 1, 237 + title: "Doc Title".to_string(), 238 + line: 0, 239 + }, 240 + TocEntry { 241 + level: 2, 242 + title: "Install".to_string(), 243 + line: 10, 244 + }, 245 + ]; 246 + 247 + assert!(should_hide_single_h1(&toc)); 248 + assert_eq!(toc_display_level(2, true, false), 1); 249 + assert_eq!(toc_display_level(3, true, false), 2); 250 + } 251 + 252 + #[test] 253 + fn toc_keeps_single_h1_when_no_h2_entries_exist() { 254 + let toc = vec![TocEntry { 255 + level: 1, 256 + title: "Doc Title".to_string(), 257 + line: 0, 258 + }]; 259 + 260 + assert!(!should_hide_single_h1(&toc)); 261 + } 262 + 263 + #[test] 264 + fn toc_promotes_h2_when_document_has_no_h1() { 265 + let toc = vec![ 266 + TocEntry { 267 + level: 2, 268 + title: "Build & install".to_string(), 269 + line: 0, 270 + }, 271 + TocEntry { 272 + level: 3, 273 + title: "Android".to_string(), 274 + line: 4, 275 + }, 276 + ]; 277 + 278 + assert!(should_promote_h2_when_no_h1(&toc)); 279 + assert_eq!(toc_display_level(2, false, true), 1); 280 + assert_eq!(toc_display_level(3, false, true), 2); 281 + let normalized = normalize_toc(toc); 282 + assert_eq!(normalized.len(), 2); 283 + assert_eq!(normalized[0].level, 2); 284 + assert_eq!(normalized[1].level, 3); 285 + } 286 + 287 + #[test] 288 + fn resolve_syntax_supports_common_language_aliases() { 289 + let ss = SyntaxSet::load_defaults_newlines(); 290 + 291 + assert_eq!( 292 + resolve_syntax("py", &ss).name, 293 + resolve_syntax("python", &ss).name 294 + ); 295 + assert_eq!( 296 + resolve_syntax("cpp", &ss).name, 297 + resolve_syntax("c++", &ss).name 298 + ); 299 + assert_eq!(resolve_syntax("json", &ss).name, "JSON"); 300 + assert_eq!( 301 + resolve_syntax("ps1", &ss).name, 302 + resolve_syntax("powershell", &ss).name 303 + ); 304 + } 305 + 306 + #[test] 307 + fn narrow_tables_fit_render_width_and_wrap_cells() { 308 + let (ss, theme) = test_assets(); 309 + let md = "| Column | Description | Value |\n| --- | --- | ---: |\n| Width | Terminal-dependent layout behavior | 80 |\n"; 310 + let (lines, _) = parse_markdown_with_width(md, &ss, &theme, 36); 311 + let rendered = rendered_non_empty_lines(&lines); 312 + 313 + assert!(rendered.len() >= 6); 314 + assert!(rendered.iter().all(|line| display_width(line) <= 36)); 315 + } 316 + 317 + #[test] 318 + fn wrapped_list_inline_code_keeps_left_padding_in_rendered_line() { 319 + let (ss, theme) = test_assets(); 320 + let source = "- `leaf --theme ocean README.md` exercises wrapping inside a list item.\n"; 321 + let (lines, _) = parse_markdown_with_width(source, &ss, &theme, 22); 322 + 323 + let target = lines 324 + .iter() 325 + .find(|line| line_plain_text(line).contains("leaf --theme")) 326 + .expect("expected wrapped inline-code line"); 327 + 328 + assert!( 329 + target 330 + .spans 331 + .iter() 332 + .any(|span| span.style.bg.is_some() && span.content.starts_with(' ')), 333 + "expected a background-styled span with left padding" 334 + ); 335 + } 336 + 337 + #[test] 338 + fn code_block_inside_list_item_is_indented_and_has_no_blank_gap_before() { 339 + let (ss, theme) = test_assets(); 340 + let md = "To put a code block within a list item, the code block needs\nto be indented *twice* -- 8 spaces or two tabs:\n\n* A list item with a code block:\n\n <code goes here>\n"; 341 + let (lines, _) = parse_markdown(md, &ss, &theme); 342 + let rendered = rendered_non_empty_lines(&lines); 343 + 344 + let item_idx = rendered 345 + .iter() 346 + .position(|line| line.contains("A list item with a code block:")) 347 + .expect("missing list item line"); 348 + let header_idx = rendered 349 + .iter() 350 + .position(|line| line.contains("┌─ text")) 351 + .expect("missing code block header"); 352 + let code_idx = rendered 353 + .iter() 354 + .position(|line| line.contains("<code goes here>")) 355 + .expect("missing code line"); 356 + 357 + assert_eq!( 358 + header_idx, 359 + item_idx + 1, 360 + "expected no blank gap before code block" 361 + ); 362 + assert!(rendered[header_idx].starts_with(" ")); 363 + assert!(rendered[code_idx].starts_with(" ")); 364 + }
+80
src/tests/mod.rs
··· 1 + use crate::*; 2 + use ratatui::backend::TestBackend; 3 + use ratatui::{text::Line, widgets::Paragraph, Terminal}; 4 + use std::{ 5 + path::PathBuf, 6 + sync::{Mutex, MutexGuard}, 7 + time::{SystemTime, UNIX_EPOCH}, 8 + }; 9 + use syntect::{ 10 + highlighting::{Theme, ThemeSet}, 11 + parsing::SyntaxSet, 12 + }; 13 + 14 + mod app; 15 + mod file_picker; 16 + mod markdown; 17 + mod render; 18 + mod theme; 19 + mod update; 20 + 21 + pub(super) static THEME_TEST_MUTEX: Mutex<()> = Mutex::new(()); 22 + 23 + pub(super) fn test_assets() -> (SyntaxSet, Theme) { 24 + let ss = SyntaxSet::load_defaults_newlines(); 25 + let ts = ThemeSet::load_defaults(); 26 + let theme = ts.themes["base16-ocean.dark"].clone(); 27 + (ss, theme) 28 + } 29 + 30 + pub(super) fn render_buffer(lines: &[Line<'static>]) -> ratatui::buffer::Buffer { 31 + let width = lines 32 + .iter() 33 + .map(|line| line.width()) 34 + .max() 35 + .unwrap_or(1) 36 + .max(1) as u16; 37 + let height = lines.len().max(1) as u16; 38 + let backend = TestBackend::new(width, height); 39 + let mut terminal = Terminal::new(backend).unwrap(); 40 + terminal 41 + .draw(|f| { 42 + f.render_widget(Paragraph::new(lines.to_vec()), f.area()); 43 + }) 44 + .unwrap(); 45 + terminal.backend().buffer().clone() 46 + } 47 + 48 + pub(super) fn find_symbol(buffer: &ratatui::buffer::Buffer, symbol: &str) -> Option<(u16, u16)> { 49 + for y in 0..buffer.area.height { 50 + for x in 0..buffer.area.width { 51 + if buffer 52 + .cell((x, y)) 53 + .is_some_and(|cell| cell.symbol() == symbol) 54 + { 55 + return Some((x, y)); 56 + } 57 + } 58 + } 59 + None 60 + } 61 + 62 + pub(super) fn rendered_non_empty_lines(lines: &[Line<'static>]) -> Vec<String> { 63 + lines 64 + .iter() 65 + .map(line_plain_text) 66 + .filter(|line| !line.is_empty()) 67 + .collect() 68 + } 69 + 70 + pub(super) fn lock_theme_test_state() -> MutexGuard<'static, ()> { 71 + THEME_TEST_MUTEX.lock().unwrap() 72 + } 73 + 74 + pub(super) fn unique_temp_dir(prefix: &str) -> PathBuf { 75 + let unique = SystemTime::now() 76 + .duration_since(UNIX_EPOCH) 77 + .unwrap() 78 + .as_nanos(); 79 + std::env::temp_dir().join(format!("{prefix}-{unique}")) 80 + }
+59
src/tests/render.rs
··· 1 + use super::{find_symbol, render_buffer, test_assets}; 2 + use crate::markdown::parse_markdown; 3 + 4 + #[test] 5 + fn code_block_box_renders_right_border_in_one_column() { 6 + let (ss, theme) = test_assets(); 7 + let md = "```ts\nconst city = \"東京\";\n\tconsole.log(city)\n```"; 8 + let (lines, _) = parse_markdown(md, &ss, &theme); 9 + let buffer = render_buffer(&lines); 10 + 11 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 12 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 13 + 14 + for y in start_y + 1..end_y { 15 + assert_eq!( 16 + buffer.cell((right_x, y)).unwrap().symbol(), 17 + "│", 18 + "missing code block right border at row {y}" 19 + ); 20 + } 21 + } 22 + 23 + #[test] 24 + fn table_render_right_border_stays_aligned() { 25 + let (ss, theme) = test_assets(); 26 + let md = "| Name | Value |\n| --- | --- |\n| 東京 | 12 |\n| tab\tcell | ok |"; 27 + let (lines, _) = parse_markdown(md, &ss, &theme); 28 + let buffer = render_buffer(&lines); 29 + 30 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 31 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 32 + 33 + for y in start_y + 1..end_y { 34 + let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 35 + assert!( 36 + matches!(symbol, "│" | "┤" | "╡"), 37 + "unexpected table edge symbol {symbol:?} at row {y}" 38 + ); 39 + } 40 + } 41 + 42 + #[test] 43 + fn table_render_right_border_stays_aligned_with_emoji_cells() { 44 + let (ss, theme) = test_assets(); 45 + let md = "| Critère | Note |\n| --- | --- |\n| Tests | ✅ Bonne couverture |\n| Sécurité | ⚠ Quelques points |\n"; 46 + let (lines, _) = parse_markdown(md, &ss, &theme); 47 + let buffer = render_buffer(&lines); 48 + 49 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 50 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 51 + 52 + for y in start_y + 1..end_y { 53 + let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 54 + assert!( 55 + matches!(symbol, "│" | "┤" | "╡"), 56 + "unexpected emoji-table edge symbol {symbol:?} at row {y}" 57 + ); 58 + } 59 + }
+97
src/tests/theme.rs
··· 1 + use super::{lock_theme_test_state, test_assets}; 2 + use crate::app::{App, AppConfig}; 3 + use crate::markdown::parse_markdown; 4 + use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 5 + use crate::*; 6 + use syntect::highlighting::ThemeSet; 7 + 8 + #[test] 9 + fn parse_theme_preset_supports_ocean_and_forest() { 10 + assert_eq!(parse_theme_preset("arctic"), Some(ThemePreset::Arctic)); 11 + assert_eq!(parse_theme_preset("ocean"), Some(ThemePreset::OceanDark)); 12 + assert_eq!(parse_theme_preset("forest"), Some(ThemePreset::Forest)); 13 + assert_eq!( 14 + parse_theme_preset("solarized-dark"), 15 + Some(ThemePreset::SolarizedDark) 16 + ); 17 + assert_eq!(parse_theme_preset("nope"), None); 18 + } 19 + 20 + #[test] 21 + fn theme_presets_are_in_alphabetical_order() { 22 + let labels: Vec<_> = THEME_PRESETS 23 + .iter() 24 + .map(|preset| theme_preset_label(*preset)) 25 + .collect(); 26 + let mut sorted = labels.clone(); 27 + sorted.sort(); 28 + assert_eq!(labels, sorted); 29 + } 30 + 31 + #[test] 32 + fn theme_picker_restores_original_preset_on_escape() { 33 + let _guard = lock_theme_test_state(); 34 + let (ss, theme) = test_assets(); 35 + let ts = ThemeSet::load_defaults(); 36 + let (lines, toc) = parse_markdown("# Demo\n", &ss, &theme); 37 + let mut app = App::new_with_source( 38 + lines, 39 + toc, 40 + AppConfig { 41 + filename: "stdin".to_string(), 42 + source: "# Demo\n".to_string(), 43 + debug_input: false, 44 + watch: false, 45 + filepath: None, 46 + last_file_state: None, 47 + }, 48 + ); 49 + 50 + let original = current_theme_preset(); 51 + set_theme_preset(ThemePreset::OceanDark); 52 + app.open_theme_picker(); 53 + assert!(app.set_theme_picker_index(theme_preset_index(ThemePreset::Forest))); 54 + app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 55 + 56 + assert_eq!(current_theme_preset(), ThemePreset::Forest); 57 + 58 + app.restore_theme_picker_preview(&ss, &ts); 59 + 60 + assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 61 + assert!(!app.is_theme_picker_open()); 62 + assert_eq!(app.theme_picker_original(), None); 63 + set_theme_preset(original); 64 + } 65 + 66 + #[test] 67 + fn theme_picker_caches_previewed_themes_for_reuse() { 68 + let _guard = lock_theme_test_state(); 69 + let (ss, theme) = test_assets(); 70 + let ts = ThemeSet::load_defaults(); 71 + let (lines, toc) = parse_markdown("# Demo\n\n```rs\nfn main() {}\n```\n", &ss, &theme); 72 + let mut app = App::new_with_source( 73 + lines, 74 + toc, 75 + AppConfig { 76 + filename: "stdin".to_string(), 77 + source: "# Demo\n\n```rs\nfn main() {}\n```\n".to_string(), 78 + debug_input: false, 79 + watch: false, 80 + filepath: None, 81 + last_file_state: None, 82 + }, 83 + ); 84 + 85 + let original = current_theme_preset(); 86 + set_theme_preset(ThemePreset::OceanDark); 87 + app.open_theme_picker(); 88 + app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 89 + 90 + assert!(app.has_cached_theme_preview(ThemePreset::Forest)); 91 + assert_eq!(current_theme_preset(), ThemePreset::Forest); 92 + 93 + app.preview_theme_preset(ThemePreset::OceanDark, &ss, &ts); 94 + assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 95 + assert!(app.has_cached_theme_preview(ThemePreset::OceanDark)); 96 + set_theme_preset(original); 97 + }
+116
src/tests/update.rs
··· 1 + use crate::update::TestAsset; 2 + use crate::*; 3 + 4 + #[test] 5 + fn asset_name_matches_supported_release_targets() { 6 + assert_eq!( 7 + asset_name_for_target("macos", "x86_64"), 8 + Some("leaf-macos-x86_64") 9 + ); 10 + assert_eq!( 11 + asset_name_for_target("macos", "aarch64"), 12 + Some("leaf-macos-arm64") 13 + ); 14 + assert_eq!( 15 + asset_name_for_target("linux", "x86_64"), 16 + Some("leaf-linux-x86_64") 17 + ); 18 + assert_eq!( 19 + asset_name_for_target("linux", "aarch64"), 20 + Some("leaf-linux-arm64") 21 + ); 22 + assert_eq!( 23 + asset_name_for_target("android", "aarch64"), 24 + Some("leaf-android-arm64") 25 + ); 26 + assert_eq!( 27 + asset_name_for_target("windows", "x86_64"), 28 + Some("leaf-windows-x86_64.exe") 29 + ); 30 + assert_eq!(asset_name_for_target("linux", "arm"), None); 31 + } 32 + 33 + #[test] 34 + fn newer_version_comparison_accepts_optional_v_prefix() { 35 + assert!(is_newer_version("1.4.2", "v1.4.3").unwrap()); 36 + assert!(!is_newer_version("1.4.2", "1.4.2").unwrap()); 37 + assert!(!is_newer_version("1.4.2", "1.4.1").unwrap()); 38 + } 39 + 40 + #[test] 41 + fn expected_asset_download_url_selects_matching_asset() { 42 + let assets = vec![ 43 + TestAsset { 44 + name: "leaf-linux-x86_64", 45 + download_url: "https://example.test/linux", 46 + }, 47 + TestAsset { 48 + name: "leaf-windows-x86_64.exe", 49 + download_url: "https://example.test/windows", 50 + }, 51 + ]; 52 + 53 + let url = expected_asset_download_url("1.4.3", &assets, "leaf-linux-x86_64").unwrap(); 54 + assert_eq!(url, "https://example.test/linux"); 55 + } 56 + 57 + #[test] 58 + fn expected_asset_download_url_errors_when_asset_is_missing() { 59 + let assets = vec![TestAsset { 60 + name: "leaf-linux-x86_64", 61 + download_url: "https://example.test/linux", 62 + }]; 63 + 64 + let err = expected_asset_download_url("1.4.3", &assets, "leaf-macos-arm64").unwrap_err(); 65 + assert!(err.to_string().contains("does not contain asset")); 66 + } 67 + 68 + #[test] 69 + fn validate_download_size_accepts_matching_non_zero_sizes() { 70 + assert!(validate_download_size(Some(42), 42).is_ok()); 71 + assert!(validate_download_size(None, 42).is_ok()); 72 + } 73 + 74 + #[test] 75 + fn validate_download_size_rejects_zero_or_mismatched_sizes() { 76 + let empty_err = validate_download_size(None, 0).unwrap_err(); 77 + assert!(empty_err.to_string().contains("is empty")); 78 + 79 + let mismatch_err = validate_download_size(Some(42), 41).unwrap_err(); 80 + assert!(mismatch_err.to_string().contains("size mismatch")); 81 + } 82 + 83 + #[test] 84 + fn find_expected_checksum_extracts_matching_asset_checksum() { 85 + let checksums = "\ 86 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa leaf-linux-x86_64 87 + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb leaf-windows-x86_64.exe 88 + "; 89 + 90 + let checksum = find_expected_checksum(checksums, "leaf-windows-x86_64.exe").unwrap(); 91 + assert_eq!( 92 + checksum, 93 + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 94 + ); 95 + } 96 + 97 + #[test] 98 + fn find_expected_checksum_rejects_missing_or_invalid_entries() { 99 + let missing = 100 + find_expected_checksum("abcd leaf-linux-x86_64\n", "leaf-macos-arm64").unwrap_err(); 101 + assert!(missing.to_string().contains("does not contain")); 102 + 103 + let invalid = 104 + find_expected_checksum("xyz leaf-linux-x86_64\n", "leaf-linux-x86_64").unwrap_err(); 105 + assert!(invalid 106 + .to_string() 107 + .contains("Invalid SHA256 checksum format")); 108 + } 109 + 110 + #[test] 111 + fn validate_sha256_hex_accepts_expected_format() { 112 + assert!(validate_sha256_hex( 113 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 114 + ) 115 + .is_ok()); 116 + }