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