basalt_tui/
app.rs

1use basalt_core::obsidian::{Note, Vault};
2use ratatui::{
3    buffer::Buffer,
4    crossterm::event::{self, Event, KeyEvent, KeyEventKind},
5    layout::{Constraint, Layout, Rect, Size},
6    widgets::{StatefulWidget, StatefulWidgetRef},
7    DefaultTerminal,
8};
9
10use std::{cell::RefCell, fmt::Debug, io::Result};
11
12use crate::{
13    command,
14    config::{self, Config},
15    explorer::{self, Explorer, ExplorerState, Visibility},
16    help_modal::{self, HelpModal, HelpModalState},
17    note_editor::{self, markdown_parser::Node, Editor, EditorState, View},
18    outline::{self, Outline, OutlineState},
19    splash_modal::{self, SplashModal, SplashModalState},
20    statusbar::{StatusBar, StatusBarState},
21    stylized_text::{self, FontStyle},
22    text_counts::{CharCount, WordCount},
23    vault_selector_modal::{self, VaultSelectorModal, VaultSelectorModalState},
24};
25
26const VERSION: &str = env!("CARGO_PKG_VERSION");
27
28const HELP_TEXT: &str = include_str!("./help.txt");
29
30#[derive(Debug, Default, Clone, PartialEq)]
31pub enum ScrollAmount {
32    #[default]
33    One,
34    HalfPage,
35}
36
37pub fn calc_scroll_amount(scroll_amount: &ScrollAmount, height: usize) -> usize {
38    match scroll_amount {
39        ScrollAmount::One => 1,
40        ScrollAmount::HalfPage => height / 2,
41    }
42}
43
44#[derive(Default, Clone)]
45pub struct AppState<'a> {
46    screen_size: Size,
47    is_running: bool,
48
49    active_pane: ActivePane,
50    explorer: ExplorerState<'a>,
51    note_editor: EditorState<'a>,
52    outline: OutlineState,
53    selected_note: Option<SelectedNote>,
54
55    splash_modal: SplashModalState<'a>,
56    help_modal: HelpModalState,
57    vault_selector_modal: VaultSelectorModalState<'a>,
58}
59
60impl<'a> AppState<'a> {
61    pub fn active_component(&self) -> ActivePane {
62        if self.help_modal.visible {
63            return ActivePane::HelpModal;
64        }
65
66        if self.vault_selector_modal.visible {
67            return ActivePane::VaultSelectorModal;
68        }
69
70        if self.splash_modal.visible {
71            return ActivePane::Splash;
72        }
73
74        self.active_pane
75    }
76
77    pub fn set_running(&self, is_running: bool) -> Self {
78        Self {
79            is_running,
80            ..self.clone()
81        }
82    }
83}
84
85#[derive(Clone, Debug, PartialEq)]
86pub enum Message<'a> {
87    Quit,
88    Exec(String),
89    Spawn(String),
90    Resize(Size),
91    SetActivePane(ActivePane),
92    OpenVault(&'a Vault),
93    SelectNote(SelectedNote),
94    UpdateSelectedNoteContent((String, Option<Vec<Node>>)),
95
96    Splash(splash_modal::Message),
97    Explorer(explorer::Message),
98    NoteEditor(note_editor::Message),
99    Outline(outline::Message),
100    HelpModal(help_modal::Message),
101    VaultSelectorModal(vault_selector_modal::Message),
102}
103
104#[derive(Debug, Default, Clone, Copy, PartialEq)]
105pub enum ActivePane {
106    #[default]
107    Splash,
108    Explorer,
109    NoteEditor,
110    Outline,
111    HelpModal,
112    VaultSelectorModal,
113}
114
115impl From<ActivePane> for &str {
116    fn from(value: ActivePane) -> Self {
117        match value {
118            ActivePane::Splash => "Splash",
119            ActivePane::Explorer => "Explorer",
120            ActivePane::NoteEditor => "Note Editor",
121            ActivePane::Outline => "Outline",
122            ActivePane::HelpModal => "Help",
123            ActivePane::VaultSelectorModal => "Vault Selector",
124        }
125    }
126}
127
128#[derive(Debug, Default, Clone, PartialEq)]
129pub struct SelectedNote {
130    name: String,
131    path: String,
132    content: String,
133}
134
135impl From<&Note> for SelectedNote {
136    fn from(value: &Note) -> Self {
137        Self {
138            name: value.name.clone(),
139            path: value.path.to_string_lossy().to_string(),
140            content: Note::read_to_string(value).unwrap_or_default(),
141        }
142    }
143}
144
145fn help_text(version: &str) -> String {
146    HELP_TEXT.replace("%version-notice", version)
147}
148
149pub struct App<'a> {
150    state: AppState<'a>,
151    config: Config<'a>,
152    terminal: RefCell<DefaultTerminal>,
153}
154
155impl<'a> App<'a> {
156    pub fn new(state: AppState<'a>, terminal: DefaultTerminal) -> Self {
157        Self {
158            state,
159            // TODO: Surface toast if read config returns error
160            config: config::load().unwrap(),
161            terminal: RefCell::new(terminal),
162        }
163    }
164
165    pub fn start(terminal: DefaultTerminal, vaults: Vec<&Vault>) -> Result<()> {
166        let version = stylized_text::stylize(&format!("{VERSION}~beta"), FontStyle::Script);
167        let size = terminal.size()?;
168
169        let state = AppState {
170            screen_size: size,
171            help_modal: HelpModalState::new(&help_text(&version)),
172            vault_selector_modal: VaultSelectorModalState::new(vaults.clone()),
173            splash_modal: SplashModalState::new(&version, vaults, true),
174            ..Default::default()
175        };
176
177        App::new(state, terminal).run()
178    }
179
180    fn run(&'a mut self) -> Result<()> {
181        self.state.is_running = true;
182
183        let mut state = self.state.clone();
184        let config = self.config.clone();
185        while state.is_running {
186            self.draw(&mut state.clone())?;
187            let event = event::read()?;
188
189            let mut message = App::handle_event(&config, &state, &event);
190            while message.is_some() {
191                message = App::update(self.terminal.get_mut(), &config, &mut state, message);
192            }
193        }
194
195        Ok(())
196    }
197
198    fn draw(&self, state: &mut AppState<'a>) -> Result<()> {
199        let mut terminal = self.terminal.borrow_mut();
200
201        terminal.draw(move |frame| {
202            let area = frame.area();
203            let buf = frame.buffer_mut();
204            self.render_ref(area, buf, state);
205        })?;
206
207        Ok(())
208    }
209
210    fn handle_event(
211        config: &'a Config,
212        state: &AppState<'_>,
213        event: &Event,
214    ) -> Option<Message<'a>> {
215        match event {
216            Event::Resize(cols, rows) => Some(Message::Resize(Size::new(*cols, *rows))),
217            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
218                App::handle_key_event(config, state, key_event)
219            }
220            _ => None,
221        }
222    }
223
224    #[rustfmt::skip]
225    fn handle_active_component_event(config: &'a Config, state: &AppState<'_>, key: &KeyEvent, active_component: ActivePane) -> Option<Message<'a>> {
226        match active_component {
227            ActivePane::Splash => config.splash.key_to_message(key.into()),
228            ActivePane::Explorer => config.explorer.key_to_message(key.into()),
229            ActivePane::Outline => config.outline.key_to_message(key.into()),
230            ActivePane::HelpModal => config.help_modal.key_to_message(key.into()),
231            ActivePane::VaultSelectorModal => config.vault_selector_modal.key_to_message(key.into()),
232            ActivePane::NoteEditor => {
233                    if state.note_editor.is_editing() {
234                        note_editor::handle_editing_event(key).map(Message::NoteEditor)
235                    } else {
236                        config.note_editor.key_to_message(key.into())
237                }
238            },
239        }
240    }
241
242    fn handle_key_event(
243        config: &'a Config,
244        state: &AppState<'_>,
245        key: &KeyEvent,
246    ) -> Option<Message<'a>> {
247        let global_message = config.global.key_to_message(key.into());
248
249        let is_editing = state.note_editor.is_editing();
250
251        if global_message.is_some() && !is_editing {
252            return global_message;
253        }
254
255        let active_component = state.active_component();
256        App::handle_active_component_event(config, state, key, active_component)
257    }
258
259    fn update(
260        terminal: &mut DefaultTerminal,
261        config: &Config,
262        state: &mut AppState<'a>,
263        message: Option<Message<'a>>,
264    ) -> Option<Message<'a>> {
265        match message? {
266            Message::Quit => state.is_running = false,
267            Message::Resize(size) => state.screen_size = size,
268            Message::SetActivePane(active_pane) => match active_pane {
269                ActivePane::Explorer => {
270                    state.active_pane = active_pane;
271                    // TODO: use event/message
272                    state.explorer.set_active(true);
273                }
274                ActivePane::NoteEditor => {
275                    state.active_pane = active_pane;
276                    // TODO: use event/message
277                    state.note_editor.set_active(true);
278                    if state.explorer.visibility == Visibility::FullWidth {
279                        return Some(Message::Explorer(explorer::Message::HidePane));
280                    }
281                }
282                ActivePane::Outline => {
283                    state.active_pane = active_pane;
284                    // TODO: use event/message
285                    state.outline.set_active(true);
286                }
287                _ => {}
288            },
289            Message::OpenVault(vault) => {
290                state.explorer = ExplorerState::new(&vault.name, vault.entries());
291                state.note_editor = EditorState::default();
292                return Some(Message::SetActivePane(ActivePane::Explorer));
293            }
294            Message::SelectNote(selected_note) => {
295                let is_different = state
296                    .selected_note
297                    .as_ref()
298                    .is_some_and(|note| note.content != selected_note.content);
299                state.selected_note = Some(selected_note.clone());
300
301                // TODO: This should be behind an event/message
302                let active = state.note_editor.active();
303                state.note_editor = EditorState::default();
304                state.note_editor.set_active(active);
305                state.note_editor.set_path(selected_note.path.into());
306                state.note_editor.set_content(&selected_note.content);
307
308                if !config.experimental_editor {
309                    state.note_editor.view = View::Read;
310                }
311
312                // TODO: This should be behind an event/message
313                state.outline = OutlineState::new(
314                    state.note_editor.nodes(),
315                    state.note_editor.current_row,
316                    state.outline.is_open(),
317                );
318
319                if state.explorer.visibility == Visibility::FullWidth && is_different {
320                    return Some(Message::Explorer(explorer::Message::HidePane));
321                }
322            }
323            Message::UpdateSelectedNoteContent((updated_content, nodes)) => {
324                if let Some(selected_note) = state.selected_note.as_mut() {
325                    selected_note.content = updated_content;
326                    return nodes.map(|nodes| Message::Outline(outline::Message::SetNodes(nodes)));
327                }
328            }
329            Message::Exec(command) => {
330                let (note_name, note_path) = state
331                    .selected_note
332                    .as_ref()
333                    .map(|note| (note.name.as_str(), note.path.as_str()))
334                    .unwrap_or_default();
335
336                return command::sync_command(
337                    terminal,
338                    command,
339                    state.explorer.title,
340                    note_name,
341                    note_path,
342                );
343            }
344
345            Message::Spawn(command) => {
346                let (note_name, note_path) = state
347                    .selected_note
348                    .as_ref()
349                    .map(|note| (note.name.as_str(), note.path.as_str()))
350                    .unwrap_or_default();
351
352                return command::spawn_command(command, state.explorer.title, note_name, note_path);
353            }
354
355            Message::HelpModal(message) => {
356                return help_modal::update(&message, state.screen_size, &mut state.help_modal);
357            }
358            Message::VaultSelectorModal(message) => {
359                return vault_selector_modal::update(&message, &mut state.vault_selector_modal);
360            }
361            Message::Splash(message) => {
362                return splash_modal::update(&message, &mut state.splash_modal);
363            }
364            Message::Explorer(message) => {
365                return explorer::update(&message, state.screen_size, &mut state.explorer);
366            }
367            Message::Outline(message) => {
368                return outline::update(&message, &mut state.outline);
369            }
370            Message::NoteEditor(message) => {
371                return note_editor::update(&message, state.screen_size, &mut state.note_editor);
372            }
373        };
374
375        None
376    }
377
378    fn render_splash(&self, area: Rect, buf: &mut Buffer, state: &mut SplashModalState<'a>) {
379        SplashModal::default().render_ref(area, buf, state)
380    }
381
382    fn render_main(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
383        let [content, statusbar] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
384            .horizontal_margin(1)
385            .areas(area);
386
387        let (left, right) = match state.explorer.visibility {
388            Visibility::Hidden => (Constraint::Length(4), Constraint::Fill(1)),
389            Visibility::Visible => (Constraint::Length(35), Constraint::Fill(1)),
390            Visibility::FullWidth => (Constraint::Fill(1), Constraint::Length(0)),
391        };
392
393        let [explorer_pane, note, outline] = Layout::horizontal([
394            left,
395            right,
396            if state.outline.is_open() {
397                Constraint::Length(35)
398            } else {
399                Constraint::Length(4)
400            },
401        ])
402        .areas(content);
403
404        Explorer::new().render(explorer_pane, buf, &mut state.explorer);
405        Editor::default().render(note, buf, &mut state.note_editor);
406        Outline.render(outline, buf, &mut state.outline);
407
408        let (_, counts) = state
409            .selected_note
410            .clone()
411            .map(|note| {
412                let content = note.content.as_str();
413                (
414                    note.name,
415                    (WordCount::from(content), CharCount::from(content)),
416                )
417            })
418            .unzip();
419
420        let (word_count, char_count) = counts.unwrap_or_default();
421
422        let mut status_bar_state = StatusBarState::new(
423            state.active_pane.into(),
424            word_count.into(),
425            char_count.into(),
426        );
427
428        let status_bar = StatusBar::default();
429        status_bar.render_ref(statusbar, buf, &mut status_bar_state);
430
431        self.render_modals(area, buf, state)
432    }
433
434    fn render_modals(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
435        if state.splash_modal.visible {
436            self.render_splash(area, buf, &mut state.splash_modal);
437        }
438
439        if state.vault_selector_modal.visible {
440            VaultSelectorModal::default().render(area, buf, &mut state.vault_selector_modal);
441        }
442
443        if state.help_modal.visible {
444            HelpModal.render(area, buf, &mut state.help_modal);
445        }
446    }
447}
448
449impl<'a> StatefulWidgetRef for App<'a> {
450    type State = AppState<'a>;
451
452    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
453        self.render_main(area, buf, state);
454    }
455}