basalt_tui/
outline.rs

1use item::{Flatten, Item};
2pub use state::OutlineState;
3
4mod item;
5mod state;
6
7use ratatui::{
8    buffer::Buffer,
9    layout::{Alignment, Rect},
10    style::{Style, Stylize},
11    text::{Line, Span},
12    widgets::{Block, BorderType, Borders, List, ListItem, Padding, StatefulWidget},
13};
14
15use crate::{
16    app::{ActivePane, Message as AppMessage},
17    explorer,
18    note_editor::{self, markdown_parser::Node},
19};
20
21#[derive(Clone, Debug, PartialEq)]
22pub enum Message {
23    Up,
24    Down,
25    Select,
26    SelectAt(usize),
27    SetNodes(Vec<Node>),
28    Expand,
29    Toggle,
30    ToggleExplorer,
31    SwitchPaneNext,
32    SwitchPanePrevious,
33}
34
35pub fn update<'a>(message: &Message, state: &mut OutlineState) -> Option<AppMessage<'a>> {
36    match message {
37        Message::Up => state.previous(1),
38        Message::Down => state.next(1),
39        Message::Expand => state.toggle_item(),
40        Message::SelectAt(index) => state.select_at(*index),
41        Message::SetNodes(nodes) => state.set_nodes(nodes),
42
43        Message::SwitchPaneNext => {
44            state.set_active(false);
45            return Some(AppMessage::SetActivePane(ActivePane::Explorer));
46        }
47        Message::SwitchPanePrevious => {
48            state.set_active(false);
49            return Some(AppMessage::SetActivePane(ActivePane::NoteEditor));
50        }
51        Message::Toggle => state.toggle(),
52        Message::Select => {
53            if let Some(item) = state.selected() {
54                return Some(AppMessage::NoteEditor(note_editor::Message::SetRow(
55                    item.get_range().start,
56                )));
57            }
58        }
59        Message::ToggleExplorer => {
60            return Some(AppMessage::Explorer(explorer::Message::Toggle));
61        }
62    };
63
64    None
65}
66
67#[derive(Default)]
68pub struct Outline;
69
70trait AsListItems {
71    fn to_list_items(&self) -> Vec<ListItem<'_>>;
72    fn to_collapsed_items(&self) -> Vec<ListItem<'_>>;
73}
74
75impl AsListItems for Vec<Item> {
76    fn to_collapsed_items(&self) -> Vec<ListItem<'_>> {
77        self.flatten()
78            .iter()
79            .map(|item| match item {
80                Item::Heading { .. } => ListItem::new(Line::from("·")).dark_gray().dim(),
81                Item::HeadingEntry { expanded: true, .. } => {
82                    ListItem::new(Line::from("✺")).red().dim()
83                }
84                Item::HeadingEntry {
85                    expanded: false, ..
86                } => ListItem::new(Line::from("◦")).dark_gray().dim(),
87            })
88            .collect()
89    }
90
91    fn to_list_items(&self) -> Vec<ListItem<'_>> {
92        fn list_item<'a>(indentation: Span<'a>, symbol: &'a str, content: &'a str) -> ListItem<'a> {
93            ListItem::new(Line::from(
94                [indentation, symbol.into(), content.into()].to_vec(),
95            ))
96        }
97
98        fn to_list_items(depth: usize) -> impl Fn(&Item) -> Vec<ListItem> {
99            let indentation = if depth > 0 {
100                Span::raw("│ ".repeat(depth)).black()
101            } else {
102                Span::raw("  ".repeat(depth)).black()
103            };
104            move |item| match item {
105                Item::Heading { content, .. } => {
106                    vec![list_item(indentation.clone(), "  ", content)]
107                }
108                Item::HeadingEntry {
109                    expanded: true,
110                    children,
111                    content,
112                    ..
113                } => {
114                    let mut items = vec![list_item(indentation.clone(), "▾ ", content)];
115                    items.extend(children.iter().flat_map(to_list_items(depth + 1)));
116                    items
117                }
118                Item::HeadingEntry {
119                    expanded: false,
120                    content,
121                    ..
122                } => vec![list_item(indentation.clone(), "▸ ", content)],
123            }
124        }
125
126        self.iter().flat_map(to_list_items(0)).collect()
127    }
128}
129
130impl StatefulWidget for Outline {
131    type State = OutlineState;
132
133    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
134        let block = Block::bordered()
135            .border_type(if state.active {
136                BorderType::Thick
137            } else {
138                BorderType::Rounded
139            })
140            .title(if state.is_open() {
141                " ▶ Outline "
142            } else {
143                " ◀ "
144            })
145            .title_alignment(Alignment::Right)
146            .padding(Padding::horizontal(1))
147            .title_style(Style::default().italic().bold());
148
149        let items = if state.is_open() {
150            state.items.to_list_items()
151        } else {
152            state.items.to_collapsed_items()
153        };
154
155        List::new(items)
156            .block(if state.is_open() {
157                block
158            } else {
159                block.borders(Borders::RIGHT | Borders::TOP | Borders::BOTTOM)
160            })
161            .highlight_style(Style::default().reversed().dark_gray())
162            .highlight_symbol("")
163            .render(area, buf, &mut state.list_state);
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use crate::note_editor::markdown_parser;
170
171    use super::*;
172    use indoc::indoc;
173    use insta::assert_snapshot;
174    use ratatui::{backend::TestBackend, Terminal};
175
176    #[test]
177    fn test_outline_render() {
178        let tests = [
179            ("empty", markdown_parser::from_str("")),
180            ("single_level", markdown_parser::from_str("# Heading 1")),
181            (
182                "only_top_level",
183                markdown_parser::from_str(indoc! {r#"
184                # Heading 1
185                # Heading 2
186                # Heading 3
187                # Heading 4
188                # Heading 5
189                # Heading 6
190            "#}),
191            ),
192            (
193                "only_deep_level",
194                markdown_parser::from_str(indoc! {r#"
195                ###### Heading 1
196                ##### Heading 2
197                ###### Heading 2.1
198                ###### Heading 2.2
199                ##### Heading 3
200                ##### Heading 4
201                ###### Heading 4.1
202                ##### Heading 5
203            "#}),
204            ),
205            (
206                "sequential_all_levels",
207                markdown_parser::from_str(indoc! {r#"
208                # Heading 1
209                ## Heading 2
210                ### Heading 3
211                #### Heading 4
212                ##### Heading 5
213                ###### Heading 6
214            "#}),
215            ),
216            (
217                "complex_nested_structure",
218                markdown_parser::from_str(indoc! {r#"
219                ## Heading 1
220                ## Heading 2
221                ### Heading 2.1
222                #### Heading 2.1.1
223                ### Heading 2.2
224                #### Heading 2.2.1
225                ## Heading 3
226                ###### Heading 3.1.1.1.1.1
227            "#}),
228            ),
229            (
230                "irregular_nesting_with_skips",
231                markdown_parser::from_str(indoc! {r#"
232                # Heading 1
233                ## Heading 2
234                ## Heading 2.1
235                #### Heading 2.1.1
236                #### Heading 2.1.2
237                ## Heading 2.2
238                ### Heading 3
239            "#}),
240            ),
241            (
242                "level_skipping",
243                markdown_parser::from_str(indoc! {r#"
244                # Level 1
245                ### Level 3 (skipped 2)
246                ##### Level 5 (skipped 4)
247                ## Level 2 (back to 2)
248                ###### Level 6 (jump to 6)
249            "#}),
250            ),
251            (
252                "reverse_hierarchy",
253                markdown_parser::from_str(indoc! {r#"
254                ###### Level 6
255                ##### Level 5
256                #### Level 4
257                ### Level 3
258                ## Level 2
259                # Level 1
260            "#}),
261            ),
262            (
263                "multiple_root_levels",
264                markdown_parser::from_str(indoc! {r#"
265                # Root 1
266                ## Child 1.1
267                ### Child 1.1.1
268
269                ## Root 2 (different level)
270                #### Child 2.1 (skipped level 3)
271
272                ### Root 3 (different level)
273                ###### Child 3.1 (deep skip)
274            "#}),
275            ),
276            (
277                "duplicate_headings",
278                markdown_parser::from_str(indoc! {r#"
279                # Duplicate
280                ## Child
281                # Duplicate
282                ## Different Child
283                # Duplicate
284            "#}),
285            ),
286            (
287                "mixed_with_content",
288                markdown_parser::from_str(indoc! {r#"
289                # Chapter 1
290                Some paragraph content here.
291
292                ## Section 1.1
293                More content.
294
295                - List item
296                - Another item
297
298                ### Subsection 1.1.1
299                Final content.
300            "#}),
301            ),
302            (
303                "boundary_conditions_systematic",
304                markdown_parser::from_str(indoc! {r#"
305                # A
306                ## B
307                ### C
308                #### D
309                ##### E
310                ###### F
311                ##### E2
312                #### D2
313                ### C2
314                ## B2
315                # A2
316            "#}),
317            ),
318        ];
319
320        let mut terminal = Terminal::new(TestBackend::new(30, 10)).unwrap();
321
322        tests.into_iter().for_each(|(name, nodes)| {
323            _ = terminal.clear();
324            let mut state = OutlineState::new(&nodes, 0, true);
325            state.expand_all();
326            terminal
327                .draw(|frame| Outline.render(frame.area(), frame.buffer_mut(), &mut state))
328                .unwrap();
329            assert_snapshot!(name, terminal.backend());
330        });
331    }
332}