1mod item;
2mod state;
3
4use basalt_core::obsidian::directory::Directory;
5pub use item::Item;
6use ratatui::layout::Position;
7use ratatui::layout::Size;
8use ratatui::widgets::Borders;
9pub use state::ExplorerState;
10pub use state::Sort;
11pub use state::Visibility;
12
13use std::{marker::PhantomData, path::PathBuf};
14
15use ratatui::{
16 buffer::Buffer,
17 layout::{Alignment, Constraint, Layout, Rect},
18 style::{Style, Stylize},
19 text::{Line, Span},
20 widgets::{Block, List, ListItem, StatefulWidget},
21};
22
23use crate::app::{
24 calc_scroll_amount, ActivePane, Message as AppMessage, ScrollAmount, SelectedNote,
25};
26use crate::config::Symbols;
27use crate::input;
28use crate::input::InputModalConfig;
29use crate::outline;
30
31#[derive(Clone, Debug, PartialEq)]
32pub enum Message {
33 Up,
34 Down,
35 Open,
36 Sort,
37 Toggle,
38 ToggleOutline,
39 ToggleInputRename,
40 HidePane,
41 ExpandPane,
42 SwitchPaneNext,
43 SwitchPanePrevious,
44 ScrollUp(ScrollAmount),
45 ScrollDown(ScrollAmount),
46 ScrollToTop,
47 ScrollToBottom,
48}
49
50pub fn update<'a>(
51 message: &Message,
52 screen_size: Size,
53 state: &mut ExplorerState,
54) -> Option<AppMessage<'a>> {
55 match message {
56 Message::Up => state.previous(1),
57 Message::Down => state.next(1),
58 Message::Sort => state.sort(),
59 Message::Toggle => state.toggle(),
60 Message::HidePane => state.hide_pane(),
61 Message::ExpandPane => state.expand_pane(),
62 Message::SwitchPaneNext => {
63 state.set_active(false);
64 return Some(AppMessage::SetActivePane(ActivePane::NoteEditor));
65 }
66 Message::SwitchPanePrevious => {
67 state.set_active(false);
68 return Some(AppMessage::SetActivePane(ActivePane::Outline));
69 }
70 Message::ScrollUp(scroll_amount) => {
71 state.previous(calc_scroll_amount(scroll_amount, screen_size.height.into()));
72 }
73 Message::ScrollDown(scroll_amount) => {
74 state.next(calc_scroll_amount(scroll_amount, screen_size.height.into()));
75 }
76 Message::ScrollToTop => {
77 state.previous(usize::MAX);
78 }
79 Message::ScrollToBottom => {
80 state.next(usize::MAX);
81 }
82 Message::ToggleOutline => {
83 return Some(AppMessage::Outline(outline::Message::Toggle));
84 }
85 Message::ToggleInputRename => {
86 if let Some(current_item) = state.current_item() {
87 let selected_index = state.list_state.selected().unwrap_or(0);
88 let (label, input, callback) = match current_item {
89 Item::File(note) => {
90 let input = note.name();
91 ("Rename", input, input::Callback::RenameNote(note.clone()))
92 }
93 Item::Directory { name, path, .. } => (
94 "Rename Directory",
95 name.as_str(),
96 input::Callback::RenameDir(Directory::new(name, path)),
97 ),
98 };
99 return Some(AppMessage::Input(input::Message::Open(InputModalConfig {
100 position: Position::from((
102 2,
103 (selected_index + 2).saturating_sub(state.list_state.offset()) as u16,
104 )),
105 label: label.to_string(),
106 initial_input: input.to_string(),
107 callback,
108 })));
109 }
110 }
111 Message::Open => {
112 state.select();
113 let note = state.selected_note.as_ref()?;
114 return Some(AppMessage::SelectNote(SelectedNote::from(note)));
115 }
116 };
117
118 None
119}
120
121#[derive(Default)]
122pub struct Explorer<'a> {
123 _lifetime: PhantomData<&'a ()>,
124}
125
126impl Explorer<'_> {
127 pub fn new() -> Self {
128 Self {
129 _lifetime: PhantomData::<&()>,
130 }
131 }
132
133 fn list_item<'a>(
134 symbols: &'a Symbols,
135 selected_path: Option<PathBuf>,
136 is_open: bool,
137 ) -> impl Fn(&'a (Item, usize)) -> ListItem<'a> {
138 move |(item, depth)| {
139 let indentation = if *depth > 0 {
140 Span::raw(format!("{} ", symbols.tree_indent).repeat(*depth)).black()
141 } else {
142 Span::raw(" ".repeat(*depth)).black()
143 };
144 match item {
145 Item::File(note) => {
146 let name = note.name();
147 let path = note.path();
148
149 let is_selected = selected_path
150 .as_ref()
151 .is_some_and(|selected| selected == path);
152 ListItem::new(Line::from(match (is_open, is_selected) {
153 (true, true) => [
154 indentation,
155 format!("{} ", symbols.selected).into(),
156 name.bold().underlined(),
157 ]
158 .to_vec(),
159 (true, false) => [
160 indentation,
161 format!("{} ", symbols.unselected).dark_gray(),
162 name.into(),
163 ]
164 .to_vec(),
165 (false, true) => [symbols.selected.clone().into()].to_vec(),
166 (false, false) => [symbols.unselected.clone().dark_gray()].to_vec(),
167 }))
168 }
169 Item::Directory { expanded, name, .. } => {
170 ListItem::new(Line::from(match (is_open, expanded) {
171 (true, true) => [
172 indentation,
173 format!("{} ", symbols.tree_expanded).dark_gray(),
174 name.into(),
175 ]
176 .to_vec(),
177 (true, false) => [
178 indentation,
179 format!("{} ", symbols.tree_collapsed).dark_gray(),
180 name.into(),
181 ]
182 .to_vec(),
183 (false, true) => {
184 [symbols.folder_expanded_collapsed.clone().dark_gray()].to_vec()
185 }
186 (false, false) => {
187 [symbols.folder_collapsed_collapsed.clone().dark_gray()].to_vec()
188 }
189 }))
190 }
191 }
192 }
193 }
194}
195
196impl<'a> StatefulWidget for Explorer<'a> {
197 type State = ExplorerState;
198
199 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
200 let block = Block::bordered()
201 .border_type(if state.active {
202 state.symbols.border_active.into()
203 } else {
204 state.symbols.border_inactive.into()
205 })
206 .title_style(Style::default().italic().bold());
207
208 let Rect { height, .. } = block.inner(area);
209 state.update_offset_mut(height.into());
210
211 let sort_symbol = match state.sort {
212 Sort::Asc => &state.symbols.sort_asc,
213 Sort::Desc => &state.symbols.sort_desc,
214 };
215
216 let items: Vec<ListItem> = state
217 .flat_items
218 .iter()
219 .map(Self::list_item(
220 &state.symbols,
221 state.selected_path(),
222 state.is_open(),
223 ))
224 .collect();
225
226 if state.is_open() {
227 List::new(items)
228 .block(
229 block
230 .title(format!(
231 "{} {} ",
232 if state.visibility == Visibility::FullWidth {
233 format!(" {} ", state.symbols.pane_full)
234 } else {
235 String::default()
236 },
237 state.title
238 ))
239 .title(
240 Line::from(vec![
241 " ".into(),
242 sort_symbol.into(),
243 format!(" {} ", state.symbols.pane_close).into(),
244 ])
245 .alignment(Alignment::Right),
246 ),
247 )
248 .highlight_style(Style::new().reversed().dark_gray())
249 .highlight_symbol(" ")
250 .render(area, buf, &mut state.list_state);
251 } else {
252 let layout = Layout::horizontal([Constraint::Length(5)]).split(area);
253
254 List::new(items)
255 .block(
256 block
257 .title(format!(" {} ", state.symbols.pane_open))
258 .borders(Borders::LEFT | Borders::TOP | Borders::BOTTOM),
259 )
260 .highlight_style(Style::new().reversed().dark_gray())
261 .highlight_symbol(" ")
262 .render(layout[0], buf, &mut state.list_state);
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use std::path::Path;
270
271 use super::*;
272 use basalt_core::obsidian::{Note, VaultEntry};
273 use insta::assert_snapshot;
274 use ratatui::{backend::TestBackend, Terminal};
275
276 #[test]
277 fn test_toggle_input_rename_position_accounts_for_scroll_offset() {
278 let items: Vec<VaultEntry> = (0..30)
279 .map(|i| {
280 VaultEntry::File(Note::new_unchecked(
281 &format!("Note_{i}"),
282 Path::new(&format!("Note_{i}.md")),
283 ))
284 })
285 .collect();
286
287 let mut state = ExplorerState::new("Test", items, &Symbols::unicode());
288 state.next(25);
289
290 let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
291 terminal
292 .draw(|frame| Explorer::default().render(frame.area(), frame.buffer_mut(), &mut state))
293 .unwrap();
294
295 let offset = state.list_state.offset();
296 assert!(offset > 0, "offset should be non-zero after scrolling");
297
298 let selected = state.list_state.selected().unwrap();
299 let result = update(&Message::ToggleInputRename, Size::new(80, 20), &mut state);
300 let expected_y = (selected + 2).saturating_sub(offset) as u16;
301
302 match result {
303 Some(AppMessage::Input(input::Message::Open(config))) => {
304 assert_eq!(config.position, Position::from((2, expected_y)));
305 }
306 other => panic!("Expected AppMessage::Input(Open(..)), got: {other:?}"),
307 }
308 }
309
310 #[test]
311 fn test_render_entries() {
312 let tests = [
313 [].to_vec(),
314 [
315 VaultEntry::File(Note::new_unchecked("Test", Path::new("Test.md"))),
316 VaultEntry::File(Note::new_unchecked("Andesite", Path::new("Andesite.md"))),
317 ]
318 .to_vec(),
319 [VaultEntry::Directory {
320 name: "TestDir".into(),
321 path: "TestDir".into(),
322 entries: vec![],
323 }]
324 .to_vec(),
325 [VaultEntry::Directory {
326 name: "TestDir".into(),
327 path: "TestDir".into(),
328 entries: vec![
329 VaultEntry::File(Note::new_unchecked("Andesite", Path::new("Andesite.md"))),
330 VaultEntry::Directory {
331 name: "Notes".into(),
332 path: "TestDir/Notes".into(),
333 entries: vec![VaultEntry::File(Note::new_unchecked(
334 "Pathing",
335 Path::new("TestDir/Notes/Pathing.md"),
336 ))],
337 },
338 VaultEntry::Directory {
339 name: "Amber Specs".into(),
340 path: "TestDir/Amber Specs".into(),
341 entries: vec![VaultEntry::File(Note::new_unchecked(
342 "Spec_01",
343 Path::new("TestDir/Amber Specs/Spec_01.md"),
344 ))],
345 },
346 ],
347 }]
348 .to_vec(),
349 ];
350
351 let mut terminal = Terminal::new(TestBackend::new(30, 10)).unwrap();
352
353 tests.into_iter().for_each(|items| {
354 _ = terminal.clear();
355 let mut state = ExplorerState::new("Test", items, &Symbols::unicode());
356 state.select();
357 state.sort();
358
359 terminal
360 .draw(|frame| {
361 Explorer::default().render(frame.area(), frame.buffer_mut(), &mut state)
362 })
363 .unwrap();
364 assert_snapshot!(terminal.backend());
365 });
366 }
367}