1use std::{
2 cell::RefCell,
3 cmp::{max, min},
4 collections::HashMap,
5 rc::Rc,
6};
7
8use crate::{EditorClipboard, ThemeColors, BORDER_PADDING_SIZE, MIN_TEXTAREA_HEIGHT};
9use crate::{MarkdownRenderer, ThemeMode};
10use anyhow;
11use anyhow::Result;
12use rand::Rng;
13use ratatui::{
14 layout::{Constraint, Direction, Layout, Rect},
15 style::Style,
16 text::Text,
17 widgets::{Block, Borders, Paragraph, Wrap},
18 Frame,
19};
20use std::collections::HashSet;
21use tui_textarea::TextArea;
22
23const RENDER_CACHE_SIZE: usize = 100;
24
25struct MarkdownCache {
26 cache: HashMap<String, Text<'static>>,
27 renderer: MarkdownRenderer,
28 current_theme: ThemeMode,
29}
30
31impl MarkdownCache {
32 fn new(theme_mode: &ThemeMode) -> Self {
33 MarkdownCache {
34 cache: HashMap::with_capacity(RENDER_CACHE_SIZE),
35 renderer: MarkdownRenderer::new(theme_mode),
36 current_theme: theme_mode.clone(),
37 }
38 }
39
40 fn update_theme(&mut self, theme_mode: &ThemeMode) {
41 if self.current_theme != *theme_mode {
42 self.renderer.set_theme(theme_mode);
43 self.current_theme = theme_mode.clone();
44 self.cache.clear();
45 }
46 }
47
48 fn get_or_render(
49 &mut self,
50 content: &str,
51 title: &str,
52 width: usize,
53 theme_mode: &ThemeMode,
54 ) -> Result<Text<'static>> {
55 self.update_theme(theme_mode);
56
57 let cache_key = format!("{}:{}:{:?}", title, content, theme_mode);
58 if let Some(cached) = self.cache.get(&cache_key) {
59 return Ok(cached.clone());
60 }
61
62 let content = format!("{}\n", content);
63
64 let rendered = self
65 .renderer
66 .render_markdown(content, title.to_string(), width)?;
67
68 if self.cache.len() >= RENDER_CACHE_SIZE {
69 if let Some(old_key) = self.cache.keys().next().cloned() {
70 self.cache.remove(&old_key);
71 }
72 }
73
74 self.cache.insert(cache_key, rendered.clone());
75 Ok(rendered)
76 }
77}
78
79pub struct ScrollableTextArea {
80 pub textareas: Vec<TextArea<'static>>,
81 pub titles: Vec<String>,
82 pub scroll: usize,
83 pub focused_index: usize,
84 pub edit_mode: bool,
85 pub full_screen_mode: bool,
86 pub viewport_height: u16,
87 pub start_sel: usize,
88 markdown_cache: Rc<RefCell<MarkdownCache>>,
89}
90
91impl Default for ScrollableTextArea {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl ScrollableTextArea {
98 pub fn new() -> Self {
99 ScrollableTextArea {
100 textareas: Vec::with_capacity(10),
101 titles: Vec::with_capacity(10),
102 scroll: 0,
103 focused_index: 0,
104 edit_mode: false,
105 full_screen_mode: false,
106 viewport_height: 0,
107 start_sel: 0,
108 markdown_cache: Rc::new(RefCell::new(MarkdownCache::new(&ThemeMode::Dark))),
109 }
110 }
111
112 pub fn toggle_full_screen(&mut self) {
113 self.full_screen_mode = !self.full_screen_mode;
114 if self.full_screen_mode {
115 self.edit_mode = false;
116 self.scroll = 0
117 }
118 }
119
120 pub fn change_title(&mut self, new_title: String) {
121 let unique_title = self.generate_unique_title(new_title);
122 if self.focused_index < self.titles.len() {
123 self.titles[self.focused_index] = unique_title;
124 }
125 }
126
127 fn generate_unique_title(&self, base_title: String) -> String {
128 if !self.titles.contains(&base_title) {
129 return base_title;
130 }
131
132 let existing_titles: HashSet<String> = self.titles.iter().cloned().collect();
133 let mut rng = rand::thread_rng();
134 let mut new_title = base_title.clone();
135 let mut counter = 1;
136
137 while existing_titles.contains(&new_title) {
138 if counter <= 5 {
139 new_title = format!("{} {}", base_title, counter);
140 } else {
141 new_title = format!("{} {}", base_title, rng.gen_range(100..1000));
142 }
143 counter += 1;
144 }
145
146 new_title
147 }
148
149 pub fn add_textarea(&mut self, textarea: TextArea<'static>, title: String) {
150 let new_index = if self.textareas.is_empty() {
151 0
152 } else {
153 self.focused_index + 1
154 };
155
156 let unique_title = self.generate_unique_title(title);
157 self.textareas.insert(new_index, textarea);
158 self.titles.insert(new_index, unique_title);
159 self.focused_index = new_index;
160 self.adjust_scroll_to_focused();
161 }
162
163 pub fn copy_textarea_contents(&self) -> Result<()> {
164 if let Some(textarea) = self.textareas.get(self.focused_index) {
165 let content = textarea.lines().join("\n");
166 let mut ctx = EditorClipboard::new()
167 .map_err(|e| anyhow::anyhow!("Failed to create clipboard context: {}", e))?;
168 ctx.set_contents(content)
169 .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?;
170 }
171 Ok(())
172 }
173
174 pub fn jump_to_textarea(&mut self, index: usize) {
175 if index < self.textareas.len() {
176 self.focused_index = index;
177 self.adjust_scroll_to_focused();
178 }
179 }
180
181 pub fn remove_textarea(&mut self, index: usize) {
182 if index < self.textareas.len() {
183 self.textareas.remove(index);
184 self.titles.remove(index);
185 if self.focused_index >= self.textareas.len() {
186 self.focused_index = self.textareas.len().saturating_sub(1);
187 }
188 self.scroll = self.scroll.min(self.focused_index);
189 }
190 }
191
192 pub fn move_focus(&mut self, direction: isize) {
193 let new_index = self.focused_index as isize + direction;
194 if new_index >= (self.textareas.len()) as isize {
195 self.focused_index = 0;
196 } else if new_index < 0 {
197 self.focused_index = self.textareas.len() - 1;
198 } else {
199 self.focused_index = new_index as usize;
200 }
201 self.adjust_scroll_to_focused();
202 }
203
204 pub fn adjust_scroll_to_focused(&mut self) {
205 if self.focused_index < self.scroll {
206 self.scroll = self.focused_index;
207 } else {
208 let mut height_sum = 0;
209 for i in self.scroll..=self.focused_index {
210 let textarea_height =
211 self.textareas[i].lines().len().max(MIN_TEXTAREA_HEIGHT) + BORDER_PADDING_SIZE;
212 height_sum += textarea_height;
213
214 if height_sum > self.viewport_height as usize {
215 self.scroll = i;
216 break;
217 }
218 }
219 }
220
221 while self.calculate_height_to_focused() > self.viewport_height
222 && self.scroll < self.focused_index
223 {
224 self.scroll += 1;
225 }
226 }
227
228 pub fn calculate_height_to_focused(&self) -> u16 {
229 self.textareas[self.scroll..=self.focused_index]
230 .iter()
231 .map(|ta| (ta.lines().len().max(MIN_TEXTAREA_HEIGHT) + BORDER_PADDING_SIZE) as u16)
232 .sum()
233 }
234
235 pub fn initialize_scroll(&mut self) {
236 self.scroll = 0;
237 self.focused_index = 0;
238 }
239
240 pub fn copy_focused_textarea_contents(&self) -> anyhow::Result<()> {
241 use std::fs::File;
242 use std::io::Write;
243
244 if let Some(textarea) = self.textareas.get(self.focused_index) {
245 let content = textarea.lines().join("\n");
246
247 if std::env::var("THOTH_TEST_CLIPBOARD_FAIL").is_ok() {
249 let backup_path = crate::get_clipboard_backup_file_path();
250 let mut file = File::create(&backup_path)?;
251 file.write_all(content.as_bytes())?;
252
253 return Err(anyhow::anyhow!(
254 "TESTING: Simulated clipboard failure.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
255 backup_path.display()
256 ));
257 }
258
259 match EditorClipboard::new() {
260 Ok(mut ctx) => {
261 if let Err(e) = ctx.set_contents(content.clone()) {
262 let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok()
263 || std::env::var("XDG_SESSION_TYPE")
264 .map(|v| v == "wayland")
265 .unwrap_or(false);
266
267 let backup_path = crate::get_clipboard_backup_file_path();
268 let mut file = File::create(&backup_path)?;
269 file.write_all(content.as_bytes())?;
270
271 if is_wayland {
272 return Err(anyhow::anyhow!(
273 "Wayland clipboard error.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
274 backup_path.display()
275 ));
276 } else {
277 return Err(anyhow::anyhow!(
278 "Clipboard error: {}.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
279 e.to_string().split('\n').next().unwrap_or("Unknown error"),
280 backup_path.display()
281 ));
282 }
283 }
284 }
285 Err(_) => {
286 let backup_path = crate::get_clipboard_backup_file_path();
287 let mut file = File::create(&backup_path)?;
288 file.write_all(content.as_bytes())?;
289
290 return Err(anyhow::anyhow!(
291 "Clipboard unavailable.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
292 backup_path.display()
293 ));
294 }
295 }
296 }
297 Ok(())
298 }
299
300 pub fn copy_selection_contents(&mut self) -> anyhow::Result<()> {
301 let mut content = String::new();
302 if let Some(textarea) = self.textareas.get(self.focused_index) {
303 let all_lines = textarea.lines();
304 let (cur_row, _) = textarea.cursor();
305 let min_row = min(cur_row, self.start_sel);
306 let max_row = max(cur_row, self.start_sel);
307
308 if min_row < all_lines.len() {
309 let max_row = min(max_row, all_lines.len());
310 content = all_lines[min_row..max_row].join("\n");
311 }
312 }
313
314 let backup_path = crate::get_clipboard_backup_file_path();
315 match EditorClipboard::new() {
316 Ok(mut ctx) => {
317 if let Err(e) = ctx.set_contents(content.clone()) {
318 let _ = std::fs::write(&backup_path, &content);
319 return Err(anyhow::anyhow!(
320 "Failed to copy selection to clipboard: {}. Content saved to: {}",
321 e.to_string().split('\n').next().unwrap_or("Unknown error"),
322 backup_path.display()
323 ));
324 }
325 }
326 Err(_) => {
327 let _ = std::fs::write(&backup_path, &content);
328 return Err(anyhow::anyhow!(
329 "Clipboard unavailable.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.",
330 backup_path.display()
331 ));
332 }
333 }
334 Ok(())
335 }
336
337 fn render_full_screen_edit(&mut self, f: &mut Frame, area: Rect, theme: &ThemeColors) {
338 let textarea = &mut self.textareas[self.focused_index];
339 let title = &self.titles[self.focused_index];
340
341 let block = Block::default()
342 .title(title.clone())
343 .borders(Borders::ALL)
344 .border_style(Style::default().fg(theme.primary));
345
346 let edit_style = Style::default().fg(theme.foreground).bg(theme.background);
347 let cursor_style = Style::default().fg(theme.foreground).bg(theme.accent);
348
349 textarea.set_block(block);
350 textarea.set_style(edit_style);
351 textarea.set_cursor_style(cursor_style);
352 textarea.set_selection_style(Style::default().bg(theme.selection));
353 f.render_widget(&*textarea, area);
354 }
355
356 pub fn render(
357 &mut self,
358 f: &mut Frame,
359 area: Rect,
360 theme: &ThemeColors,
361 theme_mode: &ThemeMode,
362 ) -> Result<()> {
363 self.viewport_height = area.height;
364
365 if self.full_screen_mode {
366 if self.edit_mode {
367 self.render_full_screen_edit(f, area, theme);
368 } else {
369 self.render_full_screen(f, area, theme, theme_mode)?;
370 }
371 } else {
372 let mut remaining_height = area.height;
373 let mut visible_textareas = Vec::with_capacity(self.textareas.len());
374
375 for (i, textarea) in self.textareas.iter_mut().enumerate().skip(self.scroll) {
376 if remaining_height == 0 {
377 break;
378 }
379
380 let content_height = (textarea.lines().len() + BORDER_PADDING_SIZE) as u16;
381 let is_focused = i == self.focused_index;
382 let is_editing = is_focused && self.edit_mode;
383
384 let height = if is_editing {
385 remaining_height
386 } else {
387 content_height
388 .min(remaining_height)
389 .max(MIN_TEXTAREA_HEIGHT as u16)
390 };
391
392 visible_textareas.push((i, textarea, height));
393 remaining_height = remaining_height.saturating_sub(height);
394
395 if is_editing {
396 break;
397 }
398 }
399
400 let chunks = Layout::default()
401 .direction(Direction::Vertical)
402 .constraints(
403 visible_textareas
404 .iter()
405 .map(|(_, _, height)| Constraint::Length(*height))
406 .collect::<Vec<_>>(),
407 )
408 .split(area);
409
410 for ((i, textarea, _), chunk) in visible_textareas.into_iter().zip(chunks.iter()) {
411 let title = &self.titles[i];
412 let is_focused = i == self.focused_index;
413 let is_editing = is_focused && self.edit_mode;
414
415 let style = if is_focused {
416 if is_editing {
417 Style::default().fg(theme.foreground).bg(theme.background)
418 } else {
419 Style::default().fg(theme.background).bg(theme.selection)
420 }
421 } else {
422 Style::default().fg(theme.foreground).bg(theme.background)
423 };
424
425 let block = Block::default()
426 .title(title.to_owned())
427 .borders(Borders::ALL)
428 .border_style(Style::default().fg(theme.primary))
429 .style(style);
430
431 if is_editing {
432 textarea.set_block(block);
433 textarea.set_style(style);
434 textarea
435 .set_cursor_style(Style::default().fg(theme.foreground).bg(theme.accent));
436 f.render_widget(&*textarea, *chunk);
437 } else {
438 let content = textarea.lines().join("\n");
439 let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
440 &content,
441 title,
442 f.size().width as usize - BORDER_PADDING_SIZE,
443 theme_mode,
444 )?;
445 let paragraph = Paragraph::new(rendered_markdown)
446 .block(block)
447 .wrap(Wrap { trim: false });
448 f.render_widget(paragraph, *chunk);
449 }
450 }
451 }
452
453 Ok(())
454 }
455
456 pub fn handle_scroll(&mut self, direction: isize) {
457 if !self.full_screen_mode {
458 return;
459 }
460
461 let current_height = self.textareas[self.focused_index].lines().len();
462 let is_scrolling_down = direction > 0;
463 let is_at_last_textarea = self.focused_index == self.textareas.len() - 1;
464 let is_at_first_textarea = self.focused_index == 0;
465
466 if is_scrolling_down {
468 let can_scroll_further = self.scroll < current_height.saturating_sub(1);
469 let can_move_to_next = !is_at_last_textarea;
470
471 if can_scroll_further {
472 self.scroll += 1;
473 } else if can_move_to_next {
474 self.focused_index += 1;
475 self.scroll = 0;
476 }
477 return;
478 }
479
480 let can_scroll_up = self.scroll > 0;
482 let can_move_to_previous = !is_at_first_textarea;
483
484 if can_scroll_up {
485 self.scroll -= 1;
486 } else if can_move_to_previous {
487 self.focused_index -= 1;
488 let prev_height = self.textareas[self.focused_index].lines().len();
489 self.scroll = prev_height.saturating_sub(1);
490 }
491 }
492
493 fn render_full_screen(
494 &mut self,
495 f: &mut Frame,
496 area: Rect,
497 theme: &ThemeColors,
498 theme_mode: &ThemeMode,
499 ) -> Result<()> {
500 let textarea = &mut self.textareas[self.focused_index];
501 textarea.set_selection_style(Style::default().bg(theme.selection));
502 let title = &self.titles[self.focused_index];
503
504 let block = Block::default()
505 .title(title.clone())
506 .borders(Borders::ALL)
507 .border_style(Style::default().fg(theme.primary));
508
509 let content = textarea.lines().join("\n");
510 let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
511 &content,
512 title,
513 f.size().width as usize - BORDER_PADDING_SIZE,
514 theme_mode,
515 )?;
516
517 let paragraph = Paragraph::new(rendered_markdown)
518 .block(block)
519 .wrap(Wrap { trim: false })
520 .scroll((self.scroll as u16, 0));
521
522 f.render_widget(paragraph, area);
523 Ok(())
524 }
525 pub fn test_get_clipboard_content(&self) -> String {
526 self.textareas[self.focused_index].lines().join("\n")
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 fn create_test_textarea() -> ScrollableTextArea {
535 ScrollableTextArea {
536 textareas: Vec::new(),
537 titles: Vec::new(),
538 scroll: 0,
539 focused_index: 0,
540 edit_mode: false,
541 full_screen_mode: false,
542 viewport_height: 0,
543 start_sel: 0,
544 markdown_cache: Rc::new(RefCell::new(MarkdownCache::new(&ThemeMode::Dark))),
545 }
546 }
547
548 #[test]
549 fn test_add_textarea() {
550 let mut sta = create_test_textarea();
551 sta.add_textarea(TextArea::default(), "Test".to_string());
552 assert_eq!(sta.textareas.len(), 1);
553 assert_eq!(sta.titles.len(), 1);
554 assert_eq!(sta.focused_index, 0);
555 }
556
557 #[test]
558 fn test_move_focus() {
559 let mut sta = create_test_textarea();
560 sta.add_textarea(TextArea::default(), "Test1".to_string());
561 assert_eq!(sta.focused_index, 0);
562 sta.add_textarea(TextArea::default(), "Test2".to_string());
563
564 assert_eq!(sta.focused_index, 1);
565 sta.move_focus(1);
566 assert_eq!(sta.focused_index, 0);
567 sta.move_focus(-1);
568 assert_eq!(sta.focused_index, 1);
569 }
570
571 #[test]
572 fn test_remove_textarea() {
573 let mut sta = create_test_textarea();
574 sta.add_textarea(TextArea::default(), "Test1".to_string());
575 sta.add_textarea(TextArea::default(), "Test2".to_string());
576 sta.remove_textarea(0);
577 assert_eq!(sta.textareas.len(), 1);
578 assert_eq!(sta.titles.len(), 1);
579 assert_eq!(sta.titles[0], "Test2");
580 }
581
582 #[test]
583 fn test_change_title() {
584 let mut sta = create_test_textarea();
585 sta.add_textarea(TextArea::default(), "Test".to_string());
586 sta.change_title("New Title".to_string());
587 assert_eq!(sta.titles[0], "New Title");
588 }
589
590 #[test]
591 fn test_toggle_full_screen() {
592 let mut sta = create_test_textarea();
593 assert!(!sta.full_screen_mode);
594 sta.toggle_full_screen();
595 assert!(sta.full_screen_mode);
596 assert!(!sta.edit_mode);
597 }
598
599 #[test]
600 fn test_copy_textarea_contents() {
601 let mut sta = create_test_textarea();
602 let mut textarea = TextArea::default();
603 textarea.insert_str("Test content");
604 sta.add_textarea(textarea, "Test".to_string());
605
606 let result = sta.copy_textarea_contents();
607
608 match result {
609 Ok(_) => println!("Clipboard operation succeeded"),
610 Err(e) => {
611 let error_message = e.to_string();
612 assert!(
613 error_message.contains("clipboard") || error_message.contains("display"),
614 "Unexpected error: {}",
615 error_message
616 );
617 }
618 }
619 }
620
621 #[test]
622 fn test_jump_to_textarea() {
623 let mut sta = create_test_textarea();
624 sta.add_textarea(TextArea::default(), "Test1".to_string());
625 sta.add_textarea(TextArea::default(), "Test2".to_string());
626 sta.jump_to_textarea(1);
627 assert_eq!(sta.focused_index, 1);
628 }
629}