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},
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 }
279 ActivePane::Outline => {
280 state.active_pane = active_pane;
281 state.outline.set_active(true);
283 }
284 _ => {}
285 },
286 Message::OpenVault(vault) => {
287 state.explorer = ExplorerState::new(&vault.name, vault.entries());
288 state.note_editor = EditorState::default();
289 return Some(Message::SetActivePane(ActivePane::Explorer));
290 }
291 Message::SelectNote(selected_note) => {
292 state.selected_note = Some(selected_note.clone());
293
294 let active = state.note_editor.active();
296 state.note_editor = EditorState::default();
297 state.note_editor.set_active(active);
298 state.note_editor.set_path(selected_note.path.into());
299 state.note_editor.set_content(&selected_note.content);
300
301 if !config.experimental_editor {
302 state.note_editor.view = View::Read;
303 }
304
305 state.outline = OutlineState::new(
307 state.note_editor.nodes(),
308 state.note_editor.current_row,
309 state.outline.is_open(),
310 );
311 }
312 Message::UpdateSelectedNoteContent((updated_content, nodes)) => {
313 if let Some(selected_note) = state.selected_note.as_mut() {
314 selected_note.content = updated_content;
315 return nodes.map(|nodes| Message::Outline(outline::Message::SetNodes(nodes)));
316 }
317 }
318 Message::Exec(command) => {
319 let (note_name, note_path) = state
320 .selected_note
321 .as_ref()
322 .map(|note| (note.name.as_str(), note.path.as_str()))
323 .unwrap_or_default();
324
325 return command::sync_command(
326 terminal,
327 command,
328 state.explorer.title,
329 note_name,
330 note_path,
331 );
332 }
333
334 Message::Spawn(command) => {
335 let (note_name, note_path) = state
336 .selected_note
337 .as_ref()
338 .map(|note| (note.name.as_str(), note.path.as_str()))
339 .unwrap_or_default();
340
341 return command::spawn_command(command, state.explorer.title, note_name, note_path);
342 }
343
344 Message::HelpModal(message) => {
345 return help_modal::update(&message, state.screen_size, &mut state.help_modal);
346 }
347 Message::VaultSelectorModal(message) => {
348 return vault_selector_modal::update(&message, &mut state.vault_selector_modal);
349 }
350 Message::Splash(message) => {
351 return splash_modal::update(&message, &mut state.splash_modal);
352 }
353 Message::Explorer(message) => {
354 return explorer::update(&message, state.screen_size, &mut state.explorer);
355 }
356 Message::Outline(message) => {
357 return outline::update(&message, &mut state.outline);
358 }
359 Message::NoteEditor(message) => {
360 return note_editor::update(&message, state.screen_size, &mut state.note_editor);
361 }
362 };
363
364 None
365 }
366
367 fn render_splash(&self, area: Rect, buf: &mut Buffer, state: &mut SplashModalState<'a>) {
368 SplashModal::default().render_ref(area, buf, state)
369 }
370
371 fn render_main(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
372 let [content, statusbar] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
373 .horizontal_margin(1)
374 .areas(area);
375
376 let (left, right) = if state.explorer.open {
377 (Constraint::Length(35), Constraint::Fill(1))
378 } else {
379 (Constraint::Length(4), Constraint::Fill(1))
380 };
381
382 let [explorer_pane, note, outline] = Layout::horizontal([
383 left,
384 right,
385 if state.outline.is_open() {
386 Constraint::Length(35)
387 } else {
388 Constraint::Length(4)
389 },
390 ])
391 .areas(content);
392
393 Explorer::new().render(explorer_pane, buf, &mut state.explorer);
394 Editor::default().render(note, buf, &mut state.note_editor);
395 Outline.render(outline, buf, &mut state.outline);
396
397 let (_, counts) = state
398 .selected_note
399 .clone()
400 .map(|note| {
401 let content = note.content.as_str();
402 (
403 note.name,
404 (WordCount::from(content), CharCount::from(content)),
405 )
406 })
407 .unzip();
408
409 let (word_count, char_count) = counts.unwrap_or_default();
410
411 let mut status_bar_state = StatusBarState::new(
412 state.active_pane.into(),
413 word_count.into(),
414 char_count.into(),
415 );
416
417 let status_bar = StatusBar::default();
418 status_bar.render_ref(statusbar, buf, &mut status_bar_state);
419
420 self.render_modals(area, buf, state)
421 }
422
423 fn render_modals(&self, area: Rect, buf: &mut Buffer, state: &mut AppState<'a>) {
424 if state.splash_modal.visible {
425 self.render_splash(area, buf, &mut state.splash_modal);
426 }
427
428 if state.vault_selector_modal.visible {
429 VaultSelectorModal::default().render(area, buf, &mut state.vault_selector_modal);
430 }
431
432 if state.help_modal.visible {
433 HelpModal.render(area, buf, &mut state.help_modal);
434 }
435 }
436}
437
438impl<'a> StatefulWidgetRef for App<'a> {
439 type State = AppState<'a>;
440
441 fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
442 self.render_main(area, buf, state);
443 }
444}