thoth_cli/
ui_handler.rs

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                        // Redraw the terminal after editing
335                        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            // populate title_select_popup with the current titles from the textareas
434            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(&current_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    // suspend the TUI
615    disable_raw_mode()?;
616    execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
617
618    let status = Command::new(&editor).arg(temp_file.path()).status()?;
619
620    // resume the TUI
621    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                    // Remove the last extra newline
645                    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}