1use basalt_core::obsidian::{Note, Vault};
2use ratatui::{
3 buffer::Buffer,
4 crossterm::event::{self, Event, KeyEvent, KeyEventKind},
5 layout::{Constraint, Layout, Rect, Size},
6 widgets::{StatefulWidget, StatefulWidgetRef},
7 DefaultTerminal,
8};
9
10use std::{cell::RefCell, fmt::Debug, fs, io::Result, path::PathBuf};
11
12use crate::{
13 command,
14 config::{self, Config},
15 explorer::{self, Explorer, ExplorerState, Visibility},
16 help_modal::{self, HelpModal, HelpModalState},
17 note_editor::{
18 self, ast,
19 editor::NoteEditor,
20 state::{NoteEditorState, View},
21 },
22 outline::{self, Outline, OutlineState},
23 splash_modal::{self, SplashModal, SplashModalState},
24 statusbar::{StatusBar, StatusBarState},
25 stylized_text::{self, FontStyle},
26 text_counts::{CharCount, WordCount},
27 vault_selector_modal::{self, VaultSelectorModal, VaultSelectorModalState},
28};
29
30const VERSION: &str = env!("CARGO_PKG_VERSION");
31
32const HELP_TEXT: &str = include_str!("./help.txt");
33
34#[derive(Debug, Default, Clone, PartialEq)]
35pub enum ScrollAmount {
36 #[default]
37 One,
38 HalfPage,
39}
40
41pub fn calc_scroll_amount(scroll_amount: &ScrollAmount, height: usize) -> usize {
42 match scroll_amount {
43 ScrollAmount::One => 1,
44 ScrollAmount::HalfPage => height / 2,
45 }
46}
47
48#[derive(Default, Clone)]
49pub struct AppState<'a> {
50 screen_size: Size,
51 is_running: bool,
52
53 active_pane: ActivePane,
54 explorer: ExplorerState<'a>,
55 note_editor: NoteEditorState<'a>,
56 outline: OutlineState,
57 selected_note: Option<SelectedNote>,
58
59 splash_modal: SplashModalState<'a>,
60 help_modal: HelpModalState,
61 vault_selector_modal: VaultSelectorModalState<'a>,
62}
63
64impl<'a> AppState<'a> {
65 pub fn active_component(&self) -> ActivePane {
66 if self.help_modal.visible {
67 return ActivePane::HelpModal;
68 }
69
70 if self.vault_selector_modal.visible {
71 return ActivePane::VaultSelectorModal;
72 }
73
74 if self.splash_modal.visible {
75 return ActivePane::Splash;
76 }
77
78 self.active_pane
79 }
80
81 pub fn set_running(&self, is_running: bool) -> Self {
82 Self {
83 is_running,
84 ..self.clone()
85 }
86 }
87}
88
89#[derive(Clone, Debug, PartialEq)]
90pub enum Message<'a> {
91 Quit,
92 Exec(String),
93 Spawn(String),
94 Resize(Size),
95 SetActivePane(ActivePane),
96 OpenVault(&'a Vault),
97 SelectNote(SelectedNote),
98 UpdateSelectedNoteContent((String, Option<Vec<ast::Node>>)),
99
100 Splash(splash_modal::Message),
101 Explorer(explorer::Message),
102 NoteEditor(note_editor::Message),
103 Outline(outline::Message),
104 HelpModal(help_modal::Message),
105 VaultSelectorModal(vault_selector_modal::Message),
106}
107
108#[derive(Debug, Default, Clone, Copy, PartialEq)]
109pub enum ActivePane {
110 #[default]
111 Splash,
112 Explorer,
113 NoteEditor,
114 Outline,
115 HelpModal,
116 VaultSelectorModal,
117}
118
119impl From<ActivePane> for &str {
120 fn from(value: ActivePane) -> Self {
121 match value {
122 ActivePane::Splash => "Splash",
123 ActivePane::Explorer => "Explorer",
124 ActivePane::NoteEditor => "Note Editor",
125 ActivePane::Outline => "Outline",
126 ActivePane::HelpModal => "Help",
127 ActivePane::VaultSelectorModal => "Vault Selector",
128 }
129 }
130}
131
132#[derive(Debug, Default, Clone, PartialEq)]
133pub struct SelectedNote {
134 name: String,
135 path: PathBuf,
136 content: String,
137}
138
139impl From<&Note> for SelectedNote {
140 fn from(value: &Note) -> Self {
141 Self {
142 name: value.name.clone(),
143 path: value.path.clone(),
144 content: fs::read_to_string(&value.path).unwrap_or_default(),
145 }
146 }
147}
148
149fn help_text(version: &str) -> String {
150 HELP_TEXT.replace("%version-notice", version)
151}
152
153pub struct App<'a> {
154 state: AppState<'a>,
155 config: Config<'a>,
156 terminal: RefCell<DefaultTerminal>,
157}
158
159impl<'a> App<'a> {
160 pub fn new(state: AppState<'a>, terminal: DefaultTerminal) -> Self {
161 Self {
162 state,
163 config: config::load().unwrap(),
165 terminal: RefCell::new(terminal),
166 }
167 }
168
169 pub fn start(terminal: DefaultTerminal, vaults: Vec<&Vault>) -> Result<()> {
170 let version = stylized_text::stylize(&format!("{VERSION}~beta"), FontStyle::Script);
171 let size = terminal.size()?;
172
173 let state = AppState {
174 screen_size: size,
175 help_modal: HelpModalState::new(&help_text(&version)),
176 vault_selector_modal: VaultSelectorModalState::new(vaults.clone()),
177 splash_modal: SplashModalState::new(&version, vaults, true),
178 ..Default::default()
179 };
180
181 App::new(state, terminal).run()
182 }
183
184 fn run(&'a mut self) -> Result<()> {
185 self.state.is_running = true;
186
187 let mut state = self.state.clone();
188 let config = self.config.clone();
189 while state.is_running {
190 self.draw(&mut state)?;
191 let event = event::read()?;
192
193 let mut message = App::handle_event(&config, &state, &event);
194 while message.is_some() {
195 message = App::update(self.terminal.get_mut(), &config, &mut state, message);
196 }
197 }
198
199 Ok(())
200 }
201
202 fn draw(&self, state: &mut AppState<'a>) -> Result<()> {
203 let mut terminal = self.terminal.borrow_mut();
204
205 terminal.draw(move |frame| {
206 let area = frame.area();
207 let buf = frame.buffer_mut();
208 self.render_ref(area, buf, state);
209 })?;
210
211 Ok(())
212 }
213
214 fn handle_event(
215 config: &'a Config,
216 state: &AppState<'_>,
217 event: &Event,
218 ) -> Option<Message<'a>> {
219 match event {
220 Event::Resize(cols, rows) => Some(Message::Resize(Size::new(*cols, *rows))),
221 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
222 App::handle_key_event(config, state, key_event)
223 }
224 _ => None,
225 }
226 }
227
228 #[rustfmt::skip]
229 fn handle_active_component_event(config: &'a Config, state: &AppState<'_>, key: &KeyEvent, active_component: ActivePane) -> Option<Message<'a>> {
230 match active_component {
231 ActivePane::Splash => config.splash.key_to_message(key.into()),
232 ActivePane::Explorer => config.explorer.key_to_message(key.into()),
233 ActivePane::Outline => config.outline.key_to_message(key.into()),
234 ActivePane::HelpModal => config.help_modal.key_to_message(key.into()),
235 ActivePane::VaultSelectorModal => config.vault_selector_modal.key_to_message(key.into()),
236 ActivePane::NoteEditor => {
237 if state.note_editor.is_editing() {
238 note_editor::handle_editing_event(key).map(Message::NoteEditor)
239 } else {
240 config.note_editor.key_to_message(key.into())
241 }
242 }
243 }
244 }
245
246 fn handle_key_event(
247 config: &'a Config,
248 state: &AppState<'_>,
249 key: &KeyEvent,
250 ) -> Option<Message<'a>> {
251 let global_message = config.global.key_to_message(key.into());
252
253 let is_editing = state.note_editor.is_editing();
254
255 if global_message.is_some() && !is_editing {
256 return global_message;
257 }
258
259 let active_component = state.active_component();
260 App::handle_active_component_event(config, state, key, active_component)
261 }
262
263 fn update(
264 terminal: &mut DefaultTerminal,
265 config: &Config,
266 state: &mut AppState<'a>,
267 message: Option<Message<'a>>,
268 ) -> Option<Message<'a>> {
269 match message? {
270 Message::Quit => state.is_running = false,
271 Message::Resize(size) => state.screen_size = size,
272 Message::SetActivePane(active_pane) => match active_pane {
273 ActivePane::Explorer => {
274 state.active_pane = active_pane;
275 state.explorer.set_active(true);
277 }
278 ActivePane::NoteEditor => {
279 state.active_pane = active_pane;
280 state.note_editor.set_active(true);
282 if state.explorer.visibility == Visibility::FullWidth {
283 return Some(Message::Explorer(explorer::Message::HidePane));
284 }
285 }
286 ActivePane::Outline => {
287 state.active_pane = active_pane;
288 state.outline.set_active(true);
290 }
291 _ => {}
292 },
293 Message::OpenVault(vault) => {
294 state.explorer = ExplorerState::new(&vault.name, vault.entries());
295 state.note_editor = NoteEditorState::default();
296 return Some(Message::SetActivePane(ActivePane::Explorer));
297 }
298 Message::SelectNote(selected_note) => {
299 let is_different = state
300 .selected_note
301 .as_ref()
302 .is_some_and(|note| note.content != selected_note.content);
303 state.selected_note = Some(selected_note.clone());
304
305 state.note_editor = NoteEditorState::new(
306 &selected_note.content,
307 &selected_note.name,
308 &selected_note.path,
309 );
310
311 state.note_editor.set_active(true);
312
313 if !config.experimental_editor {
314 state.note_editor.view = View::Read;
315 }
316
317 state.outline = OutlineState::new(
319 &state.note_editor.ast_nodes,
320 state.note_editor.current_block(),
321 state.outline.is_open(),
322 );
323
324 if state.explorer.visibility == Visibility::FullWidth && is_different {
325 return Some(Message::Explorer(explorer::Message::HidePane));
326 }
327 }
328 Message::UpdateSelectedNoteContent((updated_content, nodes)) => {
329 if let Some(selected_note) = state.selected_note.as_mut() {
330 selected_note.content = updated_content;
331 return nodes.map(|nodes| Message::Outline(outline::Message::SetNodes(nodes)));
332 }
333 }
334 Message::Exec(command) => {
335 let (note_name, note_path) = state
336 .selected_note
337 .as_ref()
338 .map(|note| (note.name.as_str(), note.path.to_string_lossy()))
339 .unwrap_or_default();
340
341 return command::sync_command(
342 terminal,
343 command,
344 state.explorer.title,
345 note_name,
346 ¬e_path,
347 );
348 }
349
350 Message::Spawn(command) => {
351 let (note_name, note_path) = state
352 .selected_note
353 .as_ref()
354 .map(|note| (note.name.as_str(), note.path.to_string_lossy()))
355 .unwrap_or_default();
356
357 return command::spawn_command(
358 command,
359 state.explorer.title,
360 note_name,
361 ¬e_path,
362 );
363 }
364
365 Message::HelpModal(message) => {
366 return help_modal::update(&message, state.screen_size, &mut state.help_modal);
367 }
368 Message::VaultSelectorModal(message) => {
369 return vault_selector_modal::update(&message, &mut state.vault_selector_modal);
370 }
371 Message::Splash(message) => {
372 return splash_modal::update(&message, &mut state.splash_modal);
373 }
374 Message::Explorer(message) => {
375 return explorer::update(&message, state.screen_size, &mut state.explorer);
376 }
377 Message::Outline(message) => {
378 return outline::update(&message, &mut state.outline);
379 }
380 Message::NoteEditor(message) => {
381 return note_editor::update(&message, state.screen_size, &mut state.note_editor);
382 }
383 };
384
385 None
386 }
387
388 fn render_splash(&self, area: Rect, buf: &mut Buffer, state: &mut SplashModalState<'a>) {
389 SplashModal::default().render_ref(area, buf, state)
390 }
391
392 fn render_main(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
393 let [content, statusbar] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
394 .horizontal_margin(1)
395 .areas(area);
396
397 let (left, right) = match state.explorer.visibility {
398 Visibility::Hidden => (Constraint::Length(4), Constraint::Fill(1)),
399 Visibility::Visible => (Constraint::Length(35), Constraint::Fill(1)),
400 Visibility::FullWidth => (Constraint::Fill(1), Constraint::Length(0)),
401 };
402
403 let [explorer_pane, note, outline] = Layout::horizontal([
404 left,
405 right,
406 if state.outline.is_open() {
407 Constraint::Length(35)
408 } else {
409 Constraint::Length(4)
410 },
411 ])
412 .areas(content);
413
414 Explorer::new().render(explorer_pane, buf, &mut state.explorer);
415 NoteEditor::default().render(note, buf, &mut state.note_editor);
416 Outline.render(outline, buf, &mut state.outline);
417
418 let (_, counts) = state
419 .selected_note
420 .clone()
421 .map(|note| {
422 let content = note.content.as_str();
423 (
424 note.name,
425 (WordCount::from(content), CharCount::from(content)),
426 )
427 })
428 .unzip();
429
430 let (word_count, char_count) = counts.unwrap_or_default();
431
432 let mut status_bar_state = StatusBarState::new(
433 state.active_pane.into(),
434 word_count.into(),
435 char_count.into(),
436 );
437
438 let status_bar = StatusBar::default();
439 status_bar.render_ref(statusbar, buf, &mut status_bar_state);
440
441 self.render_modals(area, buf, state)
442 }
443
444 fn render_modals(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
445 if state.splash_modal.visible {
446 self.render_splash(area, buf, &mut state.splash_modal);
447 }
448
449 if state.vault_selector_modal.visible {
450 VaultSelectorModal::default().render(area, buf, &mut state.vault_selector_modal);
451 }
452
453 if state.help_modal.visible {
454 HelpModal.render(area, buf, &mut state.help_modal);
455 }
456 }
457}
458
459impl<'a> StatefulWidgetRef for App<'a> {
460 type State = AppState<'a>;
461
462 fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
463 self.render_main(area, buf, state);
464 }
465}