Skip to main content

basalt_tui/explorer/
state.rs

1use std::{
2    cmp::Ordering,
3    path::{Path, PathBuf},
4};
5
6use basalt_core::obsidian::{Note, VaultEntry};
7use ratatui::widgets::ListState;
8
9use crate::config::{Symbols, Theme};
10
11use super::Item;
12
13#[derive(Debug, Default, Copy, Clone, PartialEq)]
14pub enum Sort {
15    #[default]
16    Asc,
17    Desc,
18}
19
20#[derive(Debug, Default, Copy, Clone, PartialEq)]
21pub enum Visibility {
22    Hidden,
23    #[default]
24    Visible,
25    FullWidth,
26}
27
28#[derive(Debug, Default, Clone, PartialEq)]
29pub struct ExplorerState {
30    pub(crate) title: String,
31    pub(crate) selected_note: Option<Note>,
32    pub(crate) selected_item_index: Option<usize>,
33    pub(crate) selected_item_path: Option<PathBuf>,
34    pub(crate) items: Vec<Item>,
35    pub(crate) flat_items: Vec<(Item, usize)>,
36    pub(crate) visibility: Visibility,
37    pub(crate) active: bool,
38    pub(crate) sort: Sort,
39    pub(crate) list_state: ListState,
40
41    pub(crate) symbols: Symbols,
42    pub(crate) theme: Theme,
43
44    pub(crate) editing: bool,
45}
46
47impl ExplorerState {
48    pub fn set_theme(&mut self, theme: &Theme) {
49        self.theme = *theme;
50    }
51}
52
53/// Calculates the vertical offset of list items in rows.
54///
55/// When the selected item is near the end of the list and there aren't enough items
56/// remaining to keep the selection vertically centered, we shift the offset to show
57/// as many trailing items as possible instead of centering the selection.
58///
59/// This prevents empty lines from appearing at the bottom of the list when the
60/// selection moves toward the end.
61///
62/// Without this check, you'd see output like:
63/// ╭────────╮
64/// │ 3 item │
65/// │>4 item │
66/// │ 5 item │
67/// │        │
68/// ╰────────╯
69///
70/// With this check, the list scrolls up to fill the remaining space:
71/// ╭────────╮
72/// │ 2 item │
73/// │ 3 item │
74/// │>4 item │
75/// │ 5 item │
76/// ╰────────╯
77///
78/// The goal is to avoid showing unnecessary blank rows and to maximize visible items.
79fn calculate_offset(row: usize, items_count: usize, window_height: usize) -> usize {
80    let half = window_height / 2;
81
82    if row + half > items_count.saturating_sub(1) {
83        items_count.saturating_sub(window_height)
84    } else {
85        row.saturating_sub(half)
86    }
87}
88
89pub fn flatten(sort: Sort, depth: usize) -> impl Fn(&Item) -> Vec<(Item, usize)> {
90    move |item| match item {
91        Item::File { .. } => vec![(item.clone(), depth)],
92        Item::Directory {
93            expanded: true,
94            items,
95            ..
96        } => [(item.clone(), depth)]
97            .into_iter()
98            .chain({
99                let mut items = items.clone();
100                items.sort_by(sort_items_by(sort));
101                items
102                    .iter()
103                    .flat_map(flatten(sort, depth + 1))
104                    .collect::<Vec<_>>()
105            })
106            .collect(),
107        Item::Directory {
108            expanded: false, ..
109        } => [(item.clone(), depth)].to_vec(),
110    }
111}
112
113fn sort_items_by(sort: Sort) -> impl Fn(&Item, &Item) -> Ordering {
114    move |a, b| match (a.is_dir(), b.is_dir()) {
115        (true, false) => Ordering::Less,
116        (false, true) => Ordering::Greater,
117        (true, true) => natord::compare(a.name(), b.name()),
118        _ => {
119            let a = a.name().to_lowercase();
120            let b = b.name().to_lowercase();
121            match sort {
122                Sort::Asc => natord::compare(&a, &b),
123                Sort::Desc => natord::compare(&b, &a),
124            }
125        }
126    }
127}
128
129impl ExplorerState {
130    pub fn new(title: &str, items: Vec<VaultEntry>, symbols: &Symbols) -> Self {
131        let items: Vec<Item> = items.into_iter().map(|entry| entry.into()).collect();
132        let sort = Sort::default();
133
134        let mut state = ExplorerState {
135            title: title.to_string(),
136            sort,
137            active: true,
138            visibility: Visibility::Visible,
139            selected_item_index: None,
140            selected_item_path: None,
141            selected_note: None,
142            symbols: symbols.clone(),
143            list_state: ListState::default().with_selected(Some(0)),
144            ..Default::default()
145        };
146
147        state.flatten_with_items(&items);
148        state
149    }
150
151    pub fn set_active(&mut self, active: bool) {
152        self.active = active;
153    }
154
155    fn map_to_item(&self, depth: usize, entry: VaultEntry) -> Item {
156        match entry {
157            VaultEntry::Directory {
158                name,
159                path,
160                entries,
161            } => {
162                let expanded = self
163                    .flat_items
164                    .iter()
165                    .find_map(|(item, _)| match item {
166                        Item::Directory {
167                            path: item_path,
168                            expanded,
169                            ..
170                        } if &path == item_path => Some(*expanded),
171                        _ => None,
172                    })
173                    .unwrap_or(false);
174
175                Item::Directory {
176                    name,
177                    path,
178                    expanded,
179                    depth,
180                    items: entries
181                        .into_iter()
182                        .map(|entry| self.map_to_item(depth + 1, entry))
183                        .collect(),
184                }
185            }
186            VaultEntry::File(note) => Item::File { note, depth },
187        }
188    }
189
190    pub fn with_entries(&mut self, entries: Vec<VaultEntry>, select: Option<PathBuf>) {
191        self.rebuild(entries);
192
193        if let Some(index) = select.as_deref().and_then(|path| self.index_of(path)) {
194            self.list_state.select(Some(index));
195            self.selected_item_index = Some(index);
196            self.selected_item_path = select;
197        }
198    }
199
200    /// Rebuilds the tree from a background rescan, preserving the cursor and the
201    /// picked note independently so an async vault change snaps neither one.
202    pub fn refresh_entries(&mut self, entries: Vec<VaultEntry>) {
203        let cursor = self.current_item().map(|item| item.path().to_path_buf());
204        let picked = self.selected_item_path.clone();
205
206        self.rebuild(entries);
207
208        if let Some(index) = cursor.as_deref().and_then(|path| self.index_of(path)) {
209            self.list_state.select(Some(index));
210        }
211        self.selected_item_index = picked.as_deref().and_then(|path| self.index_of(path));
212        self.selected_item_path = picked;
213    }
214
215    fn rebuild(&mut self, entries: Vec<VaultEntry>) {
216        let items: Vec<Item> = entries
217            .into_iter()
218            .map(|entry| self.map_to_item(0, entry))
219            .collect();
220        self.flatten_with_items(&items);
221    }
222
223    fn index_of(&self, path: &Path) -> Option<usize> {
224        self.flat_items
225            .iter()
226            .position(|(item, _)| item.path() == path)
227    }
228
229    pub fn reveal_path(&mut self, path: &Path) {
230        if self.select_path(path) {
231            return;
232        }
233
234        let items: Vec<Item> = path
235            .ancestors()
236            .skip(1)
237            .fold(self.items.clone(), |items, dir| {
238                items
239                    .iter()
240                    .map(|item| Self::toggle_item_in_tree(item, dir, true))
241                    .collect()
242            });
243
244        self.flatten_with_items(&items);
245        self.select_path(path);
246    }
247
248    fn select_path(&mut self, path: &Path) -> bool {
249        if let Some(index) = self
250            .flat_items
251            .iter()
252            .position(|(item, _)| item.path() == path)
253        {
254            self.list_state.select(Some(index));
255            self.selected_item_index.replace(index);
256            self.selected_item_path.replace(path.to_path_buf());
257            true
258        } else {
259            false
260        }
261    }
262
263    pub fn hide_pane(&mut self) {
264        match self.visibility {
265            Visibility::FullWidth => self.visibility = Visibility::Visible,
266            Visibility::Visible => self.visibility = Visibility::Hidden,
267            _ => {}
268        }
269    }
270
271    pub fn expand_pane(&mut self) {
272        match self.visibility {
273            Visibility::Hidden => self.visibility = Visibility::Visible,
274            Visibility::Visible => self.visibility = Visibility::FullWidth,
275            _ => {}
276        }
277    }
278
279    pub fn toggle(&mut self) {
280        if self.is_open() {
281            self.visibility = Visibility::Hidden;
282        } else {
283            self.visibility = Visibility::Visible;
284        }
285    }
286
287    pub fn flatten_with_sort(&mut self, sort: Sort) {
288        let mut items = self.items.clone();
289        items.sort_by(sort_items_by(sort));
290
291        self.flat_items = items.iter().flat_map(flatten(sort, 0)).collect();
292        self.items = items;
293        self.sort = sort;
294    }
295
296    pub fn flatten_with_items(&mut self, items: &[Item]) {
297        let mut items = items.to_vec();
298        items.sort_by(sort_items_by(self.sort));
299
300        self.flat_items = items.iter().flat_map(flatten(self.sort, 0)).collect();
301        self.items = items.to_vec();
302    }
303
304    pub fn sort(&mut self) {
305        let sort = match self.sort {
306            Sort::Asc => Sort::Desc,
307            Sort::Desc => Sort::Asc,
308        };
309
310        self.flatten_with_sort(sort)
311    }
312
313    pub fn update_offset_mut(&mut self, window_height: usize) -> &Self {
314        if !self.items.is_empty() {
315            let idx = self.list_state.selected().unwrap_or_default();
316            let items_count = self.items.len();
317
318            let offset = calculate_offset(idx, items_count, window_height);
319
320            let list_state = &mut self.list_state;
321            *list_state.offset_mut() = offset;
322        }
323
324        self
325    }
326
327    fn toggle_item_in_tree(item: &Item, identifier: &Path, always_open: bool) -> Item {
328        let item = item.clone();
329
330        match item {
331            Item::Directory {
332                expanded,
333                path,
334                name,
335                items,
336                depth,
337            } => {
338                let expanded = if path == identifier {
339                    if always_open {
340                        true
341                    } else {
342                        !expanded
343                    }
344                } else {
345                    expanded
346                };
347
348                Item::Directory {
349                    name,
350                    path,
351                    expanded,
352                    depth,
353                    items: items
354                        .iter()
355                        .map(|child| Self::toggle_item_in_tree(child, identifier, always_open))
356                        .collect(),
357                }
358            }
359            _ => item,
360        }
361    }
362
363    /// Opens the current item. Returns `Some(true)` when a note was selected, and `Some(false)`
364    /// when a directory was toggled.
365    pub fn open(&mut self) -> Option<bool> {
366        let selected_item_index = self.list_state.selected()?;
367        let current_item = self.flat_items.get(selected_item_index)?;
368
369        match current_item {
370            (Item::Directory { path, .. }, _) => {
371                let items: Vec<Item> = self
372                    .items
373                    .iter()
374                    .map(|item| Self::toggle_item_in_tree(item, path, true))
375                    .collect();
376
377                self.flatten_with_items(&items);
378                Some(false)
379            }
380            (Item::File { note, .. }, _) => {
381                self.selected_note = Some(note.clone());
382                self.selected_item_index = Some(selected_item_index);
383                self.selected_item_path = Some(note.path().to_path_buf());
384                Some(true)
385            }
386        }
387    }
388
389    /// Selects the current item. Returns `true` when a note was selected,
390    /// `false` when a directory was toggled or there is no current item.
391    pub fn select(&mut self) -> bool {
392        let Some(selected_item_index) = self.list_state.selected() else {
393            return false;
394        };
395
396        let Some(current_item) = self.flat_items.get(selected_item_index) else {
397            return false;
398        };
399
400        match current_item {
401            (Item::Directory { path, .. }, _) => {
402                let items: Vec<Item> = self
403                    .items
404                    .clone()
405                    .iter()
406                    .map(|item| Self::toggle_item_in_tree(item, path, false))
407                    .collect();
408
409                self.flatten_with_items(&items);
410                false
411            }
412            (Item::File { note, .. }, _) => {
413                self.selected_note = Some(note.clone());
414                self.selected_item_index = Some(selected_item_index);
415                self.selected_item_path = Some(note.path().to_path_buf());
416                true
417            }
418        }
419    }
420
421    pub fn current_item(&self) -> Option<&Item> {
422        let selected_item_index = self.list_state.selected()?;
423        self.flat_items
424            .get(selected_item_index)
425            .map(|(item, _)| item)
426    }
427
428    pub fn selected_path(&self) -> Option<PathBuf> {
429        self.selected_item_path.clone()
430    }
431
432    pub fn is_open(&self) -> bool {
433        matches!(self.visibility, Visibility::Visible | Visibility::FullWidth)
434    }
435
436    pub fn next(&mut self, amount: usize) {
437        let index = self.list_state.selected().map(|i| {
438            i.saturating_add(amount)
439                .min(self.flat_items.len().saturating_sub(1))
440        });
441
442        self.list_state.select(index);
443    }
444
445    pub fn previous(&mut self, amount: usize) {
446        let index = self.list_state.selected().map(|i| i.saturating_sub(amount));
447
448        self.list_state.select(index);
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn with_entries_preserves_nested_file_depth() {
458        let mut state = ExplorerState::default();
459
460        let entries = vec![VaultEntry::Directory {
461            name: "dir".into(),
462            path: PathBuf::from("dir"),
463            entries: vec![VaultEntry::File(Note::new_unchecked(
464                "nested",
465                &PathBuf::from("dir/nested"),
466            ))],
467        }];
468
469        state.with_entries(entries, None);
470
471        let Item::Directory { items, depth, .. } = &state.items[0] else {
472            panic!("expected directory");
473        };
474        assert_eq!(*depth, 0);
475        assert_eq!(items[0].depth(), 1, "nested file should keep its depth");
476    }
477
478    #[test]
479    fn reveal_path_moves_selection_to_matching_note() {
480        let mut state = ExplorerState::default();
481        let entries = vec![
482            VaultEntry::File(Note::new_unchecked("first", &PathBuf::from("first.md"))),
483            VaultEntry::File(Note::new_unchecked("second", &PathBuf::from("second.md"))),
484        ];
485        state.with_entries(entries, None);
486
487        state.reveal_path(Path::new("second.md"));
488        assert_eq!(state.list_state.selected(), Some(1));
489        assert_eq!(
490            state.selected_path().as_deref(),
491            Some(Path::new("second.md"))
492        );
493
494        // An unknown path leaves the selection where it was.
495        state.reveal_path(Path::new("missing.md"));
496        assert_eq!(state.list_state.selected(), Some(1));
497    }
498
499    #[test]
500    fn reveal_path_expands_collapsed_ancestor_folder() {
501        let mut state = ExplorerState::default();
502        let entries = vec![VaultEntry::Directory {
503            name: "dir".into(),
504            path: PathBuf::from("dir"),
505            entries: vec![VaultEntry::File(Note::new_unchecked(
506                "nested",
507                &PathBuf::from("dir/nested.md"),
508            ))],
509        }];
510        state.with_entries(entries, None);
511
512        // The folder starts collapsed, so the note is not in the flattened view.
513        assert!(!state
514            .flat_items
515            .iter()
516            .any(|(item, _)| item.path() == Path::new("dir/nested.md")));
517
518        // Revealing expands the folder and selects the note.
519        state.reveal_path(Path::new("dir/nested.md"));
520        assert_eq!(
521            state.selected_path().as_deref(),
522            Some(Path::new("dir/nested.md"))
523        );
524        assert!(state
525            .flat_items
526            .iter()
527            .any(|(item, _)| item.path() == Path::new("dir/nested.md")));
528    }
529}