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        KeyCode::Char('k') if !state.scrollable_textarea.edit_mode => handle_up_key(state, key),
456        KeyCode::Char('j') if !state.scrollable_textarea.edit_mode => handle_down_key(state, key),
457        _ => {
458            if state.scrollable_textarea.edit_mode {
459                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
460                    .input(key);
461                state.scrollable_textarea.start_sel = usize::MAX;
462                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
463                    .cancel_selection();
464            }
465        }
466    }
467    Ok(false)
468}
469
470fn handle_up_key(state: &mut UIState, key: event::KeyEvent) {
471    if state.scrollable_textarea.edit_mode {
472        let textarea =
473            &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
474        if key.modifiers.contains(KeyModifiers::SHIFT) {
475            if state.scrollable_textarea.start_sel == usize::MAX {
476                let (curr_row, _) = textarea.cursor();
477                state.scrollable_textarea.start_sel = curr_row;
478                textarea.start_selection();
479            }
480            if textarea.cursor().0 > 0 {
481                textarea.move_cursor(tui_textarea::CursorMove::Up);
482            }
483        } else {
484            textarea.move_cursor(tui_textarea::CursorMove::Up);
485            state.scrollable_textarea.start_sel = usize::MAX;
486            textarea.cancel_selection();
487        }
488    } else {
489        state.scrollable_textarea.move_focus(-1);
490    }
491}
492
493fn handle_down_key(state: &mut UIState, key: event::KeyEvent) {
494    if state.scrollable_textarea.edit_mode {
495        let textarea =
496            &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
497        if key.modifiers.contains(KeyModifiers::SHIFT) {
498            if state.scrollable_textarea.start_sel == usize::MAX {
499                let (curr_row, _) = textarea.cursor();
500                state.scrollable_textarea.start_sel = curr_row;
501                textarea.start_selection();
502            }
503            if textarea.cursor().0 < textarea.lines().len() - 1 {
504                textarea.move_cursor(tui_textarea::CursorMove::Down);
505            }
506        } else {
507            textarea.move_cursor(tui_textarea::CursorMove::Down);
508            state.scrollable_textarea.start_sel = usize::MAX;
509            textarea.cancel_selection();
510        }
511    } else {
512        state.scrollable_textarea.move_focus(1);
513    }
514}
515
516fn format_current_textarea<F>(state: &mut UIState, formatter: F) -> Result<()>
517where
518    F: Fn(&str) -> Result<String>,
519{
520    let current_content = state.scrollable_textarea.textareas
521        [state.scrollable_textarea.focused_index]
522        .lines()
523        .join("\n");
524    match formatter(&current_content) {
525        Ok(formatted) => {
526            let mut new_textarea = TextArea::default();
527            for line in formatted.lines() {
528                new_textarea.insert_str(line);
529                new_textarea.insert_newline();
530            }
531            state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] =
532                new_textarea;
533            Ok(())
534        }
535        Err(e) => {
536            state
537                .error_popup
538                .show(format!("Failed to format block: {}", e));
539            Ok(())
540        }
541    }
542}
543
544fn edit_with_external_editor(state: &mut UIState) -> Result<String> {
545    let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
546        .lines()
547        .join("\n");
548    let mut temp_file = NamedTempFile::new()?;
549
550    temp_file.write_all(content.as_bytes())?;
551    temp_file.flush()?;
552
553    let editor = env::var("VISUAL")
554        .or_else(|_| env::var("EDITOR"))
555        .unwrap_or_else(|_| "vi".to_string());
556
557    // suspend the TUI
558    disable_raw_mode()?;
559    execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
560
561    let status = Command::new(&editor).arg(temp_file.path()).status()?;
562
563    // resume the TUI
564    enable_raw_mode()?;
565    execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
566
567    if !status.success() {
568        bail!(format!("Editor '{}' returned non-zero status", editor));
569    }
570
571    let edited_content = fs::read_to_string(temp_file.path())?;
572
573    Ok(edited_content)
574}
575
576fn handle_paste(state: &mut UIState) -> Result<()> {
577    if state.scrollable_textarea.edit_mode {
578        match &mut state.clipboard {
579            Some(clip) => {
580                if let Ok(content) = clip.get_content() {
581                    let textarea = &mut state.scrollable_textarea.textareas
582                        [state.scrollable_textarea.focused_index];
583                    for line in content.lines() {
584                        textarea.insert_str(line);
585                        textarea.insert_newline();
586                    }
587                    // Remove the last extra newline
588                    if content.ends_with('\n') {
589                        textarea.delete_char();
590                    }
591                }
592            }
593            None => {
594                state
595                    .error_popup
596                    .show("Failed to create clipboard".to_string());
597            }
598        }
599    }
600    Ok(())
601}