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