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 if let Some(textarea) = self.textareas.get(self.focused_index) {
302 let all_lines = textarea.lines();
303 let (cur_row, _) = textarea.cursor();
304 let min_row = min(cur_row, self.start_sel);
305 let max_row = max(cur_row, self.start_sel);
306
307 if max_row <= all_lines.len() {
308 let content = all_lines[min_row..max_row].join("\n");
309 let mut ctx = EditorClipboard::new().unwrap();
310 ctx.set_contents(content).unwrap();
311 }
312 }
313 self.start_sel = 0;
315 Ok(())
316 }
317
318 fn render_full_screen_edit(&mut self, f: &mut Frame, area: Rect, theme: &ThemeColors) {
319 let textarea = &mut self.textareas[self.focused_index];
320 let title = &self.titles[self.focused_index];
321
322 let block = Block::default()
323 .title(title.clone())
324 .borders(Borders::ALL)
325 .border_style(Style::default().fg(theme.primary));
326
327 let edit_style = Style::default().fg(theme.foreground).bg(theme.background);
328 let cursor_style = Style::default().fg(theme.foreground).bg(theme.accent);
329
330 textarea.set_block(block);
331 textarea.set_style(edit_style);
332 textarea.set_cursor_style(cursor_style);
333 textarea.set_selection_style(Style::default().bg(theme.selection));
334 f.render_widget(&*textarea, area);
335 }
336
337 pub fn render(
338 &mut self,
339 f: &mut Frame,
340 area: Rect,
341 theme: &ThemeColors,
342 theme_mode: &ThemeMode,
343 ) -> Result<()> {
344 self.viewport_height = area.height;
345
346 if self.full_screen_mode {
347 if self.edit_mode {
348 self.render_full_screen_edit(f, area, theme);
349 } else {
350 self.render_full_screen(f, area, theme, theme_mode)?;
351 }
352 } else {
353 let mut remaining_height = area.height;
354 let mut visible_textareas = Vec::with_capacity(self.textareas.len());
355
356 for (i, textarea) in self.textareas.iter_mut().enumerate().skip(self.scroll) {
357 if remaining_height == 0 {
358 break;
359 }
360
361 let content_height = (textarea.lines().len() + BORDER_PADDING_SIZE) as u16;
362 let is_focused = i == self.focused_index;
363 let is_editing = is_focused && self.edit_mode;
364
365 let height = if is_editing {
366 remaining_height
367 } else {
368 content_height
369 .min(remaining_height)
370 .max(MIN_TEXTAREA_HEIGHT as u16)
371 };
372
373 visible_textareas.push((i, textarea, height));
374 remaining_height = remaining_height.saturating_sub(height);
375
376 if is_editing {
377 break;
378 }
379 }
380
381 let chunks = Layout::default()
382 .direction(Direction::Vertical)
383 .constraints(
384 visible_textareas
385 .iter()
386 .map(|(_, _, height)| Constraint::Length(*height))
387 .collect::<Vec<_>>(),
388 )
389 .split(area);
390
391 for ((i, textarea, _), chunk) in visible_textareas.into_iter().zip(chunks.iter()) {
392 let title = &self.titles[i];
393 let is_focused = i == self.focused_index;
394 let is_editing = is_focused && self.edit_mode;
395
396 let style = if is_focused {
397 if is_editing {
398 Style::default().fg(theme.foreground).bg(theme.background)
399 } else {
400 Style::default().fg(theme.background).bg(theme.selection)
401 }
402 } else {
403 Style::default().fg(theme.foreground).bg(theme.background)
404 };
405
406 let block = Block::default()
407 .title(title.to_owned())
408 .borders(Borders::ALL)
409 .border_style(Style::default().fg(theme.primary))
410 .style(style);
411
412 if is_editing {
413 textarea.set_block(block);
414 textarea.set_style(style);
415 textarea
416 .set_cursor_style(Style::default().fg(theme.foreground).bg(theme.accent));
417 f.render_widget(&*textarea, *chunk);
418 } else {
419 let content = textarea.lines().join("\n");
420 let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
421 &content,
422 title,
423 f.size().width as usize - BORDER_PADDING_SIZE,
424 theme_mode,
425 )?;
426 let paragraph = Paragraph::new(rendered_markdown)
427 .block(block)
428 .wrap(Wrap { trim: false });
429 f.render_widget(paragraph, *chunk);
430 }
431 }
432 }
433
434 Ok(())
435 }
436
437 pub fn handle_scroll(&mut self, direction: isize) {
438 if !self.full_screen_mode {
439 return;
440 }
441
442 let current_height = self.textareas[self.focused_index].lines().len();
443 let is_scrolling_down = direction > 0;
444 let is_at_last_textarea = self.focused_index == self.textareas.len() - 1;
445 let is_at_first_textarea = self.focused_index == 0;
446
447 if is_scrolling_down {
449 let can_scroll_further = self.scroll < current_height.saturating_sub(1);
450 let can_move_to_next = !is_at_last_textarea;
451
452 if can_scroll_further {
453 self.scroll += 1;
454 } else if can_move_to_next {
455 self.focused_index += 1;
456 self.scroll = 0;
457 }
458 return;
459 }
460
461 let can_scroll_up = self.scroll > 0;
463 let can_move_to_previous = !is_at_first_textarea;
464
465 if can_scroll_up {
466 self.scroll -= 1;
467 } else if can_move_to_previous {
468 self.focused_index -= 1;
469 let prev_height = self.textareas[self.focused_index].lines().len();
470 self.scroll = prev_height.saturating_sub(1);
471 }
472 }
473
474 fn render_full_screen(
475 &mut self,
476 f: &mut Frame,
477 area: Rect,
478 theme: &ThemeColors,
479 theme_mode: &ThemeMode,
480 ) -> Result<()> {
481 let textarea = &mut self.textareas[self.focused_index];
482 textarea.set_selection_style(Style::default().bg(theme.selection));
483 let title = &self.titles[self.focused_index];
484
485 let block = Block::default()
486 .title(title.clone())
487 .borders(Borders::ALL)
488 .border_style(Style::default().fg(theme.primary));
489
490 let content = textarea.lines().join("\n");
491 let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
492 &content,
493 title,
494 f.size().width as usize - BORDER_PADDING_SIZE,
495 theme_mode,
496 )?;
497
498 let paragraph = Paragraph::new(rendered_markdown)
499 .block(block)
500 .wrap(Wrap { trim: false })
501 .scroll((self.scroll as u16, 0));
502
503 f.render_widget(paragraph, area);
504 Ok(())
505 }
506 pub fn test_get_clipboard_content(&self) -> String {
507 self.textareas[self.focused_index].lines().join("\n")
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn create_test_textarea() -> ScrollableTextArea {
516 ScrollableTextArea {
517 textareas: Vec::new(),
518 titles: Vec::new(),
519 scroll: 0,
520 focused_index: 0,
521 edit_mode: false,
522 full_screen_mode: false,
523 viewport_height: 0,
524 start_sel: 0,
525 markdown_cache: Rc::new(RefCell::new(MarkdownCache::new(&ThemeMode::Dark))),
526 }
527 }
528
529 #[test]
530 fn test_add_textarea() {
531 let mut sta = create_test_textarea();
532 sta.add_textarea(TextArea::default(), "Test".to_string());
533 assert_eq!(sta.textareas.len(), 1);
534 assert_eq!(sta.titles.len(), 1);
535 assert_eq!(sta.focused_index, 0);
536 }
537
538 #[test]
539 fn test_move_focus() {
540 let mut sta = create_test_textarea();
541 sta.add_textarea(TextArea::default(), "Test1".to_string());
542 assert_eq!(sta.focused_index, 0);
543 sta.add_textarea(TextArea::default(), "Test2".to_string());
544
545 assert_eq!(sta.focused_index, 1);
546 sta.move_focus(1);
547 assert_eq!(sta.focused_index, 0);
548 sta.move_focus(-1);
549 assert_eq!(sta.focused_index, 1);
550 }
551
552 #[test]
553 fn test_remove_textarea() {
554 let mut sta = create_test_textarea();
555 sta.add_textarea(TextArea::default(), "Test1".to_string());
556 sta.add_textarea(TextArea::default(), "Test2".to_string());
557 sta.remove_textarea(0);
558 assert_eq!(sta.textareas.len(), 1);
559 assert_eq!(sta.titles.len(), 1);
560 assert_eq!(sta.titles[0], "Test2");
561 }
562
563 #[test]
564 fn test_change_title() {
565 let mut sta = create_test_textarea();
566 sta.add_textarea(TextArea::default(), "Test".to_string());
567 sta.change_title("New Title".to_string());
568 assert_eq!(sta.titles[0], "New Title");
569 }
570
571 #[test]
572 fn test_toggle_full_screen() {
573 let mut sta = create_test_textarea();
574 assert!(!sta.full_screen_mode);
575 sta.toggle_full_screen();
576 assert!(sta.full_screen_mode);
577 assert!(!sta.edit_mode);
578 }
579
580 #[test]
581 fn test_copy_textarea_contents() {
582 let mut sta = create_test_textarea();
583 let mut textarea = TextArea::default();
584 textarea.insert_str("Test content");
585 sta.add_textarea(textarea, "Test".to_string());
586
587 let result = sta.copy_textarea_contents();
588
589 match result {
590 Ok(_) => println!("Clipboard operation succeeded"),
591 Err(e) => {
592 let error_message = e.to_string();
593 assert!(
594 error_message.contains("clipboard") || error_message.contains("display"),
595 "Unexpected error: {}",
596 error_message
597 );
598 }
599 }
600 }
601
602 #[test]
603 fn test_jump_to_textarea() {
604 let mut sta = create_test_textarea();
605 sta.add_textarea(TextArea::default(), "Test1".to_string());
606 sta.add_textarea(TextArea::default(), "Test2".to_string());
607 sta.jump_to_textarea(1);
608 assert_eq!(sta.focused_index, 1);
609 }
610}