basalt_tui/
explorer.rs

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