md_tui/nodes/
textcomponent.rs

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