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 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}