1use crate::{get_save_backup_file_path, EditorClipboard};
2use anyhow::{bail, Result};
3use crossterm::{
4 event::{self, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers},
5 execute,
6 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{backend::CrosstermBackend, Terminal};
9use std::{
10 io::{self, Write},
11 time::Instant,
12};
13use tui_textarea::TextArea;
14
15use crate::{
16 format_json, format_markdown, get_save_file_path, load_textareas, save_textareas,
17 ui::{
18 render_edit_commands_popup, render_header, render_title_popup, render_title_select_popup,
19 render_ui_popup, EditCommandsPopup, UiPopup,
20 },
21 ScrollableTextArea, TitlePopup, TitleSelectPopup,
22};
23
24use std::env;
25use std::fs;
26use std::process::Command;
27use tempfile::NamedTempFile;
28
29pub struct UIState {
30 pub scrollable_textarea: ScrollableTextArea,
31 pub title_popup: TitlePopup,
32 pub title_select_popup: TitleSelectPopup,
33 pub error_popup: UiPopup,
34 pub copy_popup: UiPopup,
35 pub edit_commands_popup: EditCommandsPopup,
36 pub clipboard: Option<EditorClipboard>,
37 pub last_draw: Instant,
38}
39
40impl UIState {
41 pub fn new() -> Result<Self> {
42 let mut scrollable_textarea = ScrollableTextArea::new();
43 let main_save_path = get_save_file_path();
44 if main_save_path.exists() {
45 let (loaded_textareas, loaded_titles) = load_textareas(main_save_path)?;
46 for (textarea, title) in loaded_textareas.into_iter().zip(loaded_titles) {
47 scrollable_textarea.add_textarea(textarea, title);
48 }
49 } else {
50 scrollable_textarea.add_textarea(TextArea::default(), String::from("New Textarea"));
51 }
52 scrollable_textarea.initialize_scroll();
53
54 Ok(UIState {
55 scrollable_textarea,
56 title_popup: TitlePopup::new(),
57 title_select_popup: TitleSelectPopup::new(),
58 error_popup: UiPopup::new("Error".to_string()),
59 copy_popup: UiPopup::new("Block Copied".to_string()),
60 edit_commands_popup: EditCommandsPopup::new(),
61 clipboard: EditorClipboard::try_new(),
62 last_draw: Instant::now(),
63 })
64 }
65}
66
67pub fn draw_ui(
68 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
69 state: &mut UIState,
70) -> Result<()> {
71 terminal.draw(|f| {
72 let chunks = ratatui::layout::Layout::default()
73 .direction(ratatui::layout::Direction::Vertical)
74 .constraints(
75 [
76 ratatui::layout::Constraint::Length(1),
77 ratatui::layout::Constraint::Min(1),
78 ]
79 .as_ref(),
80 )
81 .split(f.size());
82
83 render_header(f, chunks[0], state.scrollable_textarea.edit_mode);
84 if state.scrollable_textarea.full_screen_mode {
85 state.scrollable_textarea.render(f, f.size()).unwrap();
86 } else {
87 state.scrollable_textarea.render(f, chunks[1]).unwrap();
88 }
89
90 if state.title_popup.visible {
91 render_title_popup(f, &state.title_popup);
92 } else if state.title_select_popup.visible {
93 render_title_select_popup(f, &state.title_select_popup);
94 }
95
96 if state.edit_commands_popup.visible {
97 render_edit_commands_popup(f);
98 }
99
100 if state.error_popup.visible {
101 render_ui_popup(f, &state.error_popup);
102 }
103
104 if state.copy_popup.visible {
105 render_ui_popup(f, &state.copy_popup);
106 }
107 })?;
108 Ok(())
109}
110
111pub fn handle_input(
112 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
113 state: &mut UIState,
114 key: event::KeyEvent,
115) -> Result<bool> {
116 if key.kind != KeyEventKind::Press {
117 return Ok(false);
118 }
119
120 if state.scrollable_textarea.full_screen_mode {
121 handle_full_screen_input(state, key)
122 } else if state.title_popup.visible {
123 handle_title_popup_input(state, key)
124 } else if state.title_select_popup.visible {
125 handle_title_select_popup_input(state, key)
126 } else {
127 handle_normal_input(terminal, state, key)
128 }
129}
130
131fn handle_full_screen_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
132 match key.code {
133 KeyCode::Esc => {
134 if state.scrollable_textarea.edit_mode {
135 state.scrollable_textarea.edit_mode = false;
136 } else {
137 state.scrollable_textarea.toggle_full_screen();
138 }
139
140 state
141 .scrollable_textarea
142 .jump_to_textarea(state.scrollable_textarea.focused_index);
143 }
144 KeyCode::Enter => {
145 if !state.scrollable_textarea.edit_mode {
146 state.scrollable_textarea.edit_mode = true;
147 } else {
148 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
149 .insert_newline();
150 }
151 }
152 KeyCode::Up => {
153 if state.scrollable_textarea.edit_mode {
154 handle_up_key(state, key);
155 } else {
156 state.scrollable_textarea.handle_scroll(-1);
157 }
158 }
159 KeyCode::Down => {
160 if state.scrollable_textarea.edit_mode {
161 handle_down_key(state, key);
162 } else {
163 state.scrollable_textarea.handle_scroll(1);
164 }
165 }
166 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
167 match state.scrollable_textarea.copy_focused_textarea_contents() {
168 Ok(_) => {
169 let curr_focused_index = state.scrollable_textarea.focused_index;
170 let curr_title_option =
171 state.scrollable_textarea.titles.get(curr_focused_index);
172
173 match curr_title_option {
174 Some(curr_title) => {
175 state
176 .copy_popup
177 .show(format!("Copied block {}", curr_title));
178 }
179 None => {
180 state
181 .error_popup
182 .show("Failed to copy selection with title".to_string());
183 }
184 }
185 }
186 Err(e) => {
187 state.error_popup.show(format!("{}", e));
188 }
189 }
190 }
191 KeyCode::Char('s')
192 if key.modifiers.contains(KeyModifiers::ALT)
193 && key.modifiers.contains(KeyModifiers::SHIFT) =>
194 {
195 if state.scrollable_textarea.edit_mode {
196 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
197 .start_selection();
198 }
199 }
200 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
201 if let Err(e) = state.scrollable_textarea.copy_selection_contents() {
202 state
203 .error_popup
204 .show(format!("Failed to copy to clipboard: {}", e));
205 }
206 }
207 _ => {
208 if state.scrollable_textarea.edit_mode {
209 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
210 .input(key);
211 }
212 }
213 }
214 Ok(false)
215}
216
217fn handle_title_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
218 match key.code {
219 KeyCode::Enter => {
220 #[allow(clippy::assigning_clones)]
221 state
222 .scrollable_textarea
223 .change_title(state.title_popup.title.clone());
224 state.title_popup.visible = false;
225 state.title_popup.title.clear();
226 }
227 KeyCode::Esc => {
228 state.title_popup.visible = false;
229 state.title_popup.title.clear();
230 }
231 KeyCode::Char(c) => {
232 state.title_popup.title.push(c);
233 }
234 KeyCode::Backspace => {
235 state.title_popup.title.pop();
236 }
237 _ => {}
238 }
239 Ok(false)
240}
241
242fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result<bool> {
243 let visible_items =
249 (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 10;
250
251 match key.code {
252 KeyCode::Enter => {
253 if !state.title_select_popup.filtered_titles.is_empty() {
254 let selected_title_match = &state.title_select_popup.filtered_titles
255 [state.title_select_popup.selected_index];
256 state
257 .scrollable_textarea
258 .jump_to_textarea(selected_title_match.index);
259 state.title_select_popup.visible = false;
260 if !state.title_select_popup.search_query.is_empty() {
261 state.title_select_popup.search_query.clear();
262 state.title_select_popup.reset_filtered_titles();
263 }
264 }
265 }
266 KeyCode::Esc => {
267 state.title_select_popup.visible = false;
268 state.edit_commands_popup.visible = false;
269 if !state.title_select_popup.search_query.is_empty() {
270 state.title_select_popup.search_query.clear();
271 state.title_select_popup.reset_filtered_titles();
272 }
273 }
274 KeyCode::Up => {
275 state.title_select_popup.move_selection_up(visible_items);
276 }
277 KeyCode::Down => {
278 state.title_select_popup.move_selection_down(visible_items);
279 }
280 KeyCode::Char(c) => {
281 state.title_select_popup.search_query.push(c);
282 state.title_select_popup.update_search();
283 }
284 KeyCode::Backspace => {
285 state.title_select_popup.search_query.pop();
286 state.title_select_popup.update_search();
287 }
288
289 _ => {}
290 }
291 Ok(false)
292}
293
294fn handle_normal_input(
295 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
296 state: &mut UIState,
297 key: event::KeyEvent,
298) -> Result<bool> {
299 match key.code {
300 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
301 format_current_textarea(state, format_markdown)?;
302 }
303 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
304 format_current_textarea(state, format_json)?;
305 }
306 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
307 if state.scrollable_textarea.edit_mode {
308 match edit_with_external_editor(state) {
309 Ok(edited_content) => {
310 let mut new_textarea = TextArea::default();
311 for line in edited_content.lines() {
312 new_textarea.insert_str(line);
313 new_textarea.insert_newline();
314 }
315 state.scrollable_textarea.textareas
316 [state.scrollable_textarea.focused_index] = new_textarea;
317
318 terminal.clear()?;
320 }
321 Err(e) => {
322 state
323 .error_popup
324 .show(format!("Failed to edit with external editor: {}", e));
325 }
326 }
327 }
328 }
329 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
330 match state.scrollable_textarea.copy_focused_textarea_contents() {
331 Ok(_) => {
332 let curr_focused_index = state.scrollable_textarea.focused_index;
333 let curr_title_option =
334 state.scrollable_textarea.titles.get(curr_focused_index);
335
336 match curr_title_option {
337 Some(curr_title) => {
338 state
339 .copy_popup
340 .show(format!("Copied block {}", curr_title));
341 }
342 None => {
343 state
344 .error_popup
345 .show("Failed to copy selection with title".to_string());
346 }
347 }
348 }
349 Err(e) => {
350 state.error_popup.show(format!("{}", e));
351 }
352 }
353 }
354 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
355 if let Err(e) = state.scrollable_textarea.copy_selection_contents() {
356 state
357 .error_popup
358 .show(format!("Failed to copy to clipboard: {}", e));
359 }
360 }
361 KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
362 handle_paste(state)?;
363 }
364 KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
365 if !state.scrollable_textarea.edit_mode {
366 state.scrollable_textarea.toggle_full_screen();
367 }
368 }
369 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
370 if state.scrollable_textarea.edit_mode {
371 state.edit_commands_popup.visible = !state.edit_commands_popup.visible;
372 }
373 }
374 #[allow(clippy::assigning_clones)]
375 KeyCode::Char('s')
376 if key.modifiers.contains(KeyModifiers::CONTROL)
377 && !key.modifiers.contains(KeyModifiers::SHIFT) =>
378 {
379 state
381 .title_select_popup
382 .set_titles(state.scrollable_textarea.titles.clone());
383 state.title_select_popup.selected_index = 0;
384 state.title_select_popup.visible = true;
385 }
386 KeyCode::Char('q') => {
387 if !state.scrollable_textarea.edit_mode {
388 save_textareas(
389 &state.scrollable_textarea.textareas,
390 &state.scrollable_textarea.titles,
391 get_save_file_path(),
392 )?;
393 save_textareas(
394 &state.scrollable_textarea.textareas,
395 &state.scrollable_textarea.titles,
396 get_save_backup_file_path(),
397 )?;
398 return Ok(true);
399 }
400 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index].input(key);
401 }
402 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
403 if !state.scrollable_textarea.edit_mode {
404 state
405 .scrollable_textarea
406 .add_textarea(TextArea::default(), String::from("New Textarea"));
407 state.scrollable_textarea.adjust_scroll_to_focused();
408 }
409 }
410 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
411 if state.scrollable_textarea.textareas.len() > 1 && !state.scrollable_textarea.edit_mode
412 {
413 state
414 .scrollable_textarea
415 .remove_textarea(state.scrollable_textarea.focused_index);
416 }
417 }
418 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
419 if state.scrollable_textarea.edit_mode {
420 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
421 .move_cursor(tui_textarea::CursorMove::Top);
422 }
423 }
424 #[allow(clippy::assigning_clones)]
425 KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
426 state.title_popup.visible = true;
427 state.title_popup.title =
428 state.scrollable_textarea.titles[state.scrollable_textarea.focused_index].clone();
429 }
430 KeyCode::Enter => {
431 if state.scrollable_textarea.edit_mode {
432 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
433 .insert_newline();
434 } else {
435 state.scrollable_textarea.edit_mode = true;
436 }
437 }
438 KeyCode::Esc => {
439 if state.edit_commands_popup.visible {
440 state.edit_commands_popup.visible = false;
441 } else {
442 state.scrollable_textarea.edit_mode = false;
443 state.edit_commands_popup.visible = false;
444 }
445
446 if state.error_popup.visible {
447 state.error_popup.hide();
448 }
449 if state.copy_popup.visible {
450 state.copy_popup.hide();
451 }
452 }
453 KeyCode::Up => handle_up_key(state, key),
454 KeyCode::Down => handle_down_key(state, key),
455 _ => {
456 if state.scrollable_textarea.edit_mode {
457 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
458 .input(key);
459 state.scrollable_textarea.start_sel = usize::MAX;
460 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
461 .cancel_selection();
462 }
463 }
464 }
465 Ok(false)
466}
467
468fn handle_up_key(state: &mut UIState, key: event::KeyEvent) {
469 if state.scrollable_textarea.edit_mode {
470 let textarea =
471 &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
472 if key.modifiers.contains(KeyModifiers::SHIFT) {
473 if state.scrollable_textarea.start_sel == usize::MAX {
474 let (curr_row, _) = textarea.cursor();
475 state.scrollable_textarea.start_sel = curr_row;
476 textarea.start_selection();
477 }
478 if textarea.cursor().0 > 0 {
479 textarea.move_cursor(tui_textarea::CursorMove::Up);
480 }
481 } else {
482 textarea.move_cursor(tui_textarea::CursorMove::Up);
483 state.scrollable_textarea.start_sel = usize::MAX;
484 textarea.cancel_selection();
485 }
486 } else {
487 state.scrollable_textarea.move_focus(-1);
488 }
489}
490
491fn handle_down_key(state: &mut UIState, key: event::KeyEvent) {
492 if state.scrollable_textarea.edit_mode {
493 let textarea =
494 &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
495 if key.modifiers.contains(KeyModifiers::SHIFT) {
496 if state.scrollable_textarea.start_sel == usize::MAX {
497 let (curr_row, _) = textarea.cursor();
498 state.scrollable_textarea.start_sel = curr_row;
499 textarea.start_selection();
500 }
501 if textarea.cursor().0 < textarea.lines().len() - 1 {
502 textarea.move_cursor(tui_textarea::CursorMove::Down);
503 }
504 } else {
505 textarea.move_cursor(tui_textarea::CursorMove::Down);
506 state.scrollable_textarea.start_sel = usize::MAX;
507 textarea.cancel_selection();
508 }
509 } else {
510 state.scrollable_textarea.move_focus(1);
511 }
512}
513
514fn format_current_textarea<F>(state: &mut UIState, formatter: F) -> Result<()>
515where
516 F: Fn(&str) -> Result<String>,
517{
518 let current_content = state.scrollable_textarea.textareas
519 [state.scrollable_textarea.focused_index]
520 .lines()
521 .join("\n");
522 match formatter(¤t_content) {
523 Ok(formatted) => {
524 let mut new_textarea = TextArea::default();
525 for line in formatted.lines() {
526 new_textarea.insert_str(line);
527 new_textarea.insert_newline();
528 }
529 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] =
530 new_textarea;
531 Ok(())
532 }
533 Err(e) => {
534 state
535 .error_popup
536 .show(format!("Failed to format block: {}", e));
537 Ok(())
538 }
539 }
540}
541
542fn edit_with_external_editor(state: &mut UIState) -> Result<String> {
543 let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
544 .lines()
545 .join("\n");
546 let mut temp_file = NamedTempFile::new()?;
547
548 temp_file.write_all(content.as_bytes())?;
549 temp_file.flush()?;
550
551 let editor = env::var("VISUAL")
552 .or_else(|_| env::var("EDITOR"))
553 .unwrap_or_else(|_| "vi".to_string());
554
555 disable_raw_mode()?;
557 execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
558
559 let status = Command::new(&editor).arg(temp_file.path()).status()?;
560
561 enable_raw_mode()?;
563 execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
564
565 if !status.success() {
566 bail!(format!("Editor '{}' returned non-zero status", editor));
567 }
568
569 let edited_content = fs::read_to_string(temp_file.path())?;
570
571 Ok(edited_content)
572}
573
574fn handle_paste(state: &mut UIState) -> Result<()> {
575 if state.scrollable_textarea.edit_mode {
576 match &mut state.clipboard {
577 Some(clip) => {
578 if let Ok(content) = clip.get_content() {
579 let textarea = &mut state.scrollable_textarea.textareas
580 [state.scrollable_textarea.focused_index];
581 for line in content.lines() {
582 textarea.insert_str(line);
583 textarea.insert_newline();
584 }
585 if content.ends_with('\n') {
587 textarea.delete_char();
588 }
589 }
590 }
591 None => {
592 state
593 .error_popup
594 .show("Failed to create clipboard".to_string());
595 }
596 }
597 }
598 Ok(())
599}