Skip to main content

basalt_tui/
app.rs

1use basalt_core::obsidian::{self, create_untitled_dir, create_untitled_note, Note, Vault};
2use ratatui::{
3    buffer::Buffer,
4    crossterm::event::{self, Event, KeyEvent, KeyEventKind},
5    layout::{Constraint, Flex, Layout, Rect, Size},
6    widgets::{StatefulWidget, Widget},
7    DefaultTerminal,
8};
9
10use std::{
11    cell::RefCell,
12    fmt::Debug,
13    fs,
14    io::Result,
15    path::PathBuf,
16    time::{Duration, Instant},
17};
18
19use crate::{
20    command,
21    config::{self, Config, Keystroke},
22    explorer::{self, Explorer, ExplorerState, Item, Visibility},
23    help_modal::{self, HelpModal, HelpModalState},
24    input::{self, Input, InputModalState},
25    note_editor::{
26        self, ast,
27        editor::NoteEditor,
28        state::{EditMode, NoteEditorState, View},
29    },
30    outline::{self, Outline, OutlineState},
31    splash_modal::{self, SplashModal, SplashModalState},
32    statusbar::{StatusBar, StatusBarState},
33    stylized_text::{self, FontStyle},
34    text_counts::{CharCount, WordCount},
35    toast::{self, Toast, TOAST_WIDTH},
36    vault_selector_modal::{self, VaultSelectorModal, VaultSelectorModalState},
37};
38
39const VERSION: &str = env!("CARGO_PKG_VERSION");
40
41const HELP_TEXT: &str = include_str!("./help.txt");
42
43#[derive(Debug, Default, Clone, PartialEq)]
44pub enum ScrollAmount {
45    #[default]
46    One,
47    HalfPage,
48}
49
50pub fn calc_scroll_amount(scroll_amount: &ScrollAmount, height: usize) -> usize {
51    match scroll_amount {
52        ScrollAmount::One => 1,
53        ScrollAmount::HalfPage => height / 2,
54    }
55}
56
57#[derive(Default, Clone)]
58pub struct AppState<'a> {
59    vault: Vault,
60    screen_size: Size,
61    is_running: bool,
62    pending_keys: Vec<Keystroke>,
63
64    active_pane: ActivePane,
65    explorer: ExplorerState,
66    note_editor: NoteEditorState<'a>,
67    outline: OutlineState,
68    selected_note: Option<SelectedNote>,
69    toasts: Vec<Toast>,
70
71    input_modal: InputModalState,
72    splash_modal: SplashModalState<'a>,
73    help_modal: HelpModalState,
74    vault_selector_modal: VaultSelectorModalState<'a>,
75}
76
77impl<'a> AppState<'a> {
78    pub fn vault(&self) -> &Vault {
79        &self.vault
80    }
81
82    pub fn active_component(&self) -> ActivePane {
83        if self.help_modal.visible {
84            return ActivePane::HelpModal;
85        }
86
87        if self.vault_selector_modal.visible {
88            return ActivePane::VaultSelectorModal;
89        }
90
91        if self.splash_modal.visible {
92            return ActivePane::Splash;
93        }
94
95        self.active_pane
96    }
97
98    pub fn set_running(&self, is_running: bool) -> Self {
99        Self {
100            is_running,
101            ..self.clone()
102        }
103    }
104}
105
106#[derive(Clone, Debug, PartialEq)]
107pub enum Message<'a> {
108    Quit,
109    Exec(String),
110    Spawn(String),
111    Resize(Size),
112    SetActivePane(ActivePane),
113    RefreshVault {
114        rename: Option<(PathBuf, PathBuf)>,
115        select: Option<PathBuf>,
116    },
117    CreateUntitledNote,
118    CreateUntitledFolder,
119    OpenVault(&'a Vault),
120    SelectNote(SelectedNote),
121    UpdateSelectedNoteContent((String, Option<Vec<ast::Node>>)),
122
123    Batch(Vec<Message<'a>>),
124    Toast(toast::Message),
125    Input(input::Message),
126    Splash(splash_modal::Message),
127    Explorer(explorer::Message),
128    NoteEditor(note_editor::Message),
129    Outline(outline::Message),
130    HelpModal(help_modal::Message),
131    VaultSelectorModal(vault_selector_modal::Message),
132}
133
134#[derive(Debug, Default, Clone, Copy, PartialEq)]
135pub enum ActivePane {
136    #[default]
137    Splash,
138    Explorer,
139    NoteEditor,
140    Outline,
141    Input,
142    HelpModal,
143    VaultSelectorModal,
144}
145
146impl From<ActivePane> for &str {
147    fn from(value: ActivePane) -> Self {
148        match value {
149            ActivePane::Splash => "Splash",
150            ActivePane::Explorer => "Explorer",
151            ActivePane::NoteEditor => "Note Editor",
152            ActivePane::Outline => "Outline",
153            ActivePane::Input => "Input",
154            ActivePane::HelpModal => "Help",
155            ActivePane::VaultSelectorModal => "Vault Selector",
156        }
157    }
158}
159
160#[derive(Debug, Default, Clone, PartialEq)]
161pub struct SelectedNote {
162    name: String,
163    path: PathBuf,
164    content: String,
165}
166
167impl From<Note> for SelectedNote {
168    fn from(value: Note) -> Self {
169        Self {
170            name: value.name().to_string(),
171            path: value.path().to_path_buf(),
172            content: fs::read_to_string(value.path()).unwrap_or_default(),
173        }
174    }
175}
176
177impl From<&Note> for SelectedNote {
178    fn from(value: &Note) -> Self {
179        Self {
180            name: value.name().to_string(),
181            path: value.path().to_path_buf(),
182            content: fs::read_to_string(value.path()).unwrap_or_default(),
183        }
184    }
185}
186
187fn help_text(version: &str) -> String {
188    HELP_TEXT.replace("%version-notice", version)
189}
190
191fn active_config_section<'a>(
192    config: &'a Config,
193    active: ActivePane,
194) -> &'a config::ConfigSection<'a> {
195    match active {
196        ActivePane::Splash => &config.splash,
197        ActivePane::Explorer => &config.explorer,
198        ActivePane::Outline => &config.outline,
199        ActivePane::HelpModal => &config.help_modal,
200        ActivePane::VaultSelectorModal => &config.vault_selector_modal,
201        ActivePane::Input => &config.input_modal,
202        ActivePane::NoteEditor => &config.note_editor,
203    }
204}
205
206pub struct App<'a> {
207    state: AppState<'a>,
208    config: Config<'a>,
209    terminal: RefCell<DefaultTerminal>,
210}
211
212impl<'a> App<'a> {
213    pub fn new(state: AppState<'a>, config: Config<'a>, terminal: DefaultTerminal) -> Self {
214        Self {
215            state,
216            // TODO: Surface toast if read config returns error
217            config,
218            terminal: RefCell::new(terminal),
219        }
220    }
221
222    pub fn start(terminal: DefaultTerminal, vaults: Vec<&Vault>) -> Result<()> {
223        let version = stylized_text::stylize(VERSION, FontStyle::Script);
224        let size = terminal.size()?;
225        let (config, warnings) = config::load().unwrap();
226
227        let state = AppState {
228            screen_size: size,
229            help_modal: HelpModalState::new(&help_text(&version)),
230            vault_selector_modal: VaultSelectorModalState::new(vaults.clone()),
231            splash_modal: SplashModalState::new(&version, vaults, true),
232            outline: OutlineState {
233                symbols: config.symbols.clone(),
234                ..Default::default()
235            },
236            toasts: warnings
237                .into_iter()
238                .map(|message| toast::Toast::warn(&message, Duration::from_secs(5)))
239                .collect(),
240            ..Default::default()
241        };
242
243        App::new(state, config, terminal).run()
244    }
245
246    fn run(&'a mut self) -> Result<()> {
247        self.state.is_running = true;
248
249        let mut state = self.state.clone();
250        let config = self.config.clone();
251
252        let tick_rate = Duration::from_millis(250);
253        let mut last_tick = Instant::now();
254
255        while state.is_running {
256            self.draw(&mut state)?;
257
258            let timeout = tick_rate.saturating_sub(last_tick.elapsed());
259
260            if event::poll(timeout)? {
261                let event = event::read()?;
262
263                let mut message = App::handle_event(&config, &mut state, event);
264                while message.is_some() {
265                    message = App::update(self.terminal.get_mut(), &config, &mut state, message);
266                }
267            }
268            if last_tick.elapsed() >= tick_rate {
269                App::update(
270                    self.terminal.get_mut(),
271                    &config,
272                    &mut state,
273                    Some(Message::Toast(toast::Message::Tick)),
274                );
275                last_tick = Instant::now();
276            }
277        }
278
279        Ok(())
280    }
281
282    fn draw(&self, state: &mut AppState<'a>) -> Result<()> {
283        let mut terminal = self.terminal.borrow_mut();
284
285        terminal.draw(move |frame| {
286            let area = frame.area();
287            let buf = frame.buffer_mut();
288            self.render(area, buf, state);
289        })?;
290
291        Ok(())
292    }
293
294    fn handle_event(
295        config: &'a Config,
296        state: &mut AppState<'_>,
297        event: Event,
298    ) -> Option<Message<'a>> {
299        match event {
300            Event::Resize(cols, rows) => Some(Message::Resize(Size::new(cols, rows))),
301            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
302                App::handle_key_event(config, state, key_event)
303            }
304            _ => None,
305        }
306    }
307
308    fn handle_key_event(
309        config: &'a Config,
310        state: &mut AppState<'_>,
311        key_event: KeyEvent,
312    ) -> Option<Message<'a>> {
313        match state.active_component() {
314            ActivePane::NoteEditor
315                if state.note_editor.is_editing() && state.note_editor.insert_mode() =>
316            {
317                state.pending_keys.clear();
318                note_editor::handle_editing_event(key_event).map(Message::NoteEditor)
319            }
320            ActivePane::Input if state.input_modal.is_editing() => {
321                state.pending_keys.clear();
322                input::handle_editing_event(key_event).map(Message::Input)
323            }
324            active => App::handle_pending_keys(
325                Keystroke::from(key_event),
326                config,
327                active,
328                &mut state.pending_keys,
329            ),
330        }
331    }
332
333    fn handle_pending_keys(
334        key: Keystroke,
335        config: &'a Config,
336        active: ActivePane,
337        pending_keys: &mut Vec<Keystroke>,
338    ) -> Option<Message<'a>> {
339        pending_keys.push(key.clone());
340        let section = active_config_section(config, active);
341
342        let global_message = config.global.sequence_to_message(pending_keys);
343        if global_message.is_some() {
344            pending_keys.clear();
345            return global_message;
346        }
347
348        let section_message = section.sequence_to_message(pending_keys);
349        if section_message.is_some() {
350            pending_keys.clear();
351            return section_message;
352        }
353
354        let is_sequence_prefix = config.global.is_sequence_prefix(pending_keys)
355            || section.is_sequence_prefix(pending_keys);
356
357        if is_sequence_prefix {
358            return None;
359        }
360
361        let is_sequence = pending_keys.len() > 1;
362
363        pending_keys.clear();
364        is_sequence
365            .then(|| App::handle_pending_keys(key, config, active, pending_keys))
366            .flatten()
367    }
368
369    fn update(
370        terminal: &mut DefaultTerminal,
371        config: &Config,
372        state: &mut AppState<'a>,
373        message: Option<Message<'a>>,
374    ) -> Option<Message<'a>> {
375        match message? {
376            Message::Batch(messages) => {
377                for msg in messages {
378                    let mut next = Some(msg);
379                    while next.is_some() {
380                        next = App::update(terminal, config, state, next);
381                    }
382                }
383            }
384            Message::Quit => state.is_running = false,
385            Message::Resize(size) => state.screen_size = size,
386            Message::RefreshVault { rename, select } => {
387                if let Some((old, new)) = &rename {
388                    // FIXME: Handle error propagation when wiki link update fails
389                    let _ = obsidian::vault::update_wiki_links(state.vault(), old, new);
390                }
391                state.explorer.with_entries(state.vault.entries(), select);
392
393                // Reload the note editor for the currently selected note
394                let selected_note = if state
395                    .explorer
396                    .list_state
397                    .selected()
398                    .zip(state.explorer.selected_item_index)
399                    .is_some_and(|(a, b)| a == b)
400                {
401                    if let Some(Item::File(note)) = state.explorer.current_item() {
402                        Some(SelectedNote::from(note))
403                    } else {
404                        None
405                    }
406                } else {
407                    state.selected_note.clone()
408                };
409
410                if let Some(note) = selected_note {
411                    return Some(Message::Batch(vec![
412                        Message::SelectNote(note),
413                        Message::SetActivePane(ActivePane::Explorer),
414                    ]));
415                }
416                return Some(Message::SetActivePane(ActivePane::Explorer));
417            }
418            Message::CreateUntitledNote => match create_untitled_note(&state.vault) {
419                Ok(note) => {
420                    return Some(Message::Batch(vec![
421                        Message::RefreshVault {
422                            rename: None,
423                            select: Some(note.path().to_path_buf()),
424                        },
425                        Message::Toast(toast::Message::Create(toast::Toast::success(
426                            "Note created",
427                            Duration::from_secs(2),
428                        ))),
429                        Message::SelectNote(note.into()),
430                    ]));
431                }
432                Err(_) => {
433                    return Some(Message::Toast(toast::Message::Create(toast::Toast::error(
434                        "Failed to create a new note",
435                        Duration::from_secs(2),
436                    ))));
437                }
438            },
439            Message::CreateUntitledFolder => match create_untitled_dir(&state.vault) {
440                Ok(note) => {
441                    return Some(Message::Batch(vec![
442                        Message::RefreshVault {
443                            rename: None,
444                            select: Some(note.path().to_path_buf()),
445                        },
446                        Message::Toast(toast::Message::Create(toast::Toast::success(
447                            "Folder created",
448                            Duration::from_secs(2),
449                        ))),
450                    ]));
451                }
452                Err(_) => {
453                    return Some(Message::Toast(toast::Message::Create(toast::Toast::error(
454                        "Failed to create a new folder",
455                        Duration::from_secs(2),
456                    ))));
457                }
458            },
459            Message::SetActivePane(active_pane) => match active_pane {
460                ActivePane::Explorer => {
461                    state.active_pane = active_pane;
462                    // TODO: use event/message
463                    state.explorer.set_active(true);
464                }
465                ActivePane::NoteEditor => {
466                    state.active_pane = active_pane;
467                    // TODO: use event/message
468                    state.note_editor.set_active(true);
469                    if state.explorer.visibility == Visibility::FullWidth {
470                        return Some(Message::Explorer(explorer::Message::HidePane));
471                    }
472                }
473                ActivePane::Outline => {
474                    state.active_pane = active_pane;
475                    // TODO: use event/message
476                    state.outline.set_active(true);
477                }
478                ActivePane::Input => {
479                    state.active_pane = active_pane;
480                }
481                _ => {}
482            },
483            Message::OpenVault(vault) => {
484                state.vault = vault.clone();
485                state.explorer = ExplorerState::new(&vault.name, vault.entries(), &config.symbols);
486                state.note_editor = NoteEditorState::default();
487                return Some(Message::SetActivePane(ActivePane::Explorer));
488            }
489            Message::SelectNote(selected_note) => {
490                let is_different = state
491                    .selected_note
492                    .as_ref()
493                    .is_some_and(|note| note.content != selected_note.content);
494                state.selected_note = Some(selected_note.clone());
495
496                state.note_editor = NoteEditorState::new(
497                    &selected_note.content,
498                    &selected_note.name,
499                    &selected_note.path,
500                    &config.symbols,
501                );
502
503                let vim_mode = config.vim_mode;
504                state.note_editor.set_vim_mode(vim_mode);
505
506                let editor_enabled = config.experimental_editor;
507                state.note_editor.set_editor_enabled(editor_enabled);
508
509                if editor_enabled && vim_mode {
510                    state.note_editor.set_view(View::Edit(EditMode::Source));
511                } else {
512                    state.note_editor.set_view(View::Read);
513                }
514
515                // TODO: This should be behind an event/message
516                state.outline = OutlineState::new(
517                    &state.note_editor.ast_nodes,
518                    state.note_editor.current_block(),
519                    state.outline.is_open(),
520                    &config.symbols,
521                );
522
523                if state.explorer.visibility == Visibility::FullWidth && is_different {
524                    return Some(Message::Explorer(explorer::Message::HidePane));
525                }
526            }
527            Message::UpdateSelectedNoteContent((updated_content, nodes)) => {
528                if let Some(selected_note) = state.selected_note.as_mut() {
529                    selected_note.content = updated_content;
530                    return nodes.map(|nodes| Message::Outline(outline::Message::SetNodes(nodes)));
531                }
532            }
533            Message::Exec(command) => {
534                let (note_name, note_path) = state
535                    .selected_note
536                    .as_ref()
537                    .map(|note| (note.name.as_str(), note.path.to_string_lossy()))
538                    .unwrap_or_default();
539
540                return command::sync_command(
541                    terminal,
542                    command,
543                    &state.vault.name,
544                    note_name,
545                    &note_path,
546                );
547            }
548
549            Message::Spawn(command) => {
550                let (note_name, note_path) = state
551                    .selected_note
552                    .as_ref()
553                    .map(|note| (note.name.as_str(), note.path.to_string_lossy()))
554                    .unwrap_or_default();
555
556                return command::spawn_command(command, &state.vault.name, note_name, &note_path);
557            }
558
559            Message::HelpModal(message) => {
560                return help_modal::update(&message, state.screen_size, &mut state.help_modal);
561            }
562            Message::VaultSelectorModal(message) => {
563                return vault_selector_modal::update(&message, &mut state.vault_selector_modal);
564            }
565            Message::Splash(message) => {
566                return splash_modal::update(&message, &mut state.splash_modal);
567            }
568            Message::Explorer(message) => {
569                return explorer::update(&message, state.screen_size, &mut state.explorer);
570            }
571            Message::Outline(message) => {
572                return outline::update(&message, &mut state.outline);
573            }
574            Message::NoteEditor(message) => {
575                return note_editor::update(message, state.screen_size, &mut state.note_editor);
576            }
577            Message::Input(message) => return input::update(message, &mut state.input_modal),
578            Message::Toast(message) => return toast::update(message, &mut state.toasts),
579        };
580
581        None
582    }
583
584    fn render_splash(&self, area: Rect, buf: &mut Buffer, state: &mut SplashModalState<'a>) {
585        let border_modal = self.config.symbols.border_modal.into();
586        let vault_active = self.config.symbols.vault_active.clone();
587        SplashModal::new(border_modal, vault_active).render(area, buf, state)
588    }
589
590    fn render_main(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
591        let [content, statusbar] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
592            .horizontal_margin(1)
593            .areas(area);
594
595        let (left, right) = match state.explorer.visibility {
596            Visibility::Hidden => (Constraint::Length(4), Constraint::Fill(1)),
597            Visibility::Visible => (Constraint::Length(35), Constraint::Fill(1)),
598            Visibility::FullWidth => (Constraint::Fill(1), Constraint::Length(0)),
599        };
600
601        let [explorer_pane, note, outline] = Layout::horizontal([
602            left,
603            right,
604            if state.outline.is_open() {
605                Constraint::Length(35)
606            } else {
607                Constraint::Length(4)
608            },
609        ])
610        .areas(content);
611
612        Explorer::new().render(explorer_pane, buf, &mut state.explorer);
613        NoteEditor::default().render(note, buf, &mut state.note_editor);
614        Outline.render(outline, buf, &mut state.outline);
615        let border_modal = self.config.symbols.border_modal.into();
616        Input::new(border_modal).render(area, buf, &mut state.input_modal);
617
618        let (_, counts) = state
619            .selected_note
620            .clone()
621            .map(|note| {
622                let content = note.content.as_str();
623                (
624                    note.name,
625                    (WordCount::from(content), CharCount::from(content)),
626                )
627            })
628            .unzip();
629
630        let (word_count, char_count) = counts.unwrap_or_default();
631
632        let mut status_bar_state = StatusBarState::new(
633            state.active_pane.into(),
634            word_count.into(),
635            char_count.into(),
636        );
637
638        let status_bar = StatusBar::default();
639        status_bar.render(statusbar, buf, &mut status_bar_state);
640
641        self.render_modals(area, buf, state);
642        self.render_toasts(area, buf, state);
643    }
644
645    fn render_modals(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
646        if state.splash_modal.visible {
647            self.render_splash(area, buf, &mut state.splash_modal);
648        }
649
650        if state.vault_selector_modal.visible {
651            let border_modal = self.config.symbols.border_modal.into();
652            let vault_active = self.config.symbols.vault_active.clone();
653            VaultSelectorModal::new(border_modal, vault_active).render(
654                area,
655                buf,
656                &mut state.vault_selector_modal,
657            );
658        }
659
660        if state.help_modal.visible {
661            let border_modal = self.config.symbols.border_modal.into();
662            HelpModal::new(border_modal).render(area, buf, &mut state.help_modal);
663        }
664    }
665
666    fn render_toasts(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
667        let [_, toast_area] =
668            Layout::horizontal([Constraint::Fill(1), Constraint::Length(TOAST_WIDTH)])
669                .horizontal_margin(1)
670                .flex(Flex::End)
671                .areas(area);
672
673        let mut y_offset: u16 = 0;
674        state.toasts.iter().rev().for_each(|toast| {
675            let mut toast_area = toast_area;
676            toast_area.y += y_offset;
677            y_offset += toast.height();
678            if toast_area.y >= area.bottom() {
679                return;
680            }
681            let mut toast = toast.clone();
682            toast.border_type = self.config.symbols.border_modal.into();
683            toast.icon = toast.level_icon(&self.config.symbols);
684            toast.render(toast_area, buf)
685        });
686    }
687}
688
689impl<'a> StatefulWidget for &App<'a> {
690    type State = AppState<'a>;
691
692    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
693        self.render_main(area, buf, state);
694    }
695}