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

chore: improve preview design

authored by

Rivo Link and committed by
GitHub
b5c51307 cc186f16

+136 -37
+71 -36
src/markdown.rs
··· 62 62 right: &'a str, 63 63 } 64 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 + 65 74 pub(crate) fn line_plain_text(line: &Line<'_>) -> String { 66 75 line.spans.iter().map(|s| s.content.as_ref()).collect() 67 76 } ··· 112 121 113 122 pub(crate) fn display_width(text: &str) -> usize { 114 123 let mut width = 0; 115 - for ch in text.chars() { 116 - if ch == '\t' { 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() { 117 128 width += TAB_STOP - (width % TAB_STOP); 118 - } else { 119 - width += UnicodeWidthChar::width(ch).unwrap_or(0); 120 129 } 121 130 } 122 131 width 123 132 } 124 133 125 134 fn expand_tabs(text: &str, start_width: usize) -> String { 135 + if !text.contains('\t') { 136 + return text.to_string(); 137 + } 138 + 126 139 let mut out = String::new(); 127 140 let mut width = start_width; 128 - for ch in text.chars() { 129 - if ch == '\t' { 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() { 130 146 let spaces = TAB_STOP - (width % TAB_STOP); 131 147 out.push_str(&" ".repeat(spaces)); 132 148 width += spaces; 133 - } else { 134 - out.push(ch); 135 - width += UnicodeWidthChar::width(ch).unwrap_or(0); 136 149 } 137 150 } 138 151 out ··· 405 418 406 419 let token_width = display_width(token); 407 420 if token_is_space { 408 - if *body_started && *current_width + token_width <= max_width { 421 + let keep_styled_padding = style.bg.is_some(); 422 + if (*body_started || keep_styled_padding) && *current_width + token_width <= max_width 423 + { 409 424 current_prefix.push(Span::styled(std::mem::take(token), style)); 410 425 *current_width += token_width; 426 + *body_started = true; 411 427 } else { 412 428 token.clear(); 413 429 } ··· 539 555 fn trim_paragraph_gap_before_block( 540 556 lines: &mut Vec<Line<'static>>, 541 557 last_block: LastBlock, 542 - item_stack: &[ItemState], 543 558 ) { 544 559 if last_block == LastBlock::Paragraph 545 - && item_stack.is_empty() 546 560 && lines.last().is_some_and(|line| line_plain_text(line).is_empty()) 547 561 { 548 562 lines.pop(); ··· 598 612 lines: &mut Vec<Line<'static>>, 599 613 code_buf: &mut String, 600 614 code_lang: &mut String, 601 - ss: &SyntaxSet, 602 - theme: &Theme, 603 - render_width: usize, 604 - theme_colors: &MarkdownTheme, 615 + ctx: CodeBlockRenderContext<'_>, 616 + item_stack: &mut [ItemState], 605 617 ) { 618 + let prefix = if !item_stack.is_empty() { 619 + list_item_prefix(ctx.blockquote_depth > 0, ctx.list_stack, item_stack) 620 + } else if ctx.blockquote_depth > 0 { 621 + block_prefix(true) 622 + } else { 623 + Vec::new() 624 + }; 625 + let prefix_width: usize = prefix 626 + .iter() 627 + .map(|span| display_width(span.content.as_ref())) 628 + .sum(); 606 629 let label = if code_lang.is_empty() { 607 630 "text".to_string() 608 631 } else { 609 632 code_lang.clone() 610 633 }; 611 - let (code_lines, inner_width) = highlight_code(code_buf, code_lang, ss, theme, render_width); 634 + let available_width = ctx.render_width.saturating_sub(prefix_width); 635 + let (code_lines, inner_width) = 636 + highlight_code(code_buf, code_lang, ctx.ss, ctx.theme, available_width); 612 637 let header_width = UnicodeWidthStr::width(label.as_str()) + 3; 613 638 let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 614 - lines.push(Line::from(vec![ 639 + let mut header = prefix.clone(); 640 + header.extend([ 615 641 Span::styled( 616 642 "┌─ ".to_string(), 617 - Style::default().fg(theme_colors.code_frame), 643 + Style::default().fg(ctx.theme_colors.code_frame), 618 644 ), 619 645 Span::styled( 620 646 format!("{label} "), 621 - Style::default().fg(theme_colors.code_label), 647 + Style::default().fg(ctx.theme_colors.code_label), 622 648 ), 623 649 Span::styled( 624 650 format!("{top_bar}┐"), 625 - Style::default().fg(theme_colors.code_frame), 651 + Style::default().fg(ctx.theme_colors.code_frame), 626 652 ), 627 - ])); 628 - lines.extend(code_lines); 629 - lines.push(Line::from(Span::styled( 653 + ]); 654 + lines.push(Line::from(header)); 655 + lines.extend(code_lines.into_iter().map(|line| { 656 + let mut spans = prefix.clone(); 657 + spans.extend(line.spans); 658 + Line::from(spans) 659 + })); 660 + let mut footer = prefix; 661 + footer.push(Span::styled( 630 662 format!("└{}┘", "─".repeat(inner_width)), 631 - Style::default().fg(theme_colors.code_frame), 632 - ))); 663 + Style::default().fg(ctx.theme_colors.code_frame), 664 + )); 665 + lines.push(Line::from(footer)); 633 666 lines.push(Line::from("")); 634 667 code_lang.clear(); 635 668 code_buf.clear(); ··· 740 773 fn start_list( 741 774 lines: &mut Vec<Line<'static>>, 742 775 last_block: LastBlock, 743 - item_stack: &[ItemState], 744 776 list_stack: &mut Vec<ListKind>, 745 777 start: Option<u64>, 746 778 ) { 747 - trim_paragraph_gap_before_block(lines, last_block, item_stack); 779 + trim_paragraph_gap_before_block(lines, last_block); 748 780 list_stack.push(match start { 749 781 Some(n) => ListKind::Ordered(n), 750 782 None => ListKind::Unordered, ··· 909 941 fn start_code_block( 910 942 lines: &mut Vec<Line<'static>>, 911 943 last_block: LastBlock, 912 - item_stack: &[ItemState], 913 944 in_code: &mut bool, 914 945 code_buf: &mut String, 915 946 code_lang: &mut String, 916 947 kind: &CodeBlockKind<'_>, 917 948 ) { 918 - trim_paragraph_gap_before_block(lines, last_block, item_stack); 949 + trim_paragraph_gap_before_block(lines, last_block); 919 950 *in_code = true; 920 951 code_buf.clear(); 921 952 *code_lang = match kind { ··· 1359 1390 start_code_block( 1360 1391 &mut lines, 1361 1392 last_block, 1362 - &item_stack, 1363 1393 &mut in_code, 1364 1394 &mut code_buf, 1365 1395 &mut code_lang, ··· 1373 1403 &mut lines, 1374 1404 &mut code_buf, 1375 1405 &mut code_lang, 1376 - ss, 1377 - theme, 1378 - render_width, 1379 - theme_colors, 1406 + CodeBlockRenderContext { 1407 + ss, 1408 + theme, 1409 + render_width, 1410 + theme_colors, 1411 + blockquote_depth, 1412 + list_stack: &list_stack, 1413 + }, 1414 + &mut item_stack, 1380 1415 ); 1381 1416 last_block = LastBlock::Other; 1382 1417 } ··· 1391 1426 last_block = LastBlock::Other; 1392 1427 } 1393 1428 MdEvent::Start(Tag::List(start)) => { 1394 - start_list(&mut lines, last_block, &item_stack, &mut list_stack, start); 1429 + start_list(&mut lines, last_block, &mut list_stack, start); 1395 1430 last_block = LastBlock::Other; 1396 1431 } 1397 1432 MdEvent::End(TagEnd::List(_)) => {
+1 -1
src/render.rs
··· 238 238 let theme = app_theme(); 239 239 if app.is_search_mode() { 240 240 return Some(vec![Span::styled( 241 - format!(" /{}", app.search_draft()), 241 + format!(" /{} ", app.search_draft()), 242 242 Style::default() 243 243 .fg(theme.ui.status_search_fg) 244 244 .bg(theme.ui.status_search_bg),
+64
src/tests.rs
··· 272 272 } 273 273 274 274 #[test] 275 + fn table_render_right_border_stays_aligned_with_emoji_cells() { 276 + let (ss, theme) = test_assets(); 277 + let md = "| Critère | Note |\n| --- | --- |\n| Tests | ✅ Bonne couverture |\n| Sécurité | ⚠ Quelques points |\n"; 278 + let (lines, _) = parse_markdown(md, &ss, &theme); 279 + let buffer = render_buffer(&lines); 280 + 281 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 282 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 283 + 284 + for y in start_y + 1..end_y { 285 + let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 286 + assert!( 287 + matches!(symbol, "│" | "┤" | "╡"), 288 + "unexpected emoji-table edge symbol {symbol:?} at row {y}" 289 + ); 290 + } 291 + } 292 + 293 + #[test] 275 294 fn narrow_tables_fit_render_width_and_wrap_cells() { 276 295 let (ss, theme) = test_assets(); 277 296 let md = "| Column | Description | Value |\n| --- | --- | ---: |\n| Width | Terminal-dependent layout behavior | 80 |\n"; ··· 859 878 assert!(!app.sync_render_width(10, &ss, &ts)); 860 879 assert_eq!(app.total(), parse_markdown_with_width(source, &ss, &theme, 20).0.len()); 861 880 } 881 + 882 + #[test] 883 + fn wrapped_list_inline_code_keeps_left_padding_in_rendered_line() { 884 + let (ss, theme) = test_assets(); 885 + let source = "- `leaf --theme ocean README.md` exercises wrapping inside a list item.\n"; 886 + let (lines, _) = parse_markdown_with_width(source, &ss, &theme, 22); 887 + 888 + let target = lines 889 + .iter() 890 + .find(|line| line_plain_text(line).contains("leaf --theme")) 891 + .expect("expected wrapped inline-code line"); 892 + 893 + assert!( 894 + target 895 + .spans 896 + .iter() 897 + .any(|span| span.style.bg.is_some() && span.content.starts_with(' ')), 898 + "expected a background-styled span with left padding" 899 + ); 900 + } 901 + 902 + #[test] 903 + fn code_block_inside_list_item_is_indented_and_has_no_blank_gap_before() { 904 + let (ss, theme) = test_assets(); 905 + 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"; 906 + let (lines, _) = parse_markdown(md, &ss, &theme); 907 + let rendered = rendered_non_empty_lines(&lines); 908 + 909 + let item_idx = rendered 910 + .iter() 911 + .position(|line| line.contains("A list item with a code block:")) 912 + .expect("missing list item line"); 913 + let header_idx = rendered 914 + .iter() 915 + .position(|line| line.contains("┌─ text")) 916 + .expect("missing code block header"); 917 + let code_idx = rendered 918 + .iter() 919 + .position(|line| line.contains("<code goes here>")) 920 + .expect("missing code line"); 921 + 922 + assert_eq!(header_idx, item_idx + 1, "expected no blank gap before code block"); 923 + assert!(rendered[header_idx].starts_with(" ")); 924 + assert!(rendered[code_idx].starts_with(" ")); 925 + }