1use crate::{get_save_backup_file_path, EditorClipboard, ThemeMode, ThothConfig};
2use anyhow::{bail, Result};
3use crossterm::{
4 event::{self, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers},
5 execute,
6 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{backend::CrosstermBackend, Terminal};
9use std::{
10 io::{self, Write},
11 time::Instant,
12};
13use tui_textarea::TextArea;
14
15use crate::{
16 format_json, format_markdown, get_save_file_path, load_textareas, save_textareas,
17 ui::{
18 render_edit_commands_popup, render_header, render_title_popup, render_title_select_popup,
19 render_ui_popup, EditCommandsPopup, UiPopup,
20 },
21 ScrollableTextArea, TitlePopup, TitleSelectPopup,
22};
23
24use std::env;
25use std::fs;
26use std::process::Command;
27use tempfile::NamedTempFile;
28
29pub struct UIState {
30 pub scrollable_textarea: ScrollableTextArea,
31 pub title_popup: TitlePopup,
32 pub title_select_popup: TitleSelectPopup,
33 pub error_popup: UiPopup,
34 pub help_popup: UiPopup,
35 pub copy_popup: UiPopup,
36 pub edit_commands_popup: EditCommandsPopup,
37 pub clipboard: Option<EditorClipboard>,
38 pub last_draw: Instant,
39 pub config: ThothConfig,
40}
41
42impl UIState {
43 pub fn new() -> Result<Self> {
44 let mut scrollable_textarea = ScrollableTextArea::new();
45 let main_save_path = get_save_file_path();
46 if main_save_path.exists() {
47 let (loaded_textareas, loaded_titles) = load_textareas(main_save_path)?;
48 for (textarea, title) in loaded_textareas.into_iter().zip(loaded_titles) {
49 scrollable_textarea.add_textarea(textarea, title);
50 }
51 } else {
52 scrollable_textarea.add_textarea(TextArea::default(), String::from("New Textarea"));
53 }
54 scrollable_textarea.initialize_scroll();
55
56 let config = ThothConfig::load()?;
57
58 Ok(UIState {
59 scrollable_textarea,
60 title_popup: TitlePopup::new(),
61 title_select_popup: TitleSelectPopup::new(),
62 error_popup: UiPopup::new("Error".to_string(), 60, 20),
63 copy_popup: UiPopup::new("Block Copied".to_string(), 60, 20),
64 help_popup: UiPopup::new("Keyboard Shortcuts".to_string(), 60, 80),
65 edit_commands_popup: EditCommandsPopup::new(),
66 clipboard: EditorClipboard::try_new(),
67 last_draw: Instant::now(),
68 config,
69 })
70 }
71}
72
73pub fn draw_ui(
74 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
75 state: &mut UIState,
76) -> Result<()> {
77 terminal.draw(|f| {
78 let theme = state.config.get_theme_colors();
79
80 let chunks = ratatui::layout::Layout::default()
81 .direction(ratatui::layout::Direction::Vertical)
82 .constraints(
83 [
84 ratatui::layout::Constraint::Length(1),
85 ratatui::layout::Constraint::Min(1),
86 ]
87 .as_ref(),
88 )
89 .split(f.size());
90
91 render_header(f, chunks[0], state.scrollable_textarea.edit_mode, theme);
92 if state.scrollable_textarea.full_screen_mode {
93 state
94 .scrollable_textarea
95 .render(f, f.size(), theme)
96 .unwrap();
97 } else {
98 state
99 .scrollable_textarea
100 .render(f, chunks[1], theme)
101 .unwrap();
102 }
103
104 if state.title_popup.visible {
105 render_title_popup(f, &state.title_popup, theme);
106 } else if state.title_select_popup.visible {
107 render_title_select_popup(f, &state.title_select_popup, theme);
108 }
109
110 if state.edit_commands_popup.visible {
111 render_edit_commands_popup(f, theme);
112 }
113
114 if state.error_popup.visible {
115 render_ui_popup(f, &state.error_popup, theme);
116 }
117 if state.help_popup.visible {
118 render_ui_popup(f, &state.help_popup, theme);
119 }
120
121 if state.copy_popup.visible {
122 render_ui_popup(f, &state.copy_popup, theme);
123 }
124
125 if state.help_popup.visible {
126 render_ui_popup(f, &state.help_popup, theme);
127 }
128 })?;
129 Ok(())
130}
131
132pub fn handle_input(
133 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
134 state: &mut UIState,
135 key: event::KeyEvent,
136) -> Result<bool> {
137 if key.kind != KeyEventKind::Press {
138 return Ok(false);
139 }
140
141 if state.scrollable_textarea.full_screen_mode {
142 handle_full_screen_input(state, key)
143 } else if state.title_popup.visible {
144 handle_title_popup_input(state, key)
145 } else if state.title_select_popup.visible {
146 handle_title_select_popup_input(state, key)
147 } else {
148 handle_normal_input(terminal, state, key)
149 }
150}
151
152fn handle_full_screen_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
153 match key.code {
154 KeyCode::Esc => {
155 if state.scrollable_textarea.edit_mode {
156 state.scrollable_textarea.edit_mode = false;
157 } else {
158 state.scrollable_textarea.toggle_full_screen();
159 }
160
161 state
162 .scrollable_textarea
163 .jump_to_textarea(state.scrollable_textarea.focused_index);
164 }
165 KeyCode::Enter => {
166 if !state.scrollable_textarea.edit_mode {
167 state.scrollable_textarea.edit_mode = true;
168 } else {
169 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
170 .insert_newline();
171 }
172 }
173 KeyCode::Up => {
174 if state.scrollable_textarea.edit_mode {
175 handle_up_key(state, key);
176 } else {
177 state.scrollable_textarea.handle_scroll(-1);
178 }
179 }
180 KeyCode::Down => {
181 if state.scrollable_textarea.edit_mode {
182 handle_down_key(state, key);
183 } else {
184 state.scrollable_textarea.handle_scroll(1);
185 }
186 }
187 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
188 match state.scrollable_textarea.copy_focused_textarea_contents() {
189 Ok(_) => {
190 let curr_focused_index = state.scrollable_textarea.focused_index;
191 let curr_title_option =
192 state.scrollable_textarea.titles.get(curr_focused_index);
193
194 match curr_title_option {
195 Some(curr_title) => {
196 state
197 .copy_popup
198 .show(format!("Copied block {}", curr_title));
199 }
200 None => {
201 state
202 .error_popup
203 .show("Failed to copy selection with title".to_string());
204 }
205 }
206 }
207 Err(e) => {
208 state.error_popup.show(format!("{}", e));
209 }
210 }
211 }
212 KeyCode::Char('s')
213 if key.modifiers.contains(KeyModifiers::ALT)
214 && key.modifiers.contains(KeyModifiers::SHIFT) =>
215 {
216 if state.scrollable_textarea.edit_mode {
217 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
218 .start_selection();
219 }
220 }
221 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
222 if let Err(e) = state.scrollable_textarea.copy_selection_contents() {
223 state
224 .error_popup
225 .show(format!("Failed to copy to clipboard: {}", e));
226 }
227 }
228 _ => {
229 if state.scrollable_textarea.edit_mode {
230 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
231 .input(key);
232 }
233 }
234 }
235 Ok(false)
236}
237
238fn handle_title_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
239 match key.code {
240 KeyCode::Enter => {
241 #[allow(clippy::assigning_clones)]
242 state
243 .scrollable_textarea
244 .change_title(state.title_popup.title.clone());
245 state.title_popup.visible = false;
246 state.title_popup.title.clear();
247 }
248 KeyCode::Esc => {
249 state.title_popup.visible = false;
250 state.title_popup.title.clear();
251 }
252 KeyCode::Char(c) => {
253 state.title_popup.title.push(c);
254 }
255 KeyCode::Backspace => {
256 state.title_popup.title.pop();
257 }
258 _ => {}
259 }
260 Ok(false)
261}
262
263fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
264 let visible_items =
265 (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 10;
266
267 match key.code {
268 KeyCode::Enter => {
269 if !state.title_select_popup.filtered_titles.is_empty() {
270 let selected_title_match = &state.title_select_popup.filtered_titles
271 [state.title_select_popup.selected_index];
272 state
273 .scrollable_textarea
274 .jump_to_textarea(selected_title_match.index);
275 state.title_select_popup.visible = false;
276 if !state.title_select_popup.search_query.is_empty() {
277 state.title_select_popup.search_query.clear();
278 state.title_select_popup.reset_filtered_titles();
279 }
280 }
281 }
282 KeyCode::Esc => {
283 state.title_select_popup.visible = false;
284 state.edit_commands_popup.visible = false;
285 if !state.title_select_popup.search_query.is_empty() {
286 state.title_select_popup.search_query.clear();
287 state.title_select_popup.reset_filtered_titles();
288 }
289 }
290 KeyCode::Up => {
291 state.title_select_popup.move_selection_up(visible_items);
292 }
293 KeyCode::Down => {
294 state.title_select_popup.move_selection_down(visible_items);
295 }
296 KeyCode::Char(c) => {
297 state.title_select_popup.search_query.push(c);
298 state.title_select_popup.update_search();
299 }
300 KeyCode::Backspace => {
301 state.title_select_popup.search_query.pop();
302 state.title_select_popup.update_search();
303 }
304
305 _ => {}
306 }
307 Ok(false)
308}
309
310fn handle_normal_input(
311 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
312 state: &mut UIState,
313 key: event::KeyEvent,
314) -> Result<bool> {
315 match key.code {
316 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
317 format_current_textarea(state, format_markdown)?;
318 }
319 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
320 format_current_textarea(state, format_json)?;
321 }
322 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
323 if state.scrollable_textarea.edit_mode {
324 match edit_with_external_editor(state) {
325 Ok(edited_content) => {
326 let mut new_textarea = TextArea::default();
327 for line in edited_content.lines() {
328 new_textarea.insert_str(line);
329 new_textarea.insert_newline();
330 }
331 state.scrollable_textarea.textareas
332 [state.scrollable_textarea.focused_index] = new_textarea;
333
334 terminal.clear()?;
336 }
337 Err(e) => {
338 state
339 .error_popup
340 .show(format!("Failed to edit with external editor: {}", e));
341 }
342 }
343 }
344 }
345 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
346 let new_theme = match state.config.theme {
347 ThemeMode::Light => ThemeMode::Dark,
348 ThemeMode::Dark => ThemeMode::Light,
349 };
350 state.config.set_theme(new_theme.clone())?;
351 }
352 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
353 match state.scrollable_textarea.copy_focused_textarea_contents() {
354 Ok(_) => {
355 let curr_focused_index = state.scrollable_textarea.focused_index;
356 let curr_title_option =
357 state.scrollable_textarea.titles.get(curr_focused_index);
358
359 match curr_title_option {
360 Some(curr_title) => {
361 state
362 .copy_popup
363 .show(format!("Copied block {}", curr_title));
364 }
365 None => {
366 state
367 .error_popup
368 .show("Failed to copy selection with title".to_string());
369 }
370 }
371 }
372 Err(e) => {
373 state.error_popup.show(format!("{}", e));
374 }
375 }
376 }
377 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
378 if let Err(e) = state.scrollable_textarea.copy_selection_contents() {
379 state
380 .error_popup
381 .show(format!("Failed to copy to clipboard: {}", e));
382 }
383 }
384 KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
385 handle_paste(state)?;
386 }
387 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
388 let help_message = "\
389NAVIGATION:
390 • ↑/↓ or j/k: Navigate between blocks
391 • Enter: Enter edit mode
392 • Esc: Exit current mode
393
394BLOCKS:
395 • ^n: Add a new block
396 • ^d: Delete current block
397 • ^t: Change block title
398 • ^s: Select block by title
399 • ^f: Toggle fullscreen mode
400
401CLIPBOARD:
402 • ^y: Copy current block
403 • ^v: Paste from clipboard
404 • ^b: Copy selection (in edit mode)
405
406FORMATTING:
407 • ^j: Format as JSON
408 • ^k: Format as Markdown
409
410OTHER:
411 • ^l: Toggle light/dark theme
412 • ^e: Edit with external editor (in edit mode)
413 • q: Quit application
414 • ^h: Show this help";
415
416 state.help_popup.show(help_message.to_string());
417 }
418 KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
419 if !state.scrollable_textarea.edit_mode {
420 state.scrollable_textarea.toggle_full_screen();
421 }
422 }
423 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
424 if state.scrollable_textarea.edit_mode {
425 state.edit_commands_popup.visible = !state.edit_commands_popup.visible;
426 }
427 }
428 #[allow(clippy::assigning_clones)]
429 KeyCode::Char('s')
430 if key.modifiers.contains(KeyModifiers::CONTROL)
431 && !key.modifiers.contains(KeyModifiers::SHIFT) =>
432 {
433 state
435 .title_select_popup
436 .set_titles(state.scrollable_textarea.titles.clone());
437 state.title_select_popup.selected_index = 0;
438 state.title_select_popup.visible = true;
439 }
440 KeyCode::Char('q') => {
441 if !state.scrollable_textarea.edit_mode {
442 save_textareas(
443 &state.scrollable_textarea.textareas,
444 &state.scrollable_textarea.titles,
445 get_save_file_path(),
446 )?;
447 save_textareas(
448 &state.scrollable_textarea.textareas,
449 &state.scrollable_textarea.titles,
450 get_save_backup_file_path(),
451 )?;
452 return Ok(true);
453 }
454 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index].input(key);
455 }
456 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
457 if !state.scrollable_textarea.edit_mode {
458 state
459 .scrollable_textarea
460 .add_textarea(TextArea::default(), String::from("New Textarea"));
461 state.scrollable_textarea.adjust_scroll_to_focused();
462 }
463 }
464 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
465 if state.scrollable_textarea.textareas.len() > 1 && !state.scrollable_textarea.edit_mode
466 {
467 state
468 .scrollable_textarea
469 .remove_textarea(state.scrollable_textarea.focused_index);
470 }
471 }
472 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
473 if state.scrollable_textarea.edit_mode {
474 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
475 .move_cursor(tui_textarea::CursorMove::Top);
476 }
477 }
478 #[allow(clippy::assigning_clones)]
479 KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
480 state.title_popup.visible = true;
481 state.title_popup.title =
482 state.scrollable_textarea.titles[state.scrollable_textarea.focused_index].clone();
483 }
484 KeyCode::Enter => {
485 if state.scrollable_textarea.edit_mode {
486 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
487 .insert_newline();
488 } else {
489 state.scrollable_textarea.edit_mode = true;
490 }
491 }
492 KeyCode::Esc => {
493 if state.edit_commands_popup.visible {
494 state.edit_commands_popup.visible = false;
495 } else {
496 state.scrollable_textarea.edit_mode = false;
497 state.edit_commands_popup.visible = false;
498 }
499
500 if state.error_popup.visible {
501 state.error_popup.hide();
502 }
503 if state.help_popup.visible {
504 state.help_popup.hide();
505 }
506 if state.copy_popup.visible {
507 state.copy_popup.hide();
508 }
509 }
510 KeyCode::Up => handle_up_key(state, key),
511 KeyCode::Down => handle_down_key(state, key),
512 KeyCode::Char('k') if !state.scrollable_textarea.edit_mode => handle_up_key(state, key),
513 KeyCode::Char('j') if !state.scrollable_textarea.edit_mode => handle_down_key(state, key),
514 _ => {
515 if state.scrollable_textarea.edit_mode {
516 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
517 .input(key);
518 state.scrollable_textarea.start_sel = usize::MAX;
519 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
520 .cancel_selection();
521 }
522 }
523 }
524 Ok(false)
525}
526
527fn handle_up_key(state: &mut UIState, key: event::KeyEvent) {
528 if state.scrollable_textarea.edit_mode {
529 let textarea =
530 &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
531 if key.modifiers.contains(KeyModifiers::SHIFT) {
532 if state.scrollable_textarea.start_sel == usize::MAX {
533 let (curr_row, _) = textarea.cursor();
534 state.scrollable_textarea.start_sel = curr_row;
535 textarea.start_selection();
536 }
537 if textarea.cursor().0 > 0 {
538 textarea.move_cursor(tui_textarea::CursorMove::Up);
539 }
540 } else {
541 textarea.move_cursor(tui_textarea::CursorMove::Up);
542 state.scrollable_textarea.start_sel = usize::MAX;
543 textarea.cancel_selection();
544 }
545 } else {
546 state.scrollable_textarea.move_focus(-1);
547 }
548}
549
550fn handle_down_key(state: &mut UIState, key: event::KeyEvent) {
551 if state.scrollable_textarea.edit_mode {
552 let textarea =
553 &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
554 if key.modifiers.contains(KeyModifiers::SHIFT) {
555 if state.scrollable_textarea.start_sel == usize::MAX {
556 let (curr_row, _) = textarea.cursor();
557 state.scrollable_textarea.start_sel = curr_row;
558 textarea.start_selection();
559 }
560 if textarea.cursor().0 < textarea.lines().len() - 1 {
561 textarea.move_cursor(tui_textarea::CursorMove::Down);
562 }
563 } else {
564 textarea.move_cursor(tui_textarea::CursorMove::Down);
565 state.scrollable_textarea.start_sel = usize::MAX;
566 textarea.cancel_selection();
567 }
568 } else {
569 state.scrollable_textarea.move_focus(1);
570 }
571}
572
573fn format_current_textarea<F>(state: &mut UIState, formatter: F) -> Result<()>
574where
575 F: Fn(&str) -> Result<String>,
576{
577 let current_content = state.scrollable_textarea.textareas
578 [state.scrollable_textarea.focused_index]
579 .lines()
580 .join("\n");
581 match formatter(¤t_content) {
582 Ok(formatted) => {
583 let mut new_textarea = TextArea::default();
584 for line in formatted.lines() {
585 new_textarea.insert_str(line);
586 new_textarea.insert_newline();
587 }
588 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] =
589 new_textarea;
590 Ok(())
591 }
592 Err(e) => {
593 state
594 .error_popup
595 .show(format!("Failed to format block: {}", e));
596 Ok(())
597 }
598 }
599}
600
601fn edit_with_external_editor(state: &mut UIState) -> Result<String> {
602 let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
603 .lines()
604 .join("\n");
605 let mut temp_file = NamedTempFile::new()?;
606
607 temp_file.write_all(content.as_bytes())?;
608 temp_file.flush()?;
609
610 let editor = env::var("VISUAL")
611 .or_else(|_| env::var("EDITOR"))
612 .unwrap_or_else(|_| "vi".to_string());
613
614 disable_raw_mode()?;
616 execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
617
618 let status = Command::new(&editor).arg(temp_file.path()).status()?;
619
620 enable_raw_mode()?;
622 execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
623
624 if !status.success() {
625 bail!(format!("Editor '{}' returned non-zero status", editor));
626 }
627
628 let edited_content = fs::read_to_string(temp_file.path())?;
629
630 Ok(edited_content)
631}
632
633fn handle_paste(state: &mut UIState) -> Result<()> {
634 if state.scrollable_textarea.edit_mode {
635 match &mut state.clipboard {
636 Some(clip) => {
637 if let Ok(content) = clip.get_content() {
638 let textarea = &mut state.scrollable_textarea.textareas
639 [state.scrollable_textarea.focused_index];
640 for line in content.lines() {
641 textarea.insert_str(line);
642 textarea.insert_newline();
643 }
644 if content.ends_with('\n') {
646 textarea.delete_char();
647 }
648 }
649 }
650 None => {
651 state
652 .error_popup
653 .show("Failed to create clipboard".to_string());
654 }
655 }
656 }
657 Ok(())
658}