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