Skip to main content

tui_dispatch_components/
scroll_view.rs

1//! Scrollable viewport component
2//!
3//! ScrollView is a flexible viewport container that handles scrolling behavior.
4//! The user provides a `render_content` callback to render visible content.
5//!
6//! For simple use cases with pre-rendered lines, use [`LinesScroller`].
7
8use crossterm::event::KeyCode;
9use ratatui::{
10    layout::Rect,
11    style::{Color, Style},
12    text::Line,
13    widgets::{Block, Paragraph, ScrollbarOrientation, ScrollbarState},
14    Frame,
15};
16use tui_dispatch_core::{Component, EventKind};
17
18use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle};
19
20/// Information about the visible range for content rendering
21#[derive(Debug, Clone, Copy)]
22pub struct VisibleRange {
23    /// First visible line index (0-based)
24    pub start: usize,
25    /// Last visible line index (exclusive)
26    pub end: usize,
27    /// Height of the viewport in lines
28    pub viewport_height: u16,
29    /// Available width for content (excluding scrollbar if shown)
30    pub available_width: u16,
31}
32
33/// Unified styling for ScrollView
34#[derive(Debug, Clone)]
35pub struct ScrollViewStyle {
36    /// Shared base style
37    pub base: BaseStyle,
38    /// Scrollbar styling
39    pub scrollbar: ScrollbarStyle,
40}
41
42impl Default for ScrollViewStyle {
43    fn default() -> Self {
44        Self {
45            base: BaseStyle {
46                fg: Some(Color::Reset),
47                ..Default::default()
48            },
49            scrollbar: ScrollbarStyle::default(),
50        }
51    }
52}
53
54impl ScrollViewStyle {
55    /// Create a style with no border
56    pub fn borderless() -> Self {
57        let mut style = Self::default();
58        style.base.border = None;
59        style
60    }
61
62    /// Create a minimal style (no border, no padding)
63    pub fn minimal() -> Self {
64        let mut style = Self::default();
65        style.base.border = None;
66        style.base.padding = Padding::default();
67        style
68    }
69}
70
71impl ComponentStyle for ScrollViewStyle {
72    fn base(&self) -> &BaseStyle {
73        &self.base
74    }
75}
76
77/// Behavior configuration for ScrollView
78#[derive(Debug, Clone)]
79pub struct ScrollViewBehavior {
80    /// Show scrollbar when content exceeds viewport
81    pub show_scrollbar: bool,
82    /// Number of lines to scroll per step
83    pub scroll_step: usize,
84    /// Page size for PageUp/PageDown (0 = viewport height)
85    pub page_step: usize,
86}
87
88impl Default for ScrollViewBehavior {
89    fn default() -> Self {
90        Self {
91            show_scrollbar: true,
92            scroll_step: 1,
93            page_step: 0,
94        }
95    }
96}
97
98/// Props for ScrollView component
99pub struct ScrollViewProps<'a, A> {
100    /// Total height of the content in lines
101    pub content_height: usize,
102    /// Current scroll offset (topmost visible line index)
103    pub scroll_offset: usize,
104    /// Whether this component has focus
105    pub is_focused: bool,
106    /// Unified styling
107    pub style: ScrollViewStyle,
108    /// Behavior configuration
109    pub behavior: ScrollViewBehavior,
110    /// Callback to create action when scroll offset changes
111    pub on_scroll: fn(usize) -> A,
112    /// Callback to render visible content
113    ///
114    /// Called with the content area and visible range. The callback should
115    /// render content for lines `range.start..range.end` into the given area.
116    pub render_content: &'a mut dyn FnMut(&mut Frame, Rect, VisibleRange),
117}
118
119/// A scrollable viewport container
120///
121/// Handles scroll behavior (keyboard, mouse wheel) and renders scrollbar.
122/// Content rendering is delegated to the `render_content` callback.
123///
124/// For simple cases with pre-rendered lines, use [`LinesScroller`].
125#[derive(Default)]
126pub struct ScrollView {
127    viewport_height: usize,
128}
129
130impl ScrollView {
131    /// Create a new ScrollView
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    fn viewport_height_value(&self) -> usize {
137        self.viewport_height.max(1)
138    }
139
140    fn max_offset(&self, content_height: usize) -> usize {
141        content_height.saturating_sub(self.viewport_height_value())
142    }
143
144    fn scrollbar_content_length(&self, content_height: usize) -> usize {
145        content_height
146            .saturating_sub(self.viewport_height_value())
147            .saturating_add(1)
148    }
149
150    fn page_size(&self, behavior: &ScrollViewBehavior) -> usize {
151        if behavior.page_step > 0 {
152            behavior.page_step
153        } else {
154            self.viewport_height_value()
155        }
156    }
157
158    fn apply_delta(&self, current: usize, delta: isize, max_offset: usize) -> usize {
159        if delta >= 0 {
160            current.saturating_add(delta as usize).min(max_offset)
161        } else {
162            current.saturating_sub((-delta) as usize)
163        }
164    }
165}
166
167impl<A> Component<A> for ScrollView {
168    type Props<'a> = ScrollViewProps<'a, A>;
169
170    fn handle_event(
171        &mut self,
172        event: &EventKind,
173        props: Self::Props<'_>,
174    ) -> impl IntoIterator<Item = A> {
175        if !props.is_focused || props.content_height == 0 {
176            return None;
177        }
178
179        let max_offset = self.max_offset(props.content_height);
180        let scroll_step = props.behavior.scroll_step.max(1) as isize;
181        let page_size = self.page_size(&props.behavior) as isize;
182
183        let next_offset = match event {
184            EventKind::Key(key) => match key.code {
185                KeyCode::Char('j') | KeyCode::Down => {
186                    Some(self.apply_delta(props.scroll_offset, scroll_step, max_offset))
187                }
188                KeyCode::Char('k') | KeyCode::Up => {
189                    Some(self.apply_delta(props.scroll_offset, -scroll_step, max_offset))
190                }
191                KeyCode::PageDown => {
192                    Some(self.apply_delta(props.scroll_offset, page_size, max_offset))
193                }
194                KeyCode::PageUp => {
195                    Some(self.apply_delta(props.scroll_offset, -page_size, max_offset))
196                }
197                KeyCode::Char('g') | KeyCode::Home => Some(0),
198                KeyCode::Char('G') | KeyCode::End => Some(max_offset),
199                _ => None,
200            },
201            EventKind::Scroll { delta, .. } => {
202                if *delta == 0 {
203                    None
204                } else {
205                    let scaled_delta = delta.saturating_mul(scroll_step);
206                    Some(self.apply_delta(props.scroll_offset, scaled_delta, max_offset))
207                }
208            }
209            _ => None,
210        };
211
212        match next_offset {
213            Some(offset) if offset != props.scroll_offset => Some((props.on_scroll)(offset)),
214            _ => None,
215        }
216    }
217
218    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
219        let style = &props.style;
220
221        // Fill background
222        if let Some(bg) = style.base.bg {
223            for y in area.y..area.y.saturating_add(area.height) {
224                for x in area.x..area.x.saturating_add(area.width) {
225                    frame.buffer_mut()[(x, y)].set_bg(bg);
226                    frame.buffer_mut()[(x, y)].set_symbol(" ");
227                }
228            }
229        }
230
231        // Apply padding
232        let content_area = Rect {
233            x: area.x + style.base.padding.left,
234            y: area.y + style.base.padding.top,
235            width: area.width.saturating_sub(style.base.padding.horizontal()),
236            height: area.height.saturating_sub(style.base.padding.vertical()),
237        };
238
239        // Apply border
240        let mut inner_area = content_area;
241        if let Some(border) = &style.base.border {
242            let block = Block::default()
243                .borders(border.borders)
244                .border_style(border.style_for_focus(props.is_focused));
245            inner_area = block.inner(content_area);
246            frame.render_widget(block, content_area);
247        }
248
249        let viewport_height = inner_area.height as usize;
250        self.viewport_height = viewport_height;
251
252        if inner_area.width == 0 || inner_area.height == 0 {
253            return;
254        }
255
256        // Determine scrollbar visibility and adjust content area
257        let show_scrollbar = props.behavior.show_scrollbar
258            && viewport_height > 0
259            && props.content_height > viewport_height
260            && inner_area.width > 1;
261
262        let (content_area, scrollbar_area) = if show_scrollbar {
263            let scrollbar_area = Rect {
264                x: inner_area.x + inner_area.width.saturating_sub(1),
265                width: 1,
266                ..inner_area
267            };
268            let content_area = Rect {
269                width: inner_area.width.saturating_sub(1),
270                ..inner_area
271            };
272            (content_area, Some(scrollbar_area))
273        } else {
274            (inner_area, None)
275        };
276
277        // Calculate visible range
278        let max_offset = self.max_offset(props.content_height);
279        let scroll_offset = props.scroll_offset.min(max_offset);
280        let visible_end = (scroll_offset + viewport_height).min(props.content_height);
281
282        let visible_range = VisibleRange {
283            start: scroll_offset,
284            end: visible_end,
285            viewport_height: viewport_height as u16,
286            available_width: content_area.width,
287        };
288
289        // Call user's render callback
290        (props.render_content)(frame, content_area, visible_range);
291
292        // Render scrollbar
293        if let Some(scrollbar_area) = scrollbar_area {
294            let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
295            let scrollbar_len = self.scrollbar_content_length(props.content_height);
296            let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
297                .position(scroll_offset)
298                .viewport_content_length(self.viewport_height_value());
299            frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
300        }
301    }
302}
303
304// ============================================================================
305// Helper: LinesScroller
306// ============================================================================
307
308/// Simple wrapper for rendering pre-rendered lines in a ScrollView
309///
310/// # Example
311///
312/// ```ignore
313/// let lines: Vec<Line> = content.iter().map(|s| Line::raw(s)).collect();
314/// let scroller = LinesScroller::new(&lines);
315///
316/// let mut scroll_view = ScrollView::new();
317/// scroll_view.render(frame, area, ScrollViewProps {
318///     content_height: scroller.content_height(),
319///     scroll_offset,
320///     is_focused: true,
321///     style: ScrollViewStyle::default(),
322///     behavior: ScrollViewBehavior::default(),
323///     on_scroll: Action::Scroll,
324///     render_content: &mut scroller.renderer(),
325/// });
326/// ```
327pub struct LinesScroller<'a> {
328    lines: &'a [Line<'a>],
329    style: Style,
330}
331
332impl<'a> LinesScroller<'a> {
333    /// Create a new LinesScroller with the given lines
334    pub fn new(lines: &'a [Line<'a>]) -> Self {
335        Self {
336            lines,
337            style: Style::default(),
338        }
339    }
340
341    /// Set the base text style
342    pub fn with_style(mut self, style: Style) -> Self {
343        self.style = style;
344        self
345    }
346
347    /// Get the total content height
348    pub fn content_height(&self) -> usize {
349        self.lines.len()
350    }
351
352    /// Get a render callback for use with ScrollView
353    pub fn renderer(&self) -> impl FnMut(&mut Frame, Rect, VisibleRange) + use<'_, 'a> {
354        move |frame: &mut Frame, area: Rect, range: VisibleRange| {
355            let visible_lines: Vec<Line<'a>> = self
356                .lines
357                .iter()
358                .skip(range.start)
359                .take(range.end.saturating_sub(range.start))
360                .cloned()
361                .collect();
362
363            let paragraph = Paragraph::new(visible_lines).style(self.style);
364            frame.render_widget(paragraph, area);
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
373    use tui_dispatch_core::testing::{key, RenderHarness};
374
375    #[derive(Debug, Clone, PartialEq)]
376    enum TestAction {
377        ScrollTo(usize),
378    }
379
380    fn make_lines(count: usize) -> Vec<Line<'static>> {
381        (0..count)
382            .map(|i| Line::raw(format!("Line {}", i)))
383            .collect()
384    }
385
386    #[test]
387    fn test_scroll_down_action() {
388        let mut view = ScrollView::new();
389        let lines = make_lines(5);
390        let scroller = LinesScroller::new(&lines);
391        let mut harness = RenderHarness::new(20, 3);
392
393        // Render once to set viewport height
394        harness.render_to_string_plain(|frame| {
395            view.render(
396                frame,
397                frame.area(),
398                ScrollViewProps {
399                    content_height: scroller.content_height(),
400                    scroll_offset: 0,
401                    is_focused: true,
402                    style: ScrollViewStyle::borderless(),
403                    behavior: ScrollViewBehavior::default(),
404                    on_scroll: TestAction::ScrollTo,
405                    render_content: &mut scroller.renderer(),
406                },
407            );
408        });
409
410        let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
411        let actions: Vec<_> = view
412            .handle_event(
413                &EventKind::Key(key("j")),
414                ScrollViewProps {
415                    content_height: lines.len(),
416                    scroll_offset: 0,
417                    is_focused: true,
418                    style: ScrollViewStyle::borderless(),
419                    behavior: ScrollViewBehavior::default(),
420                    on_scroll: TestAction::ScrollTo,
421                    render_content: &mut noop_render,
422                },
423            )
424            .into_iter()
425            .collect();
426
427        assert_eq!(actions, vec![TestAction::ScrollTo(1)]);
428    }
429
430    #[test]
431    fn test_page_down_action() {
432        let mut view = ScrollView::new();
433        let lines = make_lines(10);
434        let scroller = LinesScroller::new(&lines);
435        let mut harness = RenderHarness::new(20, 4);
436
437        harness.render_to_string_plain(|frame| {
438            view.render(
439                frame,
440                frame.area(),
441                ScrollViewProps {
442                    content_height: scroller.content_height(),
443                    scroll_offset: 0,
444                    is_focused: true,
445                    style: ScrollViewStyle::borderless(),
446                    behavior: ScrollViewBehavior::default(),
447                    on_scroll: TestAction::ScrollTo,
448                    render_content: &mut scroller.renderer(),
449                },
450            );
451        });
452
453        let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
454        let page_down = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE);
455        let actions: Vec<_> = view
456            .handle_event(
457                &EventKind::Key(page_down),
458                ScrollViewProps {
459                    content_height: lines.len(),
460                    scroll_offset: 0,
461                    is_focused: true,
462                    style: ScrollViewStyle::borderless(),
463                    behavior: ScrollViewBehavior::default(),
464                    on_scroll: TestAction::ScrollTo,
465                    render_content: &mut noop_render,
466                },
467            )
468            .into_iter()
469            .collect();
470
471        assert_eq!(actions, vec![TestAction::ScrollTo(4)]);
472    }
473
474    #[test]
475    fn test_scroll_wheel_action() {
476        let mut view = ScrollView::new();
477        let lines = make_lines(5);
478        let scroller = LinesScroller::new(&lines);
479        let mut harness = RenderHarness::new(20, 3);
480
481        harness.render_to_string_plain(|frame| {
482            view.render(
483                frame,
484                frame.area(),
485                ScrollViewProps {
486                    content_height: scroller.content_height(),
487                    scroll_offset: 1,
488                    is_focused: true,
489                    style: ScrollViewStyle::borderless(),
490                    behavior: ScrollViewBehavior::default(),
491                    on_scroll: TestAction::ScrollTo,
492                    render_content: &mut scroller.renderer(),
493                },
494            );
495        });
496
497        let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
498        let actions: Vec<_> = view
499            .handle_event(
500                &EventKind::Scroll {
501                    column: 0,
502                    row: 0,
503                    delta: -1,
504                    modifiers: KeyModifiers::NONE,
505                },
506                ScrollViewProps {
507                    content_height: lines.len(),
508                    scroll_offset: 1,
509                    is_focused: true,
510                    style: ScrollViewStyle::borderless(),
511                    behavior: ScrollViewBehavior::default(),
512                    on_scroll: TestAction::ScrollTo,
513                    render_content: &mut noop_render,
514                },
515            )
516            .into_iter()
517            .collect();
518
519        assert_eq!(actions, vec![TestAction::ScrollTo(0)]);
520    }
521
522    #[test]
523    fn test_render_respects_offset() {
524        let mut view = ScrollView::new();
525        let lines = make_lines(6);
526        let scroller = LinesScroller::new(&lines);
527        let mut harness = RenderHarness::new(20, 3);
528
529        let output = harness.render_to_string_plain(|frame| {
530            view.render(
531                frame,
532                frame.area(),
533                ScrollViewProps {
534                    content_height: scroller.content_height(),
535                    scroll_offset: 2,
536                    is_focused: true,
537                    style: ScrollViewStyle::borderless(),
538                    behavior: ScrollViewBehavior::default(),
539                    on_scroll: TestAction::ScrollTo,
540                    render_content: &mut scroller.renderer(),
541                },
542            );
543        });
544
545        assert!(output.contains("Line 2"));
546        assert!(!output.contains("Line 0"));
547    }
548
549    #[test]
550    fn test_lines_scroller_content_height() {
551        let lines = make_lines(10);
552        let scroller = LinesScroller::new(&lines);
553        assert_eq!(scroller.content_height(), 10);
554    }
555}