Skip to main content

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