use std::{
cmp::Ordering,
path::{Path, PathBuf},
};
use basalt_core::obsidian::{Note, VaultEntry};
use ratatui::widgets::ListState;
use crate::config::Symbols;
use super::Item;
#[derive(Debug, Default, Copy, Clone, PartialEq)]
pub enum Sort {
#[default]
Asc,
Desc,
}
#[derive(Debug, Default, Copy, Clone, PartialEq)]
pub enum Visibility {
Hidden,
#[default]
Visible,
FullWidth,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ExplorerState {
pub(crate) title: String,
pub(crate) selected_note: Option<Note>,
pub(crate) selected_item_index: Option<usize>,
pub(crate) selected_item_path: Option<PathBuf>,
pub(crate) items: Vec<Item>,
pub(crate) flat_items: Vec<(Item, usize)>,
pub(crate) visibility: Visibility,
pub(crate) active: bool,
pub(crate) sort: Sort,
pub(crate) list_state: ListState,
pub(crate) symbols: Symbols,
pub(crate) editing: bool,
}
fn calculate_offset(row: usize, items_count: usize, window_height: usize) -> usize {
let half = window_height / 2;
if row + half > items_count.saturating_sub(1) {
items_count.saturating_sub(window_height)
} else {
row.saturating_sub(half)
}
}
pub fn flatten(sort: Sort, depth: usize) -> impl Fn(&Item) -> Vec<(Item, usize)> {
move |item| match item {
Item::File { .. } => vec![(item.clone(), depth)],
Item::Directory {
expanded: true,
items,
..
} => [(item.clone(), depth)]
.into_iter()
.chain({
let mut items = items.clone();
items.sort_by(sort_items_by(sort));
items
.iter()
.flat_map(flatten(sort, depth + 1))
.collect::<Vec<_>>()
})
.collect(),
Item::Directory {
expanded: false, ..
} => [(item.clone(), depth)].to_vec(),
}
}
fn sort_items_by(sort: Sort) -> impl Fn(&Item, &Item) -> Ordering {
move |a, b| match (a.is_dir(), b.is_dir()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
(true, true) => natord::compare(a.name(), b.name()),
_ => {
let a = a.name().to_lowercase();
let b = b.name().to_lowercase();
match sort {
Sort::Asc => natord::compare(&a, &b),
Sort::Desc => natord::compare(&b, &a),
}
}
}
}
impl ExplorerState {
pub fn new(title: &str, items: Vec<VaultEntry>, symbols: &Symbols) -> Self {
let items: Vec<Item> = items.into_iter().map(|entry| entry.into()).collect();
let sort = Sort::default();
let mut state = ExplorerState {
title: title.to_string(),
sort,
active: true,
visibility: Visibility::Visible,
selected_item_index: None,
selected_item_path: None,
selected_note: None,
symbols: symbols.clone(),
list_state: ListState::default().with_selected(Some(0)),
..Default::default()
};
state.flatten_with_items(&items);
state
}
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
fn map_to_item(&self, depth: usize, entry: VaultEntry) -> Item {
match entry {
VaultEntry::Directory {
name,
path,
entries,
} => {
let expanded = self
.flat_items
.iter()
.find_map(|(item, _)| match item {
Item::Directory {
path: item_path,
expanded,
..
} if &path == item_path => Some(*expanded),
_ => None,
})
.unwrap_or(false);
Item::Directory {
name,
path,
expanded,
depth,
items: entries
.into_iter()
.map(|entry| self.map_to_item(depth + 1, entry))
.collect(),
}
}
VaultEntry::File(note) => Item::File { note, depth },
}
}
pub fn with_entries(&mut self, entries: Vec<VaultEntry>, select: Option<PathBuf>) {
let items: Vec<Item> = entries
.into_iter()
.map(|entry| self.map_to_item(0, entry))
.collect();
self.flatten_with_items(&items);
if let Some(path) = select {
if let Some(index) = self.flat_items.iter().position(|(item, _)| match item {
Item::File { note, .. } => note.path() == path,
Item::Directory { path: dir_path, .. } => dir_path == &path,
}) {
self.list_state.select(Some(index));
self.selected_item_index = Some(index);
self.selected_item_path = Some(path);
}
}
}
pub fn hide_pane(&mut self) {
match self.visibility {
Visibility::FullWidth => self.visibility = Visibility::Visible,
Visibility::Visible => self.visibility = Visibility::Hidden,
_ => {}
}
}
pub fn expand_pane(&mut self) {
match self.visibility {
Visibility::Hidden => self.visibility = Visibility::Visible,
Visibility::Visible => self.visibility = Visibility::FullWidth,
_ => {}
}
}
pub fn toggle(&mut self) {
if self.is_open() {
self.visibility = Visibility::Hidden;
} else {
self.visibility = Visibility::Visible;
}
}
pub fn flatten_with_sort(&mut self, sort: Sort) {
let mut items = self.items.clone();
items.sort_by(sort_items_by(sort));
self.flat_items = items.iter().flat_map(flatten(sort, 0)).collect();
self.items = items;
self.sort = sort;
}
pub fn flatten_with_items(&mut self, items: &[Item]) {
let mut items = items.to_vec();
items.sort_by(sort_items_by(self.sort));
self.flat_items = items.iter().flat_map(flatten(self.sort, 0)).collect();
self.items = items.to_vec();
}
pub fn sort(&mut self) {
let sort = match self.sort {
Sort::Asc => Sort::Desc,
Sort::Desc => Sort::Asc,
};
self.flatten_with_sort(sort)
}
pub fn update_offset_mut(&mut self, window_height: usize) -> &Self {
if !self.items.is_empty() {
let idx = self.list_state.selected().unwrap_or_default();
let items_count = self.items.len();
let offset = calculate_offset(idx, items_count, window_height);
let list_state = &mut self.list_state;
*list_state.offset_mut() = offset;
}
self
}
fn toggle_item_in_tree(item: &Item, identifier: &Path, always_open: bool) -> Item {
let item = item.clone();
match item {
Item::Directory {
expanded,
path,
name,
items,
depth,
} => {
let expanded = if path == identifier {
if always_open {
true
} else {
!expanded
}
} else {
expanded
};
Item::Directory {
name,
path,
expanded,
depth,
items: items
.iter()
.map(|child| Self::toggle_item_in_tree(child, identifier, always_open))
.collect(),
}
}
_ => item,
}
}
pub fn open(&mut self) -> Option<bool> {
let selected_item_index = self.list_state.selected()?;
let current_item = self.flat_items.get(selected_item_index)?;
match current_item {
(Item::Directory { path, .. }, _) => {
let items: Vec<Item> = self
.items
.iter()
.map(|item| Self::toggle_item_in_tree(item, path, true))
.collect();
self.flatten_with_items(&items);
Some(false)
}
(Item::File { note, .. }, _) => {
self.selected_note = Some(note.clone());
self.selected_item_index = Some(selected_item_index);
self.selected_item_path = Some(note.path().to_path_buf());
Some(true)
}
}
}
pub fn select(&mut self) -> bool {
let Some(selected_item_index) = self.list_state.selected() else {
return false;
};
let Some(current_item) = self.flat_items.get(selected_item_index) else {
return false;
};
match current_item {
(Item::Directory { path, .. }, _) => {
let items: Vec<Item> = self
.items
.clone()
.iter()
.map(|item| Self::toggle_item_in_tree(item, path, false))
.collect();
self.flatten_with_items(&items);
false
}
(Item::File { note, .. }, _) => {
self.selected_note = Some(note.clone());
self.selected_item_index = Some(selected_item_index);
self.selected_item_path = Some(note.path().to_path_buf());
true
}
}
}
pub fn current_item(&self) -> Option<&Item> {
let selected_item_index = self.list_state.selected()?;
self.flat_items
.get(selected_item_index)
.map(|(item, _)| item)
}
pub fn selected_path(&self) -> Option<PathBuf> {
self.selected_item_path.clone()
}
pub fn is_open(&self) -> bool {
matches!(self.visibility, Visibility::Visible | Visibility::FullWidth)
}
pub fn next(&mut self, amount: usize) {
let index = self.list_state.selected().map(|i| {
i.saturating_add(amount)
.min(self.flat_items.len().saturating_sub(1))
});
self.list_state.select(index);
}
pub fn previous(&mut self, amount: usize) {
let index = self.list_state.selected().map(|i| i.saturating_sub(amount));
self.list_state.select(index);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_entries_preserves_nested_file_depth() {
let mut state = ExplorerState::default();
let entries = vec![VaultEntry::Directory {
name: "dir".into(),
path: PathBuf::from("dir"),
entries: vec![VaultEntry::File(Note::new_unchecked(
"nested",
&PathBuf::from("dir/nested"),
))],
}];
state.with_entries(entries, None);
let Item::Directory { items, depth, .. } = &state.items[0] else {
panic!("expected directory");
};
assert_eq!(*depth, 0);
assert_eq!(items[0].depth(), 1, "nested file should keep its depth");
}
}