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