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 ScrollableContentStyle {
275    /// Create a minimal style without borders
276    pub fn borderless() -> Self {
277        Self {
278            show_borders: false,
279            ..Default::default()
280        }
281    }
282
283    /// Create a style with custom focus color
284    pub fn with_focus_color(mut self, color: Color) -> Self {
285        self.focused_border_style = Style::default().fg(color);
286        self
287    }
288
289    /// Set the text style
290    pub fn text_style(mut self, style: Style) -> Self {
291        self.text_style = style;
292        self
293    }
294}
295
296/// Scrollable content widget
297///
298/// A scrollable text pane that displays content with optional borders
299/// and scroll indicators. Highlights when focused.
300pub struct ScrollableContent<'a> {
301    state: &'a ScrollableContentState,
302    style: ScrollableContentStyle,
303    title: Option<&'a str>,
304}
305
306impl<'a> ScrollableContent<'a> {
307    /// Create a new scrollable content widget
308    pub fn new(state: &'a ScrollableContentState) -> Self {
309        Self {
310            state,
311            style: ScrollableContentStyle::default(),
312            title: state.title.as_deref(),
313        }
314    }
315
316    /// Set the style
317    pub fn style(mut self, style: ScrollableContentStyle) -> Self {
318        self.style = style;
319        self
320    }
321
322    /// Set the title (overrides state title)
323    pub fn title(mut self, title: &'a str) -> Self {
324        self.title = Some(title);
325        self
326    }
327
328    /// Calculate the inner area (content area without borders)
329    pub fn inner_area(&self, area: Rect) -> Rect {
330        if self.style.show_borders {
331            Rect {
332                x: area.x + 1,
333                y: area.y + 1,
334                width: area.width.saturating_sub(2),
335                height: area.height.saturating_sub(2),
336            }
337        } else {
338            area
339        }
340    }
341}
342
343impl Widget for ScrollableContent<'_> {
344    fn render(self, area: Rect, buf: &mut Buffer) {
345        if area.width == 0 || area.height == 0 {
346            return;
347        }
348
349        let border_style = if self.state.focused {
350            self.style.focused_border_style
351        } else {
352            self.style.border_style
353        };
354
355        // Create block with optional title
356        let mut block = Block::default().border_style(border_style);
357        if self.style.show_borders {
358            block = block.borders(Borders::ALL);
359        }
360        if let Some(title) = self.title {
361            let title_style = if self.state.focused {
362                border_style.add_modifier(Modifier::BOLD)
363            } else {
364                border_style
365            };
366            block = block.title(format!(" {} ", title)).title_style(title_style);
367        }
368
369        let inner = block.inner(area);
370        block.render(area, buf);
371
372        // Render content
373        let visible_height = inner.height as usize;
374        let visible_lines = self.state.visible_lines(visible_height);
375
376        let lines: Vec<Line> = visible_lines
377            .iter()
378            .map(|s| Line::from(s.as_str()).style(self.style.text_style))
379            .collect();
380
381        let paragraph = Paragraph::new(lines);
382        paragraph.render(inner, buf);
383
384        // Render scroll indicators if enabled
385        if self.style.show_scroll_indicators && self.style.show_borders {
386            let has_content_above = !self.state.is_at_top();
387            let has_content_below = !self.state.is_at_bottom(visible_height);
388
389            if has_content_above && area.height > 2 {
390                buf.set_string(
391                    area.x + area.width - 2,
392                    area.y,
393                    "▲",
394                    Style::default().fg(Color::DarkGray),
395                );
396            }
397            if has_content_below && area.height > 2 {
398                buf.set_string(
399                    area.x + area.width - 2,
400                    area.y + area.height - 1,
401                    "▼",
402                    Style::default().fg(Color::DarkGray),
403                );
404            }
405        }
406    }
407}
408
409/// Handle keyboard input for scrollable content
410///
411/// Supports:
412/// - Up/Down or j/k: Scroll by one line
413/// - PageUp/PageDown: Scroll by page
414/// - Home/End: Scroll to top/bottom
415/// - F10/Enter: Toggle fullscreen
416///
417/// Returns the action taken, if any.
418pub fn handle_scrollable_content_key(
419    state: &mut ScrollableContentState,
420    key: &crossterm::event::KeyEvent,
421    visible_height: usize,
422) -> Option<ScrollableContentAction> {
423    use crossterm::event::KeyCode;
424
425    match key.code {
426        KeyCode::Up | KeyCode::Char('k') => {
427            state.scroll_up(1);
428            Some(ScrollableContentAction::ScrollUp)
429        }
430        KeyCode::Down | KeyCode::Char('j') => {
431            state.scroll_down(1, visible_height);
432            Some(ScrollableContentAction::ScrollDown)
433        }
434        KeyCode::PageUp => {
435            state.page_up(visible_height);
436            Some(ScrollableContentAction::PageUp)
437        }
438        KeyCode::PageDown => {
439            state.page_down(visible_height);
440            Some(ScrollableContentAction::PageDown)
441        }
442        KeyCode::Home => {
443            state.scroll_to_top();
444            Some(ScrollableContentAction::ScrollToTop)
445        }
446        KeyCode::End => {
447            state.scroll_to_bottom(visible_height);
448            Some(ScrollableContentAction::ScrollToBottom)
449        }
450        KeyCode::F(10) | KeyCode::Enter => {
451            state.toggle_fullscreen();
452            Some(ScrollableContentAction::ToggleFullscreen)
453        }
454        _ => None,
455    }
456}
457
458/// Handle mouse input for scrollable content
459///
460/// Supports scroll wheel for scrolling.
461///
462/// Returns the action taken, if any.
463pub fn handle_scrollable_content_mouse(
464    state: &mut ScrollableContentState,
465    mouse: &crossterm::event::MouseEvent,
466    content_area: Rect,
467    visible_height: usize,
468) -> Option<ScrollableContentAction> {
469    use crossterm::event::MouseEventKind;
470
471    // Check if mouse is within content area
472    if mouse.column < content_area.x
473        || mouse.column >= content_area.x + content_area.width
474        || mouse.row < content_area.y
475        || mouse.row >= content_area.y + content_area.height
476    {
477        return None;
478    }
479
480    match mouse.kind {
481        MouseEventKind::ScrollUp => {
482            state.scroll_up(3);
483            Some(ScrollableContentAction::ScrollUp)
484        }
485        MouseEventKind::ScrollDown => {
486            state.scroll_down(3, visible_height);
487            Some(ScrollableContentAction::ScrollDown)
488        }
489        _ => None,
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    fn sample_lines() -> Vec<String> {
498        (1..=100).map(|i| format!("Line {}", i)).collect()
499    }
500
501    #[test]
502    fn test_state_new() {
503        let lines = vec!["a".to_string(), "b".to_string()];
504        let state = ScrollableContentState::new(lines.clone());
505        assert_eq!(state.lines(), &lines);
506        assert_eq!(state.scroll_offset(), 0);
507        assert!(!state.is_focused());
508        assert!(!state.is_fullscreen());
509    }
510
511    #[test]
512    fn test_state_empty() {
513        let state = ScrollableContentState::empty();
514        assert!(state.lines().is_empty());
515        assert_eq!(state.line_count(), 0);
516    }
517
518    #[test]
519    fn test_scroll_up() {
520        let mut state = ScrollableContentState::new(sample_lines());
521        state.set_scroll_offset(50);
522        assert_eq!(state.scroll_offset(), 50);
523
524        state.scroll_up(10);
525        assert_eq!(state.scroll_offset(), 40);
526
527        state.scroll_up(100); // Should clamp to 0
528        assert_eq!(state.scroll_offset(), 0);
529    }
530
531    #[test]
532    fn test_scroll_down() {
533        let mut state = ScrollableContentState::new(sample_lines());
534        let visible_height = 20;
535
536        state.scroll_down(10, visible_height);
537        assert_eq!(state.scroll_offset(), 10);
538
539        state.scroll_down(1000, visible_height); // Should clamp to max
540        assert_eq!(state.scroll_offset(), 100 - visible_height);
541    }
542
543    #[test]
544    fn test_scroll_to_top_bottom() {
545        let mut state = ScrollableContentState::new(sample_lines());
546        let visible_height = 20;
547
548        state.scroll_to_bottom(visible_height);
549        assert_eq!(state.scroll_offset(), 80);
550        assert!(state.is_at_bottom(visible_height));
551
552        state.scroll_to_top();
553        assert_eq!(state.scroll_offset(), 0);
554        assert!(state.is_at_top());
555    }
556
557    #[test]
558    fn test_page_up_down() {
559        let mut state = ScrollableContentState::new(sample_lines());
560        let visible_height = 20;
561
562        state.page_down(visible_height);
563        assert_eq!(state.scroll_offset(), 19); // visible_height - 1
564
565        state.page_up(visible_height);
566        assert_eq!(state.scroll_offset(), 0);
567    }
568
569    #[test]
570    fn test_visible_lines() {
571        let state = ScrollableContentState::new(sample_lines());
572        let visible = state.visible_lines(5);
573        assert_eq!(visible.len(), 5);
574        assert_eq!(visible[0], "Line 1");
575        assert_eq!(visible[4], "Line 5");
576    }
577
578    #[test]
579    fn test_focus_and_fullscreen() {
580        let mut state = ScrollableContentState::empty();
581
582        assert!(!state.is_focused());
583        state.set_focused(true);
584        assert!(state.is_focused());
585
586        assert!(!state.is_fullscreen());
587        assert!(state.toggle_fullscreen());
588        assert!(state.is_fullscreen());
589        assert!(!state.toggle_fullscreen());
590        assert!(!state.is_fullscreen());
591    }
592
593    #[test]
594    fn test_content_as_string() {
595        let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
596        let state = ScrollableContentState::new(lines);
597        assert_eq!(state.content_as_string(), "a\nb\nc");
598    }
599
600    #[test]
601    fn test_set_lines_clamps_scroll() {
602        let mut state = ScrollableContentState::new(sample_lines());
603        state.set_scroll_offset(50);
604
605        // Set shorter content
606        state.set_lines(vec!["a".to_string(), "b".to_string()]);
607        assert_eq!(state.scroll_offset(), 1); // Clamped to max valid offset
608    }
609
610    #[test]
611    fn test_style_default() {
612        let style = ScrollableContentStyle::default();
613        assert!(style.show_borders);
614        assert!(style.show_scroll_indicators);
615    }
616
617    #[test]
618    fn test_style_borderless() {
619        let style = ScrollableContentStyle::borderless();
620        assert!(!style.show_borders);
621    }
622
623    #[test]
624    fn test_handle_key_scroll() {
625        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
626
627        let mut state = ScrollableContentState::new(sample_lines());
628        let visible_height = 20;
629
630        // Down arrow
631        let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
632        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
633        assert_eq!(action, Some(ScrollableContentAction::ScrollDown));
634        assert_eq!(state.scroll_offset(), 1);
635
636        // j key
637        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
638        handle_scrollable_content_key(&mut state, &key, visible_height);
639        assert_eq!(state.scroll_offset(), 2);
640
641        // Up arrow
642        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
643        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
644        assert_eq!(action, Some(ScrollableContentAction::ScrollUp));
645        assert_eq!(state.scroll_offset(), 1);
646
647        // k key
648        let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
649        handle_scrollable_content_key(&mut state, &key, visible_height);
650        assert_eq!(state.scroll_offset(), 0);
651
652        // Home
653        state.set_scroll_offset(50);
654        let key = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
655        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
656        assert_eq!(action, Some(ScrollableContentAction::ScrollToTop));
657        assert_eq!(state.scroll_offset(), 0);
658
659        // End
660        let key = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
661        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
662        assert_eq!(action, Some(ScrollableContentAction::ScrollToBottom));
663        assert_eq!(state.scroll_offset(), 80);
664    }
665
666    #[test]
667    fn test_handle_key_fullscreen() {
668        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
669
670        let mut state = ScrollableContentState::new(sample_lines());
671        let visible_height = 20;
672
673        // F10
674        let key = KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE);
675        let action = handle_scrollable_content_key(&mut state, &key, visible_height);
676        assert_eq!(action, Some(ScrollableContentAction::ToggleFullscreen));
677        assert!(state.is_fullscreen());
678
679        // Enter
680        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
681        handle_scrollable_content_key(&mut state, &key, visible_height);
682        assert!(!state.is_fullscreen());
683    }
684
685    #[test]
686    fn test_widget_render() {
687        let state = ScrollableContentState::new(vec![
688            "Line 1".to_string(),
689            "Line 2".to_string(),
690            "Line 3".to_string(),
691        ]);
692        let widget = ScrollableContent::new(&state).title("Test");
693        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
694
695        widget.render(Rect::new(0, 0, 20, 10), &mut buf);
696
697        // Check that content was rendered
698        let content: String = buf.content.iter().map(|c| c.symbol()).collect();
699        assert!(content.contains("Line 1"));
700    }
701
702    #[test]
703    fn test_inner_area() {
704        let state = ScrollableContentState::empty();
705        let content = ScrollableContent::new(&state);
706        let area = Rect::new(0, 0, 20, 10);
707
708        let inner = content.inner_area(area);
709        assert_eq!(inner.x, 1);
710        assert_eq!(inner.y, 1);
711        assert_eq!(inner.width, 18);
712        assert_eq!(inner.height, 8);
713    }
714
715    #[test]
716    fn test_title() {
717        let mut state = ScrollableContentState::empty();
718        state.set_title("My Title");
719        assert_eq!(state.title(), Some("My Title"));
720
721        let widget = ScrollableContent::new(&state);
722        assert_eq!(widget.title, Some("My Title"));
723
724        // Override with widget title
725        let widget = ScrollableContent::new(&state).title("Override");
726        assert_eq!(widget.title, Some("Override"));
727    }
728}