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, Theme},
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, theme: &Theme) -> Vec<ListItem<'a>>;
75    fn to_collapsed_items<'a>(&'a self, symbols: &'a Symbols, theme: &Theme) -> Vec<ListItem<'a>>;
76}
77
78impl AsListItems for Vec<Item> {
79    fn to_collapsed_items<'a>(&'a self, symbols: &'a Symbols, theme: &Theme) -> 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                        .fg(theme.muted)
86                        .dim()
87                }
88                Item::HeadingEntry { expanded: true, .. } => {
89                    ListItem::new(Line::from(symbols.outline_heading_expanded.as_str()))
90                        .fg(theme.error)
91                        .dim()
92                }
93                Item::HeadingEntry {
94                    expanded: false, ..
95                } => ListItem::new(Line::from(symbols.outline_heading_collapsed.as_str()))
96                    .fg(theme.muted)
97                    .dim(),
98            })
99            .collect()
100    }
101
102    fn to_list_items<'a>(&'a self, symbols: &'a Symbols, theme: &Theme) -> Vec<ListItem<'a>> {
103        fn list_item<'a>(
104            indentation: Span<'a>,
105            symbol: Span<'a>,
106            content: Span<'a>,
107        ) -> ListItem<'a> {
108            ListItem::new(Line::from([indentation, symbol, content].to_vec()))
109        }
110
111        fn to_list_items_inner<'a>(
112            depth: usize,
113            symbols: &'a Symbols,
114            theme: Theme,
115        ) -> impl Fn(&'a Item) -> Vec<ListItem<'a>> {
116            let muted = theme.muted;
117            let text = theme.text;
118            let indentation = if depth > 0 {
119                Span::raw(format!("{} ", symbols.outline_indent).repeat(depth)).fg(muted)
120            } else {
121                Span::raw("  ".repeat(depth))
122            };
123            let expanded_marker = Span::from(format!("{} ", symbols.outline_expanded)).fg(muted);
124            let collapsed_marker = Span::from(format!("{} ", symbols.outline_collapsed)).fg(muted);
125            move |item| match item {
126                Item::Heading { content, .. } => vec![list_item(
127                    indentation.clone(),
128                    "  ".into(),
129                    Span::raw(content).fg(text),
130                )],
131                Item::HeadingEntry {
132                    expanded: true,
133                    children,
134                    content,
135                    ..
136                } => {
137                    let mut items = vec![list_item(
138                        indentation.clone(),
139                        expanded_marker.clone(),
140                        Span::raw(content).fg(text),
141                    )];
142                    items.extend(children.iter().flat_map(to_list_items_inner(
143                        depth + 1,
144                        symbols,
145                        theme,
146                    )));
147                    items
148                }
149                Item::HeadingEntry {
150                    expanded: false,
151                    content,
152                    ..
153                } => vec![list_item(
154                    indentation.clone(),
155                    collapsed_marker.clone(),
156                    Span::raw(content).fg(text),
157                )],
158            }
159        }
160
161        self.iter()
162            .flat_map(to_list_items_inner(0, symbols, *theme))
163            .collect()
164    }
165}
166
167impl StatefulWidget for Outline {
168    type State = OutlineState;
169
170    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
171        let active = state.active;
172        let pane = state.theme.outline;
173        let fallback = if active {
174            state.symbols.border_active
175        } else {
176            state.symbols.border_inactive
177        }
178        .into();
179        let border_line = pane.border_line(fallback);
180
181        let border_set = match (border_line.is_some(), state.is_open()) {
182            (false, _) => Borders::NONE,
183            (true, true) => pane.border_edges.to_borders(),
184            (true, false) => {
185                pane.collapsed_borders(Borders::RIGHT | Borders::TOP | Borders::BOTTOM)
186            }
187        };
188
189        let mut block = Block::new()
190            .borders(border_set)
191            .style(Style::new().fg(state.theme.text).bg(pane.background))
192            .border_style(Style::new().fg(pane.border(active)))
193            .title(if state.is_open() {
194                format!(" {} Outline ", state.symbols.pane_open)
195            } else {
196                format!(" {} ", state.symbols.pane_close)
197            })
198            .title_alignment(Alignment::Right)
199            .padding(Padding::horizontal(1))
200            .title_style(Style::default().italic().bold());
201        if let Some(line) = border_line {
202            block = block.border_type(line);
203        }
204
205        let items = if state.is_open() {
206            state.items.to_list_items(&state.symbols, &state.theme)
207        } else {
208            state.items.to_collapsed_items(&state.symbols, &state.theme)
209        };
210
211        List::new(items)
212            .block(block)
213            .highlight_style(Style::default().reversed().fg(state.theme.muted))
214            .highlight_symbol("")
215            .render(area, buf, &mut state.list_state);
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use crate::note_editor::parser;
222
223    use super::*;
224    use indoc::indoc;
225    use insta::assert_snapshot;
226    use ratatui::{backend::TestBackend, Terminal};
227
228    #[test]
229    fn test_outline_render() {
230        let tests = [
231            ("empty", parser::from_str("")),
232            ("single_level", parser::from_str("# Heading 1")),
233            (
234                "only_top_level",
235                parser::from_str(indoc! {r#"
236                # Heading 1
237                # Heading 2
238                # Heading 3
239                # Heading 4
240                # Heading 5
241                # Heading 6
242            "#}),
243            ),
244            (
245                "only_deep_level",
246                parser::from_str(indoc! {r#"
247                ###### Heading 1
248                ##### Heading 2
249                ###### Heading 2.1
250                ###### Heading 2.2
251                ##### Heading 3
252                ##### Heading 4
253                ###### Heading 4.1
254                ##### Heading 5
255            "#}),
256            ),
257            (
258                "sequential_all_levels",
259                parser::from_str(indoc! {r#"
260                # Heading 1
261                ## Heading 2
262                ### Heading 3
263                #### Heading 4
264                ##### Heading 5
265                ###### Heading 6
266            "#}),
267            ),
268            (
269                "complex_nested_structure",
270                parser::from_str(indoc! {r#"
271                ## Heading 1
272                ## Heading 2
273                ### Heading 2.1
274                #### Heading 2.1.1
275                ### Heading 2.2
276                #### Heading 2.2.1
277                ## Heading 3
278                ###### Heading 3.1.1.1.1.1
279            "#}),
280            ),
281            (
282                "irregular_nesting_with_skips",
283                parser::from_str(indoc! {r#"
284                # Heading 1
285                ## Heading 2
286                ## Heading 2.1
287                #### Heading 2.1.1
288                #### Heading 2.1.2
289                ## Heading 2.2
290                ### Heading 3
291            "#}),
292            ),
293            (
294                "level_skipping",
295                parser::from_str(indoc! {r#"
296                # Level 1
297                ### Level 3 (skipped 2)
298                ##### Level 5 (skipped 4)
299                ## Level 2 (back to 2)
300                ###### Level 6 (jump to 6)
301            "#}),
302            ),
303            (
304                "reverse_hierarchy",
305                parser::from_str(indoc! {r#"
306                ###### Level 6
307                ##### Level 5
308                #### Level 4
309                ### Level 3
310                ## Level 2
311                # Level 1
312            "#}),
313            ),
314            (
315                "multiple_root_levels",
316                parser::from_str(indoc! {r#"
317                # Root 1
318                ## Child 1.1
319                ### Child 1.1.1
320
321                ## Root 2 (different level)
322                #### Child 2.1 (skipped level 3)
323
324                ### Root 3 (different level)
325                ###### Child 3.1 (deep skip)
326            "#}),
327            ),
328            (
329                "duplicate_headings",
330                parser::from_str(indoc! {r#"
331                # Duplicate
332                ## Child
333                # Duplicate
334                ## Different Child
335                # Duplicate
336            "#}),
337            ),
338            (
339                "mixed_with_content",
340                parser::from_str(indoc! {r#"
341                # Chapter 1
342                Some paragraph content here.
343
344                ## Section 1.1
345                More content.
346
347                - List item
348                - Another item
349
350                ### Subsection 1.1.1
351                Final content.
352            "#}),
353            ),
354            (
355                "boundary_conditions_systematic",
356                parser::from_str(indoc! {r#"
357                # A
358                ## B
359                ### C
360                #### D
361                ##### E
362                ###### F
363                ##### E2
364                #### D2
365                ### C2
366                ## B2
367                # A2
368            "#}),
369            ),
370        ];
371
372        let mut terminal = Terminal::new(TestBackend::new(30, 10)).unwrap();
373
374        tests.into_iter().for_each(|(name, nodes)| {
375            _ = terminal.clear();
376            let mut state = OutlineState::new(&nodes, 0, true, &Symbols::unicode());
377            state.expand_all();
378            terminal
379                .draw(|frame| Outline.render(frame.area(), frame.buffer_mut(), &mut state))
380                .unwrap();
381            assert_snapshot!(name, terminal.backend());
382        });
383    }
384}