Skip to main content

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        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        // Scrolling down
467        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        // Scrolling up
481        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}