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