basalt_tui/
app.rs

1use super::markdown::{MarkdownView, MarkdownViewState};
2use basalt_core::obsidian::{Note, Vault};
3use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
4use ratatui::{
5    buffer::Buffer,
6    layout::{Constraint, Layout, Rect, Size},
7    widgets::{StatefulWidget, StatefulWidgetRef},
8    DefaultTerminal,
9};
10
11use std::{cell::RefCell, io::Result, marker::PhantomData};
12
13use crate::{
14    help_modal::{HelpModal, HelpModalState},
15    sidepanel::{SidePanel, SidePanelState},
16    start::{StartScreen, StartState},
17    statusbar::{StatusBar, StatusBarState},
18    text_counts::{CharCount, WordCount},
19    vault_selector_modal::{VaultSelectorModal, VaultSelectorModalState},
20};
21
22const VERSION: &str = env!("CARGO_PKG_VERSION");
23
24const HELP_TEXT: &str = include_str!("./help.txt");
25
26#[derive(Debug, Clone, Default, PartialEq)]
27pub enum Mode {
28    #[default]
29    Select,
30    Normal,
31    Insert,
32}
33
34impl Mode {
35    fn as_str(&self) -> &'static str {
36        match self {
37            Mode::Select => "Select",
38            Mode::Normal => "Normal",
39            Mode::Insert => "Insert",
40        }
41    }
42}
43
44#[derive(Debug, Default, Clone, PartialEq)]
45pub enum ScrollAmount {
46    #[default]
47    One,
48    HalfPage,
49}
50
51fn calc_scroll_amount(scroll_amount: ScrollAmount, size: Size) -> usize {
52    match scroll_amount {
53        ScrollAmount::One => 1,
54        ScrollAmount::HalfPage => (size.height / 3).into(),
55    }
56}
57
58#[derive(Debug, PartialEq)]
59pub enum Action {
60    Select,
61    Next,
62    Prev,
63    Insert,
64    Resize(Size),
65    ScrollUp(ScrollAmount),
66    ScrollDown(ScrollAmount),
67    ToggleMode,
68    ToggleHelp,
69    ToggleVaultSelector,
70    Quit,
71}
72
73#[derive(Debug, Default, Clone, PartialEq)]
74pub struct Start<'a> {
75    pub start_state: StartState<'a>,
76}
77
78#[derive(Debug, Default, Clone, PartialEq)]
79pub struct Main<'a> {
80    pub sidepanel_state: SidePanelState<'a>,
81    pub selected_note: Option<SelectedNote>,
82    pub markdown_view_state: MarkdownViewState,
83    pub notes: Vec<Note>,
84    pub vaults: Vec<&'a Vault>,
85    pub size: Size,
86    pub mode: Mode,
87}
88
89impl<'a> Main<'a> {
90    fn new(vault_name: &'a str, notes: Vec<Note>, size: Size, vaults: Vec<&'a Vault>) -> Self {
91        Self {
92            notes: notes.clone(),
93            sidepanel_state: SidePanelState::new(vault_name, notes),
94            vaults,
95            size,
96            ..Default::default()
97        }
98    }
99}
100
101#[derive(Debug, Clone, PartialEq)]
102pub enum Screen<'a> {
103    Start(Start<'a>),
104    Main(Main<'a>),
105}
106
107impl Default for Screen<'_> {
108    fn default() -> Self {
109        Screen::Start(Start::default())
110    }
111}
112
113#[derive(Debug, Default, Clone, PartialEq)]
114pub struct AppState<'a> {
115    pub help_modal: Option<HelpModalState>,
116    pub vault_selector_modal: Option<VaultSelectorModalState<'a>>,
117    pub size: Size,
118    pub is_running: bool,
119    pub screen: Screen<'a>,
120    _lifetime: PhantomData<&'a ()>,
121}
122
123pub struct App<'a> {
124    pub state: AppState<'a>,
125    terminal: RefCell<DefaultTerminal>,
126}
127
128impl<'a> StatefulWidgetRef for App<'a> {
129    type State = AppState<'a>;
130
131    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
132        let screen = state.screen.clone();
133
134        match screen {
135            Screen::Start(mut state) => {
136                StartScreen::default().render_ref(area, buf, &mut state.start_state)
137            }
138            Screen::Main(mut state) => {
139                let [content, statusbar] =
140                    Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
141                        .horizontal_margin(1)
142                        .areas(area);
143
144                let (left, right) = if state.mode == Mode::Select {
145                    (Constraint::Length(35), Constraint::Fill(1))
146                } else {
147                    (Constraint::Length(5), Constraint::Fill(1))
148                };
149
150                let [sidepanel, note] = Layout::horizontal([left, right]).areas(content);
151
152                SidePanel::default().render_ref(sidepanel, buf, &mut state.sidepanel_state);
153
154                MarkdownView.render_ref(note, buf, &mut state.markdown_view_state);
155
156                let mode = state.mode.as_str().to_uppercase();
157                let (name, counts) = state
158                    .selected_note
159                    .clone()
160                    .map(|note| {
161                        let content = note.content.as_str();
162                        (
163                            note.name,
164                            (WordCount::from(content), CharCount::from(content)),
165                        )
166                    })
167                    .unzip();
168
169                let (word_count, char_count) = counts.unwrap_or_default();
170
171                let mut status_bar_state = StatusBarState::new(
172                    &mode,
173                    name.as_deref(),
174                    word_count.into(),
175                    char_count.into(),
176                );
177
178                StatusBar::default().render_ref(statusbar, buf, &mut status_bar_state);
179            }
180        }
181
182        if let Some(mut vault_selector_modal_state) = state.vault_selector_modal.clone() {
183            VaultSelectorModal::default().render(area, buf, &mut vault_selector_modal_state)
184        }
185
186        if let Some(mut help_modal_state) = state.help_modal.clone() {
187            HelpModal.render(area, buf, &mut help_modal_state)
188        }
189    }
190}
191
192#[derive(Debug, Default, Clone, PartialEq)]
193pub struct SelectedNote {
194    name: String,
195    path: String,
196    content: String,
197}
198
199impl From<&Note> for SelectedNote {
200    fn from(value: &Note) -> Self {
201        Self {
202            name: value.name.clone(),
203            path: value.path.to_string_lossy().to_string(),
204            content: Note::read_to_string(value).unwrap(),
205        }
206    }
207}
208
209fn help_text() -> String {
210    let version = format!("{VERSION}~alpha");
211    HELP_TEXT.replace(
212        "%version-notice",
213        format!("This is the read-only release of Basalt ({version})").as_str(),
214    )
215}
216
217impl<'a> App<'a> {
218    pub fn start(terminal: DefaultTerminal, vaults: Vec<&Vault>) -> Result<()> {
219        let version = format!("{VERSION}~alpha");
220        let size = terminal.size()?;
221
222        let state = AppState {
223            screen: Screen::Start(Start {
224                start_state: StartState::new(&version, size, vaults),
225            }),
226            size,
227            is_running: true,
228            _lifetime: PhantomData,
229            ..Default::default()
230        };
231
232        App {
233            state: state.clone(),
234            terminal: RefCell::new(terminal),
235        }
236        .run(state)
237    }
238
239    fn run(&mut self, mut state: AppState<'a>) -> Result<()> {
240        loop {
241            self.draw(&state)?;
242            if !state.is_running {
243                break;
244            }
245            let event = event::read()?;
246            state = self.update(&state, self.handle_event(&event));
247        }
248        Ok(())
249    }
250
251    fn update_help_modal(
252        &self,
253        state: AppState<'a>,
254        inner: HelpModalState,
255        action: Action,
256    ) -> AppState<'a> {
257        match action {
258            Action::ScrollUp(amount) => AppState {
259                help_modal: Some(inner.scroll_up(calc_scroll_amount(amount, state.size))),
260                ..state
261            },
262            Action::ScrollDown(amount) => AppState {
263                help_modal: Some(inner.scroll_down(calc_scroll_amount(amount, state.size))),
264                ..state
265            },
266            Action::Next => AppState {
267                help_modal: Some(inner.scroll_down(1)),
268                ..state
269            },
270            Action::Prev => AppState {
271                help_modal: Some(inner.scroll_up(1)),
272                ..state
273            },
274            _ => state,
275        }
276    }
277
278    fn update_vault_selector_modal(
279        &self,
280        state: AppState<'a>,
281        inner: VaultSelectorModalState<'a>,
282        action: Action,
283    ) -> AppState<'a> {
284        match action {
285            Action::ToggleVaultSelector => AppState {
286                vault_selector_modal: None,
287                ..state
288            },
289            Action::Select => {
290                // TODO: Add logic to not load the vault again if the same vault was picked in the
291                // selector.
292                let alphabetically =
293                    |a: &Note, b: &Note| a.name.to_lowercase().cmp(&b.name.to_lowercase());
294
295                let vault_selector_state = inner.vault_selector_state.select();
296
297                let vault_with_notes = vault_selector_state
298                    .selected()
299                    .and_then(|index| inner.vault_selector_state.get_item(index))
300                    .map(|vault| (vault, vault.notes_sorted_by(alphabetically)));
301
302                if let Some((vault, notes)) = vault_with_notes {
303                    AppState {
304                        screen: Screen::Main(Main::new(
305                            &vault.name,
306                            notes,
307                            state.size,
308                            vault_selector_state.items(),
309                        )),
310                        vault_selector_modal: None,
311                        ..state
312                    }
313                } else {
314                    state
315                }
316            }
317            Action::Next => AppState {
318                vault_selector_modal: Some(VaultSelectorModalState {
319                    vault_selector_state: inner.vault_selector_state.next(),
320                }),
321                ..state
322            },
323            Action::Prev => AppState {
324                vault_selector_modal: Some(VaultSelectorModalState {
325                    vault_selector_state: inner.vault_selector_state.previous(),
326                }),
327                ..state
328            },
329            _ => state,
330        }
331    }
332
333    fn update_select_mode(
334        &self,
335        state: AppState<'a>,
336        inner: Main<'a>,
337        action: Action,
338    ) -> AppState<'a> {
339        match action {
340            Action::ToggleMode => AppState {
341                screen: Screen::Main(Main {
342                    mode: Mode::Normal,
343                    sidepanel_state: inner.sidepanel_state.close(),
344                    ..inner
345                }),
346                ..state
347            },
348            Action::ScrollUp(amount) => AppState {
349                screen: Screen::Main(Main {
350                    markdown_view_state: inner
351                        .markdown_view_state
352                        .scroll_up(calc_scroll_amount(amount, state.size)),
353                    ..inner
354                }),
355                ..state
356            },
357            Action::ScrollDown(amount) => AppState {
358                screen: Screen::Main(Main {
359                    markdown_view_state: inner
360                        .markdown_view_state
361                        .scroll_down(calc_scroll_amount(amount, state.size)),
362                    ..inner
363                }),
364                ..state
365            },
366            Action::Select => {
367                let sidepanel_state = inner.sidepanel_state.select();
368
369                let selected_note = inner
370                    .notes
371                    .get(sidepanel_state.selected().unwrap_or_default())
372                    .map(SelectedNote::from);
373
374                AppState {
375                    screen: Screen::Main(Main {
376                        sidepanel_state,
377                        selected_note: selected_note.clone(),
378                        markdown_view_state: inner
379                            .markdown_view_state
380                            .set_text(selected_note.map(|note| note.content).unwrap_or_default())
381                            .reset_scrollbar(),
382                        ..inner
383                    }),
384                    ..state
385                }
386            }
387            Action::Next => AppState {
388                screen: Screen::Main(Main {
389                    sidepanel_state: inner.sidepanel_state.next(),
390                    ..inner
391                }),
392                ..state
393            },
394            Action::Prev => AppState {
395                screen: Screen::Main(Main {
396                    sidepanel_state: inner.sidepanel_state.previous(),
397                    ..inner
398                }),
399                ..state
400            },
401            _ => state,
402        }
403    }
404
405    fn update_normal_mode(
406        &self,
407        state: AppState<'a>,
408        inner: Main<'a>,
409        action: Action,
410    ) -> AppState<'a> {
411        match action {
412            Action::ToggleMode => AppState {
413                screen: Screen::Main(Main {
414                    mode: Mode::Select,
415                    sidepanel_state: inner.sidepanel_state.open(),
416                    ..inner
417                }),
418                ..state
419            },
420            Action::ScrollUp(amount) => AppState {
421                screen: Screen::Main(Main {
422                    markdown_view_state: inner
423                        .markdown_view_state
424                        .scroll_up(calc_scroll_amount(amount, state.size)),
425                    ..inner
426                }),
427                ..state
428            },
429            Action::ScrollDown(amount) => AppState {
430                screen: Screen::Main(Main {
431                    markdown_view_state: inner
432                        .markdown_view_state
433                        .scroll_down(calc_scroll_amount(amount, state.size)),
434                    ..inner
435                }),
436                ..state
437            },
438            Action::Next => AppState {
439                screen: Screen::Main(Main {
440                    markdown_view_state: inner.markdown_view_state.scroll_down(1),
441                    ..inner
442                }),
443                ..state
444            },
445            Action::Prev => AppState {
446                screen: Screen::Main(Main {
447                    markdown_view_state: inner.markdown_view_state.scroll_up(1),
448                    ..inner
449                }),
450                ..state
451            },
452            _ => state,
453        }
454    }
455
456    fn update_main_state(
457        &self,
458        state: AppState<'a>,
459        inner: Main<'a>,
460        action: Action,
461    ) -> AppState<'a> {
462        if let Action::ToggleVaultSelector = action {
463            return AppState {
464                vault_selector_modal: if state.vault_selector_modal.is_some() {
465                    None
466                } else {
467                    Some(VaultSelectorModalState::new(inner.vaults.clone()))
468                },
469                ..state
470            };
471        }
472
473        match inner.mode {
474            Mode::Select => self.update_select_mode(state, inner, action),
475            Mode::Normal => self.update_normal_mode(state, inner, action),
476            Mode::Insert => state,
477        }
478    }
479
480    fn update_start_state(
481        &self,
482        state: AppState<'a>,
483        inner: Start<'a>,
484        action: Action,
485    ) -> AppState<'a> {
486        match action {
487            Action::Select => {
488                let alphabetically =
489                    |a: &Note, b: &Note| a.name.to_lowercase().cmp(&b.name.to_lowercase());
490
491                let splash_state = inner.start_state.select();
492
493                let vault_with_notes = splash_state
494                    .selected()
495                    .and_then(|index| splash_state.get_item(index))
496                    .map(|vault| (vault, vault.notes_sorted_by(alphabetically)));
497
498                if let Some((vault, notes)) = vault_with_notes {
499                    AppState {
500                        screen: Screen::Main(Main::new(
501                            &vault.name,
502                            notes,
503                            state.size,
504                            inner.start_state.items(),
505                        )),
506                        ..state
507                    }
508                } else {
509                    state
510                }
511            }
512            Action::Next => AppState {
513                screen: Screen::Start(Start {
514                    start_state: inner.start_state.next(),
515                }),
516                ..state
517            },
518            Action::Prev => AppState {
519                screen: Screen::Start(Start {
520                    start_state: inner.start_state.previous(),
521                }),
522                ..state
523            },
524            _ => state,
525        }
526    }
527
528    fn update(&self, state: &AppState<'a>, action: Option<Action>) -> AppState<'a> {
529        let state = state.clone();
530        let screen = state.screen.clone();
531
532        let Some(action) = action else {
533            return state;
534        };
535
536        match action {
537            Action::Quit => AppState {
538                is_running: false,
539                ..state
540            },
541            Action::ToggleHelp => AppState {
542                help_modal: if state.help_modal.is_some() {
543                    None
544                } else {
545                    Some(HelpModalState::new(&help_text()))
546                },
547                ..state
548            },
549            Action::Resize(size) => AppState { size, ..state },
550            _ if state.help_modal.is_some() => {
551                self.update_help_modal(state.clone(), state.help_modal.unwrap().clone(), action)
552            }
553            _ if state.vault_selector_modal.is_some() => self.update_vault_selector_modal(
554                state.clone(),
555                state.vault_selector_modal.unwrap().clone(),
556                action,
557            ),
558            _ => match screen {
559                Screen::Start(inner) => self.update_start_state(state, inner, action),
560                Screen::Main(inner) => self.update_main_state(state, inner, action),
561            },
562        }
563    }
564
565    fn handle_event(&self, event: &Event) -> Option<Action> {
566        match event {
567            Event::Resize(cols, rows) => Some(Action::Resize(Size::new(*cols, *rows))),
568            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
569                self.handle_press_key_event(key_event)
570            }
571            _ => None,
572        }
573    }
574
575    fn handle_press_key_event(&self, key_event: &KeyEvent) -> Option<Action> {
576        match key_event.code {
577            KeyCode::Char('q') => Some(Action::Quit),
578            KeyCode::Char('?') => Some(Action::ToggleHelp),
579            KeyCode::Char(' ') => Some(Action::ToggleVaultSelector),
580            KeyCode::Up => Some(Action::ScrollUp(ScrollAmount::One)),
581            KeyCode::Down => Some(Action::ScrollDown(ScrollAmount::One)),
582            KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
583                Some(Action::ScrollUp(ScrollAmount::HalfPage))
584            }
585            KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
586                Some(Action::Quit)
587            }
588            KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
589                Some(Action::ScrollDown(ScrollAmount::HalfPage))
590            }
591            KeyCode::Char('t') => Some(Action::ToggleMode),
592            KeyCode::Char('k') => Some(Action::Prev),
593            KeyCode::Char('j') => Some(Action::Next),
594            KeyCode::Enter => Some(Action::Select),
595            _ => None,
596        }
597    }
598
599    fn draw(&self, state: &AppState<'a>) -> Result<()> {
600        let mut terminal = self.terminal.borrow_mut();
601        let mut state = state.clone();
602
603        terminal.draw(move |frame| {
604            let area = frame.area();
605            let buf = frame.buffer_mut();
606            self.render_ref(area, buf, &mut state);
607        })?;
608
609        Ok(())
610    }
611}