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        if let Some(textarea) = self.textareas.get(self.focused_index) {
224            let content = textarea.lines().join("\n");
225            let mut ctx = EditorClipboard::new().unwrap();
226            ctx.set_contents(content).unwrap();
227        }
228        Ok(())
229    }
230
231    pub fn copy_selection_contents(&mut self) -> anyhow::Result<()> {
232        if let Some(textarea) = self.textareas.get(self.focused_index) {
233            let all_lines = textarea.lines();
234            let (cur_row, _) = textarea.cursor();
235            let min_row = min(cur_row, self.start_sel);
236            let max_row = max(cur_row, self.start_sel);
237
238            if max_row <= all_lines.len() {
239                let content = all_lines[min_row..max_row].join("\n");
240                let mut ctx = EditorClipboard::new().unwrap();
241                ctx.set_contents(content).unwrap();
242            }
243        }
244        // reset selection
245        self.start_sel = 0;
246        Ok(())
247    }
248
249    fn render_full_screen_edit(&mut self, f: &mut Frame, area: Rect) {
250        let textarea = &mut self.textareas[self.focused_index];
251        let title = &self.titles[self.focused_index];
252
253        let block = Block::default()
254            .title(title.clone())
255            .borders(Borders::ALL)
256            .border_style(Style::default().fg(ORANGE));
257
258        let edit_style = Style::default().fg(Color::White).bg(Color::Black);
259        let cursor_style = Style::default().fg(Color::White).bg(ORANGE);
260
261        textarea.set_block(block);
262        textarea.set_style(edit_style);
263        textarea.set_cursor_style(cursor_style);
264        textarea.set_selection_style(Style::default().bg(Color::Red));
265        f.render_widget(textarea.widget(), area);
266    }
267
268    pub fn render(&mut self, f: &mut Frame, area: Rect) -> Result<()> {
269        self.viewport_height = area.height;
270
271        if self.full_screen_mode {
272            if self.edit_mode {
273                self.render_full_screen_edit(f, area);
274            } else {
275                self.render_full_screen(f, area)?;
276            }
277        } else {
278            let mut remaining_height = area.height;
279            let mut visible_textareas = Vec::with_capacity(self.textareas.len());
280
281            for (i, textarea) in self.textareas.iter_mut().enumerate().skip(self.scroll) {
282                if remaining_height == 0 {
283                    break;
284                }
285
286                let content_height = (textarea.lines().len() + BORDER_PADDING_SIZE) as u16;
287                let is_focused = i == self.focused_index;
288                let is_editing = is_focused && self.edit_mode;
289
290                let height = if is_editing {
291                    remaining_height
292                } else {
293                    content_height
294                        .min(remaining_height)
295                        .max(MIN_TEXTAREA_HEIGHT as u16)
296                };
297
298                visible_textareas.push((i, textarea, height));
299                remaining_height = remaining_height.saturating_sub(height);
300
301                if is_editing {
302                    break;
303                }
304            }
305
306            let chunks = Layout::default()
307                .direction(Direction::Vertical)
308                .constraints(
309                    visible_textareas
310                        .iter()
311                        .map(|(_, _, height)| Constraint::Length(*height))
312                        .collect::<Vec<_>>(),
313                )
314                .split(area);
315
316            for ((i, textarea, _), chunk) in visible_textareas.into_iter().zip(chunks.iter()) {
317                let title = &self.titles[i];
318                let is_focused = i == self.focused_index;
319                let is_editing = is_focused && self.edit_mode;
320
321                let style = if is_focused {
322                    if is_editing {
323                        Style::default().fg(Color::White).bg(Color::Black)
324                    } else {
325                        Style::default().fg(Color::Black).bg(Color::DarkGray)
326                    }
327                } else {
328                    Style::default().fg(Color::White).bg(Color::Reset)
329                };
330
331                let block = Block::default()
332                    .title(title.to_owned())
333                    .borders(Borders::ALL)
334                    .border_style(Style::default().fg(ORANGE))
335                    .style(style);
336
337                if is_editing {
338                    textarea.set_block(block);
339                    textarea.set_style(style);
340                    textarea.set_cursor_style(Style::default().fg(Color::White).bg(ORANGE));
341                    f.render_widget(textarea.widget(), *chunk);
342                } else {
343                    let content = textarea.lines().join("\n");
344                    let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
345                        &content,
346                        title,
347                        f.size().width as usize - BORDER_PADDING_SIZE,
348                    )?;
349                    let paragraph = Paragraph::new(rendered_markdown)
350                        .block(block)
351                        .wrap(Wrap { trim: true });
352                    f.render_widget(paragraph, *chunk);
353                }
354            }
355        }
356
357        Ok(())
358    }
359
360    pub fn handle_scroll(&mut self, direction: isize) {
361        if !self.full_screen_mode {
362            return;
363        }
364
365        let current_height = self.textareas[self.focused_index].lines().len();
366        let is_scrolling_down = direction > 0;
367        let is_at_last_textarea = self.focused_index == self.textareas.len() - 1;
368        let is_at_first_textarea = self.focused_index == 0;
369
370        // Scrolling down
371        if is_scrolling_down {
372            let can_scroll_further = self.scroll < current_height.saturating_sub(1);
373            let can_move_to_next = !is_at_last_textarea;
374
375            if can_scroll_further {
376                self.scroll += 1;
377            } else if can_move_to_next {
378                self.focused_index += 1;
379                self.scroll = 0;
380            }
381            return;
382        }
383
384        // Scrolling up
385        let can_scroll_up = self.scroll > 0;
386        let can_move_to_previous = !is_at_first_textarea;
387
388        if can_scroll_up {
389            self.scroll -= 1;
390        } else if can_move_to_previous {
391            self.focused_index -= 1;
392            let prev_height = self.textareas[self.focused_index].lines().len();
393            self.scroll = prev_height.saturating_sub(1);
394        }
395    }
396
397    fn render_full_screen(&mut self, f: &mut Frame, area: Rect) -> Result<()> {
398        let textarea = &mut self.textareas[self.focused_index];
399        textarea.set_selection_style(Style::default().bg(Color::Red));
400        let title = &self.titles[self.focused_index];
401
402        let block = Block::default()
403            .title(title.clone())
404            .borders(Borders::ALL)
405            .border_style(Style::default().fg(ORANGE));
406
407        let content = textarea.lines().join("\n");
408        let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render(
409            &content,
410            title,
411            f.size().width as usize - BORDER_PADDING_SIZE,
412        )?;
413
414        let paragraph = Paragraph::new(rendered_markdown)
415            .block(block)
416            .wrap(Wrap { trim: true })
417            .scroll((self.scroll as u16, 0));
418
419        f.render_widget(paragraph, area);
420        Ok(())
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn create_test_textarea() -> ScrollableTextArea {
429        ScrollableTextArea {
430            textareas: Vec::new(),
431            titles: Vec::new(),
432            scroll: 0,
433            focused_index: 0,
434            edit_mode: false,
435            full_screen_mode: false,
436            viewport_height: 0,
437            start_sel: 0,
438            markdown_cache: Rc::new(RefCell::new(MarkdownCache::new())),
439        }
440    }
441
442    #[test]
443    fn test_add_textarea() {
444        let mut sta = create_test_textarea();
445        sta.add_textarea(TextArea::default(), "Test".to_string());
446        assert_eq!(sta.textareas.len(), 1);
447        assert_eq!(sta.titles.len(), 1);
448        assert_eq!(sta.focused_index, 0);
449    }
450
451    #[test]
452    fn test_move_focus() {
453        let mut sta = create_test_textarea();
454        sta.add_textarea(TextArea::default(), "Test1".to_string());
455        assert_eq!(sta.focused_index, 0);
456        sta.add_textarea(TextArea::default(), "Test2".to_string());
457
458        assert_eq!(sta.focused_index, 1);
459        sta.move_focus(1);
460        assert_eq!(sta.focused_index, 0);
461        sta.move_focus(-1);
462        assert_eq!(sta.focused_index, 1);
463    }
464
465    #[test]
466    fn test_remove_textarea() {
467        let mut sta = create_test_textarea();
468        sta.add_textarea(TextArea::default(), "Test1".to_string());
469        sta.add_textarea(TextArea::default(), "Test2".to_string());
470        sta.remove_textarea(0);
471        assert_eq!(sta.textareas.len(), 1);
472        assert_eq!(sta.titles.len(), 1);
473        assert_eq!(sta.titles[0], "Test2");
474    }
475
476    #[test]
477    fn test_change_title() {
478        let mut sta = create_test_textarea();
479        sta.add_textarea(TextArea::default(), "Test".to_string());
480        sta.change_title("New Title".to_string());
481        assert_eq!(sta.titles[0], "New Title");
482    }
483
484    #[test]
485    fn test_toggle_full_screen() {
486        let mut sta = create_test_textarea();
487        assert!(!sta.full_screen_mode);
488        sta.toggle_full_screen();
489        assert!(sta.full_screen_mode);
490        assert!(!sta.edit_mode);
491    }
492
493    #[test]
494    fn test_copy_textarea_contents() {
495        let mut sta = create_test_textarea();
496        let mut textarea = TextArea::default();
497        textarea.insert_str("Test content");
498        sta.add_textarea(textarea, "Test".to_string());
499
500        let result = sta.copy_textarea_contents();
501
502        match result {
503            Ok(_) => println!("Clipboard operation succeeded"),
504            Err(e) => {
505                let error_message = e.to_string();
506                assert!(
507                    error_message.contains("clipboard") || error_message.contains("display"),
508                    "Unexpected error: {}",
509                    error_message
510                );
511            }
512        }
513    }
514
515    #[test]
516    fn test_jump_to_textarea() {
517        let mut sta = create_test_textarea();
518        sta.add_textarea(TextArea::default(), "Test1".to_string());
519        sta.add_textarea(TextArea::default(), "Test2".to_string());
520        sta.jump_to_textarea(1);
521        assert_eq!(sta.focused_index, 1);
522    }
523}