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 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}