Skip to main content

ratatui_interact/components/
scrollable_content.rs

1//! Scrollable content component
2//!
3//! A scrollable text pane with focus support, keyboard navigation, and mouse scrolling.
4//! Ideal for displaying log output, help text, or any scrollable content.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use ratatui_interact::components::{
10//!     ScrollableContent, ScrollableContentState, ScrollableContentStyle,
11//!     handle_scrollable_content_key, handle_scrollable_content_mouse,
12//! };
13//! use ratatui::prelude::*;
14//!
15//! // Create state with content
16//! let mut state = ScrollableContentState::new(vec![
17//!     "Line 1".to_string(),
18//!     "Line 2".to_string(),
19//!     "Line 3".to_string(),
20//! ]);
21//! state.set_focused(true);
22//!
23//! // In render:
24//! let content = ScrollableContent::new(&state)
25//!     .title("My Content")
26//!     .style(ScrollableContentStyle::default());
27//! content.render(area, buf);
28//!
29//! // Handle events:
30//! handle_scrollable_content_key(&mut state, &key_event, visible_height);
31//! handle_scrollable_content_mouse(&mut state, &mouse_event, content_area);
32//! ```
33
34use ratatui::{
35    buffer::Buffer,
36    layout::Rect,
37    style::{Color, Modifier, Style},
38    text::Line,
39    widgets::{Block, Borders, Paragraph, Widget},
40};
41
42/// Actions that can result from scrollable content interaction
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ScrollableContentAction {
45    /// Content was scrolled up
46    ScrollUp,
47    /// Content was scrolled down
48    ScrollDown,
49    /// Scrolled to top
50    ScrollToTop,
51    /// Scrolled to bottom
52    ScrollToBottom,
53    /// Page up
54    PageUp,
55    /// Page down
56    PageDown,
57    /// Toggle fullscreen
58    ToggleFullscreen,
59}
60
61/// State for the ScrollableContent component
62#[derive(Debug, Clone)]
63pub struct ScrollableContentState {
64    /// Content lines to display
65    lines: Vec<String>,
66    /// Current scroll position (line offset from top)
67    scroll_offset: usize,
68    /// Whether this pane is focused
69    focused: bool,
70    /// Whether this pane is in fullscreen mode
71    fullscreen: bool,
72    /// Title for the content pane
73    title: Option<String>,
74}
75
76impl ScrollableContentState {
77    /// Create a new state with the given content lines
78    pub fn new(lines: Vec<String>) -> Self {
79        Self {
80            lines,
81            scroll_offset: 0,
82            focused: false,
83            fullscreen: false,
84            title: None,
85        }
86    }
87
88    /// Create an empty state
89    pub fn empty() -> Self {
90        Self::new(Vec::new())
91    }
92
93    /// Set the content lines
94    pub fn set_lines(&mut self, lines: Vec<String>) {
95        self.lines = lines;
96        // Clamp scroll offset to valid range
97        if !self.lines.is_empty() {
98            self.scroll_offset = self.scroll_offset.min(self.lines.len() - 1);
99        } else {
100            self.scroll_offset = 0;
101        }
102    }
103
104    /// Get the content lines
105    pub fn lines(&self) -> &[String] {
106        &self.lines
107    }
108
109    /// Push a line to the content
110    pub fn push_line(&mut self, line: impl Into<String>) {
111        self.lines.push(line.into());
112    }
113
114    /// Clear all content
115    pub fn clear(&mut self) {
116        self.lines.clear();
117        self.scroll_offset = 0;
118    }
119
120    /// Get the number of lines
121    pub fn line_count(&self) -> usize {
122        self.lines.len()
123    }
124
125    /// Get the current scroll offset
126    pub fn scroll_offset(&self) -> usize {
127        self.scroll_offset
128    }
129
130    /// Set the scroll offset
131    pub fn set_scroll_offset(&mut self, offset: usize) {
132        if !self.lines.is_empty() {
133            self.scroll_offset = offset.min(self.lines.len() - 1);
134        } else {
135            self.scroll_offset = 0;
136        }
137    }
138
139    /// Check if focused
140    pub fn is_focused(&self) -> bool {
141        self.focused
142    }
143
144    /// Set focus state
145    pub fn set_focused(&mut self, focused: bool) {
146        self.focused = focused;
147    }
148
149    /// Check if in fullscreen mode
150    pub fn is_fullscreen(&self) -> bool {
151        self.fullscreen
152    }
153
154    /// Set fullscreen mode
155    pub fn set_fullscreen(&mut self, fullscreen: bool) {
156        self.fullscreen = fullscreen;
157    }
158
159    /// Toggle fullscreen mode
160    pub fn toggle_fullscreen(&mut self) -> bool {
161        self.fullscreen = !self.fullscreen;
162        self.fullscreen
163    }
164
165    /// Set the title
166    pub fn set_title(&mut self, title: impl Into<String>) {
167        self.title = Some(title.into());
168    }
169
170    /// Get the title
171    pub fn title(&self) -> Option<&str> {
172        self.title.as_deref()
173    }
174
175    /// Scroll up by the given number of lines
176    pub fn scroll_up(&mut self, lines: usize) {
177        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
178    }
179
180    /// Scroll down by the given number of lines
181    pub fn scroll_down(&mut self, lines: usize, visible_height: usize) {
182        if self.lines.is_empty() {
183            return;
184        }
185        let max_offset = self.lines.len().saturating_sub(visible_height);
186        self.scroll_offset = (self.scroll_offset + lines).min(max_offset);
187    }
188
189    /// Scroll to the top
190    pub fn scroll_to_top(&mut self) {
191        self.scroll_offset = 0;
192    }
193
194    /// Scroll to the bottom
195    pub fn scroll_to_bottom(&mut self, visible_height: usize) {
196        if self.lines.is_empty() {
197            return;
198        }
199        self.scroll_offset = self.lines.len().saturating_sub(visible_height);
200    }
201
202    /// Page up
203    pub fn page_up(&mut self, visible_height: usize) {
204        self.scroll_up(visible_height.saturating_sub(1));
205    }
206
207    /// Page down
208    pub fn page_down(&mut self, visible_height: usize) {
209        self.scroll_down(visible_height.saturating_sub(1), visible_height);
210    }
211
212    /// Get a slice of visible lines based on current scroll position and height
213    pub fn visible_lines(&self, height: usize) -> &[String] {
214        if self.lines.is_empty() {
215            return &[];
216        }
217        let start = self.scroll_offset.min(self.lines.len() - 1);
218        let end = (start + height).min(self.lines.len());
219        &self.lines[start..end]
220    }
221
222    /// Check if scrolled to top
223    pub fn is_at_top(&self) -> bool {
224        self.scroll_offset == 0
225    }
226
227    /// Check if scrolled to bottom (given visible height)
228    pub fn is_at_bottom(&self, visible_height: usize) -> bool {
229        if self.lines.is_empty() {
230            return true;
231        }
232        self.scroll_offset >= self.lines.len().saturating_sub(visible_height)
233    }
234
235    /// Get the content as a single string (for clipboard copy)
236    pub fn content_as_string(&self) -> String {
237        self.lines.join("\n")
238    }
239}
240
241impl Default for ScrollableContentState {
242    fn default() -> Self {
243        Self::empty()
244    }
245}
246
247/// Style configuration for ScrollableContent
248#[derive(Debug, Clone)]
249pub struct ScrollableContentStyle {
250    /// Border style when not focused
251    pub border_style: Style,
252    /// Border style when focused
253    pub focused_border_style: Style,
254    /// Text style
255    pub text_style: Style,
256    /// Whether to show borders
257    pub show_borders: bool,
258    /// Whether to show scroll indicators
259    pub show_scroll_indicators: bool,
260}
261
262impl Default for ScrollableContentStyle {
263    fn default() -> Self {
264        Self {
265            border_style: Style::default().fg(Color::DarkGray),
266            focused_border_style: Style::default().fg(Color::Cyan),
267            text_style: Style::default().fg(Color::White),
268            show_borders: true,
269            show_scroll_indicators: true,
270        }
271    }
272}
273
274impl From<&crate::theme::Theme> for ScrollableContentStyle {
275    fn from(theme: &crate::theme::Theme) -> Self {
276        let p = &theme.palette;
277        Self {
278            border_style: Style::default().fg(p.border_disabled),
279            focused_border_style: Style::default().fg(p.border_accent),
280            text_style: Style::default().fg(p.text),
281            show_borders: true,
282            show_scroll_indicators: true,
283        }
284    }
285}
286
287impl ScrollableContentStyle {
288    /// Create a minimal style without borders
289    pub fn borderless() -> Self {
290        Self {
291            show_borders: false,
292            ..Default::default()
293        }
294    }
295
296    /// Create a style with custom focus color
297    pub fn with_focus_color(mut self, color: Color) -> Self {
298        self.focused_border_style = Style::default().fg(color);
299        self
300    }
301
302    /// Set the text style
303    pub fn text_style(mut self, style: Style) -> Self {
304        self.text_style = style;
305        self
306    }
307}
308
309/// Scrollable content widget
310///
311/// A scrollable text pane that displays content with optional borders
312/// and scroll indicators. Highlights when focused.
313pub struct ScrollableContent<'a> {
314    state: &'a ScrollableContentState,
315    style: ScrollableContentStyle,
316    title: Option<&'a str>,
317}
318
319impl<'a> ScrollableContent<'a> {
320    /// Create a new scrollable content widget
321    pub fn new(state: &'a ScrollableContentState) -> Self {
322        Self {
323            state,
324            style: ScrollableContentStyle::default(),
325            title: state.title.as_deref(),
326        }
327    }
328
329    /// Set the style
330    pub fn style(mut self, style: ScrollableContentStyle) -> Self {
331        self.style = style;
332        self
333    }
334
335    /// Apply a theme to derive the style
336    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
337        self.style(ScrollableContentStyle::from(theme))
338    }
339
340    /// Set the title (overrides state title)
341    pub fn title(mut self, title: &'a str) -> Self {
342        self.title = Some(title);
343        self
344    }
345
346    /// Calculate the inner area (content area without borders)
347    pub fn inner_area(&self, area: Rect) -> Rect {
348        if self.style.show_borders {
349            Rect {
350                x: area.x + 1,
351                y: area.y + 1,
352                width: area.width.saturating_sub(2),
353                height: area.height.saturating_sub(2),
354            }
355        } else {
356            area
357        }
358    }
359}
360
361impl Widget for ScrollableContent<'_> {
362    fn render(self, area: Rect, buf: &mut Buffer) {
363        if area.width == 0 || area.height == 0 {
364            return;
365        }
366
367        let border_style = if self.state.focused {
368            self.style.focused_border_style
369        } else {
370            self.style.border_style
371        };
372
373        // Create block with optional title
374        let mut block = Block::default().border_style(border_style);
375        if self.style.show_borders {
376            block = block.borders(Borders::ALL);
377        }
378        if let Some(title) = self.title {
379            let title_style = if self.state.focused {
380                border_style.add_modifier(Modifier::BOLD)
381            } else {
382                border_style
383            };
384            block = block.title(format!(" {} ", title)).title_style(title_style);
385        }
386
387        let inner = block.inner(area);
388        block.render(area, buf);
389
390        // Render content
391        let visible_height = inner.height as usize;
392        let visible_lines = self.state.visible_lines(visible_height);
393
394        let lines: Vec<Line> = visible_lines
395            .iter()
396            .map(|s| Line::from(s.as_str()).style(self.style.text_style))
397            .collect();
398
399        let paragraph = Paragraph::new(lines);
400        paragraph.render(inner, buf);
401
402        // Render scroll indicators if enabled
403        if self.style.show_scroll_indicators && self.style.show_borders {
404            let has_content_above = !self.state.is_at_top();
405            let has_content_below = !self.state.is_at_bottom(visible_height);
406
407            if has_content_above && area.height > 2 {
408                buf.set_string(
409                    area.x + area.width - 2,
410                    area.y,
411                    "▲",
412                    Style::default().fg(Color::DarkGray),
413                );
414            }
415            if has_content_below && area.height > 2 {
416                buf.set_string(
417                    area.x + area.width - 2,
418                    area.y + area.height - 1,
419                    "▼",
420                    Style::default().fg(Color::DarkGray),
421                );
422            }
423        }
424    }
425}
426
427/// Handle keyboard input for scrollable content
428///
429/// Supports:
430/// - Up/Down or j/k: Scroll by one line
431/// - PageUp/PageDown: Scroll by page
432/// - Home/End: Scroll to top/bottom
433/// - F10/Enter: Toggle fullscreen
434///
435/// Returns the action taken, if any.
436pub fn handle_scrollable_content_key(
437    state: &mut ScrollableContentState,
438    key: &crossterm::event::KeyEvent,
439    visible_height: usize,
440) -> Option<ScrollableContentAction> {
441    use crossterm::event::KeyCode;
442
443    match key.code {
444        KeyCode::Up | KeyCode::Char('k') => {
445            state.scroll_up(1);
446            Some(ScrollableContentAction::ScrollUp)
447        }
448        KeyCode::Down | KeyCode::Char('j') => {
449            state.scroll_down(1, visible_height);
450            Some(ScrollableContentAction::ScrollDown)
451        }
452        KeyCode::PageUp => {
453            state.page_up(visible_height);
454            Some(ScrollableContentAction::PageUp)
455        }
456        KeyCode::PageDown => {
457            state.page_down(visible_height);
458            Some(ScrollableContentAction::PageDown)
459        }
460        KeyCode::Home => {
461            state.scroll_to_top();
462            Some(ScrollableContentAction::ScrollToTop)
463        }
464        KeyCode::End => {
465            state.scroll_to_bottom(visible_height);
466            Some(ScrollableContentAction::ScrollToBottom)
467        }
468        KeyCode::F(10) | KeyCode::Enter => {
469            state.toggle_fullscreen();
470            Some(ScrollableContentAction::ToggleFullscreen)
471        }
472        _ => None,
473    }
474}
475
476/// Handle mouse input for scrollable content
477///
478/// Supports scroll wheel for scrolling.
479///
480/// Returns the action taken, if any.
481pub fn handle_scrollable_content_mouse(
482    state: &mut ScrollableContentState,
483    mouse: &crossterm::event::MouseEvent,
484    content_area: Rect,
485    visible_height: usize,
486) -> Option<ScrollableContentAction> {
487    use crossterm::event::MouseEventKind;
488
489    // Check if mouse is within content area
490    if mouse.column < content_area.x
491        || mouse.column >= content_area.x + content_area.width
492        || mouse.row < content_area.y
493        || mouse.row >= content_area.y + content_area.height
494    {
495        return None;
496    }
497
498    match mouse.kind {
499        MouseEventKind::ScrollUp => {
500            state.scroll_up(3);
501            Some(ScrollableContentAction::ScrollUp)
502        }
503        MouseEventKind::ScrollDown => {
504            state.scroll_down(3, visible_height);
505            Some(ScrollableContentAction::ScrollDown)
506        }
507        _ => None,
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn sample_lines() -> Vec<String> {
516        (1..=100).map(|i| format!("Line {}", i)).collect()
517    }
518
519    #[test]
520    fn test_state_new() {
521        let lines = vec!["a".to_string(), "b".to_string()];
522        let state = ScrollableContentState::new(lines.clone());
523        assert_eq!(state.lines(), &lines);
524        assert_eq!(state.scroll_offset(), 0);
525        assert!(!state.is_focused());
526        assert!(!state.is_fullscreen());
527    }
528
529    #[test]
530    fn test_state_empty() {
531        let state = ScrollableContentState::empty();
532        assert!(state.lines().is_empty());
533        assert_eq!(state.line_count(), 0);
534    }
535
536    #[test]
537    fn test_scroll_up() {
538        let mut state = ScrollableContentState::new(sample_lines());
539        state.set_scroll_offset(50);
540        assert_eq!(state.scroll_offset(), 50);
541
542        state.scroll_up(10);
543        assert_eq!(state.scroll_offset(), 40);
544
545        state.scroll_up(100); // Should clamp to 0
546        assert_eq!(state.scroll_offset(), 0);
547    }
548
549    #[test]
550    fn test_scroll_down() {
551        let mut state = ScrollableContentState::new(sample_lines());
552        let visible_height = 20;
553
554        state.scroll_down(10, visible_height);
555        assert_eq!(state.scroll_offset(), 10);
556
557        state.scroll_down(1000, visible_height); // Should clamp to max
558        assert_eq!(state.scroll_offset(), 100 - visible_height);
559    }
560
561    #[test]
562    fn test_scroll_to_top_bottom() {
563        let mut state = ScrollableContentState::new(sample_lines());
564        let visible_height = 20;
565
566        state.scroll_to_bottom(visible_height);
567        assert_eq!(state.scroll_offset(), 80);
568        assert!(state.is_at_bottom(visible_height));
569
570        state.scroll_to_top();
571        assert_eq!(state.scroll_offset(), 0);
572        assert!(state.is_at_top());
573    }
574
575    #[test]
576    fn test_page_up_down() {
577        let mut state = ScrollableContentState::new(sample_lines());
578        let visible_height = 20;
579
580        state.page_down(visible_height);
581        assert_eq!(state.scroll_offset(), 19); // visible_height - 1
582
583        state.page_up(visible_height);
584        assert_eq!(state.scroll_offset(), 0);
585    }
586
587    #[test]
588    fn test_visible_lines() {
589        let state = ScrollableContentState::new(sample_lines());
590        let visible = state.visible_lines(5);
591        assert_eq!(visible.len(), 5);
592        assert_eq!(visible[0], "Line 1");
593        assert_eq!(visible[4], "Line 5");
594    }
595
596    #[test]
597    fn test_focus_and_fullscreen() {
598        let mut state = ScrollableContentState::empty();
599
600        assert!(!state.is_focused());
601        state.set_focused(true);
602        assert!(state.is_focused());
603
604        assert!(!state.is_fullscreen());
605        assert!(state.toggle_fullscreen());
606        assert!(state.is_fullscreen());
607        assert!(!state.toggle_fullscreen());
608        assert!(!state.is_fullscreen());
609    }
610
611    #[test]
612    fn test_content_as_string() {
613        let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
614        let state = ScrollableContentState::new(lines);
615        assert_eq!(state.content_as_string(), "a\nb\nc");
616    }
617
618    #[test]
619    fn test_set_lines_clamps_scroll() {
620        let mut state = ScrollableContentState::new(sample_lines());
621        state.set_scroll_offset(50);
622
623        // Set shorter content
624        state.set_lines(vec!["a".to_string(), "b".to_string()]);
625        assert_eq!(state.scroll_offset(), 1); // Clamped to max valid offset
626    }
627
628    #[test]
629    fn test_style_default() {
630        let style = ScrollableContentStyle::default();
631        assert!(style.show_borders);
632        assert!(style.show_scroll_indicators);
633    }
634
635    #[test]
636    fn test_style_borderless() {
637        let style = ScrollableContentStyle::borderless();
638        assert!(!style.show_borders);
639    }
640
641    #[test]
642    fn test_handle_key_scroll() {
643        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
644
645        let mut state = ScrollableContentState::new(sample_lines());
646        let visible_height = 20;
647
648        // Down arrow
649        let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
650        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
651        assert_eq!(action, Some(ScrollableContentAction::ScrollDown));
652        assert_eq!(state.scroll_offset(), 1);
653
654        // j key
655        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
656        handle_scrollable_content_key(&mut state, &key, visible_height);
657        assert_eq!(state.scroll_offset(), 2);
658
659        // Up arrow
660        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
661        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
662        assert_eq!(action, Some(ScrollableContentAction::ScrollUp));
663        assert_eq!(state.scroll_offset(), 1);
664
665        // k key
666        let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
667        handle_scrollable_content_key(&mut state, &key, visible_height);
668        assert_eq!(state.scroll_offset(), 0);
669
670        // Home
671        state.set_scroll_offset(50);
672        let key = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
673        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
674        assert_eq!(action, Some(ScrollableContentAction::ScrollToTop));
675        assert_eq!(state.scroll_offset(), 0);
676
677        // End
678        let key = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
679        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
680        assert_eq!(action, Some(ScrollableContentAction::ScrollToBottom));
681        assert_eq!(state.scroll_offset(), 80);
682    }
683
684    #[test]
685    fn test_handle_key_fullscreen() {
686        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
687
688        let mut state = ScrollableContentState::new(sample_lines());
689        let visible_height = 20;
690
691        // F10
692        let key = KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE);
693        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
694        assert_eq!(action, Some(ScrollableContentAction::ToggleFullscreen));
695        assert!(state.is_fullscreen());
696
697        // Enter
698        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
699        handle_scrollable_content_key(&mut state, &key, visible_height);
700        assert!(!state.is_fullscreen());
701    }
702
703    #[test]
704    fn test_widget_render() {
705        let state = ScrollableContentState::new(vec![
706            "Line 1".to_string(),
707            "Line 2".to_string(),
708            "Line 3".to_string(),
709        ]);
710        let widget = ScrollableContent::new(&state).title("Test");
711        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
712
713        widget.render(Rect::new(0, 0, 20, 10), &mut buf);
714
715        // Check that content was rendered
716        let content: String = buf.content.iter().map(|c| c.symbol()).collect();
717        assert!(content.contains("Line 1"));
718    }
719
720    #[test]
721    fn test_inner_area() {
722        let state = ScrollableContentState::empty();
723        let content = ScrollableContent::new(&state);
724        let area = Rect::new(0, 0, 20, 10);
725
726        let inner = content.inner_area(area);
727        assert_eq!(inner.x, 1);
728        assert_eq!(inner.y, 1);
729        assert_eq!(inner.width, 18);
730        assert_eq!(inner.height, 8);
731    }
732
733    #[test]
734    fn test_title() {
735        let mut state = ScrollableContentState::empty();
736        state.set_title("My Title");
737        assert_eq!(state.title(), Some("My Title"));
738
739        let widget = ScrollableContent::new(&state);
740        assert_eq!(widget.title, Some("My Title"));
741
742        // Override with widget title
743        let widget = ScrollableContent::new(&state).title("Override");
744        assert_eq!(widget.title, Some("Override"));
745    }
746}