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