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('k') => {
167            if state.scrollable_textarea.edit_mode {
168                handle_up_key(state, key);
169            } else {
170                state.scrollable_textarea.handle_scroll(-1);
171            }
172        }
173        KeyCode::Char('j') => {
174            if state.scrollable_textarea.edit_mode {
175                handle_down_key(state, key);
176            } else {
177                state.scrollable_textarea.handle_scroll(1);
178            }
179        }
180        KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
181            match state.scrollable_textarea.copy_focused_textarea_contents() {
182                Ok(_) => {
183                    let curr_focused_index = state.scrollable_textarea.focused_index;
184                    let curr_title_option =
185                        state.scrollable_textarea.titles.get(curr_focused_index);
186
187                    match curr_title_option {
188                        Some(curr_title) => {
189                            state
190                                .copy_popup
191                                .show(format!("Copied block {}", curr_title));
192                        }
193                        None => {
194                            state
195                                .error_popup
196                                .show("Failed to copy selection with title".to_string());
197                        }
198                    }
199                }
200                Err(e) => {
201                    state.error_popup.show(format!("{}", e));
202                }
203            }
204        }
205        KeyCode::Char('s')
206            if key.modifiers.contains(KeyModifiers::ALT)
207                && key.modifiers.contains(KeyModifiers::SHIFT) =>
208        {
209            if state.scrollable_textarea.edit_mode {
210                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
211                    .start_selection();
212            }
213        }
214        KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
215            if let Err(e) = state.scrollable_textarea.copy_selection_contents() {
216                state
217                    .error_popup
218                    .show(format!("Failed to copy to clipboard: {}", e));
219            }
220        }
221        _ => {
222            if state.scrollable_textarea.edit_mode {
223                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
224                    .input(key);
225            }
226        }
227    }
228    Ok(false)
229}
230
231fn handle_title_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
232    match key.code {
233        KeyCode::Enter => {
234            #[allow(clippy::assigning_clones)]
235            state
236                .scrollable_textarea
237                .change_title(state.title_popup.title.clone());
238            state.title_popup.visible = false;
239            state.title_popup.title.clear();
240        }
241        KeyCode::Esc => {
242            state.title_popup.visible = false;
243            state.title_popup.title.clear();
244        }
245        KeyCode::Char(c) => {
246            state.title_popup.title.push(c);
247        }
248        KeyCode::Backspace => {
249            state.title_popup.title.pop();
250        }
251        _ => {}
252    }
253    Ok(false)
254}
255
256fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
257    // Subtract 2 from viewport height to account for the top and bottom borders
258    // drawn by Block::default().borders(Borders::ALL) in ui.rs render_title_select_popup.
259    // The borders are rendered using unicode box-drawing characters:
260    // top border    : ┌───┐
261    // bottom border : └───┘
262    let visible_items =
263        (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 10;
264
265    match key.code {
266        KeyCode::Enter => {
267            if !state.title_select_popup.filtered_titles.is_empty() {
268                let selected_title_match = &state.title_select_popup.filtered_titles
269                    [state.title_select_popup.selected_index];
270                state
271                    .scrollable_textarea
272                    .jump_to_textarea(selected_title_match.index);
273                state.title_select_popup.visible = false;
274                if !state.title_select_popup.search_query.is_empty() {
275                    state.title_select_popup.search_query.clear();
276                    state.title_select_popup.reset_filtered_titles();
277                }
278            }
279        }
280        KeyCode::Esc => {
281            state.title_select_popup.visible = false;
282            state.edit_commands_popup.visible = false;
283            if !state.title_select_popup.search_query.is_empty() {
284                state.title_select_popup.search_query.clear();
285                state.title_select_popup.reset_filtered_titles();
286            }
287        }
288        KeyCode::Up => {
289            state.title_select_popup.move_selection_up(visible_items);
290        }
291        KeyCode::Down => {
292            state.title_select_popup.move_selection_down(visible_items);
293        }
294        KeyCode::Char(c) => {
295            state.title_select_popup.search_query.push(c);
296            state.title_select_popup.update_search();
297        }
298        KeyCode::Backspace => {
299            state.title_select_popup.search_query.pop();
300            state.title_select_popup.update_search();
301        }
302
303        _ => {}
304    }
305    Ok(false)
306}
307
308fn handle_normal_input(
309    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
310    state: &mut UIState,
311    key: event::KeyEvent,
312) -> Result<bool> {
313    match key.code {
314        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
315            format_current_textarea(state, format_markdown)?;
316        }
317        KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
318            format_current_textarea(state, format_json)?;
319        }
320        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
321            if state.scrollable_textarea.edit_mode {
322                match edit_with_external_editor(state) {
323                    Ok(edited_content) => {
324                        let mut new_textarea = TextArea::default();
325                        for line in edited_content.lines() {
326                            new_textarea.insert_str(line);
327                            new_textarea.insert_newline();
328                        }
329                        state.scrollable_textarea.textareas
330                            [state.scrollable_textarea.focused_index] = new_textarea;
331
332                        // Redraw the terminal after editing
333                        terminal.clear()?;
334                    }
335                    Err(e) => {
336                        state
337                            .error_popup
338                            .show(format!("Failed to edit with external editor: {}", e));
339                    }
340                }
341            }
342        }
343        KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
344            match state.scrollable_textarea.copy_focused_textarea_contents() {
345                Ok(_) => {
346                    let curr_focused_index = state.scrollable_textarea.focused_index;
347                    let curr_title_option =
348                        state.scrollable_textarea.titles.get(curr_focused_index);
349
350                    match curr_title_option {
351                        Some(curr_title) => {
352                            state
353                                .copy_popup
354                                .show(format!("Copied block {}", curr_title));
355                        }
356                        None => {
357                            state
358                                .error_popup
359                                .show("Failed to copy selection with title".to_string());
360                        }
361                    }
362                }
363                Err(e) => {
364                    state.error_popup.show(format!("{}", e));
365                }
366            }
367        }
368        KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
369            if let Err(e) = state.scrollable_textarea.copy_selection_contents() {
370                state
371                    .error_popup
372                    .show(format!("Failed to copy to clipboard: {}", e));
373            }
374        }
375        KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
376            handle_paste(state)?;
377        }
378        KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
379            if !state.scrollable_textarea.edit_mode {
380                state.scrollable_textarea.toggle_full_screen();
381            }
382        }
383        KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
384            if state.scrollable_textarea.edit_mode {
385                state.edit_commands_popup.visible = !state.edit_commands_popup.visible;
386            }
387        }
388        #[allow(clippy::assigning_clones)]
389        KeyCode::Char('s')
390            if key.modifiers.contains(KeyModifiers::CONTROL)
391                && !key.modifiers.contains(KeyModifiers::SHIFT) =>
392        {
393            // populate title_select_popup with the current titles from the textareas
394            state
395                .title_select_popup
396                .set_titles(state.scrollable_textarea.titles.clone());
397            state.title_select_popup.selected_index = 0;
398            state.title_select_popup.visible = true;
399        }
400        KeyCode::Char('q') => {
401            if !state.scrollable_textarea.edit_mode {
402                save_textareas(
403                    &state.scrollable_textarea.textareas,
404                    &state.scrollable_textarea.titles,
405                    get_save_file_path(),
406                )?;
407                save_textareas(
408                    &state.scrollable_textarea.textareas,
409                    &state.scrollable_textarea.titles,
410                    get_save_backup_file_path(),
411                )?;
412                return Ok(true);
413            }
414            state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index].input(key);
415        }
416        KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
417            if !state.scrollable_textarea.edit_mode {
418                state
419                    .scrollable_textarea
420                    .add_textarea(TextArea::default(), String::from("New Textarea"));
421                state.scrollable_textarea.adjust_scroll_to_focused();
422            }
423        }
424        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
425            if state.scrollable_textarea.textareas.len() > 1 && !state.scrollable_textarea.edit_mode
426            {
427                state
428                    .scrollable_textarea
429                    .remove_textarea(state.scrollable_textarea.focused_index);
430            }
431        }
432        KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
433            if state.scrollable_textarea.edit_mode {
434                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
435                    .move_cursor(tui_textarea::CursorMove::Top);
436            }
437        }
438        #[allow(clippy::assigning_clones)]
439        KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
440            state.title_popup.visible = true;
441            state.title_popup.title =
442                state.scrollable_textarea.titles[state.scrollable_textarea.focused_index].clone();
443        }
444        KeyCode::Enter => {
445            if state.scrollable_textarea.edit_mode {
446                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
447                    .insert_newline();
448            } else {
449                state.scrollable_textarea.edit_mode = true;
450            }
451        }
452        KeyCode::Esc => {
453            if state.edit_commands_popup.visible {
454                state.edit_commands_popup.visible = false;
455            } else {
456                state.scrollable_textarea.edit_mode = false;
457                state.edit_commands_popup.visible = false;
458            }
459
460            if state.error_popup.visible {
461                state.error_popup.hide();
462            }
463            if state.copy_popup.visible {
464                state.copy_popup.hide();
465            }
466        }
467        KeyCode::Up => handle_up_key(state, key),
468        KeyCode::Down => handle_down_key(state, key),
469        KeyCode::Char('k') => handle_up_key(state, key),
470        KeyCode::Char('j') => handle_down_key(state, key),
471        _ => {
472            if state.scrollable_textarea.edit_mode {
473                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
474                    .input(key);
475                state.scrollable_textarea.start_sel = usize::MAX;
476                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
477                    .cancel_selection();
478            }
479        }
480    }
481    Ok(false)
482}
483
484fn handle_up_key(state: &mut UIState, key: event::KeyEvent) {
485    if state.scrollable_textarea.edit_mode {
486        let textarea =
487            &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
488        if key.modifiers.contains(KeyModifiers::SHIFT) {
489            if state.scrollable_textarea.start_sel == usize::MAX {
490                let (curr_row, _) = textarea.cursor();
491                state.scrollable_textarea.start_sel = curr_row;
492                textarea.start_selection();
493            }
494            if textarea.cursor().0 > 0 {
495                textarea.move_cursor(tui_textarea::CursorMove::Up);
496            }
497        } else {
498            textarea.move_cursor(tui_textarea::CursorMove::Up);
499            state.scrollable_textarea.start_sel = usize::MAX;
500            textarea.cancel_selection();
501        }
502    } else {
503        state.scrollable_textarea.move_focus(-1);
504    }
505}
506
507fn handle_down_key(state: &mut UIState, key: event::KeyEvent) {
508    if state.scrollable_textarea.edit_mode {
509        let textarea =
510            &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
511        if key.modifiers.contains(KeyModifiers::SHIFT) {
512            if state.scrollable_textarea.start_sel == usize::MAX {
513                let (curr_row, _) = textarea.cursor();
514                state.scrollable_textarea.start_sel = curr_row;
515                textarea.start_selection();
516            }
517            if textarea.cursor().0 < textarea.lines().len() - 1 {
518                textarea.move_cursor(tui_textarea::CursorMove::Down);
519            }
520        } else {
521            textarea.move_cursor(tui_textarea::CursorMove::Down);
522            state.scrollable_textarea.start_sel = usize::MAX;
523            textarea.cancel_selection();
524        }
525    } else {
526        state.scrollable_textarea.move_focus(1);
527    }
528}
529
530fn format_current_textarea<F>(state: &mut UIState, formatter: F) -> Result<()>
531where
532    F: Fn(&str) -> Result<String>,
533{
534    let current_content = state.scrollable_textarea.textareas
535        [state.scrollable_textarea.focused_index]
536        .lines()
537        .join("\n");
538    match formatter(&current_content) {
539        Ok(formatted) => {
540            let mut new_textarea = TextArea::default();
541            for line in formatted.lines() {
542                new_textarea.insert_str(line);
543                new_textarea.insert_newline();
544            }
545            state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] =
546                new_textarea;
547            Ok(())
548        }
549        Err(e) => {
550            state
551                .error_popup
552                .show(format!("Failed to format block: {}", e));
553            Ok(())
554        }
555    }
556}
557
558fn edit_with_external_editor(state: &mut UIState) -> Result<String> {
559    let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
560        .lines()
561        .join("\n");
562    let mut temp_file = NamedTempFile::new()?;
563
564    temp_file.write_all(content.as_bytes())?;
565    temp_file.flush()?;
566
567    let editor = env::var("VISUAL")
568        .or_else(|_| env::var("EDITOR"))
569        .unwrap_or_else(|_| "vi".to_string());
570
571    // suspend the TUI
572    disable_raw_mode()?;
573    execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
574
575    let status = Command::new(&editor).arg(temp_file.path()).status()?;
576
577    // resume the TUI
578    enable_raw_mode()?;
579    execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
580
581    if !status.success() {
582        bail!(format!("Editor '{}' returned non-zero status", editor));
583    }
584
585    let edited_content = fs::read_to_string(temp_file.path())?;
586
587    Ok(edited_content)
588}
589
590fn handle_paste(state: &mut UIState) -> Result<()> {
591    if state.scrollable_textarea.edit_mode {
592        match &mut state.clipboard {
593            Some(clip) => {
594                if let Ok(content) = clip.get_content() {
595                    let textarea = &mut state.scrollable_textarea.textareas
596                        [state.scrollable_textarea.focused_index];
597                    for line in content.lines() {
598                        textarea.insert_str(line);
599                        textarea.insert_newline();
600                    }
601                    // Remove the last extra newline
602                    if content.ends_with('\n') {
603                        textarea.delete_char();
604                    }
605                }
606            }
607            None => {
608                state
609                    .error_popup
610                    .show("Failed to create clipboard".to_string());
611            }
612        }
613    }
614    Ok(())
615}