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 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
249fn 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 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
310fn normal_mode_raw_key(
313 editor: ¬e_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 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 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 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 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 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 state.explorer.set_active(true);
722 }
723 ActivePane::NoteEditor => {
724 state.active_pane = active_pane;
725 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 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 ¬e_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, ¬e_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 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}