Skip to main content

basalt_tui/
explorer.rs

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                    // Offset of 2 is used to move the area two rows down so that the original row is visible.
102                    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}