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 #2 from RivoLink/chore/improve-design

chore: improve design

authored by

Rivo Link and committed by
GitHub
2205cdd4 82573ec8

+559 -114
+2 -1
README.md
··· 76 76 - ✅ Unicode box-drawing tables with left / center / right alignment 77 77 - ✅ TOC sidebar with active section tracking and two-level navigation 78 78 - ✅ Search with match highlighting and `n` / `N` 79 - - ✅ Code blocks `╭─ lang ───╮` 79 + - ✅ Code blocks `┌─ lang ───┐` 80 80 - ✅ Bold, italic, strikethrough, blockquotes, lists, and horizontal rules 81 81 - ✅ YAML frontmatter is ignored in both preview and TOC 82 82 - ✅ Native stdin input ··· 95 95 96 96 - [x] Themes (light / custom) 97 97 - [ ] Copy code block `y` 98 + - [ ] Code block horizontal scroll 98 99 - [ ] Improve search performance on large files
+24 -3
src/app.rs
··· 1 1 use crate::{ 2 - markdown::{build_plain_lines, hash_file_contents, hash_str, parse_markdown, read_file_state}, 2 + markdown::{ 3 + build_plain_lines, hash_file_contents, hash_str, parse_markdown_with_width, read_file_state, 4 + }, 3 5 render::{build_status_bar, build_toc_line_with_index, toc_header_line}, 4 6 theme::{ 5 7 current_syntect_theme, current_theme_preset, set_theme_preset, theme_preset_index, ··· 83 85 pub(crate) theme_picker_index: usize, 84 86 pub(crate) theme_picker_original: Option<ThemePreset>, 85 87 pub(crate) theme_preview_cache: Vec<Option<ThemePreviewCacheEntry>>, 88 + pub(crate) render_width: usize, 86 89 } 87 90 88 91 impl App { ··· 161 164 theme_picker_index: theme_preset_index(current_theme_preset()), 162 165 theme_picker_original: None, 163 166 theme_preview_cache: vec![None; crate::theme::THEME_PRESETS.len()], 167 + render_width: 80, 164 168 }; 165 169 app.store_current_theme_preview(); 166 170 app.refresh_static_caches(); ··· 413 417 } 414 418 415 419 let theme = current_syntect_theme(themes); 416 - let (new_lines, new_toc) = parse_markdown(&self.source, ss, theme); 420 + let (new_lines, new_toc) = 421 + parse_markdown_with_width(&self.source, ss, theme, self.render_width); 417 422 self.store_theme_preview(preset, &new_lines, &new_toc); 418 423 self.replace_content(new_lines, new_toc); 419 424 } ··· 553 558 ((self.scroll * 100) / (self.total() - vh).max(1)) as u16 554 559 } 555 560 561 + pub(crate) fn sync_render_width( 562 + &mut self, 563 + render_width: usize, 564 + ss: &SyntaxSet, 565 + themes: &ThemeSet, 566 + ) -> bool { 567 + let next_width = render_width.max(20); 568 + if self.render_width == next_width { 569 + return false; 570 + } 571 + self.render_width = next_width; 572 + self.reparse_source(ss, themes); 573 + true 574 + } 575 + 556 576 pub(crate) fn check_modified(&mut self) -> Option<FileChange> { 557 577 const HASH_FALLBACK_INTERVAL: Duration = Duration::from_secs(2); 558 578 ··· 581 601 pub(crate) fn reparse_source(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { 582 602 let theme = current_syntect_theme(themes); 583 603 let old_total = self.total(); 584 - let (new_lines, new_toc) = parse_markdown(&self.source, ss, theme); 604 + let (new_lines, new_toc) = 605 + parse_markdown_with_width(&self.source, ss, theme, self.render_width); 585 606 let new_total = new_lines.len(); 586 607 587 608 if old_total > 0 {
+378 -61
src/markdown.rs
··· 25 25 Ordered(u64), 26 26 } 27 27 28 + #[derive(Clone, Copy, PartialEq, Eq)] 29 + enum LastBlock { 30 + Other, 31 + Paragraph, 32 + } 33 + 28 34 struct ItemState { 29 35 marker_emitted: bool, 30 36 continuation_indent: usize, ··· 141 147 ) 142 148 } 143 149 150 + const DEFAULT_RENDER_WIDTH: usize = 80; 151 + 144 152 fn strip_frontmatter(src: &str) -> &str { 145 153 let Some(rest) = src.strip_prefix("---\n") else { 146 154 return src; ··· 208 216 lang: &str, 209 217 ss: &SyntaxSet, 210 218 theme: &Theme, 219 + render_width: usize, 211 220 ) -> (Vec<Line<'static>>, usize) { 212 221 let theme_colors = &app_theme().markdown; 213 222 let syntax = resolve_syntax(lang, ss); ··· 217 226 let mut raw: Vec<(Vec<Span<'static>>, usize)> = Vec::new(); 218 227 for line_str in LinesWithEndings::from(code) { 219 228 let regions = hl.highlight_line(line_str, ss).unwrap_or_default(); 220 - let mut spans = vec![Span::raw(" "), Span::styled("│ ", gutter)]; 229 + let mut spans = vec![Span::styled("│ ", gutter)]; 221 230 let mut text_width: usize = 0; 222 231 for (st, text) in &regions { 223 232 let t = expand_tabs(text.trim_end_matches('\n'), text_width); ··· 251 260 252 261 let label = if lang.is_empty() { "text" } else { lang }; 253 262 let max_text = raw.iter().map(|(_, w)| *w).max().unwrap_or(0); 254 - let min_inner = (UnicodeWidthStr::width(label) + 3).max(44); 263 + let max_inner_width = render_width 264 + .saturating_sub(4) 265 + .max(UnicodeWidthStr::width(label) + 3); 266 + let min_inner = (UnicodeWidthStr::width(label) + 3) 267 + .max(44) 268 + .min(max_inner_width); 255 269 let inner_width = (max_text + 2).max(min_inner); 256 270 257 271 let mut out = Vec::new(); ··· 267 281 fn block_prefix(in_bq: bool) -> Vec<Span<'static>> { 268 282 let theme = &app_theme().markdown; 269 283 if in_bq { 270 - vec![Span::styled( 271 - " ▏ ", 272 - Style::default().fg(theme.blockquote_marker), 273 - )] 284 + vec![Span::styled("▏ ", Style::default().fg(theme.blockquote_marker))] 274 285 } else { 275 - vec![Span::raw(" ")] 286 + vec![] 276 287 } 277 288 } 278 289 ··· 318 329 prefix 319 330 } 320 331 332 + fn push_wrapped_blockquote_lines( 333 + lines: &mut Vec<Line<'static>>, 334 + body_spans: &mut Vec<Span<'static>>, 335 + render_width: usize, 336 + ) { 337 + if body_spans.is_empty() { 338 + return; 339 + } 340 + 341 + let prefix = block_prefix(true); 342 + let prefix_width: usize = prefix.iter().map(|span| display_width(span.content.as_ref())).sum(); 343 + let max_width = render_width.saturating_sub(prefix_width).max(8); 344 + 345 + let mut current_prefix = prefix.clone(); 346 + let mut current_width = 0usize; 347 + let mut body_started = false; 348 + 349 + let push_current = |lines: &mut Vec<Line<'static>>, 350 + current_prefix: &mut Vec<Span<'static>>, 351 + body_started: &mut bool, 352 + current_width: &mut usize| { 353 + if *body_started { 354 + lines.push(Line::from(std::mem::take(current_prefix))); 355 + *current_prefix = prefix.clone(); 356 + *body_started = false; 357 + *current_width = 0; 358 + } 359 + }; 360 + 361 + for span in body_spans.drain(..) { 362 + let style = span.style; 363 + let mut token = String::new(); 364 + let mut token_is_space = false; 365 + 366 + let flush_token = |token: &mut String, 367 + token_is_space: bool, 368 + lines: &mut Vec<Line<'static>>, 369 + current_prefix: &mut Vec<Span<'static>>, 370 + body_started: &mut bool, 371 + current_width: &mut usize| { 372 + if token.is_empty() { 373 + return; 374 + } 375 + 376 + let token_width = display_width(token); 377 + if token_is_space { 378 + if *body_started && *current_width + token_width <= max_width { 379 + current_prefix.push(Span::styled(std::mem::take(token), style)); 380 + *current_width += token_width; 381 + } else { 382 + token.clear(); 383 + } 384 + return; 385 + } 386 + 387 + if *body_started && *current_width + token_width > max_width { 388 + push_current(lines, current_prefix, body_started, current_width); 389 + } 390 + 391 + if token_width <= max_width { 392 + current_prefix.push(Span::styled(std::mem::take(token), style)); 393 + *current_width += token_width; 394 + *body_started = true; 395 + return; 396 + } 397 + 398 + let mut chunk = String::new(); 399 + let mut chunk_width = 0usize; 400 + for ch in token.chars() { 401 + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); 402 + let would_overflow = if *body_started { 403 + *current_width + chunk_width + ch_width > max_width 404 + } else { 405 + chunk_width + ch_width > max_width 406 + }; 407 + if would_overflow { 408 + if !chunk.is_empty() { 409 + current_prefix.push(Span::styled(std::mem::take(&mut chunk), style)); 410 + *body_started = true; 411 + } 412 + push_current(lines, current_prefix, body_started, current_width); 413 + chunk_width = 0; 414 + } 415 + 416 + chunk.push(ch); 417 + chunk_width += ch_width; 418 + } 419 + 420 + if !chunk.is_empty() { 421 + current_prefix.push(Span::styled(chunk, style)); 422 + *current_width += chunk_width; 423 + *body_started = true; 424 + } 425 + token.clear(); 426 + }; 427 + 428 + for ch in span.content.chars() { 429 + let is_space = ch.is_whitespace(); 430 + if token.is_empty() { 431 + token_is_space = is_space; 432 + } else if token_is_space != is_space { 433 + flush_token( 434 + &mut token, 435 + token_is_space, 436 + lines, 437 + &mut current_prefix, 438 + &mut body_started, 439 + &mut current_width, 440 + ); 441 + token_is_space = is_space; 442 + } 443 + token.push(ch); 444 + } 445 + 446 + flush_token( 447 + &mut token, 448 + token_is_space, 449 + lines, 450 + &mut current_prefix, 451 + &mut body_started, 452 + &mut current_width, 453 + ); 454 + } 455 + 456 + if body_started { 457 + lines.push(Line::from(current_prefix)); 458 + } 459 + } 460 + 321 461 impl TableBuf { 322 462 fn new(alignments: Vec<Alignment>) -> Self { 323 463 Self { ··· 348 488 self.in_header = false; 349 489 } 350 490 351 - fn render(&self) -> Vec<Line<'static>> { 491 + fn render(&self, render_width: usize) -> Vec<Line<'static>> { 352 492 let theme = &app_theme().markdown; 353 493 if self.rows.is_empty() { 354 494 return vec![]; ··· 359 499 } 360 500 361 501 let mut col_widths: Vec<usize> = vec![1; col_count]; 502 + let mut min_widths: Vec<usize> = vec![4; col_count]; 362 503 for row in &self.rows { 363 504 for (ci, cell) in row.iter().enumerate() { 364 505 if ci < col_count { 365 506 col_widths[ci] = col_widths[ci].max(display_width(cell)); 507 + min_widths[ci] = min_widths[ci].max(min_table_cell_width(cell)); 366 508 } 367 509 } 368 510 } 369 511 512 + fit_table_widths(&mut col_widths, &min_widths, render_width); 513 + 370 514 let border = Style::default().fg(theme.table_border); 371 515 let sep = Style::default().fg(theme.table_separator); 372 516 let header = Style::default() 373 517 .fg(theme.table_header) 374 518 .add_modifier(Modifier::BOLD); 375 519 let cell = Style::default().fg(theme.table_cell); 376 - let ind = " "; 520 + let ind = ""; 377 521 378 - let mut out: Vec<Line<'static>> = vec![Line::from("")]; 522 + let mut out: Vec<Line<'static>> = Vec::new(); 379 523 out.push(self.hline( 380 524 ind, 381 525 TableBorder { 382 - left: "╭", 526 + left: "┌", 383 527 fill: "─", 384 528 cross: "┬", 385 - right: "╮", 529 + right: "┐", 386 530 }, 387 531 &col_widths, 388 532 border, ··· 390 534 391 535 for (ri, row) in self.rows.iter().enumerate() { 392 536 let is_hdr = ri < self.header_count; 393 - let mut spans = vec![Span::raw(ind), Span::styled("│", border)]; 394 - for (ci, width) in col_widths.iter().copied().enumerate().take(col_count) { 395 - let txt = row.get(ci).map(|s| s.as_str()).unwrap_or(""); 396 - let align = self.alignments.get(ci).copied().unwrap_or(Alignment::None); 397 - let pad = align_cell(txt, width, align); 398 - let st = if is_hdr { header } else { cell }; 399 - spans.push(Span::raw(" ")); 400 - spans.push(Span::styled(pad, st)); 401 - spans.push(Span::raw(" ")); 402 - spans.push(Span::styled("│", border)); 537 + let wrapped_cells: Vec<Vec<String>> = col_widths 538 + .iter() 539 + .copied() 540 + .enumerate() 541 + .take(col_count) 542 + .map(|(ci, width)| wrap_table_cell(row.get(ci).map(|s| s.as_str()).unwrap_or(""), width)) 543 + .collect(); 544 + let row_height = wrapped_cells.iter().map(|lines| lines.len()).max().unwrap_or(1); 545 + 546 + for line_idx in 0..row_height { 547 + let mut spans = vec![Span::raw(ind), Span::styled("│", border)]; 548 + for (ci, width) in col_widths.iter().copied().enumerate().take(col_count) { 549 + let txt = wrapped_cells[ci] 550 + .get(line_idx) 551 + .map(|s| s.as_str()) 552 + .unwrap_or(""); 553 + let align = self.alignments.get(ci).copied().unwrap_or(Alignment::None); 554 + let pad = align_cell(txt, width, align); 555 + let st = if is_hdr { header } else { cell }; 556 + spans.push(Span::raw(" ")); 557 + spans.push(Span::styled(pad, st)); 558 + spans.push(Span::raw(" ")); 559 + spans.push(Span::styled("│", border)); 560 + } 561 + out.push(Line::from(spans)); 403 562 } 404 - out.push(Line::from(spans)); 405 563 406 564 if is_hdr && ri == self.header_count - 1 { 407 565 out.push(self.hline( ··· 433 591 out.push(self.hline( 434 592 ind, 435 593 TableBorder { 436 - left: "╰", 594 + left: "└", 437 595 fill: "─", 438 596 cross: "┴", 439 - right: "╯", 597 + right: "┘", 440 598 }, 441 599 &col_widths, 442 600 border, ··· 467 625 } 468 626 } 469 627 628 + fn min_table_cell_width(text: &str) -> usize { 629 + let max_word = text 630 + .split_whitespace() 631 + .map(display_width) 632 + .max() 633 + .unwrap_or(0) 634 + .min(12); 635 + max_word.max(4) 636 + } 637 + 638 + fn fit_table_widths(col_widths: &mut [usize], min_widths: &[usize], render_width: usize) { 639 + if col_widths.is_empty() { 640 + return; 641 + } 642 + 643 + let col_count = col_widths.len(); 644 + let border_width = 3 * col_count + 1; 645 + let available = render_width.saturating_sub(border_width).max(col_count); 646 + let min_total: usize = min_widths.iter().sum(); 647 + 648 + if min_total >= available { 649 + let mut widths = vec![1; col_count]; 650 + let mut remaining = available.saturating_sub(col_count); 651 + let mut order: Vec<usize> = (0..col_count).collect(); 652 + order.sort_by_key(|&idx| std::cmp::Reverse(min_widths[idx])); 653 + for idx in order { 654 + if remaining == 0 { 655 + break; 656 + } 657 + let extra = (min_widths[idx].saturating_sub(1)).min(remaining); 658 + widths[idx] += extra; 659 + remaining -= extra; 660 + } 661 + col_widths.copy_from_slice(&widths); 662 + return; 663 + } 664 + 665 + while col_widths.iter().sum::<usize>() > available { 666 + let Some((idx, _)) = col_widths 667 + .iter() 668 + .enumerate() 669 + .filter(|(idx, width)| **width > min_widths[*idx]) 670 + .max_by_key(|(_, width)| **width) else { 671 + break; 672 + }; 673 + col_widths[idx] -= 1; 674 + } 675 + } 676 + 677 + fn wrap_table_cell(text: &str, width: usize) -> Vec<String> { 678 + if width == 0 { 679 + return vec![String::new()]; 680 + } 681 + let expanded = expand_tabs(text, 0); 682 + if expanded.is_empty() { 683 + return vec![String::new()]; 684 + } 685 + 686 + let mut lines = Vec::new(); 687 + let mut current = String::new(); 688 + let mut current_width = 0usize; 689 + 690 + for word in expanded.split_whitespace() { 691 + let word_width = display_width(word); 692 + 693 + if word_width > width { 694 + if !current.is_empty() { 695 + lines.push(std::mem::take(&mut current)); 696 + current_width = 0; 697 + } 698 + let mut chunk = String::new(); 699 + let mut chunk_width = 0usize; 700 + for ch in word.chars() { 701 + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); 702 + if chunk_width + ch_width > width && !chunk.is_empty() { 703 + lines.push(std::mem::take(&mut chunk)); 704 + chunk_width = 0; 705 + } 706 + chunk.push(ch); 707 + chunk_width += ch_width; 708 + } 709 + if !chunk.is_empty() { 710 + current = chunk; 711 + current_width = chunk_width; 712 + } 713 + continue; 714 + } 715 + 716 + let sep = if current.is_empty() { 0 } else { 1 }; 717 + if current_width + sep + word_width > width && !current.is_empty() { 718 + lines.push(std::mem::take(&mut current)); 719 + current_width = 0; 720 + } 721 + if !current.is_empty() { 722 + current.push(' '); 723 + current_width += 1; 724 + } 725 + current.push_str(word); 726 + current_width += word_width; 727 + } 728 + 729 + if !current.is_empty() { 730 + lines.push(current); 731 + } 732 + if lines.is_empty() { 733 + lines.push(String::new()); 734 + } 735 + lines 736 + } 737 + 470 738 fn align_cell(text: &str, width: usize, align: Alignment) -> String { 471 739 let text = expand_tabs(text, 0); 472 740 let len = display_width(&text); ··· 489 757 ss: &SyntaxSet, 490 758 theme: &Theme, 491 759 ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 760 + parse_markdown_with_width(src, ss, theme, DEFAULT_RENDER_WIDTH) 761 + } 762 + 763 + fn rule_width(render_width: usize, indent: usize) -> usize { 764 + render_width.saturating_sub(indent).max(8) 765 + } 766 + 767 + pub(crate) fn parse_markdown_with_width( 768 + src: &str, 769 + ss: &SyntaxSet, 770 + theme: &Theme, 771 + render_width: usize, 772 + ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 492 773 let theme_colors = &app_theme().markdown; 493 774 let src = strip_frontmatter(src); 494 775 let mut lines: Vec<Line<'static>> = Vec::new(); ··· 507 788 let mut list_stack: Vec<ListKind> = Vec::new(); 508 789 let mut item_stack: Vec<ItemState> = Vec::new(); 509 790 let mut table: Option<TableBuf> = None; 791 + let mut last_block = LastBlock::Other; 510 792 511 793 macro_rules! flush { 512 794 ($prefix:expr) => {{ ··· 558 840 continue; 559 841 } 560 842 MdEvent::End(TagEnd::Table) => { 561 - lines.extend(tb.render()); 843 + lines.extend(tb.render(render_width)); 562 844 table = None; 563 845 continue; 564 846 } ··· 577 859 HeadingLevel::H3 => 3, 578 860 _ => 4, 579 861 }); 580 - lines.push(Line::from("")); 581 862 } 582 863 MdEvent::End(TagEnd::Heading(_)) => { 583 864 let lvl = in_heading.unwrap_or(1); 584 - let (color, marker): (Color, &str) = match lvl { 585 - 1 => (theme_colors.heading_1, "█ "), 586 - 2 => (theme_colors.heading_2, "▌ "), 587 - 3 => (theme_colors.heading_3, "▎ "), 588 - _ => (theme_colors.heading_other, " "), 865 + let color: Color = match lvl { 866 + 1 => theme_colors.heading_1, 867 + 2 => theme_colors.heading_2, 868 + 3 => theme_colors.heading_3, 869 + _ => theme_colors.heading_other, 589 870 }; 590 - let style = Style::default().fg(color).add_modifier(Modifier::BOLD); 871 + let style = Style::default().fg(color).add_modifier(match lvl { 872 + 1 => Modifier::BOLD, 873 + 2 => Modifier::BOLD, 874 + 3 => Modifier::BOLD, 875 + _ => Modifier::empty(), 876 + }); 591 877 let title: String = spans.iter().map(|s| s.content.as_ref()).collect(); 878 + let rendered_title = if lvl == 3 { 879 + format!("{title} ") 880 + } else { 881 + title.clone() 882 + }; 592 883 toc.push(TocEntry { 593 884 level: lvl, 594 885 title: title.clone(), 595 886 line: lines.len(), 596 887 }); 597 - let mut all = vec![ 598 - Span::raw(" "), 599 - Span::styled( 600 - marker.to_string(), 601 - Style::default().fg(theme_colors.heading_marker), 602 - ), 603 - ]; 604 - all.extend(spans.drain(..).map(|s| Span::styled(s.content, style))); 888 + let all = vec![Span::styled(rendered_title, style)]; 889 + spans.clear(); 605 890 lines.push(Line::from(all)); 606 891 if lvl == 1 { 607 892 lines.push(Line::from(Span::styled( 608 - format!(" {}", "─".repeat((display_width(&title) + 4).min(68))), 893 + "═".repeat(display_width(&title).min(rule_width(render_width, 0))), 894 + Style::default().fg(theme_colors.heading_underline), 895 + ))); 896 + } 897 + if lvl == 2 { 898 + lines.push(Line::from(Span::styled( 899 + "─".repeat(display_width(&title).min(rule_width(render_width, 0))), 609 900 Style::default().fg(theme_colors.heading_underline), 610 901 ))); 611 902 } 612 - lines.push(Line::from("")); 903 + last_block = LastBlock::Other; 613 904 in_heading = None; 614 905 } 615 906 MdEvent::Start(Tag::Paragraph) => {} 616 907 MdEvent::End(TagEnd::Paragraph) => { 617 - let prefix = if item_stack.is_empty() { 618 - block_prefix(blockquote_depth > 0) 908 + if blockquote_depth > 0 && item_stack.is_empty() { 909 + push_wrapped_blockquote_lines(&mut lines, &mut spans, render_width); 619 910 } else { 620 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack) 621 - }; 622 - flush!(prefix); 911 + let prefix = if item_stack.is_empty() { 912 + block_prefix(false) 913 + } else { 914 + list_item_prefix(false, &list_stack, &mut item_stack) 915 + }; 916 + flush!(prefix); 917 + } 623 918 lines.push(Line::from("")); 919 + last_block = LastBlock::Paragraph; 624 920 } 625 921 MdEvent::Start(Tag::CodeBlock(kind)) => { 922 + if last_block == LastBlock::Paragraph 923 + && item_stack.is_empty() 924 + && lines.last().is_some_and(|line| line_plain_text(line).is_empty()) 925 + { 926 + lines.pop(); 927 + } 626 928 in_code = true; 627 929 code_buf.clear(); 628 930 code_lang = match kind { 629 931 CodeBlockKind::Fenced(l) => l.to_string(), 630 932 CodeBlockKind::Indented => String::new(), 631 933 }; 934 + last_block = LastBlock::Other; 632 935 } 633 936 MdEvent::End(TagEnd::CodeBlock) => { 634 937 in_code = false; ··· 637 940 } else { 638 941 code_lang.clone() 639 942 }; 640 - let (code_lines, inner_width) = highlight_code(&code_buf, &code_lang, ss, theme); 943 + let (code_lines, inner_width) = 944 + highlight_code(&code_buf, &code_lang, ss, theme, render_width); 641 945 let header_width = UnicodeWidthStr::width(ld.as_str()) + 3; 642 946 let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 643 947 lines.push(Line::from(vec![ 644 - Span::raw(" "), 645 948 Span::styled( 646 - "╭─ ".to_string(), 949 + "┌─ ".to_string(), 647 950 Style::default().fg(theme_colors.code_frame), 648 951 ), 649 952 Span::styled( ··· 651 954 Style::default().fg(theme_colors.code_label), 652 955 ), 653 956 Span::styled( 654 - format!("{}╮", top_bar), 957 + format!("{}┐", top_bar), 655 958 Style::default().fg(theme_colors.code_frame), 656 959 ), 657 960 ])); 658 961 lines.extend(code_lines); 659 962 lines.push(Line::from(Span::styled( 660 - format!(" ╰{}╯", "─".repeat(inner_width)), 963 + format!("└{}┘", "─".repeat(inner_width)), 661 964 Style::default().fg(theme_colors.code_frame), 662 965 ))); 663 966 lines.push(Line::from("")); 664 967 code_lang.clear(); 665 968 code_buf.clear(); 969 + last_block = LastBlock::Other; 666 970 } 667 971 MdEvent::Code(text) => { 668 972 spans.push(Span::styled( ··· 677 981 } 678 982 MdEvent::End(TagEnd::BlockQuote(_)) => { 679 983 flush!(vec![Span::styled( 680 - " ▏ ", 984 + "▏ ", 681 985 Style::default().fg(theme_colors.blockquote_marker) 682 986 )]); 683 987 blockquote_depth = blockquote_depth.saturating_sub(1); 684 988 lines.push(Line::from("")); 989 + last_block = LastBlock::Other; 685 990 } 686 991 MdEvent::Start(Tag::List(start)) => { 992 + if last_block == LastBlock::Paragraph 993 + && item_stack.is_empty() 994 + && lines.last().is_some_and(|line| line_plain_text(line).is_empty()) 995 + { 996 + lines.pop(); 997 + } 687 998 list_stack.push(match start { 688 999 Some(n) => ListKind::Ordered(n), 689 1000 None => ListKind::Unordered, 690 1001 }); 1002 + last_block = LastBlock::Other; 691 1003 } 692 1004 MdEvent::End(TagEnd::List(_)) => { 693 1005 list_stack.pop(); 694 1006 if list_stack.is_empty() { 695 1007 lines.push(Line::from("")); 696 1008 } 1009 + last_block = LastBlock::Other; 697 1010 } 698 1011 MdEvent::Start(Tag::Item) => { 699 1012 item_stack.push(ItemState { ··· 714 1027 } 715 1028 } 716 1029 MdEvent::Rule => { 717 - lines.push(Line::from("")); 718 1030 lines.push(Line::from(Span::styled( 719 - format!(" {}", "─".repeat(62)), 1031 + "─".repeat(rule_width(render_width, 0)), 720 1032 Style::default().fg(theme_colors.rule), 721 1033 ))); 722 1034 lines.push(Line::from("")); 1035 + last_block = LastBlock::Other; 723 1036 } 724 1037 MdEvent::Start(Tag::Strong) => in_strong = true, 725 1038 MdEvent::End(TagEnd::Strong) => in_strong = false, ··· 765 1078 } 766 1079 MdEvent::SoftBreak | MdEvent::HardBreak => { 767 1080 if !in_code { 768 - let prefix = if item_stack.is_empty() { 769 - block_prefix(blockquote_depth > 0) 1081 + if blockquote_depth > 0 && item_stack.is_empty() { 1082 + push_wrapped_blockquote_lines(&mut lines, &mut spans, render_width); 770 1083 } else { 771 - list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack) 772 - }; 773 - flush!(prefix); 1084 + let prefix = if item_stack.is_empty() { 1085 + block_prefix(false) 1086 + } else { 1087 + list_item_prefix(false, &list_stack, &mut item_stack) 1088 + }; 1089 + flush!(prefix); 1090 + } 774 1091 } 775 1092 } 776 1093 _ => {}
+25 -3
src/render.rs
··· 6 6 layout::{Constraint, Direction, Layout, Rect}, 7 7 style::{Color, Modifier, Style}, 8 8 text::{Line, Span}, 9 - widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, 9 + widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, 10 10 Frame, 11 11 }; 12 + 13 + const CONTENT_HORIZONTAL_PADDING: u16 = 1; 14 + const SCROLLBAR_WIDTH: u16 = 1; 12 15 13 16 pub(crate) fn ui(f: &mut Frame, app: &mut App) { 14 17 let area = f.area(); ··· 84 87 85 88 fn render_content_panel(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 86 89 let theme = app_theme(); 90 + f.render_widget( 91 + Paragraph::new("").style(Style::default().bg(theme.ui.content_bg)), 92 + area, 93 + ); 94 + let content_area = inner_content_area(area); 87 95 let scroll = app.scroll; 88 96 let active_highlight_line = app.active_highlight_line(); 89 97 if let Some(line_idx) = active_highlight_line { ··· 102 110 } 103 111 104 112 f.render_widget( 105 - Paragraph::new(visible_lines).style(Style::default().bg(theme.ui.content_bg)), 106 - area, 113 + Paragraph::new(visible_lines) 114 + .style(Style::default().bg(theme.ui.content_bg)) 115 + .wrap(Wrap { trim: false }), 116 + content_area, 107 117 ); 108 118 109 119 let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll); ··· 116 126 area, 117 127 &mut scrollbar_state, 118 128 ); 129 + } 130 + 131 + fn inner_content_area(area: Rect) -> Rect { 132 + Rect { 133 + x: area.x.saturating_add(CONTENT_HORIZONTAL_PADDING), 134 + y: area.y, 135 + width: area 136 + .width 137 + .saturating_sub(CONTENT_HORIZONTAL_PADDING.saturating_mul(2)) 138 + .saturating_sub(SCROLLBAR_WIDTH), 139 + height: area.height, 140 + } 119 141 } 120 142 121 143 fn render_status_bar(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) {
+29 -1
src/runtime.rs
··· 8 8 use std::{fs::OpenOptions, io, io::Write, time::Duration}; 9 9 use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 10 10 11 + const CONTENT_HORIZONTAL_PADDING: u16 = 1; 12 + const SCROLLBAR_WIDTH: u16 = 1; 13 + 11 14 pub(crate) fn should_handle_key(kind: KeyEventKind) -> bool { 12 15 !matches!(kind, KeyEventKind::Release) 13 16 } ··· 35 38 const FLASH_DURATION: Duration = Duration::from_millis(1500); 36 39 const MOUSE_SCROLL_STEP: usize = 3; 37 40 let mut needs_redraw = true; 41 + sync_render_width(terminal, app, ss, themes)?; 38 42 39 43 loop { 40 44 if needs_redraw { ··· 164 168 _ => state_changed = false, 165 169 } 166 170 } 171 + if sync_render_width(terminal, app, ss, themes)? { 172 + needs_redraw = true; 173 + } 167 174 if state_changed { 168 175 needs_redraw = true; 169 176 } ··· 188 195 needs_redraw = true; 189 196 } 190 197 } 191 - Event::Resize(_, _) => needs_redraw = true, 198 + Event::Resize(_, _) => { 199 + let _ = sync_render_width(terminal, app, ss, themes)?; 200 + needs_redraw = true; 201 + } 192 202 _ => {} 193 203 } 194 204 } ··· 213 223 } 214 224 Ok(()) 215 225 } 226 + 227 + fn sync_render_width( 228 + terminal: &Terminal<CrosstermBackend<io::Stdout>>, 229 + app: &mut App, 230 + ss: &SyntaxSet, 231 + themes: &ThemeSet, 232 + ) -> Result<bool> { 233 + let area = terminal.size()?; 234 + let content_width = if app.toc_visible && !app.toc.is_empty() { 235 + area.width.saturating_sub(30) 236 + } else { 237 + area.width 238 + }; 239 + let effective_width = content_width 240 + .saturating_sub(CONTENT_HORIZONTAL_PADDING.saturating_mul(2)) 241 + .saturating_sub(SCROLLBAR_WIDTH); 242 + Ok(app.sync_render_width(effective_width as usize, ss, themes)) 243 + }
+93 -32
src/tests.rs
··· 1 1 use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 2 2 use crate::*; 3 + use crate::markdown::{parse_markdown, parse_markdown_with_width}; 3 4 use crossterm::event::KeyEventKind; 4 5 use ratatui::backend::TestBackend; 5 6 use ratatui::{text::Line, widgets::Paragraph, Terminal}; ··· 48 49 } 49 50 } 50 51 None 51 - } 52 - 53 - fn line_symbols(buffer: &ratatui::buffer::Buffer, y: u16) -> String { 54 - (0..buffer.area.width) 55 - .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) 56 - .collect() 57 52 } 58 53 59 54 fn rendered_non_empty_lines(lines: &[Line<'static>]) -> Vec<String> { ··· 226 221 let (lines, _) = parse_markdown(md, &ss, &theme); 227 222 let buffer = render_buffer(&lines); 228 223 229 - let (right_x, start_y) = find_symbol(&buffer, "╮").unwrap(); 230 - let (_, end_y) = find_symbol(&buffer, "╯").unwrap(); 224 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 225 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 231 226 232 227 for y in start_y + 1..end_y { 233 228 assert_eq!( ··· 245 240 let (lines, _) = parse_markdown(md, &ss, &theme); 246 241 let buffer = render_buffer(&lines); 247 242 248 - let (right_x, start_y) = find_symbol(&buffer, "╮").unwrap(); 249 - let (_, end_y) = find_symbol(&buffer, "╯").unwrap(); 243 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 244 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 250 245 251 246 for y in start_y + 1..end_y { 252 247 let symbol = buffer.cell((right_x, y)).unwrap().symbol(); ··· 258 253 } 259 254 260 255 #[test] 261 - fn h1_underline_matches_display_width_for_wide_titles() { 256 + fn narrow_tables_fit_render_width_and_wrap_cells() { 257 + let (ss, theme) = test_assets(); 258 + let md = "| Column | Description | Value |\n| --- | --- | ---: |\n| Width | Terminal-dependent layout behavior | 80 |\n"; 259 + let (lines, _) = parse_markdown_with_width(md, &ss, &theme, 36); 260 + let rendered = rendered_non_empty_lines(&lines); 261 + 262 + assert!(rendered.len() >= 6); 263 + assert!(rendered.iter().all(|line| display_width(line) <= 36)); 264 + } 265 + 266 + #[test] 267 + fn h1_headings_render_double_rule_without_bottom_spacing() { 262 268 let (ss, theme) = test_assets(); 263 269 let (lines, _) = parse_markdown("# 東京\n", &ss, &theme); 264 - let title_y = lines 265 - .iter() 266 - .position(|line| line_plain_text(line).contains("東京")) 267 - .unwrap() as u16; 268 - let underline_y = title_y + 1; 269 - let buffer = render_buffer(&lines); 270 + let rendered = rendered_non_empty_lines(&lines); 270 271 271 - let underline = line_symbols(&buffer, underline_y); 272 - let underline_count = underline.chars().filter(|&ch| ch == '─').count(); 273 - assert_eq!(underline_count, display_width("東京") + 4); 272 + assert_eq!(rendered[0], "東京"); 273 + assert_eq!(rendered[1], "═".repeat(display_width("東京"))); 274 274 } 275 275 276 276 #[test] ··· 310 310 311 311 assert!(first.contains("• first line")); 312 312 assert!(!second.contains('•')); 313 - assert!(second.starts_with(" ")); 313 + assert!(second.starts_with(" ")); 314 314 } 315 315 316 316 #[test] ··· 333 333 assert_eq!( 334 334 rendered, 335 335 vec![ 336 - " • first loose item", 337 - " • second loose item after a blank line", 338 - " • third loose item", 339 - " continuation paragraph", 336 + "• first loose item", 337 + "• second loose item after a blank line", 338 + "• third loose item", 339 + " continuation paragraph", 340 340 ] 341 341 ); 342 342 } ··· 351 351 assert_eq!( 352 352 rendered, 353 353 vec![ 354 - " 7. seventh item", 355 - " 8. eighth item", 356 - " continuation paragraph", 354 + "7. seventh item", 355 + "8. eighth item", 356 + " continuation paragraph", 357 357 ] 358 358 ); 359 359 } ··· 364 364 let (lines, _) = parse_markdown("3. third item\n4. fourth item\n", &ss, &theme); 365 365 let rendered = rendered_non_empty_lines(&lines); 366 366 367 - assert_eq!(rendered, vec![" 3. third item", " 4. fourth item"]); 367 + assert_eq!(rendered, vec!["3. third item", "4. fourth item"]); 368 + } 369 + 370 + #[test] 371 + fn paragraph_and_following_list_have_no_blank_gap() { 372 + let (ss, theme) = test_assets(); 373 + let (lines, _) = parse_markdown("Intro paragraph\n\n- first\n- second\n", &ss, &theme); 374 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 375 + let intro_idx = rendered.iter().position(|line| line == "Intro paragraph").unwrap(); 376 + 377 + assert_eq!(rendered[intro_idx + 1], "• first"); 378 + } 379 + 380 + #[test] 381 + fn paragraph_and_following_code_block_have_no_blank_gap() { 382 + let (ss, theme) = test_assets(); 383 + let src = "Intro paragraph\n\n```rs\nfn main() {}\n```\n"; 384 + let (lines, _) = parse_markdown(src, &ss, &theme); 385 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 386 + let intro_idx = rendered.iter().position(|line| line == "Intro paragraph").unwrap(); 387 + 388 + assert!(rendered[intro_idx + 1].starts_with("┌─ rs ")); 368 389 } 369 390 370 391 #[test] ··· 374 395 let (lines, _) = parse_markdown(src, &ss, &theme); 375 396 let rendered = rendered_non_empty_lines(&lines); 376 397 377 - assert!(rendered.iter().any(|line| line == " ▏ outer")); 378 - assert!(rendered.iter().any(|line| line == " ▏ inner")); 379 - assert!(rendered.iter().any(|line| line == " ▏ outer again")); 398 + assert!(rendered.iter().any(|line| line == "▏ outer")); 399 + assert!(rendered.iter().any(|line| line == "▏ inner")); 400 + assert!(rendered.iter().any(|line| line == "▏ outer again")); 401 + } 402 + 403 + #[test] 404 + fn long_blockquotes_wrap_into_multiple_prefixed_lines() { 405 + let (ss, theme) = test_assets(); 406 + let src = "> This is a long blockquote line that should wrap into multiple quoted lines at narrow widths.\n"; 407 + let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 28); 408 + let rendered = rendered_non_empty_lines(&lines); 409 + let quoted: Vec<_> = rendered 410 + .into_iter() 411 + .filter(|line| line.starts_with('▏')) 412 + .collect(); 413 + 414 + assert!(quoted.len() >= 2); 415 + assert!(quoted.iter().all(|line| line.starts_with("▏ "))); 380 416 } 381 417 382 418 #[test] ··· 401 437 assert!(rendered.iter().any(|line| line.contains("Visible"))); 402 438 assert_eq!(toc.len(), 1); 403 439 assert_eq!(toc[0].title, "Visible"); 440 + } 441 + 442 + #[test] 443 + fn h2_headings_are_underlined_and_compact() { 444 + let (ss, theme) = test_assets(); 445 + let (lines, _) = parse_markdown_with_width("Intro\n\n## Section\nBody\n", &ss, &theme, 40); 446 + let rendered = rendered_non_empty_lines(&lines); 447 + 448 + assert!(rendered.iter().any(|line| line.contains("Section"))); 449 + assert!(rendered.iter().any(|line| line.contains("────"))); 450 + } 451 + 452 + #[test] 453 + fn rules_use_render_width_without_extra_blank_after() { 454 + let (ss, theme) = test_assets(); 455 + let (lines, _) = parse_markdown_with_width("Alpha\n\n---\nBeta\n", &ss, &theme, 24); 456 + let rendered = rendered_non_empty_lines(&lines); 457 + let rule = rendered 458 + .iter() 459 + .find(|line| line.trim_start().starts_with('─')) 460 + .unwrap(); 461 + 462 + assert_eq!(display_width(rule.trim_start()), 24); 463 + let rule_idx = rendered.iter().position(|line| line == rule).unwrap(); 464 + assert_eq!(rendered[rule_idx + 1], "Beta"); 404 465 } 405 466 406 467 #[test]
+8 -13
src/theme.rs
··· 71 71 pub(crate) heading_2: Color, 72 72 pub(crate) heading_3: Color, 73 73 pub(crate) heading_other: Color, 74 - pub(crate) heading_marker: Color, 75 74 pub(crate) heading_underline: Color, 76 75 pub(crate) code_frame: Color, 77 76 pub(crate) code_label: Color, ··· 134 133 heading_2: Color::Rgb(48, 140, 98), 135 134 heading_3: Color::Rgb(176, 128, 48), 136 135 heading_other: Color::Rgb(108, 116, 126), 137 - heading_marker: Color::Rgb(116, 132, 152), 138 136 heading_underline: Color::Rgb(160, 176, 194), 139 137 code_frame: Color::Rgb(132, 148, 164), 140 138 code_label: Color::Rgb(92, 116, 140), 141 - inline_code_fg: Color::Rgb(184, 112, 72), 142 - inline_code_bg: Color::Rgb(242, 232, 226), 139 + inline_code_fg: Color::Rgb(170, 108, 76), 140 + inline_code_bg: Color::Rgb(232, 226, 222), 143 141 rule: Color::Rgb(180, 192, 204), 144 142 link_icon: Color::Rgb(62, 124, 188), 145 143 link_text: Color::Rgb(62, 124, 188), ··· 198 196 heading_2: Color::Rgb(120, 214, 170), 199 197 heading_3: Color::Rgb(224, 190, 126), 200 198 heading_other: Color::Rgb(188, 194, 188), 201 - heading_marker: Color::Rgb(74, 98, 90), 202 199 heading_underline: Color::Rgb(52, 68, 60), 203 200 code_frame: Color::Rgb(50, 66, 60), 204 201 code_label: Color::Rgb(128, 158, 142), 205 - inline_code_fg: Color::Rgb(238, 176, 130), 206 - inline_code_bg: Color::Rgb(46, 36, 30), 202 + inline_code_fg: Color::Rgb(224, 170, 132), 203 + inline_code_bg: Color::Rgb(42, 35, 31), 207 204 rule: Color::Rgb(56, 70, 62), 208 205 link_icon: Color::Rgb(102, 170, 255), 209 206 link_text: Color::Rgb(110, 182, 255), ··· 262 259 heading_2: Color::Rgb(120, 210, 170), 263 260 heading_3: Color::Rgb(210, 180, 120), 264 261 heading_other: Color::Rgb(180, 180, 190), 265 - heading_marker: Color::Rgb(55, 75, 115), 266 262 heading_underline: Color::Rgb(40, 50, 75), 267 263 code_frame: Color::Rgb(40, 48, 68), 268 264 code_label: Color::Rgb(95, 110, 145), 269 - inline_code_fg: Color::Rgb(235, 155, 115), 270 - inline_code_bg: Color::Rgb(40, 30, 28), 265 + inline_code_fg: Color::Rgb(220, 150, 118), 266 + inline_code_bg: Color::Rgb(38, 32, 31), 271 267 rule: Color::Rgb(48, 56, 76), 272 268 link_icon: Color::Rgb(85, 148, 235), 273 269 link_text: Color::Rgb(88, 152, 238), ··· 326 322 heading_2: Color::Rgb(42, 161, 152), 327 323 heading_3: Color::Rgb(181, 137, 0), 328 324 heading_other: Color::Rgb(147, 161, 161), 329 - heading_marker: Color::Rgb(88, 110, 117), 330 325 heading_underline: Color::Rgb(88, 110, 117), 331 326 code_frame: Color::Rgb(88, 110, 117), 332 327 code_label: Color::Rgb(131, 148, 150), 333 - inline_code_fg: Color::Rgb(203, 75, 22), 334 - inline_code_bg: Color::Rgb(18, 52, 58), 328 + inline_code_fg: Color::Rgb(190, 92, 48), 329 + inline_code_bg: Color::Rgb(22, 55, 60), 335 330 rule: Color::Rgb(88, 110, 117), 336 331 link_icon: Color::Rgb(38, 139, 210), 337 332 link_text: Color::Rgb(38, 139, 210),