thoth_cli/
ui_handler.rs

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