Skip to main content

md_tui/nodes/
root.rs

1use std::collections::HashSet;
2
3use crate::search::{compare_heading, find_and_mark};
4
5use super::{
6    image::ImageComponent,
7    textcomponent::{TextComponent, TextNode},
8    word::{Word, WordType},
9};
10
11pub struct ComponentRoot {
12    file_name: Option<String>,
13    components: Vec<Component>,
14    is_focused: bool,
15}
16
17impl ComponentRoot {
18    #[must_use]
19    pub fn new(file_name: Option<String>, components: Vec<Component>) -> Self {
20        Self {
21            file_name,
22            components,
23            is_focused: false,
24        }
25    }
26
27    #[must_use]
28    pub fn children(&self) -> Vec<&Component> {
29        self.components.iter().collect()
30    }
31
32    pub fn children_mut(&mut self) -> Vec<&mut Component> {
33        self.components.iter_mut().collect()
34    }
35
36    #[must_use]
37    pub fn components(&self) -> Vec<&TextComponent> {
38        self.components
39            .iter()
40            .filter_map(|c| match c {
41                Component::TextComponent(comp) => Some(comp),
42                Component::Image(_) => None,
43            })
44            .collect()
45    }
46
47    pub fn components_mut(&mut self) -> Vec<&mut TextComponent> {
48        self.components
49            .iter_mut()
50            .filter_map(|c| match c {
51                Component::TextComponent(comp) => Some(comp),
52                Component::Image(_) => None,
53            })
54            .collect()
55    }
56
57    #[must_use]
58    pub fn file_name(&self) -> Option<&str> {
59        self.file_name.as_deref()
60    }
61
62    #[must_use]
63    pub fn words(&self) -> Vec<&Word> {
64        self.components
65            .iter()
66            .filter_map(|c| match c {
67                Component::TextComponent(comp) => Some(comp),
68                Component::Image(_) => None,
69            })
70            .flat_map(|c| c.content().iter().flatten())
71            .collect()
72    }
73
74    pub fn find_and_mark(&mut self, search: &str) {
75        let mut words = self
76            .components
77            .iter_mut()
78            .filter_map(|c| match c {
79                Component::TextComponent(comp) => Some(comp),
80                Component::Image(_) => None,
81            })
82            .flat_map(|c| c.words_mut())
83            .collect::<Vec<_>>();
84        find_and_mark(search, &mut words);
85    }
86
87    #[must_use]
88    pub fn search_results_heights(&self) -> Vec<usize> {
89        self.components
90            .iter()
91            .filter_map(|c| match c {
92                Component::TextComponent(comp) => Some(comp),
93                Component::Image(_) => None,
94            })
95            .flat_map(|c| {
96                let mut heights = c.selected_heights();
97                heights.iter_mut().for_each(|h| *h += c.y_offset() as usize);
98                heights
99            })
100            .collect()
101    }
102
103    pub fn clear(&mut self) {
104        self.file_name = None;
105        self.components.clear();
106    }
107
108    pub fn select(&mut self, index: usize) -> Result<u16, String> {
109        self.deselect();
110        self.is_focused = true;
111        let mut count = 0;
112        for comp in self.components.iter_mut().filter_map(|f| match f {
113            Component::TextComponent(comp) => Some(comp),
114            Component::Image(_) => None,
115        }) {
116            let link_inside_comp = index - count < comp.num_links();
117            if link_inside_comp {
118                comp.visually_select(index - count)?;
119                return Ok(comp.y_offset());
120            }
121            count += comp.num_links();
122        }
123        Err(format!("Index out of bounds: {index} >= {count}"))
124    }
125
126    pub fn deselect(&mut self) {
127        self.is_focused = false;
128        for comp in self.components.iter_mut().filter_map(|f| match f {
129            Component::TextComponent(comp) => Some(comp),
130            Component::Image(_) => None,
131        }) {
132            comp.deselect();
133        }
134    }
135
136    #[must_use]
137    pub fn find_footnote(&self, search: &str) -> String {
138        let footnote = self
139            .components
140            .iter()
141            .filter_map(|f| match f {
142                Component::TextComponent(text_component) => {
143                    if text_component.kind() == TextNode::Footnote {
144                        Some(text_component)
145                    } else {
146                        None
147                    }
148                }
149                Component::Image(_) => None,
150            })
151            .filter(|f| {
152                if let Some(foot_ref) = f.meta_info().iter().next() {
153                    foot_ref.content() == search
154                } else {
155                    false
156                }
157            })
158            .flat_map(|f| f.content().iter().flatten())
159            .filter(|f| f.kind() == WordType::Footnote)
160            .map(Word::content)
161            .collect::<String>();
162
163        if footnote.is_empty() {
164            String::from("Footnote not found")
165        } else {
166            footnote
167        }
168    }
169
170    #[must_use]
171    pub fn link_index_and_height(&self) -> Vec<(usize, u16)> {
172        let mut indexes = Vec::new();
173        let mut count = 0;
174        self.components
175            .iter()
176            .filter_map(|f| match f {
177                Component::TextComponent(comp) => Some(comp),
178                Component::Image(_) => None,
179            })
180            .filter(|comp| !comp.is_hidden())
181            .for_each(|comp| {
182                let height = comp.y_offset();
183                comp.content().iter().enumerate().for_each(|(index, row)| {
184                    row.iter().for_each(|c| {
185                        if matches!(
186                            c.kind(),
187                            WordType::Link | WordType::Selected | WordType::FootnoteInline
188                        ) {
189                            indexes.push((count, height + index as u16));
190                            count += 1;
191                        }
192                    });
193                });
194            });
195
196        indexes
197    }
198
199    /// Sets the y offset of the components
200    pub fn set_scroll(&mut self, scroll: u16) {
201        let mut y_offset = 0;
202        for component in &mut self.components {
203            component.set_y_offset(y_offset);
204            component.set_scroll_offset(scroll);
205            y_offset += component.height();
206        }
207    }
208
209    pub fn heading_offset(&self, heading: &str) -> Result<u16, String> {
210        let mut y_offset = 0;
211        for component in &self.components {
212            match component {
213                Component::TextComponent(comp) => {
214                    if comp.kind() == TextNode::Heading
215                        && compare_heading(&heading[1..], comp.content())
216                    {
217                        return Ok(y_offset);
218                    }
219                    y_offset += comp.height();
220                }
221                Component::Image(e) => y_offset += e.height(),
222            }
223        }
224        Err(format!("Heading not found: {heading}"))
225    }
226
227    /// Return the content of the components, where each element a line
228    #[must_use]
229    pub fn content(&self) -> Vec<String> {
230        self.components()
231            .iter()
232            .flat_map(|c| c.content_as_lines())
233            .collect()
234    }
235
236    #[must_use]
237    pub fn selected(&self) -> &str {
238        let block = self
239            .components
240            .iter()
241            .filter_map(|f| match f {
242                Component::TextComponent(comp) => Some(comp),
243                Component::Image(_) => None,
244            })
245            .find(|c| c.is_focused())
246            .unwrap();
247        block.highlight_link().unwrap()
248    }
249
250    #[must_use]
251    pub fn selected_underlying_type(&self) -> WordType {
252        let selected = self
253            .components
254            .iter()
255            .filter_map(|f| match f {
256                Component::TextComponent(comp) => Some(comp),
257                Component::Image(_) => None,
258            })
259            .find(|c| c.is_focused())
260            .unwrap()
261            .content()
262            .iter()
263            .flatten()
264            .filter(|c| c.kind() == WordType::Selected)
265            .collect::<Vec<_>>();
266
267        selected.first().unwrap().previous_type()
268    }
269
270    /// Transforms the content of the components to fit the given width
271    pub fn transform(&mut self, width: u16) {
272        for component in self.components_mut() {
273            component.transform(width);
274        }
275    }
276
277    /// Because of the parsing, every table has a missing newline at the end
278    #[must_use]
279    pub fn add_missing_components(self) -> Self {
280        let mut components = Vec::new();
281        let mut iter = self.components.into_iter().peekable();
282        while let Some(component) = iter.next() {
283            let kind = component.kind();
284            let curr_ids: Vec<u32> = match &component {
285                Component::TextComponent(tc) => tc.owning_details_ids().to_vec(),
286                Component::Image(_) => Vec::new(),
287            };
288            components.push(component);
289            if let Some(next) = iter.peek()
290                && kind != TextNode::LineBreak
291                && next.kind() != TextNode::LineBreak
292            {
293                let next_ids: Vec<u32> = match next {
294                    Component::TextComponent(tc) => tc.owning_details_ids().to_vec(),
295                    Component::Image(_) => Vec::new(),
296                };
297                // An inserted LineBreak inherits the longest common
298                // outermost prefix of its two neighbors' owning-details
299                // chains, so it is hidden iff both neighbors are inside
300                // the same folded `<details>` body.
301                let shared_ids: Vec<u32> = curr_ids
302                    .iter()
303                    .zip(next_ids.iter())
304                    .take_while(|(a, b)| a == b)
305                    .map(|(a, _)| *a)
306                    .collect();
307                let mut lb = TextComponent::new(TextNode::LineBreak, Vec::new());
308                lb.set_owning_details_ids(shared_ids);
309                components.push(Component::TextComponent(lb));
310            }
311        }
312        Self {
313            file_name: self.file_name,
314            components,
315            is_focused: self.is_focused,
316        }
317    }
318
319    #[must_use]
320    pub fn height(&self) -> u16 {
321        self.components.iter().map(ComponentProps::height).sum()
322    }
323
324    #[must_use]
325    pub fn num_links(&self) -> usize {
326        self.components
327            .iter()
328            .filter_map(|f| match f {
329                Component::TextComponent(comp) => Some(comp),
330                Component::Image(_) => None,
331            })
332            .map(TextComponent::num_links)
333            .sum()
334    }
335
336    /// Walk all components and set their `hidden` flag based on whether
337    /// any of their `owning_details_ids` references a currently-folded
338    /// `<details>` block. Must be called after parse and after every
339    /// fold-toggle so that `height()`, `num_links()`, etc. return the
340    /// post-fold values used by `set_scroll` and the renderer.
341    pub fn recompute_visibility(&mut self) {
342        let folded: HashSet<u32> = self
343            .components
344            .iter()
345            .filter_map(|f| match f {
346                Component::TextComponent(comp) => Some(comp),
347                Component::Image(_) => None,
348            })
349            .filter_map(|tc| match tc.kind() {
350                TextNode::DetailsSummary {
351                    id, folded: true, ..
352                } => Some(id),
353                _ => None,
354            })
355            .collect();
356
357        for c in self.components.iter_mut() {
358            if let Component::TextComponent(tc) = c {
359                let hidden = tc.owning_details_ids().iter().any(|id| folded.contains(id));
360                tc.set_hidden(hidden);
361            }
362        }
363    }
364
365    /// Count of `<details>` summary headers that are currently *visible*
366    /// (i.e. not hidden by an outer folded block). Used by the event
367    /// handler to bound the cyclable selection index.
368    #[must_use]
369    pub fn num_details(&self) -> usize {
370        self.components
371            .iter()
372            .filter_map(|f| match f {
373                Component::TextComponent(comp) => Some(comp),
374                Component::Image(_) => None,
375            })
376            .filter(|comp| {
377                !comp.is_hidden() && matches!(comp.kind(), TextNode::DetailsSummary { .. })
378            })
379            .count()
380    }
381
382    /// Returns `(index, y_offset)` for each visible details summary, in
383    /// document order. Parallels `link_index_and_height` — callers use it
384    /// to pick the summary nearest the current scroll position.
385    #[must_use]
386    pub fn details_index_and_height(&self) -> Vec<(usize, u16)> {
387        let mut out = Vec::new();
388        let mut idx = 0usize;
389        for c in &self.components {
390            if let Component::TextComponent(comp) = c
391                && !comp.is_hidden()
392                && matches!(comp.kind(), TextNode::DetailsSummary { .. })
393            {
394                out.push((idx, comp.y_offset()));
395                idx += 1;
396            }
397        }
398        out
399    }
400
401    /// Visually mark the `index`-th visible details summary as focused,
402    /// returning its `y_offset` so the caller can scroll it into view.
403    /// Clears any prior details focus first.
404    pub fn select_details(&mut self, index: usize) -> Result<u16, String> {
405        self.deselect_details();
406        let mut count = 0;
407        for c in self.components.iter_mut() {
408            if let Component::TextComponent(comp) = c
409                && !comp.is_hidden()
410                && matches!(comp.kind(), TextNode::DetailsSummary { .. })
411            {
412                if count == index {
413                    comp.visually_select_summary();
414                    return Ok(comp.y_offset());
415                }
416                count += 1;
417            }
418        }
419        Err(format!("Details index out of bounds: {index} >= {count}"))
420    }
421
422    /// Clear focus from whichever details summary currently has it.
423    pub fn deselect_details(&mut self) {
424        for c in self.components.iter_mut() {
425            if let Component::TextComponent(comp) = c
426                && matches!(comp.kind(), TextNode::DetailsSummary { .. })
427            {
428                comp.deselect_summary();
429            }
430        }
431    }
432
433    /// Flip the `folded` flag on the currently-focused details summary
434    /// and recompute visibility. Returns `Err` if no details summary is
435    /// focused.
436    pub fn toggle_selected_details(&mut self) -> Result<(), String> {
437        let mut toggled = false;
438        for c in self.components.iter_mut() {
439            if let Component::TextComponent(comp) = c
440                && comp.is_focused()
441                && let TextNode::DetailsSummary { folded, .. } = comp.kind()
442            {
443                comp.set_details_folded(!folded);
444                toggled = true;
445                break;
446            }
447        }
448        if !toggled {
449            return Err("No details summary is focused".to_string());
450        }
451        self.recompute_visibility();
452        Ok(())
453    }
454}
455
456pub trait ComponentProps {
457    fn height(&self) -> u16;
458    fn set_y_offset(&mut self, y_offset: u16);
459    fn set_scroll_offset(&mut self, scroll: u16);
460    fn kind(&self) -> TextNode;
461}
462
463pub enum Component {
464    TextComponent(TextComponent),
465    Image(ImageComponent),
466}
467
468impl From<TextComponent> for Component {
469    fn from(comp: TextComponent) -> Self {
470        Component::TextComponent(comp)
471    }
472}
473
474impl ComponentProps for Component {
475    fn height(&self) -> u16 {
476        match self {
477            Component::TextComponent(comp) => comp.height(),
478            Component::Image(comp) => comp.height(),
479        }
480    }
481
482    fn set_y_offset(&mut self, y_offset: u16) {
483        match self {
484            Component::TextComponent(comp) => comp.set_y_offset(y_offset),
485            Component::Image(comp) => comp.set_y_offset(y_offset),
486        }
487    }
488
489    fn set_scroll_offset(&mut self, scroll: u16) {
490        match self {
491            Component::TextComponent(comp) => comp.set_scroll_offset(scroll),
492            Component::Image(comp) => comp.set_scroll_offset(scroll),
493        }
494    }
495
496    fn kind(&self) -> TextNode {
497        match self {
498            Component::TextComponent(comp) => comp.kind(),
499            Component::Image(comp) => comp.kind(),
500        }
501    }
502}