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