1mod item;
2mod state;
3
4pub use item::Item;
5use ratatui::layout::Size;
6use ratatui::widgets::Borders;
7pub use state::ExplorerState;
8pub use state::Sort;
9pub use state::Visibility;
10
11use std::{marker::PhantomData, path::PathBuf};
12
13use basalt_core::obsidian::Note;
14use ratatui::{
15 buffer::Buffer,
16 layout::{Alignment, Constraint, Layout, Rect},
17 style::{Style, Stylize},
18 text::{Line, Span},
19 widgets::{Block, BorderType, List, ListItem, StatefulWidget},
20};
21
22use crate::app::{
23 calc_scroll_amount, ActivePane, Message as AppMessage, ScrollAmount, SelectedNote,
24};
25use crate::outline;
26
27const SORT_SYMBOL_ASC: &str = "↑𝌆";
28const SORT_SYMBOL_DESC: &str = "↓𝌆";
29
30#[derive(Clone, Debug, PartialEq)]
31pub enum Message {
32 Up,
33 Down,
34 Open,
35 Sort,
36 Toggle,
37 ToggleOutline,
38 HidePane,
39 ExpandPane,
40 SwitchPaneNext,
41 SwitchPanePrevious,
42 ScrollUp(ScrollAmount),
43 ScrollDown(ScrollAmount),
44}
45
46pub fn update<'a>(
47 message: &Message,
48 screen_size: Size,
49 state: &mut ExplorerState,
50) -> Option<AppMessage<'a>> {
51 match message {
52 Message::Up => state.previous(1),
53 Message::Down => state.next(1),
54 Message::Sort => state.sort(),
55 Message::Toggle => state.toggle(),
56 Message::HidePane => state.hide_pane(),
57 Message::ExpandPane => state.expand_pane(),
58 Message::SwitchPaneNext => {
59 state.set_active(false);
60 return Some(AppMessage::SetActivePane(ActivePane::NoteEditor));
61 }
62 Message::SwitchPanePrevious => {
63 state.set_active(false);
64 return Some(AppMessage::SetActivePane(ActivePane::Outline));
65 }
66 Message::ScrollUp(scroll_amount) => {
67 state.previous(calc_scroll_amount(scroll_amount, screen_size.height.into()));
68 }
69 Message::ScrollDown(scroll_amount) => {
70 state.next(calc_scroll_amount(scroll_amount, screen_size.height.into()));
71 }
72 Message::ToggleOutline => {
73 return Some(AppMessage::Outline(outline::Message::Toggle));
74 }
75 Message::Open => {
76 state.select();
77 let note = state.selected_note.as_ref()?;
78 return Some(AppMessage::SelectNote(SelectedNote::from(note)));
79 }
80 };
81
82 None
83}
84
85#[derive(Default)]
86pub struct Explorer<'a> {
87 _lifetime: PhantomData<&'a ()>,
88}
89
90impl Explorer<'_> {
91 pub fn new() -> Self {
92 Self {
93 _lifetime: PhantomData::<&()>,
94 }
95 }
96
97 fn list_item<'a>(
98 selected_path: Option<PathBuf>,
99 is_open: bool,
100 ) -> impl Fn(&'a (Item, usize)) -> ListItem<'a> {
101 move |(item, depth)| {
102 let indentation = if *depth > 0 {
103 Span::raw("│ ".repeat(*depth)).black()
104 } else {
105 Span::raw(" ".repeat(*depth)).black()
106 };
107 match item {
108 Item::File(Note { path, name }) => {
109 let is_selected = selected_path
110 .as_ref()
111 .is_some_and(|selected| selected == path);
112 ListItem::new(Line::from(match (is_open, is_selected) {
113 (true, true) => [indentation, "◆ ".into(), name.into()].to_vec(),
114 (true, false) => [indentation, " ".into(), name.into()].to_vec(),
115 (false, true) => ["◆".into()].to_vec(),
116 (false, false) => ["◦".dark_gray()].to_vec(),
117 }))
118 }
119 Item::Directory { expanded, name, .. } => {
120 ListItem::new(Line::from(match (is_open, expanded) {
121 (true, true) => [indentation, "▾ ".dark_gray(), name.into()].to_vec(),
122 (true, false) => [indentation, "▸ ".dark_gray(), name.into()].to_vec(),
123 (false, true) => ["▪".dark_gray()].to_vec(),
124 (false, false) => ["▫".dark_gray()].to_vec(),
125 }))
126 }
127 }
128 }
129 }
130}
131
132impl<'a> StatefulWidget for Explorer<'a> {
133 type State = ExplorerState<'a>;
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_style(Style::default().italic().bold());
143
144 let Rect { height, .. } = block.inner(area);
145 state.update_offset_mut(height.into());
146
147 let sort_symbol = match state.sort {
148 Sort::Asc => SORT_SYMBOL_ASC,
149 Sort::Desc => SORT_SYMBOL_DESC,
150 };
151
152 let items: Vec<ListItem> = state
153 .flat_items
154 .iter()
155 .map(Explorer::list_item(state.selected_path(), state.is_open()))
156 .collect();
157
158 if state.is_open() {
159 List::new(items)
160 .block(
161 block
162 .title(format!(
163 "{} {} ",
164 if state.visibility == Visibility::FullWidth {
165 " ⟹ "
166 } else {
167 ""
168 },
169 state.title
170 ))
171 .title(
172 Line::from(vec![" ".into(), sort_symbol.into(), " ◀ ".into()])
173 .alignment(Alignment::Right),
174 ),
175 )
176 .highlight_style(Style::new().reversed().dark_gray())
177 .highlight_symbol(" ")
178 .render(area, buf, &mut state.list_state);
179 } else {
180 let layout = Layout::horizontal([Constraint::Length(5)]).split(area);
181
182 List::new(items)
183 .block(
184 block
185 .title(" ▶ ")
186 .borders(Borders::LEFT | Borders::TOP | Borders::BOTTOM),
187 )
188 .highlight_style(Style::new().reversed().dark_gray())
189 .highlight_symbol(" ")
190 .render(layout[0], buf, &mut state.list_state);
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use basalt_core::obsidian::VaultEntry;
199 use insta::assert_snapshot;
200 use ratatui::{backend::TestBackend, Terminal};
201
202 #[test]
203 fn test_render_entries() {
204 let tests = [
205 [].to_vec(),
206 [
207 VaultEntry::File(Note {
208 name: "Test".into(),
209 path: "test.md".into(),
210 }),
211 VaultEntry::File(Note {
212 name: "Andesite".into(),
213 path: "andesite.md".into(),
214 }),
215 ]
216 .to_vec(),
217 [VaultEntry::Directory {
218 name: "TestDir".into(),
219 path: "test_dir".into(),
220 entries: vec![],
221 }]
222 .to_vec(),
223 [VaultEntry::Directory {
224 name: "TestDir".into(),
225 path: "test_dir".into(),
226 entries: vec![
227 VaultEntry::File(Note {
228 name: "Andesite".into(),
229 path: "test_dir/andesite.md".into(),
230 }),
231 VaultEntry::Directory {
232 name: "Notes".into(),
233 path: "test_dir/notes".into(),
234 entries: vec![VaultEntry::File(Note {
235 name: "Pathing".into(),
236 path: "test_dir/notes/pathing.md".into(),
237 })],
238 },
239 VaultEntry::Directory {
240 name: "Amber Specs".into(),
241 path: "test_dir/amber_specs".into(),
242 entries: vec![VaultEntry::File(Note {
243 name: "Spec_01".into(),
244 path: "test_dir/amber_specs/spec_01.md".into(),
245 })],
246 },
247 ],
248 }]
249 .to_vec(),
250 ];
251
252 let mut terminal = Terminal::new(TestBackend::new(30, 10)).unwrap();
253
254 tests.into_iter().for_each(|items| {
255 _ = terminal.clear();
256 let mut state = ExplorerState::new("Test", items);
257 state.select();
258 state.sort();
259
260 terminal
261 .draw(|frame| {
262 Explorer::default().render(frame.area(), frame.buffer_mut(), &mut state)
263 })
264 .unwrap();
265 assert_snapshot!(terminal.backend());
266 });
267 }
268}