Skip to main content

basalt_tui/
tabs.rs

1use std::{collections::HashMap, path::Path};
2
3use crate::{app::SelectedNote, config::Theme, note_editor::state::NoteEditorState};
4
5#[derive(Clone)]
6pub struct Tab<'a> {
7    pub note: SelectedNote,
8    pub editor: NoteEditorState<'a>,
9}
10
11#[derive(Default, Clone)]
12pub struct Tabs<'a> {
13    tabs: Vec<Tab<'a>>,
14    active: usize,
15}
16
17impl<'a> Tabs<'a> {
18    pub fn is_empty(&self) -> bool {
19        self.tabs.is_empty()
20    }
21
22    pub fn len(&self) -> usize {
23        self.tabs.len()
24    }
25
26    pub fn active_note(&self) -> Option<&SelectedNote> {
27        self.tabs.get(self.active).map(|tab| &tab.note)
28    }
29
30    pub fn active_note_mut(&mut self) -> Option<&mut SelectedNote> {
31        self.tabs.get_mut(self.active).map(|tab| &mut tab.note)
32    }
33
34    pub fn active_editor(&self) -> Option<&NoteEditorState<'a>> {
35        self.tabs.get(self.active).map(|tab| &tab.editor)
36    }
37
38    pub fn active_editor_mut(&mut self) -> Option<&mut NoteEditorState<'a>> {
39        self.tabs.get_mut(self.active).map(|tab| &mut tab.editor)
40    }
41
42    /// Applies a theme to every open tab's editor so switching tabs never
43    /// reveals a stale palette.
44    pub fn set_theme(&mut self, theme: &Theme) {
45        for tab in &mut self.tabs {
46            tab.editor.set_theme(theme);
47        }
48    }
49
50    fn index_of(&self, path: &Path) -> Option<usize> {
51        self.tabs.iter().position(|tab| tab.note.path() == path)
52    }
53
54    pub fn open_or_focus(&mut self, path: &Path) -> bool {
55        match self.index_of(path) {
56            Some(index) => {
57                self.active = index;
58                true
59            }
60            None => false,
61        }
62    }
63
64    pub fn open(&mut self, tab: Tab<'a>) {
65        self.tabs.push(tab);
66        self.active = self.tabs.len() - 1;
67    }
68
69    pub fn next(&mut self) {
70        if !self.tabs.is_empty() {
71            self.active = (self.active + 1) % self.tabs.len();
72        }
73    }
74
75    pub fn prev(&mut self) {
76        if !self.tabs.is_empty() {
77            self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
78        }
79    }
80
81    pub fn close_active(&mut self) {
82        if self.active < self.tabs.len() {
83            self.tabs.remove(self.active);
84            self.active = self.active.min(self.tabs.len().saturating_sub(1));
85        }
86    }
87
88    pub fn rename(&mut self, old: &Path, new: &Path, name: &str) {
89        if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.note.path() == old) {
90            tab.note.set_path(new);
91            tab.note.set_name(name);
92            tab.editor.set_filepath(new);
93            tab.editor.set_filename(name);
94        }
95    }
96
97    pub(crate) fn titles(&self) -> Vec<(String, bool, bool)> {
98        let mut counts: HashMap<&str, usize> = HashMap::new();
99        for tab in &self.tabs {
100            *counts.entry(tab.note.name()).or_default() += 1;
101        }
102        self.tabs
103            .iter()
104            .enumerate()
105            .map(|(index, tab)| {
106                let name = tab.note.name();
107                let label = if counts[name] > 1 {
108                    tab.note
109                        .path()
110                        .parent()
111                        .and_then(|parent| parent.file_name())
112                        .and_then(|dir| dir.to_str())
113                        .map(|dir| format!("{dir}/{name}"))
114                        .unwrap_or_else(|| name.to_string())
115                } else {
116                    name.to_string()
117                };
118                (label, index == self.active, tab.editor.modified())
119            })
120            .collect()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::path::{Path, PathBuf};
127
128    use super::*;
129    use crate::config::Symbols;
130
131    fn tab(name: &str) -> Tab<'static> {
132        tab_in("", name)
133    }
134
135    fn tab_in(dir: &str, name: &str) -> Tab<'static> {
136        let path = PathBuf::from(format!("/vault/{dir}/{name}.md"));
137        let editor = NoteEditorState::new("", name, &path, &Symbols::unicode());
138        Tab {
139            note: SelectedNote::new(name, &path, ""),
140            editor,
141        }
142    }
143
144    #[test]
145    fn open_focuses_new_tab() {
146        let mut tabs = Tabs::default();
147        tabs.open(tab("a"));
148        tabs.open(tab("b"));
149        assert_eq!(tabs.active_note().map(SelectedNote::name), Some("b"));
150    }
151
152    #[test]
153    fn open_or_focus_reuses_open_tab() {
154        let mut tabs = Tabs::default();
155        tabs.open(tab("a"));
156        tabs.open(tab("b"));
157
158        assert!(tabs.open_or_focus(Path::new("/vault/a.md")));
159        assert_eq!(tabs.active_note().map(SelectedNote::name), Some("a"));
160        assert!(!tabs.open_or_focus(Path::new("/vault/c.md")));
161    }
162
163    #[test]
164    fn next_and_prev_wrap_around() {
165        let mut tabs = Tabs::default();
166        tabs.open(tab("a"));
167        tabs.open(tab("b"));
168
169        tabs.next();
170        assert_eq!(tabs.active_note().map(SelectedNote::name), Some("a"));
171        tabs.prev();
172        assert_eq!(tabs.active_note().map(SelectedNote::name), Some("b"));
173    }
174
175    #[test]
176    fn close_active_clamps_focus() {
177        let mut tabs = Tabs::default();
178        tabs.open(tab("a"));
179        tabs.open(tab("b"));
180
181        tabs.close_active();
182        assert_eq!(tabs.active_note().map(SelectedNote::name), Some("a"));
183        tabs.close_active();
184        assert!(tabs.is_empty());
185        assert_eq!(tabs.active_note(), None);
186    }
187
188    #[test]
189    fn same_name_tabs_are_disambiguated_by_parent() {
190        let mut tabs = Tabs::default();
191        tabs.open(tab_in("alpha", "note"));
192        tabs.open(tab_in("beta", "note"));
193        tabs.open(tab_in("gamma", "unique"));
194
195        let labels: Vec<String> = tabs.titles().into_iter().map(|(label, ..)| label).collect();
196        assert_eq!(labels, ["alpha/note", "beta/note", "unique"]);
197    }
198}