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, KeyCode, KeyEvent, KeyEventKind},
5    layout::{Constraint, Flex, Layout, Rect, Size},
6    style::Style,
7    widgets::{Block, StatefulWidget, Widget},
8    DefaultTerminal,
9};
10use tracing::{debug, error, info, warn};
11
12use std::{
13    cell::RefCell,
14    fmt::Debug,
15    fs,
16    io::Result,
17    path::{Path, PathBuf},
18    time::{Duration, Instant},
19};
20
21use crate::{
22    command,
23    config::{self, Config, Keystroke, Theme},
24    debug_log::{self, DebugLogModal, DebugLogModalState, LogLevel},
25    explorer::{self, Explorer, ExplorerState, Item, Visibility},
26    header::Header,
27    help_modal::{self, HelpModal, HelpModalState},
28    input::{self, Input, InputModalState},
29    note_editor::{
30        self, ast,
31        editor::NoteEditor,
32        state::{EditMode, NoteEditorState, View},
33    },
34    outline::{self, Outline, OutlineState},
35    splash_modal::{self, SplashModal, SplashModalState},
36    statusbar::{StatusBar, StatusBarState},
37    stylized_text::{self, FontStyle},
38    tabs::{Tab, Tabs},
39    text_counts::{CharCount, WordCount},
40    theme_selector_modal::{self, ThemeSelectorModal, ThemeSelectorModalState},
41    toast::{self, Toast, TOAST_WIDTH},
42    vault_selector_modal::{self, VaultSelectorModal, VaultSelectorModalState},
43    vault_watcher::VaultWatcher,
44};
45
46const VERSION: &str = env!("CARGO_PKG_VERSION");
47
48const HELP_TEXT: &str = include_str!("./help.txt");
49
50#[derive(Debug, Default, Clone, PartialEq)]
51pub enum ScrollAmount {
52    #[default]
53    One,
54    HalfPage,
55}
56
57pub fn calc_scroll_amount(scroll_amount: &ScrollAmount, height: usize) -> usize {
58    match scroll_amount {
59        ScrollAmount::One => 1,
60        ScrollAmount::HalfPage => height / 2,
61    }
62}
63
64#[derive(Default, Clone)]
65pub struct AppState<'a> {
66    vault: Vault,
67    screen_size: Size,
68    is_running: bool,
69    pending_keys: Vec<Keystroke>,
70
71    active_pane: ActivePane,
72    theme: Theme,
73    explorer: ExplorerState,
74    tabs: Tabs<'a>,
75    outline: OutlineState,
76    toasts: Vec<Toast>,
77
78    input_modal: InputModalState,
79    splash_modal: SplashModalState<'a>,
80    help_modal: HelpModalState,
81    vault_selector_modal: VaultSelectorModalState<'a>,
82    debug_log_modal: DebugLogModalState,
83    theme_selector_modal: ThemeSelectorModalState,
84}
85
86impl<'a> AppState<'a> {
87    pub fn vault(&self) -> &Vault {
88        &self.vault
89    }
90
91    pub fn active_component(&self) -> ActivePane {
92        // Ordered top-most first, matching the modal render (z) order so the
93        // visually top modal is the one that receives input.
94        if self.debug_log_modal.visible {
95            return ActivePane::DebugLogModal;
96        }
97
98        if self.help_modal.visible {
99            return ActivePane::HelpModal;
100        }
101
102        if self.theme_selector_modal.visible {
103            return ActivePane::ThemeSelectorModal;
104        }
105
106        if self.vault_selector_modal.visible {
107            return ActivePane::VaultSelectorModal;
108        }
109
110        if self.splash_modal.visible {
111            return ActivePane::Splash;
112        }
113
114        self.active_pane
115    }
116
117    pub fn set_running(&self, is_running: bool) -> Self {
118        Self {
119            is_running,
120            ..self.clone()
121        }
122    }
123}
124
125#[derive(Clone, Debug, PartialEq)]
126pub enum Message<'a> {
127    Quit,
128    Exec(String),
129    Spawn(String),
130    CopyToClipboard(String),
131    Resize(Size),
132    SetActivePane(ActivePane),
133    RefreshVault {
134        rename: Option<(PathBuf, PathBuf)>,
135        select: Option<PathBuf>,
136    },
137    RescanVault,
138    CreateUntitledNote,
139    CreateUntitledFolder,
140    OpenVault(&'a Vault),
141    SelectNote(SelectedNote),
142    UpdateSelectedNoteContent((String, Option<Vec<ast::Node>>)),
143    TabNext,
144    TabPrevious,
145    CloseTab,
146
147    Batch(Vec<Message<'a>>),
148    Toast(toast::Message),
149    Input(input::Message),
150    Splash(splash_modal::Message),
151    Explorer(explorer::Message),
152    NoteEditor(note_editor::Message),
153    Outline(outline::Message),
154    HelpModal(help_modal::Message),
155    VaultSelectorModal(vault_selector_modal::Message),
156    DebugLog(debug_log::Message),
157    ThemeSelectorModal(theme_selector_modal::Message),
158    PreviewTheme(Theme),
159    SaveTheme(String),
160}
161
162#[derive(Debug, Default, Clone, Copy, PartialEq)]
163pub enum ActivePane {
164    #[default]
165    Splash,
166    Explorer,
167    NoteEditor,
168    Outline,
169    Input,
170    HelpModal,
171    VaultSelectorModal,
172    DebugLogModal,
173    ThemeSelectorModal,
174}
175
176impl From<ActivePane> for &str {
177    fn from(value: ActivePane) -> Self {
178        match value {
179            ActivePane::Splash => "Splash",
180            ActivePane::Explorer => "Explorer",
181            ActivePane::NoteEditor => "Note Editor",
182            ActivePane::Outline => "Outline",
183            ActivePane::Input => "Input",
184            ActivePane::HelpModal => "Help",
185            ActivePane::VaultSelectorModal => "Vault Selector",
186            ActivePane::DebugLogModal => "Debug Log",
187            ActivePane::ThemeSelectorModal => "Theme Selector",
188        }
189    }
190}
191
192#[derive(Debug, Default, Clone, PartialEq)]
193pub struct SelectedNote {
194    name: String,
195    path: PathBuf,
196    content: String,
197}
198
199impl SelectedNote {
200    pub fn new(name: &str, path: &Path, content: &str) -> Self {
201        Self {
202            name: name.to_string(),
203            path: path.to_path_buf(),
204            content: content.to_string(),
205        }
206    }
207
208    pub fn name(&self) -> &str {
209        &self.name
210    }
211
212    pub fn path(&self) -> &Path {
213        &self.path
214    }
215
216    pub fn set_name(&mut self, name: &str) {
217        self.name = name.to_string();
218    }
219
220    pub fn set_path(&mut self, path: &Path) {
221        self.path = path.to_path_buf();
222    }
223}
224
225impl From<Note> for SelectedNote {
226    fn from(value: Note) -> Self {
227        Self {
228            name: value.name().to_string(),
229            path: value.path().to_path_buf(),
230            content: fs::read_to_string(value.path()).unwrap_or_default(),
231        }
232    }
233}
234
235impl From<&Note> for SelectedNote {
236    fn from(value: &Note) -> Self {
237        Self {
238            name: value.name().to_string(),
239            path: value.path().to_path_buf(),
240            content: fs::read_to_string(value.path()).unwrap_or_default(),
241        }
242    }
243}
244
245fn help_text(version: &str) -> String {
246    HELP_TEXT.replace("%version-notice", version)
247}
248
249/// Applies a colour theme to the app and every sub-state that caches it.
250/// The note editor re-lays out so its baked-in span colours update.
251fn apply_theme(state: &mut AppState, theme: Theme) {
252    state.theme = theme;
253    state.explorer.set_theme(&theme);
254    state.outline.set_theme(&theme);
255    state.tabs.set_theme(&theme);
256}
257
258fn active_config_section<'a>(
259    config: &'a Config,
260    active: ActivePane,
261) -> &'a config::ConfigSection<'a> {
262    match active {
263        ActivePane::Splash => &config.splash,
264        ActivePane::Explorer => &config.explorer,
265        ActivePane::Outline => &config.outline,
266        ActivePane::HelpModal => &config.help_modal,
267        ActivePane::VaultSelectorModal => &config.vault_selector_modal,
268        ActivePane::ThemeSelectorModal => &config.theme_selector_modal,
269        ActivePane::Input => &config.input_modal,
270        ActivePane::NoteEditor => &config.note_editor,
271        ActivePane::DebugLogModal => &config.debug_log_modal,
272    }
273}
274
275fn rebuild_outline(state: &mut AppState, config: &Config) {
276    let is_open = state.outline.is_open();
277    let was_active = state.outline.active;
278    state.outline = match state.tabs.active_editor() {
279        Some(editor) => OutlineState::new(
280            &editor.ast_nodes,
281            editor.current_block_idx(),
282            is_open,
283            &config.symbols,
284        ),
285        None => OutlineState::new(&[], 0, is_open, &config.symbols),
286    };
287    state.outline.set_active(was_active);
288    // A fresh OutlineState carries the default theme; re-apply the active one so
289    // rebuilding (e.g. on a tab switch) doesn't revert it to the default look.
290    state.outline.set_theme(&state.theme);
291}
292
293fn focus_active_editor(state: &mut AppState) {
294    let focused = state.active_pane == ActivePane::NoteEditor;
295    if let Some(editor) = state.tabs.active_editor_mut() {
296        editor.set_active(focused);
297    }
298}
299
300fn sync_explorer_to_active_tab(state: &mut AppState) {
301    if let Some(path) = state
302        .tabs
303        .active_note()
304        .map(|note| note.path().to_path_buf())
305    {
306        state.explorer.reveal_path(&path);
307    }
308}
309
310/// Raw normal-mode keys that no static binding can express; `None` falls
311/// through to the config keybindings.
312fn normal_mode_raw_key(
313    editor: &note_editor::state::NoteEditorState,
314    key: &KeyEvent,
315) -> Option<note_editor::Message> {
316    let KeyCode::Char(character) = key.code else {
317        return None;
318    };
319    if editor.awaiting_replace() {
320        return Some(note_editor::Message::ReplaceTarget(character));
321    }
322    if editor.awaiting_text_object() {
323        return Some(note_editor::Message::TextObjectTarget(character));
324    }
325    if editor.awaiting_find_target() {
326        return Some(note_editor::Message::FindTarget(character));
327    }
328    match character.to_digit(10) {
329        // A lone `0` is the line-start motion; only a count in progress claims it.
330        Some(digit) if digit != 0 || editor.has_pending_count() => {
331            Some(note_editor::Message::CountDigit(digit as u8))
332        }
333        _ => None,
334    }
335}
336
337pub struct App<'a> {
338    state: AppState<'a>,
339    config: Config<'a>,
340    terminal: RefCell<DefaultTerminal>,
341    vault_watcher: RefCell<Option<VaultWatcher>>,
342}
343
344impl<'a> App<'a> {
345    pub fn new(state: AppState<'a>, config: Config<'a>, terminal: DefaultTerminal) -> Self {
346        Self {
347            state,
348            // TODO: Surface toast if read config returns error
349            config,
350            terminal: RefCell::new(terminal),
351            vault_watcher: RefCell::new(None),
352        }
353    }
354
355    fn ensure_watcher_for(&self, path: &Path) {
356        let mut current = self.vault_watcher.borrow_mut();
357        let needs_swap = match current.as_ref() {
358            Some(watcher) => watcher.path() != path,
359            None => !path.as_os_str().is_empty(),
360        };
361        if !needs_swap {
362            return;
363        }
364        *current = if path.is_dir() {
365            VaultWatcher::new(path).ok()
366        } else {
367            None
368        };
369    }
370
371    fn watcher_has_changes(&self) -> bool {
372        self.vault_watcher
373            .borrow()
374            .as_ref()
375            .is_some_and(|w| w.drain())
376    }
377
378    pub fn start(
379        terminal: DefaultTerminal,
380        vaults: Vec<&Vault>,
381        initial_vault: Option<Vault>,
382        debug: bool,
383        log_level: LogLevel,
384        theme_override: Option<String>,
385    ) -> Result<()> {
386        let version = stylized_text::stylize(VERSION, FontStyle::Script);
387        let size = terminal.size()?;
388        let (mut config, warnings) = config::load().unwrap();
389
390        if let Some(name) = &theme_override {
391            config.theme = config::theme::theme_by_name(name);
392        }
393
394        let vault = initial_vault.clone().unwrap_or_default();
395        let explorer = match &initial_vault {
396            Some(v) => ExplorerState::new(&v.name, v.entries(), &config.symbols),
397            None => ExplorerState::default(),
398        };
399        let active_pane = if initial_vault.is_some() {
400            ActivePane::Explorer
401        } else {
402            ActivePane::default()
403        };
404
405        let mut state = AppState {
406            vault,
407            explorer,
408            active_pane,
409            theme: config.theme,
410            screen_size: size,
411            help_modal: HelpModalState::new(&help_text(&version)),
412            vault_selector_modal: VaultSelectorModalState::new(vaults.clone()),
413            theme_selector_modal: ThemeSelectorModalState::new(config::theme::load_themes()),
414            splash_modal: SplashModalState::new(&version, vaults, initial_vault.is_none()),
415            outline: OutlineState {
416                symbols: config.symbols.clone(),
417                ..Default::default()
418            },
419            debug_log_modal: DebugLogModalState {
420                visible: debug,
421                min_level: log_level,
422                ..Default::default()
423            },
424            toasts: warnings
425                .into_iter()
426                .map(|message| {
427                    warn!(message, "config warning");
428                    toast::Toast::warn(&message, Duration::from_secs(5))
429                })
430                .collect(),
431            ..Default::default()
432        };
433
434        apply_theme(&mut state, config.theme);
435
436        App::new(state, config, terminal).run()
437    }
438
439    fn run(&'a mut self) -> Result<()> {
440        self.state.is_running = true;
441
442        let mut state = self.state.clone();
443        let config = self.config.clone();
444
445        self.ensure_watcher_for(&state.vault.path);
446
447        let tick_rate = Duration::from_millis(250);
448        let mut last_tick = Instant::now();
449
450        while state.is_running {
451            self.draw(&mut state)?;
452
453            let timeout = tick_rate.saturating_sub(last_tick.elapsed());
454
455            if event::poll(timeout)? {
456                let event = event::read()?;
457
458                let mut message = App::handle_event(&config, &mut state, event);
459                while message.is_some() {
460                    message = App::update(self.terminal.get_mut(), &config, &mut state, message);
461                }
462                self.ensure_watcher_for(&state.vault.path);
463            }
464
465            if self.watcher_has_changes() {
466                let mut message = Some(Message::RescanVault);
467                while message.is_some() {
468                    message = App::update(self.terminal.get_mut(), &config, &mut state, message);
469                }
470            }
471
472            if last_tick.elapsed() >= tick_rate {
473                App::update(
474                    self.terminal.get_mut(),
475                    &config,
476                    &mut state,
477                    Some(Message::Toast(toast::Message::Tick)),
478                );
479                last_tick = Instant::now();
480            }
481        }
482
483        Ok(())
484    }
485
486    fn draw(&self, state: &mut AppState<'a>) -> Result<()> {
487        let mut terminal = self.terminal.borrow_mut();
488
489        terminal.draw(move |frame| {
490            let area = frame.area();
491            let buf = frame.buffer_mut();
492            self.render(area, buf, state);
493        })?;
494
495        Ok(())
496    }
497
498    fn handle_event(
499        config: &'a Config,
500        state: &mut AppState<'_>,
501        event: Event,
502    ) -> Option<Message<'a>> {
503        match event {
504            Event::Resize(cols, rows) => Some(Message::Resize(Size::new(cols, rows))),
505            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
506                App::handle_key_event(config, state, key_event)
507            }
508            _ => None,
509        }
510    }
511
512    fn handle_key_event(
513        config: &'a Config,
514        state: &mut AppState<'_>,
515        key_event: KeyEvent,
516    ) -> Option<Message<'a>> {
517        // Vim normal-mode inputs no static binding can express: a pending
518        // replace/find/text-object target, or count digits.
519        if matches!(state.active_component(), ActivePane::NoteEditor) {
520            let raw = state
521                .tabs
522                .active_editor()
523                .filter(|editor| editor.is_editing() && editor.vim_mode() && !editor.insert_mode())
524                .and_then(|editor| normal_mode_raw_key(editor, &key_event));
525            if let Some(message) = raw {
526                state.pending_keys.clear();
527                return Some(Message::NoteEditor(message));
528            }
529        }
530
531        match state.active_component() {
532            ActivePane::NoteEditor
533                if state
534                    .tabs
535                    .active_editor()
536                    .is_some_and(|editor| editor.is_editing() && editor.insert_mode()) =>
537            {
538                state.pending_keys.clear();
539                note_editor::handle_editing_event(key_event).map(Message::NoteEditor)
540            }
541            ActivePane::Input if state.input_modal.is_editing() => {
542                state.pending_keys.clear();
543                input::handle_editing_event(key_event).map(Message::Input)
544            }
545            active => App::handle_pending_keys(
546                Keystroke::from(key_event),
547                config,
548                active,
549                &mut state.pending_keys,
550            ),
551        }
552    }
553
554    fn handle_pending_keys(
555        key: Keystroke,
556        config: &'a Config,
557        active: ActivePane,
558        pending_keys: &mut Vec<Keystroke>,
559    ) -> Option<Message<'a>> {
560        pending_keys.push(key.clone());
561        let section = active_config_section(config, active);
562
563        let global_message = config.global.sequence_to_message(pending_keys);
564        if global_message.is_some() {
565            pending_keys.clear();
566            return global_message;
567        }
568
569        let section_message = section.sequence_to_message(pending_keys);
570        if section_message.is_some() {
571            pending_keys.clear();
572            return section_message;
573        }
574
575        let is_sequence_prefix = config.global.is_sequence_prefix(pending_keys)
576            || section.is_sequence_prefix(pending_keys);
577
578        if is_sequence_prefix {
579            return None;
580        }
581
582        let is_sequence = pending_keys.len() > 1;
583
584        pending_keys.clear();
585        is_sequence
586            .then(|| App::handle_pending_keys(key, config, active, pending_keys))
587            .flatten()
588    }
589
590    fn update(
591        terminal: &mut DefaultTerminal,
592        config: &Config,
593        state: &mut AppState<'a>,
594        message: Option<Message<'a>>,
595    ) -> Option<Message<'a>> {
596        match message? {
597            Message::Batch(messages) => {
598                for msg in messages {
599                    let mut next = Some(msg);
600                    while next.is_some() {
601                        next = App::update(terminal, config, state, next);
602                    }
603                }
604            }
605            Message::Quit => state.is_running = false,
606            Message::Resize(size) => state.screen_size = size,
607            Message::RefreshVault { rename, select } => {
608                if let Some((old, new)) = &rename {
609                    // FIXME: Handle error propagation when wiki link update fails
610                    if let Err(error) = obsidian::vault::update_wiki_links(state.vault(), old, new)
611                    {
612                        warn!(?error, "failed to update wiki links");
613                    }
614                    let name = new
615                        .file_stem()
616                        .and_then(|stem| stem.to_str())
617                        .unwrap_or_default();
618                    state.tabs.rename(old, new, name);
619                }
620                state.explorer.with_entries(state.vault.entries(), select);
621                debug!(?rename, "refreshed vault");
622
623                // Reload the note editor for the currently selected note
624                let selected_note = if state
625                    .explorer
626                    .list_state
627                    .selected()
628                    .zip(state.explorer.selected_item_index)
629                    .is_some_and(|(a, b)| a == b)
630                {
631                    if let Some(Item::File { note, .. }) = state.explorer.current_item() {
632                        Some(SelectedNote::from(note))
633                    } else {
634                        None
635                    }
636                } else {
637                    state.tabs.active_note().cloned()
638                };
639
640                if let Some(note) = selected_note {
641                    return Some(Message::Batch(vec![
642                        Message::SelectNote(note),
643                        Message::SetActivePane(ActivePane::Explorer),
644                    ]));
645                }
646                return Some(Message::SetActivePane(ActivePane::Explorer));
647            }
648            Message::RescanVault => {
649                state.explorer.refresh_entries(state.vault.entries());
650                debug!("rescanned vault after watcher change");
651            }
652            Message::CreateUntitledNote => {
653                let path = match state.explorer.current_item() {
654                    Some(Item::Directory { path, .. }) => path,
655                    Some(Item::File { note, .. }) => {
656                        note.path().parent().unwrap_or(&state.vault.path)
657                    }
658                    _ => &state.vault.path,
659                };
660                match create_untitled_note(path) {
661                    Ok(note) => {
662                        info!(path = %note.path().display(), "created note");
663                        return Some(Message::Batch(vec![
664                            Message::Explorer(explorer::Message::Open),
665                            Message::RefreshVault {
666                                rename: None,
667                                select: Some(note.path().to_path_buf()),
668                            },
669                            Message::Toast(toast::Message::Create(toast::Toast::success(
670                                "Note created",
671                                Duration::from_secs(2),
672                            ))),
673                            Message::SelectNote(note.into()),
674                        ]));
675                    }
676                    Err(error) => {
677                        error!(?error, "failed to create note");
678                        return Some(Message::Toast(toast::Message::Create(toast::Toast::error(
679                            "Failed to create a new note",
680                            Duration::from_secs(2),
681                        ))));
682                    }
683                }
684            }
685            Message::CreateUntitledFolder => {
686                let path = match state.explorer.current_item() {
687                    Some(Item::Directory { path, .. }) => path,
688                    Some(Item::File { note, .. }) => {
689                        note.path().parent().unwrap_or(&state.vault.path)
690                    }
691                    _ => &state.vault.path,
692                };
693                match create_untitled_dir(path) {
694                    Ok(note) => {
695                        info!(path = %note.path().display(), "created folder");
696                        return Some(Message::Batch(vec![
697                            Message::Explorer(explorer::Message::Open),
698                            Message::RefreshVault {
699                                rename: None,
700                                select: Some(note.path().to_path_buf()),
701                            },
702                            Message::Toast(toast::Message::Create(toast::Toast::success(
703                                "Folder created",
704                                Duration::from_secs(2),
705                            ))),
706                        ]));
707                    }
708                    Err(error) => {
709                        error!(?error, "failed to create folder");
710                        return Some(Message::Toast(toast::Message::Create(toast::Toast::error(
711                            "Failed to create a new folder",
712                            Duration::from_secs(2),
713                        ))));
714                    }
715                }
716            }
717            Message::SetActivePane(active_pane) => match active_pane {
718                ActivePane::Explorer => {
719                    state.active_pane = active_pane;
720                    // TODO: use event/message
721                    state.explorer.set_active(true);
722                }
723                ActivePane::NoteEditor => {
724                    state.active_pane = active_pane;
725                    // TODO: use event/message
726                    if let Some(editor) = state.tabs.active_editor_mut() {
727                        editor.set_active(true);
728                    }
729                    if state.explorer.visibility == Visibility::FullWidth {
730                        return Some(Message::Explorer(explorer::Message::HidePane));
731                    }
732                }
733                ActivePane::Outline => {
734                    state.active_pane = active_pane;
735                    // TODO: use event/message
736                    state.outline.set_active(true);
737                }
738                ActivePane::Input => {
739                    state.active_pane = active_pane;
740                }
741                _ => {}
742            },
743            Message::OpenVault(vault) => {
744                info!(vault = %vault.name, "opened vault");
745                state.vault = vault.clone();
746                state.explorer = ExplorerState::new(&vault.name, vault.entries(), &config.symbols);
747                state.tabs = Tabs::default();
748                rebuild_outline(state, config);
749                apply_theme(state, state.theme);
750                return Some(Message::SetActivePane(ActivePane::Explorer));
751            }
752            Message::SelectNote(selected_note) => {
753                info!(note = %selected_note.name, "selected note");
754                let is_different = state
755                    .tabs
756                    .active_note()
757                    .is_some_and(|note| note.content != selected_note.content);
758
759                if !state.tabs.open_or_focus(selected_note.path()) {
760                    let mut editor = NoteEditorState::new(
761                        &selected_note.content,
762                        &selected_note.name,
763                        &selected_note.path,
764                        &config.symbols,
765                    );
766                    editor.set_vim_mode(config.vim_mode);
767                    editor.set_editor_enabled(config.experimental_editor);
768                    if config.experimental_editor && config.vim_mode {
769                        editor.set_view(View::Edit(EditMode::Source));
770                    } else {
771                        editor.set_view(View::Read);
772                    }
773                    state.tabs.open(Tab {
774                        note: selected_note,
775                        editor,
776                    });
777                }
778
779                rebuild_outline(state, config);
780
781                apply_theme(state, state.theme);
782
783                if state.explorer.visibility == Visibility::FullWidth && is_different {
784                    return Some(Message::Explorer(explorer::Message::HidePane));
785                }
786            }
787            Message::UpdateSelectedNoteContent((updated_content, nodes)) => {
788                if let Some(selected_note) = state.tabs.active_note_mut() {
789                    selected_note.content = updated_content;
790                    return nodes.map(|nodes| Message::Outline(outline::Message::SetNodes(nodes)));
791                }
792            }
793            Message::TabNext => {
794                state.tabs.next();
795                focus_active_editor(state);
796                sync_explorer_to_active_tab(state);
797                rebuild_outline(state, config);
798            }
799            Message::TabPrevious => {
800                state.tabs.prev();
801                focus_active_editor(state);
802                sync_explorer_to_active_tab(state);
803                rebuild_outline(state, config);
804            }
805            Message::CloseTab => {
806                state.tabs.close_active();
807                focus_active_editor(state);
808                sync_explorer_to_active_tab(state);
809                rebuild_outline(state, config);
810            }
811            Message::Exec(command) => {
812                let (note_name, note_path) = state
813                    .tabs
814                    .active_note()
815                    .map(|note| (note.name(), note.path().to_string_lossy()))
816                    .unwrap_or_default();
817
818                return command::sync_command(
819                    terminal,
820                    command,
821                    &state.vault.name,
822                    note_name,
823                    &note_path,
824                );
825            }
826
827            Message::Spawn(command) => {
828                let (note_name, note_path) = state
829                    .tabs
830                    .active_note()
831                    .map(|note| (note.name(), note.path().to_string_lossy()))
832                    .unwrap_or_default();
833
834                return command::spawn_command(command, &state.vault.name, note_name, &note_path);
835            }
836
837            Message::CopyToClipboard(text) => {
838                let toast = match crate::clipboard::copy(&text) {
839                    Ok(_) => Toast::success("Yanked to clipboard", Duration::from_secs(2)),
840                    Err(_) => Toast::error("Failed to copy to clipboard", Duration::from_secs(2)),
841                };
842                return Some(Message::Toast(toast::Message::Create(toast)));
843            }
844
845            Message::HelpModal(message) => {
846                return help_modal::update(&message, state.screen_size, &mut state.help_modal);
847            }
848            Message::VaultSelectorModal(message) => {
849                return vault_selector_modal::update(&message, &mut state.vault_selector_modal);
850            }
851            Message::ThemeSelectorModal(message) => {
852                return theme_selector_modal::update(
853                    &message,
854                    &mut state.theme_selector_modal,
855                    state.theme,
856                );
857            }
858            Message::PreviewTheme(theme) => apply_theme(state, theme),
859            Message::SaveTheme(name) => {
860                let toast = match config::save_theme(&name) {
861                    Ok(_) => {
862                        Toast::success(&format!("Saved theme \"{name}\""), Duration::from_secs(2))
863                    }
864                    Err(error) => Toast::error(
865                        &format!("Could not save theme: {error}"),
866                        Duration::from_secs(4),
867                    ),
868                };
869                return Some(Message::Toast(toast::Message::Create(toast)));
870            }
871            Message::Splash(message) => {
872                return splash_modal::update(&message, &mut state.splash_modal);
873            }
874            Message::Explorer(message) => {
875                return explorer::update(&message, state.screen_size, &mut state.explorer);
876            }
877            Message::Outline(message) => {
878                return outline::update(&message, &mut state.outline);
879            }
880            Message::NoteEditor(message) => {
881                let size = state.screen_size;
882                if let Some(editor) = state.tabs.active_editor_mut() {
883                    return note_editor::update(message, size, editor);
884                }
885                // With no open tab there is no editor to update, but pane
886                // navigation must still work so the user isn't trapped in the
887                // empty editor.
888                return match message {
889                    note_editor::Message::SwitchPaneNext => {
890                        Some(Message::SetActivePane(ActivePane::Outline))
891                    }
892                    note_editor::Message::SwitchPanePrevious => {
893                        Some(Message::SetActivePane(ActivePane::Explorer))
894                    }
895                    note_editor::Message::ToggleExplorer => {
896                        Some(Message::Explorer(explorer::Message::Toggle))
897                    }
898                    note_editor::Message::ToggleOutline => {
899                        Some(Message::Outline(outline::Message::Toggle))
900                    }
901                    _ => None,
902                };
903            }
904            Message::Input(message) => return input::update(message, &mut state.input_modal),
905            Message::DebugLog(message) => {
906                return debug_log::update(&message, state.screen_size, &mut state.debug_log_modal);
907            }
908            Message::Toast(message) => return toast::update(message, &mut state.toasts),
909        };
910
911        None
912    }
913
914    fn render_splash(
915        &self,
916        area: Rect,
917        buf: &mut Buffer,
918        theme: Theme,
919        state: &mut SplashModalState<'a>,
920    ) {
921        let border_modal = self.config.symbols.border_modal.into();
922        let vault_active = self.config.symbols.vault_active.clone();
923        SplashModal::new(border_modal, vault_active, theme).render(area, buf, state)
924    }
925
926    fn render_main(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
927        let [header, content, statusbar] = Layout::vertical([
928            Constraint::Length(1),
929            Constraint::Fill(1),
930            Constraint::Length(1),
931        ])
932        .horizontal_margin(1)
933        .areas(area);
934
935        Header::new(&self.config.symbols, &state.theme, &state.tabs).render(header, buf);
936
937        let (left, right) = match state.explorer.visibility {
938            Visibility::Hidden => (Constraint::Length(4), Constraint::Fill(1)),
939            Visibility::Visible => (Constraint::Length(35), Constraint::Fill(1)),
940            Visibility::FullWidth => (Constraint::Fill(1), Constraint::Length(0)),
941        };
942
943        let [explorer_pane, note, outline] = Layout::horizontal([
944            left,
945            right,
946            if state.outline.is_open() {
947                Constraint::Length(35)
948            } else {
949                Constraint::Length(4)
950            },
951        ])
952        .areas(content);
953
954        let theme = state.theme;
955
956        Explorer::new().render(explorer_pane, buf, &mut state.explorer);
957        match state.tabs.active_editor_mut() {
958            Some(editor) => NoteEditor::default().render(note, buf, editor),
959            None => {
960                let mut empty = NoteEditorState::new("", "", Path::new(""), &self.config.symbols);
961                empty.set_theme(&theme);
962                empty.set_active(state.active_pane == ActivePane::NoteEditor);
963                NoteEditor::default().render(note, buf, &mut empty);
964            }
965        }
966        Outline.render(outline, buf, &mut state.outline);
967        let border_modal = self.config.symbols.border_modal.into();
968        Input::new(border_modal, theme).render(explorer_pane, buf, &mut state.input_modal);
969
970        let (word_count, char_count) = state
971            .tabs
972            .active_note()
973            .map(|note| {
974                let content = note.content.as_str();
975                (WordCount::from(content), CharCount::from(content))
976            })
977            .unwrap_or_default();
978
979        let mode = state
980            .tabs
981            .active_editor()
982            .map(|editor| editor.mode())
983            .unwrap_or_default();
984
985        let mut status_bar_state = StatusBarState::new(
986            state.active_pane.into(),
987            mode,
988            word_count.into(),
989            char_count.into(),
990        );
991
992        let status_bar = StatusBar::new(&theme);
993        status_bar.render(statusbar, buf, &mut status_bar_state);
994
995        self.render_modals(area, buf, state);
996        self.render_toasts(area, buf, state);
997
998        if state.debug_log_modal.visible {
999            let border_modal = self.config.symbols.border_modal.into();
1000            let memory_mb =
1001                memory_stats::memory_stats().map(|stats| stats.physical_mem as f64 / 1_048_576.0);
1002            DebugLogModal::new(border_modal, state.theme, memory_mb).render(
1003                area,
1004                buf,
1005                &mut state.debug_log_modal,
1006            );
1007        }
1008    }
1009
1010    fn render_modals(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
1011        let theme = state.theme;
1012
1013        if state.splash_modal.visible {
1014            self.render_splash(area, buf, theme, &mut state.splash_modal);
1015        }
1016
1017        if state.vault_selector_modal.visible {
1018            let border_modal = self.config.symbols.border_modal.into();
1019            let vault_active = self.config.symbols.vault_active.clone();
1020            VaultSelectorModal::new(border_modal, vault_active, theme).render(
1021                area,
1022                buf,
1023                &mut state.vault_selector_modal,
1024            );
1025        }
1026
1027        if state.theme_selector_modal.visible {
1028            let border_modal = self.config.symbols.border_modal.into();
1029            ThemeSelectorModal::new(border_modal, theme).render(
1030                area,
1031                buf,
1032                &mut state.theme_selector_modal,
1033            );
1034        }
1035
1036        if state.help_modal.visible {
1037            let border_modal = self.config.symbols.border_modal.into();
1038            HelpModal::new(border_modal, theme).render(area, buf, &mut state.help_modal);
1039        }
1040    }
1041
1042    fn render_toasts(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
1043        let [_, toast_area] =
1044            Layout::horizontal([Constraint::Fill(1), Constraint::Length(TOAST_WIDTH)])
1045                .horizontal_margin(1)
1046                .flex(Flex::End)
1047                .areas(area);
1048
1049        let mut y_offset: u16 = 0;
1050        state.toasts.iter().rev().for_each(|toast| {
1051            let mut toast_area = toast_area;
1052            toast_area.y += y_offset;
1053            y_offset += toast.height();
1054            if toast_area.y >= area.bottom() {
1055                return;
1056            }
1057            let mut toast = toast.clone();
1058            toast.border_type = self.config.symbols.border_modal.into();
1059            toast.icon = toast.level_icon(&self.config.symbols);
1060            toast.theme = state.theme;
1061            toast.render(toast_area, buf)
1062        });
1063    }
1064}
1065
1066impl<'a> StatefulWidget for &App<'a> {
1067    type State = AppState<'a>;
1068
1069    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
1070        Block::new()
1071            .style(Style::new().bg(state.theme.background))
1072            .render(area, buf);
1073        self.render_main(area, buf, state);
1074    }
1075}