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, 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            // Force clipboard failure if env var is set (for testing)
248            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        // reset selection
314        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        // Scrolling down
448        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        // Scrolling up
462        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}