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