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 KeyCode::Char('k') if !state.scrollable_textarea.edit_mode => handle_up_key(state, key),
456 KeyCode::Char('j') if !state.scrollable_textarea.edit_mode => handle_down_key(state, key),
457 _ => {
458 if state.scrollable_textarea.edit_mode {
459 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
460 .input(key);
461 state.scrollable_textarea.start_sel = usize::MAX;
462 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
463 .cancel_selection();
464 }
465 }
466 }
467 Ok(false)
468}
469
470fn handle_up_key(state: &mut UIState, key: event::KeyEvent) {
471 if state.scrollable_textarea.edit_mode {
472 let textarea =
473 &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
474 if key.modifiers.contains(KeyModifiers::SHIFT) {
475 if state.scrollable_textarea.start_sel == usize::MAX {
476 let (curr_row, _) = textarea.cursor();
477 state.scrollable_textarea.start_sel = curr_row;
478 textarea.start_selection();
479 }
480 if textarea.cursor().0 > 0 {
481 textarea.move_cursor(tui_textarea::CursorMove::Up);
482 }
483 } else {
484 textarea.move_cursor(tui_textarea::CursorMove::Up);
485 state.scrollable_textarea.start_sel = usize::MAX;
486 textarea.cancel_selection();
487 }
488 } else {
489 state.scrollable_textarea.move_focus(-1);
490 }
491}
492
493fn handle_down_key(state: &mut UIState, key: event::KeyEvent) {
494 if state.scrollable_textarea.edit_mode {
495 let textarea =
496 &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index];
497 if key.modifiers.contains(KeyModifiers::SHIFT) {
498 if state.scrollable_textarea.start_sel == usize::MAX {
499 let (curr_row, _) = textarea.cursor();
500 state.scrollable_textarea.start_sel = curr_row;
501 textarea.start_selection();
502 }
503 if textarea.cursor().0 < textarea.lines().len() - 1 {
504 textarea.move_cursor(tui_textarea::CursorMove::Down);
505 }
506 } else {
507 textarea.move_cursor(tui_textarea::CursorMove::Down);
508 state.scrollable_textarea.start_sel = usize::MAX;
509 textarea.cancel_selection();
510 }
511 } else {
512 state.scrollable_textarea.move_focus(1);
513 }
514}
515
516fn format_current_textarea<F>(state: &mut UIState, formatter: F) -> Result<()>
517where
518 F: Fn(&str) -> Result<String>,
519{
520 let current_content = state.scrollable_textarea.textareas
521 [state.scrollable_textarea.focused_index]
522 .lines()
523 .join("\n");
524 match formatter(¤t_content) {
525 Ok(formatted) => {
526 let mut new_textarea = TextArea::default();
527 for line in formatted.lines() {
528 new_textarea.insert_str(line);
529 new_textarea.insert_newline();
530 }
531 state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] =
532 new_textarea;
533 Ok(())
534 }
535 Err(e) => {
536 state
537 .error_popup
538 .show(format!("Failed to format block: {}", e));
539 Ok(())
540 }
541 }
542}
543
544fn edit_with_external_editor(state: &mut UIState) -> Result<String> {
545 let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]
546 .lines()
547 .join("\n");
548 let mut temp_file = NamedTempFile::new()?;
549
550 temp_file.write_all(content.as_bytes())?;
551 temp_file.flush()?;
552
553 let editor = env::var("VISUAL")
554 .or_else(|_| env::var("EDITOR"))
555 .unwrap_or_else(|_| "vi".to_string());
556
557 disable_raw_mode()?;
559 execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
560
561 let status = Command::new(&editor).arg(temp_file.path()).status()?;
562
563 enable_raw_mode()?;
565 execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
566
567 if !status.success() {
568 bail!(format!("Editor '{}' returned non-zero status", editor));
569 }
570
571 let edited_content = fs::read_to_string(temp_file.path())?;
572
573 Ok(edited_content)
574}
575
576fn handle_paste(state: &mut UIState) -> Result<()> {
577 if state.scrollable_textarea.edit_mode {
578 match &mut state.clipboard {
579 Some(clip) => {
580 if let Ok(content) = clip.get_content() {
581 let textarea = &mut state.scrollable_textarea.textareas
582 [state.scrollable_textarea.focused_index];
583 for line in content.lines() {
584 textarea.insert_str(line);
585 textarea.insert_newline();
586 }
587 if content.ends_with('\n') {
589 textarea.delete_char();
590 }
591 }
592 }
593 None => {
594 state
595 .error_popup
596 .show("Failed to create clipboard".to_string());
597 }
598 }
599 }
600 Ok(())
601}