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