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