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 #50 from RivoLink/fix/inline-code-latex-in-tables

fix: inline code/latex in tables

authored by

Rivo Link and committed by
GitHub
33819b63 fd5d2def

+297 -82
+192 -82
src/markdown/tables.rs
··· 6 6 }; 7 7 use unicode_width::UnicodeWidthChar; 8 8 9 + use super::latex; 9 10 use super::width::{display_width, expand_tabs}; 10 11 12 + #[derive(Clone)] 13 + enum CellFragment { 14 + Text(String), 15 + Code(String), 16 + InlineMath(String), 17 + } 18 + 19 + impl CellFragment { 20 + fn rendered_text(&self) -> String { 21 + match self { 22 + CellFragment::Text(t) | CellFragment::Code(t) => t.clone(), 23 + CellFragment::InlineMath(t) => latex::to_unicode(t), 24 + } 25 + } 26 + 27 + fn display_width(&self) -> usize { 28 + let w = display_width(&self.rendered_text()); 29 + match self { 30 + CellFragment::Text(_) => w, 31 + _ => w + 2, 32 + } 33 + } 34 + 35 + fn is_text(&self) -> bool { 36 + matches!(self, CellFragment::Text(_)) 37 + } 38 + } 39 + 11 40 pub(super) struct TableBuf { 12 41 pub(super) alignments: Vec<Alignment>, 13 - rows: Vec<Vec<String>>, 42 + rows: Vec<Vec<Vec<CellFragment>>>, 14 43 header_count: usize, 15 - current_row: Vec<String>, 16 - current_cell: String, 44 + current_row: Vec<Vec<CellFragment>>, 45 + current_cell: Vec<CellFragment>, 17 46 pub(super) in_header: bool, 18 47 } 19 48 ··· 35 64 }; 36 65 37 66 match ev { 38 - MdEvent::Text(t) | MdEvent::Code(t) => { 67 + MdEvent::Text(t) => { 39 68 tb.push_text(t.as_ref()); 69 + true 70 + } 71 + MdEvent::Code(t) => { 72 + tb.push_code(t.as_ref()); 73 + true 74 + } 75 + MdEvent::InlineMath(t) => { 76 + tb.push_inline_math(t.as_ref()); 40 77 true 41 78 } 42 79 MdEvent::Start(Tag::TableCell) => true, ··· 84 121 rows: vec![], 85 122 header_count: 0, 86 123 current_row: vec![], 87 - current_cell: String::new(), 124 + current_cell: vec![], 88 125 in_header: false, 89 126 } 90 127 } 91 128 fn push_text(&mut self, t: &str) { 92 - self.current_cell.push_str(t); 129 + self.current_cell.push(CellFragment::Text(t.to_string())); 130 + } 131 + fn push_code(&mut self, t: &str) { 132 + self.current_cell.push(CellFragment::Code(t.to_string())); 133 + } 134 + fn push_inline_math(&mut self, t: &str) { 135 + self.current_cell 136 + .push(CellFragment::InlineMath(t.to_string())); 93 137 } 94 138 fn end_cell(&mut self) { 95 - let cell = std::mem::take(&mut self.current_cell).trim().to_string(); 96 - self.current_row.push(cell); 139 + let mut frags = std::mem::take(&mut self.current_cell); 140 + if let Some(CellFragment::Text(t)) = frags.first_mut() { 141 + *t = t.trim_start().to_string(); 142 + } 143 + if let Some(CellFragment::Text(t)) = frags.last_mut() { 144 + *t = t.trim_end().to_string(); 145 + } 146 + self.current_row.push(frags); 97 147 } 98 148 fn end_row(&mut self) { 99 149 let row = std::mem::take(&mut self.current_row); ··· 122 172 for row in &self.rows { 123 173 for (ci, cell) in row.iter().enumerate() { 124 174 if ci < col_count { 125 - col_widths[ci] = col_widths[ci].max(display_width(cell)); 175 + col_widths[ci] = col_widths[ci].max(fragments_display_width(cell)); 126 176 min_widths[ci] = min_widths[ci].max(min_table_cell_width(cell)); 127 177 } 128 178 } ··· 151 201 border, 152 202 )); 153 203 204 + let empty_cell: Vec<CellFragment> = vec![]; 154 205 for (ri, row) in self.rows.iter().enumerate() { 155 206 let is_hdr = ri < self.header_count; 156 - let wrapped_cells: Vec<Vec<String>> = col_widths 207 + let wrapped_cells: Vec<Vec<Vec<CellFragment>>> = col_widths 157 208 .iter() 158 209 .copied() 159 210 .enumerate() 160 211 .take(col_count) 161 - .map(|(ci, width)| { 162 - wrap_table_cell(row.get(ci).map(|s| s.as_str()).unwrap_or(""), width) 163 - }) 212 + .map(|(ci, width)| wrap_table_cell(row.get(ci).unwrap_or(&empty_cell), width)) 164 213 .collect(); 165 214 let row_height = wrapped_cells 166 215 .iter() ··· 171 220 for line_idx in 0..row_height { 172 221 let mut spans = vec![Span::raw(ind), Span::styled("│", border)]; 173 222 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(""); 223 + let frags = wrapped_cells[ci].get(line_idx).unwrap_or(&empty_cell); 178 224 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 }; 225 + let base_style = if is_hdr { header } else { cell }; 226 + let cell_spans = align_cell(frags, width, align, base_style, theme); 181 227 spans.push(Span::raw(" ")); 182 - spans.push(Span::styled(pad, st)); 228 + spans.extend(cell_spans); 183 229 spans.push(Span::raw(" ")); 184 230 spans.push(Span::styled("│", border)); 185 231 } ··· 250 296 } 251 297 } 252 298 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) 299 + fn fragments_display_width(frags: &[CellFragment]) -> usize { 300 + frags.iter().map(|f| f.display_width()).sum() 301 + } 302 + 303 + fn min_table_cell_width(frags: &[CellFragment]) -> usize { 304 + let mut max_width = 4usize; 305 + for frag in frags { 306 + let w = if frag.is_text() { 307 + frag.rendered_text() 308 + .split_whitespace() 309 + .map(display_width) 310 + .max() 311 + .unwrap_or(0) 312 + .min(12) 313 + } else { 314 + frag.display_width() 315 + }; 316 + max_width = max_width.max(w); 317 + } 318 + max_width 261 319 } 262 320 263 321 fn fit_table_widths(col_widths: &mut [usize], min_widths: &[usize], render_width: usize) { ··· 300 358 } 301 359 } 302 360 303 - fn wrap_table_cell(text: &str, width: usize) -> Vec<String> { 361 + fn wrap_table_cell(frags: &[CellFragment], width: usize) -> Vec<Vec<CellFragment>> { 304 362 if width == 0 { 305 - return vec![String::new()]; 363 + return vec![vec![]]; 306 364 } 307 - let expanded = expand_tabs(text, 0); 308 - if expanded.is_empty() { 309 - return vec![String::new()]; 365 + if frags.is_empty() { 366 + return vec![vec![]]; 310 367 } 311 368 312 - let mut lines = Vec::new(); 313 - let mut current = String::new(); 369 + let mut lines: Vec<Vec<CellFragment>> = Vec::new(); 370 + let mut current_line: Vec<CellFragment> = Vec::new(); 314 371 let mut current_width = 0usize; 315 372 316 - for word in expanded.split_whitespace() { 317 - let word_width = display_width(word); 373 + for frag in frags { 374 + match frag { 375 + CellFragment::Text(t) => { 376 + let expanded = expand_tabs(t, 0); 377 + for word in expanded.split_whitespace() { 378 + let word_width = display_width(word); 318 379 319 - if word_width > width { 320 - if !current.is_empty() { 321 - lines.push(std::mem::take(&mut current)); 322 - current_width = 0; 380 + if word_width > width { 381 + if !current_line.is_empty() || current_width > 0 { 382 + lines.push(std::mem::take(&mut current_line)); 383 + current_width = 0; 384 + } 385 + let mut chunk = String::new(); 386 + let mut chunk_width = 0usize; 387 + for ch in word.chars() { 388 + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); 389 + if chunk_width + ch_width > width && !chunk.is_empty() { 390 + lines.push(vec![CellFragment::Text(std::mem::take(&mut chunk))]); 391 + chunk_width = 0; 392 + } 393 + chunk.push(ch); 394 + chunk_width += ch_width; 395 + } 396 + if !chunk.is_empty() { 397 + current_line.push(CellFragment::Text(chunk)); 398 + current_width = chunk_width; 399 + } 400 + continue; 401 + } 402 + 403 + let sep = if current_width == 0 { 0 } else { 1 }; 404 + if current_width + sep + word_width > width && current_width > 0 { 405 + lines.push(std::mem::take(&mut current_line)); 406 + current_width = 0; 407 + } 408 + if current_width > 0 { 409 + current_line.push(CellFragment::Text(" ".to_string())); 410 + current_width += 1; 411 + } 412 + current_line.push(CellFragment::Text(word.to_string())); 413 + current_width += word_width; 414 + } 323 415 } 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; 416 + CellFragment::Code(_) | CellFragment::InlineMath(_) => { 417 + let frag_width = frag.display_width(); 418 + let sep = if current_width == 0 { 0 } else { 1 }; 419 + if current_width + sep + frag_width > width && current_width > 0 { 420 + lines.push(std::mem::take(&mut current_line)); 421 + current_width = 0; 331 422 } 332 - chunk.push(ch); 333 - chunk_width += ch_width; 423 + if current_width > 0 { 424 + current_line.push(CellFragment::Text(" ".to_string())); 425 + current_width += 1; 426 + } 427 + current_line.push(frag.clone()); 428 + current_width += frag_width; 334 429 } 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 430 } 347 - if !current.is_empty() { 348 - current.push(' '); 349 - current_width += 1; 350 - } 351 - current.push_str(word); 352 - current_width += word_width; 353 431 } 354 432 355 - if !current.is_empty() { 356 - lines.push(current); 433 + if !current_line.is_empty() { 434 + lines.push(current_line); 357 435 } 358 436 if lines.is_empty() { 359 - lines.push(String::new()); 437 + lines.push(vec![]); 360 438 } 361 439 lines 362 440 } 363 441 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; 442 + fn align_cell( 443 + frags: &[CellFragment], 444 + width: usize, 445 + align: Alignment, 446 + base_style: Style, 447 + theme: &crate::theme::MarkdownTheme, 448 + ) -> Vec<Span<'static>> { 449 + let mut spans = Vec::new(); 450 + let mut content_width = 0usize; 451 + 452 + for frag in frags { 453 + match frag { 454 + CellFragment::Text(t) => { 455 + let expanded = expand_tabs(t, 0); 456 + content_width += display_width(&expanded); 457 + spans.push(Span::styled(expanded, base_style)); 458 + } 459 + CellFragment::Code(_) | CellFragment::InlineMath(_) => { 460 + let styled = format!(" {} ", frag.rendered_text()); 461 + content_width += display_width(&styled); 462 + let (fg, bg) = match frag { 463 + CellFragment::Code(_) => (theme.inline_code_fg, theme.inline_code_bg), 464 + _ => (theme.latex_inline_fg, theme.latex_inline_bg), 465 + }; 466 + spans.push(Span::styled(styled, Style::default().fg(fg).bg(bg))); 467 + } 468 + } 369 469 } 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)) 470 + 471 + if content_width < width { 472 + let pad = width - content_width; 473 + match align { 474 + Alignment::Right => { 475 + spans.insert(0, Span::styled(" ".repeat(pad), base_style)); 476 + } 477 + Alignment::Center => { 478 + let l = pad / 2; 479 + spans.insert(0, Span::styled(" ".repeat(l), base_style)); 480 + spans.push(Span::styled(" ".repeat(pad - l), base_style)); 481 + } 482 + _ => { 483 + spans.push(Span::styled(" ".repeat(pad), base_style)); 484 + } 376 485 } 377 - _ => format!("{}{}", text, " ".repeat(pad)), 378 486 } 487 + 488 + spans 379 489 }
+105
src/tests/markdown.rs
··· 1 1 use super::{rendered_non_empty_lines, test_assets}; 2 2 use crate::markdown::{parse_markdown, parse_markdown_with_width, resolve_syntax}; 3 + use crate::theme::app_theme; 3 4 use crate::*; 4 5 use syntect::parsing::SyntaxSet; 5 6 ··· 432 433 "latex block in blockquote should have quote prefix" 433 434 ); 434 435 } 436 + 437 + #[test] 438 + fn table_inline_code_has_code_style() { 439 + let (ss, theme) = test_assets(); 440 + let md = "| A |\n|---|\n| `code` |\n"; 441 + let (lines, _) = parse_markdown(md, &ss, &theme); 442 + let theme_colors = &app_theme().markdown; 443 + 444 + let has_code_span = lines.iter().any(|line| { 445 + line.spans.iter().any(|span| { 446 + span.style.bg == Some(theme_colors.inline_code_bg) && span.content.contains("code") 447 + }) 448 + }); 449 + assert!( 450 + has_code_span, 451 + "inline code in table cell should have inline_code_bg" 452 + ); 453 + } 454 + 455 + #[test] 456 + fn table_inline_code_has_padding() { 457 + let (ss, theme) = test_assets(); 458 + let md = "| A |\n|---|\n| `x` |\n"; 459 + let (lines, _) = parse_markdown(md, &ss, &theme); 460 + let theme_colors = &app_theme().markdown; 461 + 462 + let has_padded_span = lines.iter().any(|line| { 463 + line.spans 464 + .iter() 465 + .any(|span| span.style.bg == Some(theme_colors.inline_code_bg) && span.content == " x ") 466 + }); 467 + assert!( 468 + has_padded_span, 469 + "inline code in table should be padded with spaces" 470 + ); 471 + } 472 + 473 + #[test] 474 + fn table_inline_math_renders_with_latex_style() { 475 + let (ss, theme) = test_assets(); 476 + let md = "| A |\n|---|\n| $\\alpha$ |\n"; 477 + let (lines, _) = parse_markdown(md, &ss, &theme); 478 + let theme_colors = &app_theme().markdown; 479 + 480 + let has_math_span = lines.iter().any(|line| { 481 + line.spans.iter().any(|span| { 482 + span.style.bg == Some(theme_colors.latex_inline_bg) && span.content.contains('α') 483 + }) 484 + }); 485 + assert!( 486 + has_math_span, 487 + "inline math in table cell should have latex_inline_bg and render Unicode" 488 + ); 489 + } 490 + 491 + #[test] 492 + fn table_mixed_text_and_code_renders_both_styles() { 493 + let (ss, theme) = test_assets(); 494 + let md = "| A |\n|---|\n| hello `world` bye |\n"; 495 + let (lines, _) = parse_markdown(md, &ss, &theme); 496 + let theme_colors = &app_theme().markdown; 497 + 498 + let table_line = lines 499 + .iter() 500 + .find(|line| line.spans.iter().any(|span| span.content.contains("hello"))); 501 + let line = table_line.expect("should find line with 'hello'"); 502 + 503 + let has_text = line.spans.iter().any(|span| { 504 + span.style.fg == Some(theme_colors.table_cell) && span.content.contains("hello") 505 + }); 506 + let has_code = line.spans.iter().any(|span| { 507 + span.style.bg == Some(theme_colors.inline_code_bg) && span.content.contains("world") 508 + }); 509 + assert!(has_text, "text fragment should use table_cell style"); 510 + assert!(has_code, "code fragment should use inline_code_bg style"); 511 + } 512 + 513 + #[test] 514 + fn table_without_inline_styles_renders_normally() { 515 + let (ss, theme) = test_assets(); 516 + let md = "| A | B |\n|---|---|\n| one | two |\n"; 517 + let (lines, _) = parse_markdown(md, &ss, &theme); 518 + let rendered = rendered_non_empty_lines(&lines); 519 + 520 + assert!(rendered.iter().any(|line| line.contains("one"))); 521 + assert!(rendered.iter().any(|line| line.contains("two"))); 522 + } 523 + 524 + #[test] 525 + fn table_inline_code_col_width_includes_padding() { 526 + let (ss, theme) = test_assets(); 527 + let md = "| A |\n|---|\n| `longcode` |\n"; 528 + let (lines, _) = parse_markdown(md, &ss, &theme); 529 + let rendered = rendered_non_empty_lines(&lines); 530 + 531 + let top_border = rendered.iter().find(|l| l.contains('┌')).unwrap(); 532 + let cell_line = rendered.iter().find(|l| l.contains("longcode")).unwrap(); 533 + let top_width = display_width(top_border); 534 + let cell_width = display_width(cell_line); 535 + assert_eq!( 536 + top_width, cell_width, 537 + "border and cell lines should have same width" 538 + ); 539 + }