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