Skip to main content

md_tui/nodes/
textcomponent.rs

1use std::cmp;
2
3use itertools::Itertools;
4use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
5
6use ratatui::style::Color;
7use tree_sitter_highlight::HighlightEvent;
8
9use crate::{
10    highlight::{COLOR_MAP, HighlightInfo, highlight_code},
11    nodes::word::MetaData,
12};
13
14use super::word::{Word, WordType};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum TextNode {
18    Image,
19    Paragraph,
20    LineBreak,
21    Heading,
22    Task,
23    List,
24    Footnote,
25    /// (`widths_by_column`, `heights_by_row`)
26    Table(Vec<u16>, Vec<u16>),
27    CodeBlock,
28    Quote,
29    HorizontalSeparator,
30}
31
32pub(crate) const TABLE_CELL_PADDING: u16 = 1;
33
34#[derive(Debug, Clone)]
35pub struct TextComponent {
36    kind: TextNode,
37    content: Vec<Vec<Word>>,
38    meta_info: Vec<Word>,
39    height: u16,
40    offset: u16,
41    scroll_offset: u16,
42    focused: bool,
43    focused_index: usize,
44}
45
46impl TextComponent {
47    #[must_use]
48    pub fn new(kind: TextNode, content: Vec<Word>) -> Self {
49        let meta_info: Vec<Word> = content
50            .iter()
51            .filter(|c| !c.is_renderable() || c.kind() == WordType::FootnoteInline)
52            .cloned()
53            .collect();
54
55        let content = content.into_iter().filter(Word::is_renderable).collect();
56
57        Self {
58            kind,
59            content: vec![content],
60            meta_info,
61            height: 0,
62            offset: 0,
63            scroll_offset: 0,
64            focused: false,
65            focused_index: 0,
66        }
67    }
68
69    #[must_use]
70    pub fn new_formatted(kind: TextNode, content: Vec<Vec<Word>>) -> Self {
71        Self::new_formatted_with_meta(kind, content, Vec::new())
72    }
73
74    #[must_use]
75    pub fn new_formatted_with_meta(
76        kind: TextNode,
77        content: Vec<Vec<Word>>,
78        mut meta_info: Vec<Word>,
79    ) -> Self {
80        meta_info.extend(
81            content
82                .iter()
83                .flatten()
84                .filter(|c| !c.is_renderable())
85                .cloned(),
86        );
87
88        let content: Vec<Vec<Word>> = content
89            .into_iter()
90            .map(|c| c.into_iter().filter(Word::is_renderable).collect())
91            .collect();
92
93        Self {
94            kind,
95            height: content.len() as u16,
96            meta_info,
97            content,
98            offset: 0,
99            scroll_offset: 0,
100            focused: false,
101            focused_index: 0,
102        }
103    }
104
105    #[must_use]
106    pub fn kind(&self) -> TextNode {
107        self.kind.clone()
108    }
109
110    #[must_use]
111    pub fn content(&self) -> &Vec<Vec<Word>> {
112        &self.content
113    }
114
115    #[must_use]
116    pub fn content_as_lines(&self) -> Vec<String> {
117        if let TextNode::Table(widths, _) = self.kind() {
118            let column_count = widths.len();
119
120            let moved_content = self.content.chunks(column_count).collect::<Vec<_>>();
121
122            let mut lines = Vec::new();
123
124            moved_content.iter().for_each(|line| {
125                let temp = line
126                    .iter()
127                    .map(|c| c.iter().map(Word::content).join(""))
128                    .join(" ");
129                lines.push(temp);
130            });
131
132            lines
133        } else {
134            self.content
135                .iter()
136                .map(|c| c.iter().map(Word::content).collect::<Vec<_>>().join(""))
137                .collect()
138        }
139    }
140
141    #[must_use]
142    pub fn content_as_bytes(&self) -> Vec<u8> {
143        match self.kind() {
144            TextNode::CodeBlock => self.content_as_lines().join("").as_bytes().to_vec(),
145            _ => {
146                let strings = self.content_as_lines();
147                let string = strings.join("\n");
148                string.as_bytes().to_vec()
149            }
150        }
151    }
152
153    #[must_use]
154    pub fn content_owned(self) -> Vec<Vec<Word>> {
155        self.content
156    }
157
158    #[must_use]
159    pub fn meta_info(&self) -> &Vec<Word> {
160        &self.meta_info
161    }
162
163    #[must_use]
164    pub fn height(&self) -> u16 {
165        self.height
166    }
167
168    #[must_use]
169    pub fn y_offset(&self) -> u16 {
170        self.offset
171    }
172
173    #[must_use]
174    pub fn scroll_offset(&self) -> u16 {
175        self.scroll_offset
176    }
177
178    pub fn set_y_offset(&mut self, y_offset: u16) {
179        self.offset = y_offset;
180    }
181
182    pub fn set_scroll_offset(&mut self, offset: u16) {
183        self.scroll_offset = offset;
184    }
185
186    #[must_use]
187    pub fn is_focused(&self) -> bool {
188        self.focused
189    }
190
191    pub fn deselect(&mut self) {
192        self.focused = false;
193        self.focused_index = 0;
194        self.content
195            .iter_mut()
196            .flatten()
197            .filter(|c| c.kind() == WordType::Selected)
198            .for_each(|c| {
199                c.clear_kind();
200            });
201    }
202
203    pub fn visually_select(&mut self, index: usize) -> Result<(), String> {
204        self.focused = true;
205        self.focused_index = index;
206
207        if index >= self.num_links() {
208            return Err(format!(
209                "Index out of bounds: {} >= {}",
210                index,
211                self.num_links()
212            ));
213        }
214
215        // Transform nth link to selected
216        self.link_words_mut()
217            .get_mut(index)
218            .ok_or("index out of bounds")?
219            .iter_mut()
220            .for_each(|c| {
221                c.set_kind(WordType::Selected);
222            });
223        Ok(())
224    }
225
226    fn link_words_mut(&mut self) -> Vec<Vec<&mut Word>> {
227        let mut selection: Vec<Vec<&mut Word>> = Vec::new();
228        let mut iter = self.content.iter_mut().flatten().peekable();
229        while let Some(e) = iter.peek() {
230            if matches!(e.kind(), WordType::Link | WordType::FootnoteInline) {
231                selection.push(
232                    iter.by_ref()
233                        .take_while(|c| {
234                            matches!(c.kind(), WordType::Link | WordType::FootnoteInline)
235                        })
236                        .collect(),
237                );
238            } else {
239                iter.next();
240            }
241        }
242        selection
243    }
244
245    #[must_use]
246    pub fn get_footnote(&self, search: &str) -> String {
247        self.content()
248            .iter()
249            .flatten()
250            .skip_while(|c| c.kind() != WordType::FootnoteData && c.content() != search)
251            .take_while(|c| c.kind() == WordType::Footnote)
252            .map(Word::content)
253            .collect()
254    }
255
256    pub fn highlight_link(&self) -> Result<&str, String> {
257        Ok(self
258            .meta_info()
259            .iter()
260            .filter(|c| matches!(c.kind(), WordType::LinkData | WordType::FootnoteInline))
261            .nth(self.focused_index)
262            .ok_or("index out of bounds")?
263            .content())
264    }
265
266    #[must_use]
267    pub fn num_links(&self) -> usize {
268        self.meta_info
269            .iter()
270            .filter(|c| matches!(c.kind(), WordType::LinkData | WordType::FootnoteInline))
271            .count()
272    }
273
274    #[must_use]
275    pub fn selected_heights(&self) -> Vec<usize> {
276        let mut heights = Vec::new();
277
278        if let TextNode::Table(widths, row_heights) = self.kind() {
279            let column_count = widths.len();
280            let iter = self.content.chunks(column_count).enumerate();
281
282            for (i, line) in iter {
283                if line
284                    .iter()
285                    .flatten()
286                    .any(|c| c.kind() == WordType::Selected)
287                {
288                    let offset = 1
289                        + row_heights.iter().take(i).copied().sum::<u16>() as usize
290                        + usize::from(i > 0);
291                    heights.push(offset);
292                }
293            }
294            return heights;
295        }
296
297        for (i, line) in self.content.iter().enumerate() {
298            if line.iter().any(|c| c.kind() == WordType::Selected) {
299                heights.push(i);
300            }
301        }
302        heights
303    }
304
305    pub fn words_mut(&mut self) -> Vec<&mut Word> {
306        self.content.iter_mut().flatten().collect()
307    }
308
309    pub fn transform(&mut self, width: u16) {
310        match self.kind {
311            TextNode::List => {
312                transform_list(self, width);
313            }
314            TextNode::CodeBlock => {
315                transform_codeblock(self);
316            }
317            TextNode::Paragraph | TextNode::Task | TextNode::Quote => {
318                transform_paragraph(self, width);
319            }
320            TextNode::LineBreak | TextNode::Heading => {
321                self.height = 1;
322            }
323            TextNode::Table(_, _) => {
324                transform_table(self, width);
325            }
326            TextNode::HorizontalSeparator => self.height = 1,
327            TextNode::Image => unreachable!("Image should not be transformed"),
328            TextNode::Footnote => self.height = 0,
329        }
330    }
331}
332
333pub(crate) fn word_wrapping<'a>(
334    words: impl IntoIterator<Item = &'a Word>,
335    width: usize,
336    allow_hyphen: bool,
337) -> Vec<Vec<Word>> {
338    let enable_hyphen = allow_hyphen && width > 4;
339
340    let mut lines = Vec::new();
341    let mut line = Vec::new();
342    let mut line_len = 0;
343    for word in words {
344        let word_len = display_width(word.content());
345        if line_len + word_len <= width {
346            line_len += word_len;
347            line.push(word.clone());
348        } else if word_len <= width {
349            lines.push(line);
350            let mut word = word.clone();
351            let content = word.content().trim_start().to_owned();
352            word.set_content(content);
353
354            line_len = display_width(word.content());
355            line = vec![word];
356        } else {
357            let content = word.content().to_owned();
358
359            if width - line_len < 4 {
360                line_len = 0;
361                lines.push(line);
362                line = Vec::new();
363            }
364
365            let split_width = if enable_hyphen && !content.ends_with('-') {
366                width - line_len - 1
367            } else {
368                width - line_len
369            };
370
371            let (mut content, mut newline_content) = split_by_width(&content, split_width);
372            if enable_hyphen && !content.ends_with('-') && !content.is_empty() {
373                if let Some(last_char) = content.pop() {
374                    newline_content.insert(0, last_char);
375                }
376                content.push('-');
377            }
378
379            line.push(Word::new(content, word.kind()));
380            lines.push(line);
381
382            while display_width(&newline_content) > width {
383                let split_width = if enable_hyphen && !newline_content.ends_with('-') {
384                    width - 1
385                } else {
386                    width
387                };
388                let (mut content, mut next_newline_content) =
389                    split_by_width(&newline_content, split_width);
390                if enable_hyphen && !newline_content.ends_with('-') && !content.is_empty() {
391                    if let Some(last_char) = content.pop() {
392                        next_newline_content.insert(0, last_char);
393                    }
394                    content.push('-');
395                }
396
397                line = vec![Word::new(content, word.kind())];
398                lines.push(line);
399                newline_content = next_newline_content;
400            }
401
402            if newline_content.is_empty() {
403                line_len = 0;
404                line = Vec::new();
405            } else {
406                line_len = display_width(&newline_content);
407                line = vec![Word::new(newline_content, word.kind())];
408            }
409        }
410    }
411
412    if !line.is_empty() {
413        lines.push(line);
414    }
415
416    lines
417}
418
419fn display_width(text: &str) -> usize {
420    UnicodeWidthStr::width(text)
421}
422
423fn split_by_width(text: &str, max_width: usize) -> (String, String) {
424    if max_width == 0 {
425        return (String::new(), text.to_string());
426    }
427
428    let mut width = 0;
429    let mut split_idx = 0;
430    // Track the byte index where the visible width reaches (or just exceeds) max_width.
431    for (i, c) in text.char_indices() {
432        let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
433        if width + char_width > max_width {
434            if split_idx == 0 {
435                split_idx = i + c.len_utf8();
436            }
437            break;
438        }
439        width += char_width;
440        split_idx = i + c.len_utf8();
441        if width == max_width {
442            break;
443        }
444    }
445
446    let (head, tail) = text.split_at(split_idx);
447    (head.to_string(), tail.to_string())
448}
449
450fn transform_paragraph(component: &mut TextComponent, width: u16) {
451    let width = match component.kind {
452        TextNode::Paragraph => width as usize - 1,
453        TextNode::Task => width as usize - 4,
454        TextNode::Quote => width as usize - 2,
455        _ => unreachable!(),
456    };
457
458    let mut lines = word_wrapping(component.content.iter().flatten(), width, true);
459
460    if component.kind() == TextNode::Quote {
461        let is_special_quote = !component.meta_info.is_empty();
462
463        for line in lines.iter_mut().skip(usize::from(is_special_quote)) {
464            line.insert(0, Word::new(" ".to_string(), WordType::Normal));
465        }
466    }
467
468    component.height = lines.len() as u16;
469    component.content = lines;
470}
471
472fn transform_codeblock(component: &mut TextComponent) {
473    let language = if let Some(word) = component.meta_info().first() {
474        word.content()
475    } else {
476        ""
477    };
478
479    let highlight = highlight_code(language, &component.content_as_bytes());
480
481    let content = component.content_as_lines().join("");
482
483    let mut new_content = Vec::new();
484
485    if language.is_empty() {
486        component.content.insert(
487            0,
488            vec![Word::new(String::new(), WordType::CodeBlock(Color::Reset))],
489        );
490    }
491    match highlight {
492        HighlightInfo::Highlighted(e) => {
493            let mut color = Color::Reset;
494            for event in e {
495                match event {
496                    HighlightEvent::Source { start, end } => {
497                        let word =
498                            Word::new(content[start..end].to_string(), WordType::CodeBlock(color));
499                        new_content.push(word);
500                    }
501                    HighlightEvent::HighlightStart(index) => {
502                        color = COLOR_MAP[index.0];
503                    }
504                    HighlightEvent::HighlightEnd => color = Color::Reset,
505                }
506            }
507
508            // Find all the new lines to split the content correctly
509            let mut final_content = Vec::new();
510            let mut inner_content = Vec::new();
511            for word in new_content {
512                if word.content().contains('\n') {
513                    let mut start = 0;
514                    let mut end;
515                    for (i, c) in word.content().char_indices() {
516                        if c == '\n' {
517                            end = i;
518                            let new_word =
519                                Word::new(word.content()[start..end].to_string(), word.kind());
520                            inner_content.push(new_word);
521                            start = i + 1;
522                            final_content.push(inner_content);
523                            inner_content = Vec::new();
524                        } else if i == word.content().len() - 1 {
525                            let new_word =
526                                Word::new(word.content()[start..].to_string(), word.kind());
527                            inner_content.push(new_word);
528                        }
529                    }
530                } else {
531                    inner_content.push(word);
532                }
533            }
534
535            final_content.push(vec![Word::new(String::new(), WordType::CodeBlock(color))]);
536
537            component.content = final_content;
538        }
539        HighlightInfo::Unhighlighted => (),
540    }
541
542    let height = component.content.len() as u16;
543    component.height = height;
544}
545
546fn transform_list(component: &mut TextComponent, width: u16) {
547    let mut len = 0;
548    let mut lines = Vec::new();
549    let mut line = Vec::new();
550    let indent_iter = component
551        .meta_info
552        .iter()
553        .filter(|c| c.content().trim() == "");
554    let list_type_iter = component.meta_info.iter().filter(|c| {
555        matches!(
556            c.kind(),
557            WordType::MetaInfo(MetaData::OList | MetaData::UList)
558        )
559    });
560
561    let mut zip_iter = indent_iter.zip(list_type_iter);
562
563    let mut o_list_counter_stack = vec![0];
564    let mut max_stack_len = 1;
565    let mut indent = 0;
566    let mut extra_indent = 0;
567    let mut tmp = indent;
568    for word in component.content.iter_mut().flatten() {
569        let word_len = display_width(word.content());
570        if word_len + len < width as usize && word.kind() != WordType::ListMarker {
571            len += word_len;
572            line.push(word.clone());
573        } else {
574            let filler_content = if word.kind() == WordType::ListMarker {
575                indent = if let Some((meta, list_type)) = zip_iter.next() {
576                    match tmp.cmp(&display_width(meta.content())) {
577                        cmp::Ordering::Less => {
578                            o_list_counter_stack.push(0);
579                            max_stack_len += 1;
580                        }
581                        cmp::Ordering::Greater => {
582                            o_list_counter_stack.pop();
583                        }
584                        cmp::Ordering::Equal => (),
585                    }
586                    if list_type.kind() == WordType::MetaInfo(MetaData::OList) {
587                        let counter = o_list_counter_stack
588                            .last_mut()
589                            .expect("List parse error. Stack is empty");
590
591                        *counter += 1;
592
593                        word.set_content(format!("{counter}. "));
594
595                        extra_indent = 1; // Ordered list is longer than unordered and needs extra space
596                    } else {
597                        extra_indent = 0;
598                    }
599                    tmp = display_width(meta.content());
600                    tmp
601                } else {
602                    0
603                };
604
605                " ".repeat(indent)
606            } else {
607                " ".repeat(indent + 2 + extra_indent)
608            };
609
610            let filler = Word::new(filler_content, WordType::Normal);
611
612            lines.push(line);
613            let content = word.content().trim_start().to_owned();
614            word.set_content(content);
615            len = display_width(word.content()) + display_width(filler.content());
616            line = vec![filler, word.to_owned()];
617        }
618    }
619    lines.push(line);
620    // Remove empty lines
621    lines.retain(|l| l.iter().any(|c| c.content() != ""));
622
623    // Find out if there are ordered indexes longer than 3 chars. F.ex. `1. ` is three chars, but `10. ` is four chars.
624    // To align the list on the same column, we need to find the longest index and add the difference to the shorter indexes.
625    let mut indent_correction = vec![0; max_stack_len];
626    let mut indent_index: u32 = 0;
627    let mut indent_len = 0;
628
629    for line in &lines {
630        if !line[1]
631            .content()
632            .strip_prefix(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
633            .is_some_and(|c| c.ends_with(". "))
634        {
635            continue;
636        }
637
638        match indent_len.cmp(&display_width(line[0].content())) {
639            cmp::Ordering::Less => {
640                indent_index += 1;
641                indent_len = display_width(line[0].content());
642            }
643            cmp::Ordering::Greater => {
644                indent_index = indent_index.saturating_sub(1);
645                indent_len = display_width(line[0].content());
646            }
647            cmp::Ordering::Equal => (),
648        }
649
650        indent_correction[indent_index as usize] = cmp::max(
651            indent_correction[indent_index as usize],
652            display_width(line[1].content()),
653        );
654    }
655
656    // Finally, apply the indent correction to the list for each ordered index which is shorter
657    // than the longest index.
658
659    indent_index = 0;
660    indent_len = 0;
661    let mut unordered_list_skip = true; // Skip unordered list items. They are already aligned.
662
663    for line in &mut lines {
664        if line[1]
665            .content()
666            .strip_prefix(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
667            .is_some_and(|c| c.ends_with(". "))
668        {
669            unordered_list_skip = false;
670        }
671
672        if line[1].content() == "• " || unordered_list_skip {
673            unordered_list_skip = true;
674            continue;
675        }
676
677        let amount = if line[1]
678            .content()
679            .strip_prefix(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
680            .is_some_and(|c| c.ends_with(". "))
681        {
682            match indent_len.cmp(&display_width(line[0].content())) {
683                cmp::Ordering::Less => {
684                    indent_index += 1;
685                    indent_len = display_width(line[0].content());
686                }
687                cmp::Ordering::Greater => {
688                    indent_index = indent_index.saturating_sub(1);
689                    indent_len = display_width(line[0].content());
690                }
691                cmp::Ordering::Equal => (),
692            }
693            indent_correction[indent_index as usize]
694                .saturating_sub(display_width(line[1].content()))
695                + display_width(line[0].content())
696        } else {
697            // -3 because that is the length of the shortest ordered index (1. )
698            (indent_correction[indent_index as usize] + display_width(line[0].content()))
699                .saturating_sub(3)
700        };
701
702        line[0].set_content(" ".repeat(amount));
703    }
704
705    component.height = lines.len() as u16;
706    component.content = lines;
707}
708
709fn table_styling_width(column_count: usize) -> u16 {
710    1 + column_count as u16 * (TABLE_CELL_PADDING * 2 + 1)
711}
712
713fn transform_table(component: &mut TextComponent, width: u16) {
714    // Subtract 1 to match the actual render area width (consistent with transform_paragraph)
715    let width = width.saturating_sub(1);
716    let content = &mut component.content;
717
718    let column_count = component
719        .meta_info
720        .iter()
721        .filter(|w| w.kind() == WordType::MetaInfo(MetaData::ColumnsCount))
722        .count();
723
724    if !content.len().is_multiple_of(column_count) || column_count == 0 {
725        component.height = 1;
726        component.kind = TextNode::Table(vec![], vec![]);
727        return;
728    }
729
730    assert!(
731        content.len().is_multiple_of(column_count),
732        "Invalid table cell distribution: content.len() = {}, column_count = {}",
733        content.len(),
734        column_count
735    );
736
737    let row_count = content.len() / column_count;
738
739    ///////////////////////////
740    // Find unbalanced width //
741    ///////////////////////////
742    let widths = {
743        let mut widths = vec![0; column_count];
744        content.chunks(column_count).for_each(|row| {
745            row.iter().enumerate().for_each(|(col_i, entry)| {
746                let len = content_entry_len(entry);
747                if len > widths[col_i] as usize {
748                    widths[col_i] = len as u16;
749                }
750            });
751        });
752
753        widths
754    };
755
756    let styling_width = table_styling_width(column_count);
757    let unbalanced_cells_width = widths.iter().sum::<u16>();
758
759    /////////////////////////////////////
760    // Return if unbalanced width fits //
761    /////////////////////////////////////
762    if width >= unbalanced_cells_width + styling_width {
763        component.height = row_count as u16 + 3;
764        component.kind = TextNode::Table(widths, vec![1; row_count]);
765        return;
766    }
767
768    //////////////////////////////
769    // Find overflowing columns //
770    //////////////////////////////
771    let overflow_threshold = width.saturating_sub(styling_width) / column_count as u16;
772    let mut overflowing_columns = vec![];
773
774    let (overflowing_width, non_overflowing_width) = {
775        let mut overflowing_width = 0;
776        let mut non_overflowing_width = 0;
777
778        for (column_i, column_width) in widths.iter().enumerate() {
779            if *column_width > overflow_threshold {
780                overflowing_columns.push((column_i, column_width));
781
782                overflowing_width += column_width;
783            } else {
784                non_overflowing_width += column_width;
785            }
786        }
787
788        (overflowing_width, non_overflowing_width)
789    };
790
791    if overflowing_columns.is_empty() {
792        component.height = row_count as u16 + 3;
793        component.kind = TextNode::Table(widths, vec![1; row_count]);
794        return;
795    }
796
797    /////////////////////////////////////////////
798    // Assign new width to overflowing columns //
799    /////////////////////////////////////////////
800    let mut available_balanced_width = width.saturating_sub(non_overflowing_width + styling_width);
801    let mut available_overflowing_width = overflowing_width;
802
803    let overflowing_column_min_width =
804        (available_balanced_width / (2 * overflowing_columns.len() as u16)).max(1);
805
806    let mut widths_balanced: Vec<u16> = widths.clone();
807    for (column_i, old_column_width) in overflowing_columns
808        .iter()
809        // Sorting ensures the smallest overflowing cells receive minimum area without the
810        // need for recalculating the larger cells
811        .sorted_by(|a, b| Ord::cmp(a.1, b.1))
812    {
813        // Ensure the longest cell gets the most amount of area
814        let ratio = f32::from(**old_column_width) / f32::from(available_overflowing_width);
815        let mut balanced_column_width =
816            (ratio * f32::from(available_balanced_width)).floor() as u16;
817
818        if balanced_column_width < overflowing_column_min_width {
819            balanced_column_width = overflowing_column_min_width;
820            available_overflowing_width -= **old_column_width;
821            available_balanced_width =
822                available_balanced_width.saturating_sub(balanced_column_width);
823        }
824
825        widths_balanced[*column_i] = balanced_column_width;
826    }
827
828    ////////////////////////////////////////
829    // Wrap words based on balanced width //
830    ////////////////////////////////////////
831    let mut heights = vec![1; row_count];
832    for (row_i, row) in content
833        .iter_mut()
834        .chunks(column_count)
835        .into_iter()
836        .enumerate()
837    {
838        for (column_i, entry) in row.into_iter().enumerate() {
839            let lines = word_wrapping(
840                entry.drain(..).as_ref(),
841                widths_balanced[column_i] as usize,
842                true,
843            );
844
845            if heights[row_i] < lines.len() as u16 {
846                heights[row_i] = lines.len() as u16;
847            }
848
849            let _drop = std::mem::replace(entry, lines.into_iter().flatten().collect());
850        }
851    }
852
853    component.height = heights.iter().copied().sum::<u16>() + 3;
854
855    component.kind = TextNode::Table(widths_balanced, heights);
856}
857
858#[must_use]
859pub fn content_entry_len(words: &[Word]) -> usize {
860    words.iter().map(|word| display_width(word.content())).sum()
861}