Skip to main content

thoth_cli/
ui_handler.rs

1use crate::{
2    get_save_backup_file_path, utils::extract_code_blocks, CodeBlockPopup, EditorClipboard,
3    ThemeMode, ThothConfig,
4};
5use anyhow::{bail, Result};
6use crossterm::{
7    event::{self, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers},
8    execute,
9    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use ratatui::{backend::CrosstermBackend, Terminal};
12use std::{
13    io::{self, Write},
14    time::Instant,
15};
16use tui_textarea::TextArea;
17
18use crate::{
19    format_json, format_markdown, get_save_file_path, load_textareas, save_textareas,
20    ui::{
21        render_code_block_popup, render_edit_commands_popup, render_header, render_title_popup,
22        render_title_select_popup, render_ui_popup, EditCommandsPopup, UiPopup,
23    },
24    ScrollableTextArea, TitlePopup, TitleSelectPopup,
25};
26
27use std::env;
28use std::fs;
29use std::process::Command;
30use tempfile::NamedTempFile;
31
32pub struct UIState {
33    pub scrollable_textarea: ScrollableTextArea,
34    pub title_popup: TitlePopup,
35    pub title_select_popup: TitleSelectPopup,
36    pub error_popup: UiPopup,
37    pub help_popup: UiPopup,
38    pub copy_popup: UiPopup,
39    pub edit_commands_popup: EditCommandsPopup,
40    pub code_block_popup: CodeBlockPopup,
41    pub clipboard: Option<EditorClipboard>,
42    pub last_draw: Instant,
43    pub last_backup: Instant,
44    pub config: ThothConfig,
45}
46
47impl UIState {
48    pub fn new() -> Result<Self> {
49        let mut scrollable_textarea = ScrollableTextArea::new();
50        let main_save_path = get_save_file_path();
51        if main_save_path.exists() {
52            let (loaded_textareas, loaded_titles) = load_textareas(main_save_path)?;
53            for (textarea, title) in loaded_textareas.into_iter().zip(loaded_titles) {
54                scrollable_textarea.add_textarea(textarea, title);
55            }
56        } else {
57            scrollable_textarea.add_textarea(TextArea::default(), String::from("New Textarea"));
58        }
59        scrollable_textarea.initialize_scroll();
60
61        let config = ThothConfig::load()?;
62
63        Ok(UIState {
64            scrollable_textarea,
65            title_popup: TitlePopup::new(),
66            title_select_popup: TitleSelectPopup::new(),
67            error_popup: UiPopup::new("Error".to_string(), 60, 20),
68            copy_popup: UiPopup::new("Block Copied".to_string(), 60, 20),
69            help_popup: UiPopup::new("Keyboard Shortcuts".to_string(), 60, 80),
70            edit_commands_popup: EditCommandsPopup::new(),
71            code_block_popup: CodeBlockPopup::new(),
72            clipboard: EditorClipboard::try_new(),
73            last_draw: Instant::now(),
74            last_backup: Instant::now(),
75            config,
76        })
77    }
78}
79
80pub fn draw_ui(
81    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
82    state: &mut UIState,
83) -> Result<()> {
84    terminal.draw(|f| {
85        let theme = state.config.get_theme_colors();
86
87        let chunks = ratatui::layout::Layout::default()
88            .direction(ratatui::layout::Direction::Vertical)
89            .constraints(
90                [
91                    ratatui::layout::Constraint::Length(1),
92                    ratatui::layout::Constraint::Min(1),
93                ]
94                .as_ref(),
95            )
96            .split(f.size());
97
98        render_header(f, chunks[0], state.scrollable_textarea.edit_mode, theme);
99        if state.scrollable_textarea.full_screen_mode {
100            state
101                .scrollable_textarea
102                .render(f, f.size(), theme, &state.config.theme)
103                .unwrap();
104        } else {
105            state
106                .scrollable_textarea
107                .render(f, chunks[1], theme, &state.config.theme)
108                .unwrap();
109        }
110
111        if state.copy_popup.visible {
112            render_ui_popup(f, &state.copy_popup, theme);
113        }
114        if state.title_popup.visible {
115            render_title_popup(f, &state.title_popup, theme);
116        } else if state.title_select_popup.visible {
117            render_title_select_popup(f, &state.title_select_popup, theme);
118        } else if state.code_block_popup.visible {
119            render_code_block_popup(f, &state.code_block_popup, theme);
120        }
121
122        if state.edit_commands_popup.visible {
123            render_edit_commands_popup(f, theme);
124        }
125
126        if state.error_popup.visible {
127            render_ui_popup(f, &state.error_popup, theme);
128        }
129        if state.help_popup.visible {
130            render_ui_popup(f, &state.help_popup, theme);
131        }
132    })?;
133    Ok(())
134}
135
136fn handle_code_block_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
137    let visible_items =
138        (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 4;
139
140    match key.code {
141        KeyCode::Enter if !state.code_block_popup.filtered_blocks.is_empty() => {
142            let selected_index = state.code_block_popup.selected_index;
143            let content = state.code_block_popup.filtered_blocks[selected_index]
144                .content
145                .clone();
146            let language = state.code_block_popup.filtered_blocks[selected_index]
147                .language
148                .clone();
149
150            if let Err(e) = copy_code_block_content_to_clipboard(state, &content, &language) {
151                state.error_popup.show(format!("{}", e));
152            }
153
154            state.code_block_popup.visible = false;
155        }
156        KeyCode::Esc => {
157            state.code_block_popup.visible = false;
158        }
159        KeyCode::Up => {
160            state.code_block_popup.move_selection_up(visible_items);
161        }
162        KeyCode::Down => {
163            state.code_block_popup.move_selection_down(visible_items);
164        }
165        _ => {}
166    }
167    Ok(false)
168}
169
170fn extract_and_show_code_blocks(state: &mut UIState) -> Result<()> {
171    let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
172        .lines()
173        .join("\n");
174
175    let code_blocks = extract_code_blocks(&content);
176
177    if code_blocks.is_empty() {
178        state
179            .error_popup
180            .show("No code blocks found in the current note.".to_string());
181        return Ok(());
182    }
183
184    state.code_block_popup.set_code_blocks(code_blocks);
185    state.code_block_popup.visible = true;
186    Ok(())
187}
188
189fn copy_code_block_content_to_clipboard(
190    state: &mut UIState,
191    content: &str,
192    language: &str,
193) -> Result<()> {
194    match &mut state.clipboard {
195        Some(clip) => {
196            if let Err(e) = clip.set_contents(content.to_string()) {
197                let backup_path = crate::get_clipboard_backup_file_path();
198                std::fs::write(&backup_path, content)?;
199
200                return Err(anyhow::anyhow!(
201                    "Clipboard error: {}.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
202                    e.to_string().split('\n').next().unwrap_or("Unknown error"),
203                    backup_path.display()
204                ));
205            }
206
207            state.copy_popup.show(format!(
208                "Copied code block [{}] to clipboard",
209                if language.is_empty() {
210                    "no language"
211                } else {
212                    language
213                }
214            ));
215        }
216        None => {
217            let backup_path = crate::get_clipboard_backup_file_path();
218            std::fs::write(&backup_path, content)?;
219
220            return Err(anyhow::anyhow!(
221                "Clipboard unavailable.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
222                backup_path.display()
223            ));
224        }
225    }
226    Ok(())
227}
228
229pub fn handle_input(
230    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
231    state: &mut UIState,
232    key: event::KeyEvent,
233) -> Result<bool> {
234    if key.kind != KeyEventKind::Press {
235        return Ok(false);
236    }
237
238    if state.code_block_popup.visible {
239        handle_code_block_popup_input(state, key)
240    } else if state.scrollable_textarea.full_screen_mode {
241        handle_full_screen_input(terminal, state, key)
242    } else if state.title_popup.visible {
243        handle_title_popup_input(state, key)
244    } else if state.title_select_popup.visible {
245        handle_title_select_popup_input(state, key)
246    } else {
247        handle_normal_input(terminal, state, key)
248    }
249}
250
251fn handle_full_screen_input(
252    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
253    state: &mut UIState,
254    key: event::KeyEvent,
255) -> Result<bool> {
256    match key.code {
257        KeyCode::Esc => {
258            if state.copy_popup.visible {
259                state.copy_popup.hide();
260            } else if state.error_popup.visible {
261                state.error_popup.hide();
262            } else if state.help_popup.visible {
263                state.help_popup.hide();
264            } else if state.edit_commands_popup.visible {
265                state.edit_commands_popup.visible = false;
266            } else if state.scrollable_textarea.edit_mode {
267                state.scrollable_textarea.edit_mode = false;
268            } else {
269                state.scrollable_textarea.toggle_full_screen();
270                state
271                    .scrollable_textarea
272                    .jump_to_textarea(state.scrollable_textarea.focused_index);
273            }
274        }
275        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
276            if state.scrollable_textarea.edit_mode {
277                match edit_with_external_editor(state) {
278                    Ok(edited_content) => {
279                        let mut new_textarea = TextArea::default();
280                        for line in edited_content.lines() {
281                            new_textarea.insert_str(line);
282                            new_textarea.insert_newline();
283                        }
284                        state.scrollable_textarea.textareas
285                            [state.scrollable_textarea.focused_index] = new_textarea;
286
287                        terminal.clear()?;
288                    }
289                    Err(e) => {
290                        state
291                            .error_popup
292                            .show(format!("Failed to edit with external editor: {}", e));
293                    }
294                }
295            }
296        }
297        KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
298            if state.scrollable_textarea.edit_mode {
299                state.edit_commands_popup.visible = !state.edit_commands_popup.visible;
300            }
301        }
302        KeyCode::Enter => {
303            if !state.scrollable_textarea.edit_mode {
304                state.scrollable_textarea.edit_mode = true;
305            } else {
306                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
307                    .insert_newline();
308            }
309        }
310        KeyCode::Up => {
311            if state.scrollable_textarea.edit_mode {
312                handle_up_key(state, key);
313            } else {
314                state.scrollable_textarea.handle_scroll(-1);
315            }
316        }
317        KeyCode::Down => {
318            if state.scrollable_textarea.edit_mode {
319                handle_down_key(state, key);
320            } else {
321                state.scrollable_textarea.handle_scroll(1);
322            }
323        }
324        KeyCode::Char('k') if !state.scrollable_textarea.edit_mode => {
325            state.scrollable_textarea.handle_scroll(-1);
326        }
327        KeyCode::Char('j') if !state.scrollable_textarea.edit_mode => {
328            state.scrollable_textarea.handle_scroll(1);
329        }
330        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
331            if !state.scrollable_textarea.edit_mode {
332                if let Err(e) = extract_and_show_code_blocks(state) {
333                    state
334                        .error_popup
335                        .show(format!("Error extracting code blocks: {}", e));
336                }
337            } else {
338                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
339                    .input(key);
340            }
341        }
342        KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
343            match state.scrollable_textarea.copy_focused_textarea_contents() {
344                Ok(_) => {
345                    let curr_focused_index = state.scrollable_textarea.focused_index;
346                    let curr_title_option =
347                        state.scrollable_textarea.titles.get(curr_focused_index);
348
349                    match curr_title_option {
350                        Some(curr_title) => {
351                            state
352                                .copy_popup
353                                .show(format!("Copied block {}", curr_title));
354                        }
355                        None => {
356                            state
357                                .error_popup
358                                .show("Failed to copy selection with title".to_string());
359                        }
360                    }
361                }
362                Err(e) => {
363                    state.error_popup.show(format!("{}", e));
364                }
365            }
366        }
367        _ => {
368            if state.scrollable_textarea.edit_mode {
369                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
370                    .input(key);
371            }
372        }
373    }
374    Ok(false)
375}
376
377fn handle_title_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
378    match key.code {
379        KeyCode::Enter => {
380            #[allow(clippy::assigning_clones)]
381            state
382                .scrollable_textarea
383                .change_title(state.title_popup.title.clone());
384            state.title_popup.visible = false;
385            state.title_popup.title.clear();
386        }
387        KeyCode::Esc => {
388            state.title_popup.visible = false;
389            state.title_popup.title.clear();
390        }
391        KeyCode::Char(c) => {
392            state.title_popup.title.push(c);
393        }
394        KeyCode::Backspace => {
395            state.title_popup.title.pop();
396        }
397        _ => {}
398    }
399    Ok(false)
400}
401
402fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
403    let visible_items =
404        (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 10;
405
406    match key.code {
407        KeyCode::Enter if !state.title_select_popup.filtered_titles.is_empty() => {
408            let selected_title_match =
409                &state.title_select_popup.filtered_titles[state.title_select_popup.selected_index];
410            state
411                .scrollable_textarea
412                .jump_to_textarea(selected_title_match.index);
413            state.title_select_popup.visible = false;
414            if !state.title_select_popup.search_query.is_empty() {
415                state.title_select_popup.search_query.clear();
416                state.title_select_popup.reset_filtered_titles();
417            }
418        }
419        KeyCode::Esc => {
420            state.title_select_popup.visible = false;
421            state.edit_commands_popup.visible = false;
422            if !state.title_select_popup.search_query.is_empty() {
423                state.title_select_popup.search_query.clear();
424                state.title_select_popup.reset_filtered_titles();
425            }
426        }
427        KeyCode::Up => {
428            state.title_select_popup.move_selection_up(visible_items);
429        }
430        KeyCode::Down => {
431            state.title_select_popup.move_selection_down(visible_items);
432        }
433        KeyCode::Char(c) => {
434            state.title_select_popup.search_query.push(c);
435            state.title_select_popup.update_search();
436        }
437        KeyCode::Backspace => {
438            state.title_select_popup.search_query.pop();
439            state.title_select_popup.update_search();
440        }
441
442        _ => {}
443    }
444    Ok(false)
445}
446
447fn handle_normal_input(
448    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
449    state: &mut UIState,
450    key: event::KeyEvent,
451) -> Result<bool> {
452    match key.code {
453        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
454            format_current_textarea(state, format_markdown)?;
455        }
456        KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
457            format_current_textarea(state, format_json)?;
458        }
459        KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
460            if state.scrollable_textarea.edit_mode {
461                match edit_with_external_editor(state) {
462                    Ok(edited_content) => {
463                        let mut new_textarea = TextArea::default();
464                        for line in edited_content.lines() {
465                            new_textarea.insert_str(line);
466                            new_textarea.insert_newline();
467                        }
468                        state.scrollable_textarea.textareas
469                            [state.scrollable_textarea.focused_index] = new_textarea;
470
471                        // Redraw the terminal after editing
472                        terminal.clear()?;
473                    }
474                    Err(e) => {
475                        state
476                            .error_popup
477                            .show(format!("Failed to edit with external editor: {}", e));
478                    }
479                }
480            }
481        }
482        KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
483            let new_theme = match state.config.theme {
484                ThemeMode::Light => ThemeMode::Dark,
485                ThemeMode::Dark => ThemeMode::Light,
486            };
487            state.config.set_theme(new_theme.clone())?;
488        }
489        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
490            if !state.scrollable_textarea.edit_mode {
491                if let Err(e) = extract_and_show_code_blocks(state) {
492                    state
493                        .error_popup
494                        .show(format!("Error extracting code blocks: {}", e));
495                }
496            } else {
497                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
498                    .input(key);
499            }
500        }
501        KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
502            match state.scrollable_textarea.copy_focused_textarea_contents() {
503                Ok(_) => {
504                    let curr_focused_index = state.scrollable_textarea.focused_index;
505                    let curr_title_option =
506                        state.scrollable_textarea.titles.get(curr_focused_index);
507
508                    match curr_title_option {
509                        Some(curr_title) => {
510                            state
511                                .copy_popup
512                                .show(format!("Copied block {}", curr_title));
513                        }
514                        None => {
515                            state
516                                .error_popup
517                                .show("Failed to copy selection with title".to_string());
518                        }
519                    }
520                }
521                Err(e) => {
522                    state.error_popup.show(format!("{}", e));
523                }
524            }
525        }
526        KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
527            if let Err(e) = state.scrollable_textarea.copy_selection_contents() {
528                state
529                    .error_popup
530                    .show(format!("Failed to copy to clipboard: {}", e));
531            }
532        }
533        KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
534            handle_paste(state)?;
535        }
536        KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
537            let help_message = "\
538NAVIGATION:
539  • ↑/↓ or j/k: Navigate between blocks
540  • Enter: Enter edit mode
541  • Esc: Exit current mode
542
543BLOCKS:
544  • ^n: Add a new block
545  • ^d: Delete current block
546  • ^t: Change block title
547  • ^s: Select block by title
548  • ^f: Toggle fullscreen mode
549
550CLIPBOARD:
551  • ^y: Copy current block
552  • ^v: Paste from clipboard
553  • ^b: Copy selection (in edit mode)
554  • ^c: Copy code block from current note
555
556FORMATTING:
557  • ^j: Format as JSON
558  • ^k: Format as Markdown
559
560OTHER:
561  • ^l: Toggle light/dark theme
562  • ^e: Edit with external editor (in edit mode)
563  •  q: Quit application
564  • ^h: Show this help (or edit-commands popup in edit mode)";
565
566            if state.scrollable_textarea.edit_mode {
567                state.edit_commands_popup.visible = !state.edit_commands_popup.visible;
568            } else {
569                state.help_popup.show(help_message.to_string());
570            }
571        }
572        KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
573            if !state.scrollable_textarea.edit_mode {
574                state.scrollable_textarea.toggle_full_screen();
575            }
576        }
577        #[allow(clippy::assigning_clones)]
578        KeyCode::Char('s')
579            if key.modifiers.contains(KeyModifiers::CONTROL)
580                && !key.modifiers.contains(KeyModifiers::SHIFT) =>
581        {
582            // populate title_select_popup with the current titles from the textareas
583            state
584                .title_select_popup
585                .set_titles(state.scrollable_textarea.titles.clone());
586            state.title_select_popup.selected_index = 0;
587            state.title_select_popup.visible = true;
588        }
589        KeyCode::Char('q') => {
590            if !state.scrollable_textarea.edit_mode {
591                save_textareas(
592                    &state.scrollable_textarea.textareas,
593                    &state.scrollable_textarea.titles,
594                    get_save_file_path(),
595                )?;
596                save_textareas(
597                    &state.scrollable_textarea.textareas,
598                    &state.scrollable_textarea.titles,
599                    get_save_backup_file_path(),
600                )?;
601                return Ok(true);
602            }
603            state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index].input(key);
604        }
605        KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
606            if !state.scrollable_textarea.edit_mode {
607                state
608                    .scrollable_textarea
609                    .add_textarea(TextArea::default(), String::from("New Textarea"));
610                state.scrollable_textarea.adjust_scroll_to_focused();
611            }
612        }
613        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
614            if state.scrollable_textarea.textareas.len() > 1 && !state.scrollable_textarea.edit_mode
615            {
616                state
617                    .scrollable_textarea
618                    .remove_textarea(state.scrollable_textarea.focused_index);
619            }
620        }
621        KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
622            if state.scrollable_textarea.edit_mode {
623                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
624                    .move_cursor(tui_textarea::CursorMove::Top);
625            }
626        }
627        #[allow(clippy::assigning_clones)]
628        KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
629            state.title_popup.visible = true;
630            state.title_popup.title =
631                state.scrollable_textarea.titles[state.scrollable_textarea.focused_index].clone();
632        }
633        KeyCode::Enter => {
634            if state.scrollable_textarea.edit_mode {
635                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
636                    .insert_newline();
637            } else {
638                state.scrollable_textarea.edit_mode = true;
639            }
640        }
641        KeyCode::Esc => {
642            if state.edit_commands_popup.visible {
643                state.edit_commands_popup.visible = false;
644            } else {
645                state.scrollable_textarea.edit_mode = false;
646                state.edit_commands_popup.visible = false;
647            }
648
649            if state.error_popup.visible {
650                state.error_popup.hide();
651            }
652            if state.help_popup.visible {
653                state.help_popup.hide();
654            }
655            if state.copy_popup.visible {
656                state.copy_popup.hide();
657            }
658        }
659        KeyCode::Up => handle_up_key(state, key),
660        KeyCode::Down => handle_down_key(state, key),
661        KeyCode::Char('k') if !state.scrollable_textarea.edit_mode => handle_up_key(state, key),
662        KeyCode::Char('j') if !state.scrollable_textarea.edit_mode => handle_down_key(state, key),
663        _ => {
664            if state.scrollable_textarea.edit_mode {
665                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
666                    .input(key);
667                state.scrollable_textarea.start_sel = usize::MAX;
668                state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
669                    .cancel_selection();
670            }
671        }
672    }
673    Ok(false)
674}
675
676fn handle_up_key(state: &mut UIState, key: event::KeyEvent) {
677    if state.scrollable_textarea.edit_mode {
678        let textarea =
679            &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
680        if key.modifiers.contains(KeyModifiers::SHIFT) {
681            if state.scrollable_textarea.start_sel == usize::MAX {
682                let (curr_row, _) = textarea.cursor();
683                state.scrollable_textarea.start_sel = curr_row;
684                textarea.start_selection();
685            }
686            if textarea.cursor().0 > 0 {
687                textarea.move_cursor(tui_textarea::CursorMove::Up);
688            }
689        } else {
690            textarea.move_cursor(tui_textarea::CursorMove::Up);
691            state.scrollable_textarea.start_sel = usize::MAX;
692            textarea.cancel_selection();
693        }
694    } else {
695        state.scrollable_textarea.move_focus(-1);
696    }
697}
698
699fn handle_down_key(state: &mut UIState, key: event::KeyEvent) {
700    if state.scrollable_textarea.edit_mode {
701        let textarea =
702            &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
703        if key.modifiers.contains(KeyModifiers::SHIFT) {
704            if state.scrollable_textarea.start_sel == usize::MAX {
705                let (curr_row, _) = textarea.cursor();
706                state.scrollable_textarea.start_sel = curr_row;
707                textarea.start_selection();
708            }
709            if textarea.cursor().0 < textarea.lines().len() - 1 {
710                textarea.move_cursor(tui_textarea::CursorMove::Down);
711            }
712        } else {
713            textarea.move_cursor(tui_textarea::CursorMove::Down);
714            state.scrollable_textarea.start_sel = usize::MAX;
715            textarea.cancel_selection();
716        }
717    } else {
718        state.scrollable_textarea.move_focus(1);
719    }
720}
721
722fn format_current_textarea<F>(state: &mut UIState, formatter: F) -> Result<()>
723where
724    F: Fn(&str) -> Result<String>,
725{
726    let current_content = state.scrollable_textarea.textareas
727        [state.scrollable_textarea.focused_index]
728        .lines()
729        .join("\n");
730    match formatter(&current_content) {
731        Ok(formatted) => {
732            let mut new_textarea = TextArea::default();
733            for line in formatted.lines() {
734                new_textarea.insert_str(line);
735                new_textarea.insert_newline();
736            }
737            state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] =
738                new_textarea;
739            Ok(())
740        }
741        Err(e) => {
742            state
743                .error_popup
744                .show(format!("Failed to format block: {}", e));
745            Ok(())
746        }
747    }
748}
749
750fn edit_with_external_editor(state: &mut UIState) -> Result<String> {
751    let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
752        .lines()
753        .join("\n");
754    let mut temp_file = NamedTempFile::new()?;
755
756    temp_file.write_all(content.as_bytes())?;
757    temp_file.flush()?;
758
759    let editor = env::var("VISUAL")
760        .or_else(|_| env::var("EDITOR"))
761        .unwrap_or_else(|_| "vi".to_string());
762
763    // suspend the TUI
764    disable_raw_mode()?;
765    execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
766
767    let status = Command::new(&editor).arg(temp_file.path()).status()?;
768
769    // resume the TUI
770    enable_raw_mode()?;
771    execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
772
773    if !status.success() {
774        bail!(format!("Editor '{}' returned non-zero status", editor));
775    }
776
777    let edited_content = fs::read_to_string(temp_file.path())?;
778
779    Ok(edited_content)
780}
781
782fn handle_paste(state: &mut UIState) -> Result<()> {
783    if state.scrollable_textarea.edit_mode {
784        match &mut state.clipboard {
785            Some(clip) => {
786                if let Ok(content) = clip.get_content() {
787                    let textarea = &mut state.scrollable_textarea.textareas
788                        [state.scrollable_textarea.focused_index];
789                    for (i, line) in content.lines().enumerate() {
790                        if i > 0 {
791                            textarea.insert_newline();
792                        }
793                        textarea.insert_str(line);
794                    }
795                }
796            }
797            None => {
798                state
799                    .error_popup
800                    .show("Failed to create clipboard".to_string());
801            }
802        }
803    }
804    Ok(())
805}