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