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},
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                }
279                ActivePane::Outline => {
280                    state.active_pane = active_pane;
281                    // TODO: use event/message
282                    state.outline.set_active(true);
283                }
284                _ => {}
285            },
286            Message::OpenVault(vault) => {
287                state.explorer = ExplorerState::new(&vault.name, vault.entries());
288                state.note_editor = EditorState::default();
289                return Some(Message::SetActivePane(ActivePane::Explorer));
290            }
291            Message::SelectNote(selected_note) => {
292                state.selected_note = Some(selected_note.clone());
293
294                // TODO: This should be behind an event/message
295                let active = state.note_editor.active();
296                state.note_editor = EditorState::default();
297                state.note_editor.set_active(active);
298                state.note_editor.set_path(selected_note.path.into());
299                state.note_editor.set_content(&selected_note.content);
300
301                if !config.experimental_editor {
302                    state.note_editor.view = View::Read;
303                }
304
305                // TODO: This should be behind an event/message
306                state.outline = OutlineState::new(
307                    state.note_editor.nodes(),
308                    state.note_editor.current_row,
309                    state.outline.is_open(),
310                );
311            }
312            Message::UpdateSelectedNoteContent((updated_content, nodes)) => {
313                if let Some(selected_note) = state.selected_note.as_mut() {
314                    selected_note.content = updated_content;
315                    return nodes.map(|nodes| Message::Outline(outline::Message::SetNodes(nodes)));
316                }
317            }
318            Message::Exec(command) => {
319                let (note_name, note_path) = state
320                    .selected_note
321                    .as_ref()
322                    .map(|note| (note.name.as_str(), note.path.as_str()))
323                    .unwrap_or_default();
324
325                return command::sync_command(
326                    terminal,
327                    command,
328                    state.explorer.title,
329                    note_name,
330                    note_path,
331                );
332            }
333
334            Message::Spawn(command) => {
335                let (note_name, note_path) = state
336                    .selected_note
337                    .as_ref()
338                    .map(|note| (note.name.as_str(), note.path.as_str()))
339                    .unwrap_or_default();
340
341                return command::spawn_command(command, state.explorer.title, note_name, note_path);
342            }
343
344            Message::HelpModal(message) => {
345                return help_modal::update(&message, state.screen_size, &mut state.help_modal);
346            }
347            Message::VaultSelectorModal(message) => {
348                return vault_selector_modal::update(&message, &mut state.vault_selector_modal);
349            }
350            Message::Splash(message) => {
351                return splash_modal::update(&message, &mut state.splash_modal);
352            }
353            Message::Explorer(message) => {
354                return explorer::update(&message, state.screen_size, &mut state.explorer);
355            }
356            Message::Outline(message) => {
357                return outline::update(&message, &mut state.outline);
358            }
359            Message::NoteEditor(message) => {
360                return note_editor::update(&message, state.screen_size, &mut state.note_editor);
361            }
362        };
363
364        None
365    }
366
367    fn render_splash(&self, area: Rect, buf: &mut Buffer, state: &mut SplashModalState<'a>) {
368        SplashModal::default().render_ref(area, buf, state)
369    }
370
371    fn render_main(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
372        let [content, statusbar] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
373            .horizontal_margin(1)
374            .areas(area);
375
376        let (left, right) = if state.explorer.open {
377            (Constraint::Length(35), Constraint::Fill(1))
378        } else {
379            (Constraint::Length(4), Constraint::Fill(1))
380        };
381
382        let [explorer_pane, note, outline] = Layout::horizontal([
383            left,
384            right,
385            if state.outline.is_open() {
386                Constraint::Length(35)
387            } else {
388                Constraint::Length(4)
389            },
390        ])
391        .areas(content);
392
393        Explorer::new().render(explorer_pane, buf, &mut state.explorer);
394        Editor::default().render(note, buf, &mut state.note_editor);
395        Outline.render(outline, buf, &mut state.outline);
396
397        let (_, counts) = state
398            .selected_note
399            .clone()
400            .map(|note| {
401                let content = note.content.as_str();
402                (
403                    note.name,
404                    (WordCount::from(content), CharCount::from(content)),
405                )
406            })
407            .unzip();
408
409        let (word_count, char_count) = counts.unwrap_or_default();
410
411        let mut status_bar_state = StatusBarState::new(
412            state.active_pane.into(),
413            word_count.into(),
414            char_count.into(),
415        );
416
417        let status_bar = StatusBar::default();
418        status_bar.render_ref(statusbar, buf, &mut status_bar_state);
419
420        self.render_modals(area, buf, state)
421    }
422
423    fn render_modals(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
424        if state.splash_modal.visible {
425            self.render_splash(area, buf, &mut state.splash_modal);
426        }
427
428        if state.vault_selector_modal.visible {
429            VaultSelectorModal::default().render(area, buf, &mut state.vault_selector_modal);
430        }
431
432        if state.help_modal.visible {
433            HelpModal.render(area, buf, &mut state.help_modal);
434        }
435    }
436}
437
438impl<'a> StatefulWidgetRef for App<'a> {
439    type State = AppState<'a>;
440
441    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
442        self.render_main(area, buf, state);
443    }
444}