Skip to content

Commit 7479322

Browse files
alankyshumclaude
andcommitted
feat: width-aware markdown table rendering with text wrapping (v0.7.2)
Tables in markdown preview now auto-fit to the terminal pane width. Column widths are proportionally distributed and cell text wraps on word boundaries instead of extending beyond the visible area. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 89ee744 commit 7479322

File tree

5 files changed

+158
-20
lines changed

5 files changed

+158
-20
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "semantic-diff"
3-
version = "0.7.1"
3+
version = "0.7.2"
44
edition = "2021"
55
description = "A terminal diff viewer with AI-powered semantic grouping (Claude CLI / Copilot)"
66
license = "MIT"

src/preview/markdown.rs

Lines changed: 152 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ pub enum PreviewBlock {
2020
}
2121

2222
/// Parse markdown source and return a list of preview blocks.
23-
pub fn parse_markdown(source: &str) -> Vec<PreviewBlock> {
23+
/// `width` is the available terminal columns for text wrapping (0 = no limit).
24+
pub fn parse_markdown(source: &str, width: u16) -> Vec<PreviewBlock> {
2425
let mut opts = Options::empty();
2526
opts.insert(Options::ENABLE_TABLES);
2627
opts.insert(Options::ENABLE_STRIKETHROUGH);
@@ -31,7 +32,7 @@ pub fn parse_markdown(source: &str) -> Vec<PreviewBlock> {
3132

3233
let mut blocks: Vec<PreviewBlock> = Vec::new();
3334
let mut lines: Vec<Line<'static>> = Vec::new();
34-
let mut renderer = MarkdownRenderer::new();
35+
let mut renderer = MarkdownRenderer::new(width);
3536

3637
let mut i = 0;
3738
while i < events.len() {
@@ -90,6 +91,8 @@ struct MarkdownRenderer {
9091
/// Whether we're inside a code block (non-mermaid)
9192
in_code_block: bool,
9293
code_block_lang: String,
94+
/// Available terminal width for table wrapping
95+
pane_width: u16,
9396
}
9497

9598
struct TableState {
@@ -100,7 +103,7 @@ struct TableState {
100103
}
101104

102105
impl MarkdownRenderer {
103-
fn new() -> Self {
106+
fn new(pane_width: u16) -> Self {
104107
Self {
105108
style_stack: vec![Style::default()],
106109
current_spans: Vec::new(),
@@ -110,6 +113,7 @@ impl MarkdownRenderer {
110113
table_state: None,
111114
in_code_block: false,
112115
code_block_lang: String::new(),
116+
pane_width,
113117
}
114118
}
115119

@@ -354,7 +358,7 @@ impl MarkdownRenderer {
354358
}
355359
Event::End(TagEnd::Table) => {
356360
if let Some(table) = self.table_state.take() {
357-
lines.extend(render_table(&table.rows));
361+
lines.extend(render_table(&table.rows, self.pane_width));
358362
lines.push(Line::raw(""));
359363
}
360364
}
@@ -416,22 +420,32 @@ impl MarkdownRenderer {
416420
}
417421

418422
/// Render a table as aligned ratatui Lines with box-drawing characters.
419-
fn render_table(rows: &[Vec<String>]) -> Vec<Line<'static>> {
423+
/// Columns are constrained to fit within `pane_width` and cell text wraps.
424+
fn render_table(rows: &[Vec<String>], pane_width: u16) -> Vec<Line<'static>> {
420425
if rows.is_empty() {
421426
return Vec::new();
422427
}
423428

424-
// Calculate column widths
425429
let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
426-
let mut col_widths = vec![0usize; num_cols];
430+
if num_cols == 0 {
431+
return Vec::new();
432+
}
433+
434+
// Natural (max content) width per column
435+
let mut natural_widths = vec![0usize; num_cols];
427436
for row in rows {
428437
for (i, cell) in row.iter().enumerate() {
429438
if i < num_cols {
430-
col_widths[i] = col_widths[i].max(cell.len());
439+
natural_widths[i] = natural_widths[i].max(cell.len());
431440
}
432441
}
433442
}
434443

444+
// Compute column widths that fit within pane_width.
445+
// Overhead: 2 (indent) + num_cols+1 (border chars │) + num_cols*2 (padding spaces)
446+
let overhead = 2 + (num_cols + 1) + num_cols * 2;
447+
let col_widths = fit_column_widths(&natural_widths, pane_width as usize, overhead);
448+
435449
let mut lines = Vec::new();
436450
let header_style = Style::default()
437451
.fg(Color::Cyan)
@@ -454,13 +468,33 @@ fn render_table(rows: &[Vec<String>]) -> Vec<Line<'static>> {
454468
let is_header = ri == 0;
455469
let style = if is_header { header_style } else { cell_style };
456470

457-
let mut spans = vec![Span::styled(" │".to_string(), border_style)];
471+
// Word-wrap each cell into its column width
472+
let mut wrapped_cells: Vec<Vec<String>> = Vec::new();
473+
let mut max_lines = 1usize;
458474
for (ci, width) in col_widths.iter().enumerate() {
459475
let cell = row.get(ci).map(|s| s.as_str()).unwrap_or("");
460-
spans.push(Span::styled(format!(" {cell:<width$} ", width = width), style));
461-
spans.push(Span::styled("│".to_string(), border_style));
476+
let cell_lines = wrap_text(cell, *width);
477+
max_lines = max_lines.max(cell_lines.len());
478+
wrapped_cells.push(cell_lines);
479+
}
480+
481+
// Emit one Line per wrapped row
482+
for line_idx in 0..max_lines {
483+
let mut spans = vec![Span::styled(" │".to_string(), border_style)];
484+
for (ci, width) in col_widths.iter().enumerate() {
485+
let text = wrapped_cells
486+
.get(ci)
487+
.and_then(|wc| wc.get(line_idx))
488+
.map(|s| s.as_str())
489+
.unwrap_or("");
490+
spans.push(Span::styled(
491+
format!(" {text:<width$} ", width = width),
492+
style,
493+
));
494+
spans.push(Span::styled("│".to_string(), border_style));
495+
}
496+
lines.push(Line::from(spans));
462497
}
463-
lines.push(Line::from(spans));
464498

465499
// Separator after header row
466500
if is_header {
@@ -490,28 +524,131 @@ fn render_table(rows: &[Vec<String>]) -> Vec<Line<'static>> {
490524
lines
491525
}
492526

527+
/// Compute column widths that fit within `total_width` (including `overhead`).
528+
/// Distributes available space proportionally to natural widths. Minimum column width is 4.
529+
fn fit_column_widths(natural: &[usize], total_width: usize, overhead: usize) -> Vec<usize> {
530+
let available = total_width.saturating_sub(overhead);
531+
let mut widths: Vec<usize> = natural.iter().map(|&w| w.max(1)).collect();
532+
let min_col = 4usize;
533+
534+
let total_natural: usize = widths.iter().sum();
535+
if total_natural <= available || available == 0 {
536+
return widths;
537+
}
538+
539+
// Proportionally distribute available space
540+
let mut remaining = available;
541+
for (i, w) in widths.iter_mut().enumerate() {
542+
if i == natural.len() - 1 {
543+
// Last column gets whatever is left
544+
*w = remaining.max(min_col);
545+
} else {
546+
let proportion = (natural[i] as f64) / (total_natural as f64);
547+
let alloc = (proportion * available as f64).floor() as usize;
548+
*w = alloc.max(min_col);
549+
remaining = remaining.saturating_sub(*w);
550+
}
551+
}
552+
553+
widths
554+
}
555+
556+
/// Wrap text to fit within `width` characters, breaking on word boundaries.
557+
fn wrap_text(text: &str, width: usize) -> Vec<String> {
558+
if width == 0 || text.len() <= width {
559+
return vec![text.to_string()];
560+
}
561+
562+
let mut lines = Vec::new();
563+
let mut current = String::new();
564+
565+
for word in text.split_whitespace() {
566+
if current.is_empty() {
567+
if word.len() > width {
568+
// Hard-break long words
569+
let mut remaining = word;
570+
while remaining.len() > width {
571+
lines.push(remaining[..width].to_string());
572+
remaining = &remaining[width..];
573+
}
574+
current = remaining.to_string();
575+
} else {
576+
current = word.to_string();
577+
}
578+
} else if current.len() + 1 + word.len() <= width {
579+
current.push(' ');
580+
current.push_str(word);
581+
} else {
582+
lines.push(current);
583+
if word.len() > width {
584+
let mut remaining = word;
585+
while remaining.len() > width {
586+
lines.push(remaining[..width].to_string());
587+
remaining = &remaining[width..];
588+
}
589+
current = remaining.to_string();
590+
} else {
591+
current = word.to_string();
592+
}
593+
}
594+
}
595+
if !current.is_empty() {
596+
lines.push(current);
597+
}
598+
if lines.is_empty() {
599+
lines.push(String::new());
600+
}
601+
602+
lines
603+
}
604+
493605
#[cfg(test)]
494606
mod tests {
495607
use super::*;
496608

497609
#[test]
498610
fn test_heading_parsing() {
499-
let blocks = parse_markdown("# Hello\n\nSome text");
611+
let blocks = parse_markdown("# Hello\n\nSome text", 80);
500612
assert!(!blocks.is_empty());
501613
}
502614

503615
#[test]
504616
fn test_mermaid_extraction() {
505617
let md = "# Diagram\n\n```mermaid\ngraph TD\n A-->B\n```\n\nAfter.";
506-
let blocks = parse_markdown(md);
618+
let blocks = parse_markdown(md, 80);
507619
let has_mermaid = blocks.iter().any(|b| matches!(b, PreviewBlock::Mermaid(_)));
508620
assert!(has_mermaid, "Should extract mermaid block");
509621
}
510622

511623
#[test]
512624
fn test_table_rendering() {
513625
let md = "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |";
514-
let blocks = parse_markdown(md);
626+
let blocks = parse_markdown(md, 80);
515627
assert!(!blocks.is_empty());
516628
}
629+
630+
#[test]
631+
fn test_table_wraps_in_narrow_width() {
632+
let rows = vec![
633+
vec!["Name".to_string(), "Description".to_string()],
634+
vec!["Alice".to_string(), "A very long description that should wrap".to_string()],
635+
vec!["Bob".to_string(), "Short".to_string()],
636+
];
637+
let lines = render_table(&rows, 40);
638+
for line in &lines {
639+
// Use char count, not byte count (box-drawing chars are multi-byte)
640+
let total: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
641+
assert!(total <= 40, "Line width {total} exceeds pane width 40: {:?}",
642+
line.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<_>>());
643+
}
644+
// The wrapped table should have more lines than a 3-row table normally would
645+
assert!(lines.len() > 5, "Table should have wrapped rows, got {} lines", lines.len());
646+
}
647+
648+
#[test]
649+
fn test_wrap_text() {
650+
assert_eq!(wrap_text("hello world", 5), vec!["hello", "world"]);
651+
assert_eq!(wrap_text("hi", 10), vec!["hi"]);
652+
assert_eq!(wrap_text("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
653+
}
517654
}

src/ui/preview_view.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub fn render_preview(app: &App, frame: &mut Frame, area: Rect) -> Vec<PendingIm
4242
}
4343
};
4444

45-
let blocks = crate::preview::markdown::parse_markdown(&file_content);
45+
let blocks = crate::preview::markdown::parse_markdown(&file_content, area.width);
4646
let can_render_images = matches!(app.image_support, ImageSupport::Supported(_));
4747

4848
// Build segments
@@ -274,7 +274,8 @@ impl Segment {
274274
lines
275275
.iter()
276276
.map(|line| {
277-
let char_width: usize = line.spans.iter().map(|s| s.content.len()).sum();
277+
let char_width: usize =
278+
line.spans.iter().map(|s| s.content.chars().count()).sum();
278279
if char_width == 0 {
279280
1
280281
} else {

tests/preview_mode.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ graph TD
343343
[Link](https://example.com)
344344
"#;
345345

346-
let blocks = parse_markdown(md);
346+
let blocks = parse_markdown(md, 120);
347347
assert!(!blocks.is_empty(), "Should produce blocks");
348348

349349
let has_text = blocks.iter().any(|b| matches!(b, PreviewBlock::Text(_)));

0 commit comments

Comments
 (0)