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