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