thoth_cli/
scrollable_textarea.rs

1use std::{
2    cell::RefCell,
3    cmp::{max, min},
4    collections::HashMap,
5    rc::Rc,
6};
7
8use crate::{EditorClipboard, BORDER_PADDING_SIZE, MIN_TEXTAREA_HEIGHT};
9use crate::{MarkdownRenderer, ORANGE};
10use anyhow;
11use anyhow::Result;
12use rand::Rng;
13use ratatui::{
14    layout::{Constraint, Direction, Layout, Rect},
15    style::{Color, 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            // Force clipboard failure if env var is set (for testing)
230            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        // reset selection
296        self.start_sel = 0;
297        Ok(())
298    }
299
300    fn render_full_screen_edit(&mut self, f: &mut Frame, area: Rect) {
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(ORANGE));
308
309        let edit_style = Style::default().fg(Color::White).bg(Color::Black);
310        let cursor_style = Style::default().fg(Color::White).bg(ORANGE);
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(Color::Red));
316        f.render_widget(textarea.widget(), area);
317    }
318
319    pub fn render(&mut self, f: &mut Frame, area: Rect) -> 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);
325            } else {
326                self.render_full_screen(f, area)?;
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(Color::White).bg(Color::Black)
375                    } else {
376                        Style::default().fg(Color::Black).bg(Color::DarkGray)
377                    }
378                } else {
379                    Style::default().fg(Color::White).bg(Color::Reset)
380                };
381
382                let block = Block::default()
383                    .title(title.to_owned())
384                    .borders(Borders::ALL)
385                    .border_style(Style::default().fg(ORANGE))
386                    .style(style);
387
388                if is_editing {
389                    textarea.set_block(block);
390                    textarea.set_style(style);
391                    textarea.set_cursor_style(Style::default().fg(Color::White).bg(ORANGE));
392                    f.render_widget(textarea.widget(), *chunk);
393                } else {
394                    let content = textarea.lines().join("\n");
395                    let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
396                        &content,
397                        title,
398                        f.size().width as usize - BORDER_PADDING_SIZE,
399                    )?;
400                    let paragraph = Paragraph::new(rendered_markdown)
401                        .block(block)
402                        .wrap(Wrap { trim: false });
403                    f.render_widget(paragraph, *chunk);
404                }
405            }
406        }
407
408        Ok(())
409    }
410
411    pub fn handle_scroll(&mut self, direction: isize) {
412        if !self.full_screen_mode {
413            return;
414        }
415
416        let current_height = self.textareas[self.focused_index].lines().len();
417        let is_scrolling_down = direction > 0;
418        let is_at_last_textarea = self.focused_index == self.textareas.len() - 1;
419        let is_at_first_textarea = self.focused_index == 0;
420
421        // Scrolling down
422        if is_scrolling_down {
423            let can_scroll_further = self.scroll < current_height.saturating_sub(1);
424            let can_move_to_next = !is_at_last_textarea;
425
426            if can_scroll_further {
427                self.scroll += 1;
428            } else if can_move_to_next {
429                self.focused_index += 1;
430                self.scroll = 0;
431            }
432            return;
433        }
434
435        // Scrolling up
436        let can_scroll_up = self.scroll > 0;
437        let can_move_to_previous = !is_at_first_textarea;
438
439        if can_scroll_up {
440            self.scroll -= 1;
441        } else if can_move_to_previous {
442            self.focused_index -= 1;
443            let prev_height = self.textareas[self.focused_index].lines().len();
444            self.scroll = prev_height.saturating_sub(1);
445        }
446    }
447
448    fn render_full_screen(&mut self, f: &mut Frame, area: Rect) -> Result<()> {
449        let textarea = &mut self.textareas[self.focused_index];
450        textarea.set_selection_style(Style::default().bg(Color::Red));
451        let title = &self.titles[self.focused_index];
452
453        let block = Block::default()
454            .title(title.clone())
455            .borders(Borders::ALL)
456            .border_style(Style::default().fg(ORANGE));
457
458        let content = textarea.lines().join("\n");
459        let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
460            &content,
461            title,
462            f.size().width as usize - BORDER_PADDING_SIZE,
463        )?;
464
465        let paragraph = Paragraph::new(rendered_markdown)
466            .block(block)
467            .wrap(Wrap { trim: false })
468            .scroll((self.scroll as u16, 0));
469
470        f.render_widget(paragraph, area);
471        Ok(())
472    }
473    pub fn test_get_clipboard_content(&self) -> String {
474        self.textareas[self.focused_index].lines().join("\n")
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    fn create_test_textarea() -> ScrollableTextArea {
483        ScrollableTextArea {
484            textareas: Vec::new(),
485            titles: Vec::new(),
486            scroll: 0,
487            focused_index: 0,
488            edit_mode: false,
489            full_screen_mode: false,
490            viewport_height: 0,
491            start_sel: 0,
492            markdown_cache: Rc::new(RefCell::new(MarkdownCache::new())),
493        }
494    }
495
496    #[test]
497    fn test_add_textarea() {
498        let mut sta = create_test_textarea();
499        sta.add_textarea(TextArea::default(), "Test".to_string());
500        assert_eq!(sta.textareas.len(), 1);
501        assert_eq!(sta.titles.len(), 1);
502        assert_eq!(sta.focused_index, 0);
503    }
504
505    #[test]
506    fn test_move_focus() {
507        let mut sta = create_test_textarea();
508        sta.add_textarea(TextArea::default(), "Test1".to_string());
509        assert_eq!(sta.focused_index, 0);
510        sta.add_textarea(TextArea::default(), "Test2".to_string());
511
512        assert_eq!(sta.focused_index, 1);
513        sta.move_focus(1);
514        assert_eq!(sta.focused_index, 0);
515        sta.move_focus(-1);
516        assert_eq!(sta.focused_index, 1);
517    }
518
519    #[test]
520    fn test_remove_textarea() {
521        let mut sta = create_test_textarea();
522        sta.add_textarea(TextArea::default(), "Test1".to_string());
523        sta.add_textarea(TextArea::default(), "Test2".to_string());
524        sta.remove_textarea(0);
525        assert_eq!(sta.textareas.len(), 1);
526        assert_eq!(sta.titles.len(), 1);
527        assert_eq!(sta.titles[0], "Test2");
528    }
529
530    #[test]
531    fn test_change_title() {
532        let mut sta = create_test_textarea();
533        sta.add_textarea(TextArea::default(), "Test".to_string());
534        sta.change_title("New Title".to_string());
535        assert_eq!(sta.titles[0], "New Title");
536    }
537
538    #[test]
539    fn test_toggle_full_screen() {
540        let mut sta = create_test_textarea();
541        assert!(!sta.full_screen_mode);
542        sta.toggle_full_screen();
543        assert!(sta.full_screen_mode);
544        assert!(!sta.edit_mode);
545    }
546
547    #[test]
548    fn test_copy_textarea_contents() {
549        let mut sta = create_test_textarea();
550        let mut textarea = TextArea::default();
551        textarea.insert_str("Test content");
552        sta.add_textarea(textarea, "Test".to_string());
553
554        let result = sta.copy_textarea_contents();
555
556        match result {
557            Ok(_) => println!("Clipboard operation succeeded"),
558            Err(e) => {
559                let error_message = e.to_string();
560                assert!(
561                    error_message.contains("clipboard") || error_message.contains("display"),
562                    "Unexpected error: {}",
563                    error_message
564                );
565            }
566        }
567    }
568
569    #[test]
570    fn test_jump_to_textarea() {
571        let mut sta = create_test_textarea();
572        sta.add_textarea(TextArea::default(), "Test1".to_string());
573        sta.add_textarea(TextArea::default(), "Test2".to_string());
574        sta.jump_to_textarea(1);
575        assert_eq!(sta.focused_index, 1);
576    }
577}