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