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