@@ -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
9598struct TableState {
@@ -100,7 +103,7 @@ struct TableState {
100103}
101104
102105impl 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) ]
494606mod tests {
495607 use super :: * ;
496608
497609 #[ test]
498610 fn test_heading_parsing ( ) {
499- let blocks = parse_markdown ( "# Hello\n \n Some text" ) ;
611+ let blocks = parse_markdown ( "# Hello\n \n Some text" , 80 ) ;
500612 assert ! ( !blocks. is_empty( ) ) ;
501613 }
502614
503615 #[ test]
504616 fn test_mermaid_extraction ( ) {
505617 let md = "# Diagram\n \n ```mermaid\n graph TD\n A-->B\n ```\n \n After." ;
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}
0 commit comments