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