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 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 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 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(¤t_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 disable_raw_mode()?;
561 execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
562
563 let status = Command::new(&editor).arg(temp_file.path()).status()?;
564
565 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 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}