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;
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
43    pub(crate) editing: bool,
44}
45
46/// Calculates the vertical offset of list items in rows.
47///
48/// When the selected item is near the end of the list and there aren't enough items
49/// remaining to keep the selection vertically centered, we shift the offset to show
50/// as many trailing items as possible instead of centering the selection.
51///
52/// This prevents empty lines from appearing at the bottom of the list when the
53/// selection moves toward the end.
54///
55/// Without this check, you'd see output like:
56/// ╭────────╮
57/// │ 3 item │
58/// │>4 item │
59/// │ 5 item │
60/// │        │
61/// ╰────────╯
62///
63/// With this check, the list scrolls up to fill the remaining space:
64/// ╭────────╮
65/// │ 2 item │
66/// │ 3 item │
67/// │>4 item │
68/// │ 5 item │
69/// ╰────────╯
70///
71/// The goal is to avoid showing unnecessary blank rows and to maximize visible items.
72fn calculate_offset(row: usize, items_count: usize, window_height: usize) -> usize {
73    let half = window_height / 2;
74
75    if row + half > items_count.saturating_sub(1) {
76        items_count.saturating_sub(window_height)
77    } else {
78        row.saturating_sub(half)
79    }
80}
81
82pub fn flatten(sort: Sort, depth: usize) -> impl Fn(&Item) -> Vec<(Item, usize)> {
83    move |item| match item {
84        Item::File(..) => vec![(item.clone(), depth)],
85        Item::Directory {
86            expanded: true,
87            items,
88            ..
89        } => [(item.clone(), depth)]
90            .into_iter()
91            .chain({
92                let mut items = items.clone();
93                items.sort_by(sort_items_by(sort));
94                items
95                    .iter()
96                    .flat_map(flatten(sort, depth + 1))
97                    .collect::<Vec<_>>()
98            })
99            .collect(),
100        Item::Directory {
101            expanded: false, ..
102        } => [(item.clone(), depth)].to_vec(),
103    }
104}
105
106fn sort_items_by(sort: Sort) -> impl Fn(&Item, &Item) -> Ordering {
107    move |a, b| match (a.is_dir(), b.is_dir()) {
108        (true, false) => Ordering::Less,
109        (false, true) => Ordering::Greater,
110        (true, true) => natord::compare(a.name(), b.name()),
111        _ => {
112            let a = a.name().to_lowercase();
113            let b = b.name().to_lowercase();
114            match sort {
115                Sort::Asc => natord::compare(&a, &b),
116                Sort::Desc => natord::compare(&b, &a),
117            }
118        }
119    }
120}
121
122impl ExplorerState {
123    pub fn new(title: &str, items: Vec<VaultEntry>, symbols: &Symbols) -> Self {
124        let items: Vec<Item> = items.into_iter().map(|entry| entry.into()).collect();
125        let sort = Sort::default();
126
127        let mut state = ExplorerState {
128            title: title.to_string(),
129            sort,
130            active: true,
131            visibility: Visibility::Visible,
132            selected_item_index: None,
133            selected_item_path: None,
134            selected_note: None,
135            symbols: symbols.clone(),
136            list_state: ListState::default().with_selected(Some(0)),
137            ..Default::default()
138        };
139
140        state.flatten_with_items(&items);
141        state
142    }
143
144    pub fn set_active(&mut self, active: bool) {
145        self.active = active;
146    }
147
148    fn map_to_item(&self, entry: VaultEntry) -> Item {
149        match entry {
150            VaultEntry::Directory {
151                name,
152                path,
153                entries,
154            } => {
155                let expanded = self
156                    .flat_items
157                    .iter()
158                    .find_map(|(item, _)| match item {
159                        Item::Directory {
160                            path: item_path,
161                            expanded,
162                            ..
163                        } if &path == item_path => Some(*expanded),
164                        _ => None,
165                    })
166                    .unwrap_or(false);
167
168                Item::Directory {
169                    name,
170                    path,
171                    expanded,
172                    items: entries
173                        .into_iter()
174                        .map(|entry| self.map_to_item(entry))
175                        .collect(),
176                }
177            }
178            _ => entry.into(),
179        }
180    }
181
182    pub fn with_entries(&mut self, entries: Vec<VaultEntry>, select: Option<PathBuf>) {
183        let items: Vec<Item> = entries
184            .into_iter()
185            .map(|entry| self.map_to_item(entry))
186            .collect();
187
188        self.flatten_with_items(&items);
189
190        if let Some(path) = select {
191            if let Some(index) = self.flat_items.iter().position(|(item, _)| match item {
192                Item::File(note) => note.path() == path,
193                Item::Directory { path: dir_path, .. } => dir_path == &path,
194            }) {
195                self.list_state.select(Some(index));
196                self.selected_item_index = Some(index);
197                self.selected_item_path = Some(path);
198            }
199        }
200    }
201
202    pub fn hide_pane(&mut self) {
203        match self.visibility {
204            Visibility::FullWidth => self.visibility = Visibility::Visible,
205            Visibility::Visible => self.visibility = Visibility::Hidden,
206            _ => {}
207        }
208    }
209
210    pub fn expand_pane(&mut self) {
211        match self.visibility {
212            Visibility::Hidden => self.visibility = Visibility::Visible,
213            Visibility::Visible => self.visibility = Visibility::FullWidth,
214            _ => {}
215        }
216    }
217
218    pub fn toggle(&mut self) {
219        if self.is_open() {
220            self.visibility = Visibility::Hidden;
221        } else {
222            self.visibility = Visibility::Visible;
223        }
224    }
225
226    pub fn flatten_with_sort(&mut self, sort: Sort) {
227        let mut items = self.items.clone();
228        items.sort_by(sort_items_by(sort));
229
230        self.flat_items = items.iter().flat_map(flatten(sort, 0)).collect();
231        self.items = items;
232        self.sort = sort;
233    }
234
235    pub fn flatten_with_items(&mut self, items: &[Item]) {
236        let mut items = items.to_vec();
237        items.sort_by(sort_items_by(self.sort));
238
239        self.flat_items = items.iter().flat_map(flatten(self.sort, 0)).collect();
240        self.items = items.to_vec();
241    }
242
243    pub fn sort(&mut self) {
244        let sort = match self.sort {
245            Sort::Asc => Sort::Desc,
246            Sort::Desc => Sort::Asc,
247        };
248
249        self.flatten_with_sort(sort)
250    }
251
252    pub fn update_offset_mut(&mut self, window_height: usize) -> &Self {
253        if !self.items.is_empty() {
254            let idx = self.list_state.selected().unwrap_or_default();
255            let items_count = self.items.len();
256
257            let offset = calculate_offset(idx, items_count, window_height);
258
259            let list_state = &mut self.list_state;
260            *list_state.offset_mut() = offset;
261        }
262
263        self
264    }
265
266    fn toggle_item_in_tree(item: &Item, identifier: &Path) -> Item {
267        let item = item.clone();
268
269        match item {
270            Item::Directory {
271                expanded,
272                path,
273                name,
274                items,
275            } => {
276                let expanded = if path == identifier {
277                    !expanded
278                } else {
279                    expanded
280                };
281
282                Item::Directory {
283                    name,
284                    path,
285                    expanded,
286                    items: items
287                        .iter()
288                        .map(|child| Self::toggle_item_in_tree(child, identifier))
289                        .collect(),
290                }
291            }
292            _ => item,
293        }
294    }
295
296    pub fn select(&mut self) {
297        let Some(selected_item_index) = self.list_state.selected() else {
298            return;
299        };
300
301        let Some(current_item) = self.flat_items.get(selected_item_index) else {
302            return;
303        };
304
305        match current_item {
306            (Item::Directory { path, .. }, _) => {
307                let items: Vec<Item> = self
308                    .items
309                    .clone()
310                    .iter()
311                    .map(|item| Self::toggle_item_in_tree(item, path))
312                    .collect();
313
314                self.flatten_with_items(&items)
315            }
316            (Item::File(note), _) => {
317                self.selected_note = Some(note.clone());
318                self.selected_item_index = Some(selected_item_index);
319                self.selected_item_path = Some(note.path().to_path_buf());
320            }
321        }
322    }
323
324    pub fn current_item(&self) -> Option<&Item> {
325        let selected_item_index = self.list_state.selected()?;
326        self.flat_items
327            .get(selected_item_index)
328            .map(|(item, _)| item)
329    }
330
331    pub fn selected_path(&self) -> Option<PathBuf> {
332        self.selected_item_path.clone()
333    }
334
335    pub fn is_open(&self) -> bool {
336        matches!(self.visibility, Visibility::Visible | Visibility::FullWidth)
337    }
338
339    pub fn next(&mut self, amount: usize) {
340        let index = self.list_state.selected().map(|i| {
341            i.saturating_add(amount)
342                .min(self.flat_items.len().saturating_sub(1))
343        });
344
345        self.list_state.select(index);
346    }
347
348    pub fn previous(&mut self, amount: usize) {
349        let index = self.list_state.selected().map(|i| i.saturating_sub(amount));
350
351        self.list_state.select(index);
352    }
353}