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