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