Skip to main content

fresh/view/ui/
scroll_panel.rs

1//! Reusable scrollable panel for lists with variable-height items
2//!
3//! This module provides a `ScrollablePanel` that handles:
4//! - Row-based scrolling (not item-based) for variable-height items
5//! - Automatic ensure-visible for focused items
6//! - Sub-focus support for navigating within large items (e.g., TextList rows)
7//! - Scrollbar rendering with proper thumb sizing
8//!
9//! Inspired by patterns from Flutter (Sliver), WPF (ScrollViewer), Qt (QAbstractScrollArea).
10//!
11//! # Usage Flow
12//!
13//! 1. **Define items** - Implement `ScrollItem` for your item type:
14//!    ```ignore
15//!    impl ScrollItem for MyItem {
16//!        fn height(&self) -> u16 { ... }
17//!        fn focus_regions(&self) -> Vec<FocusRegion> { ... } // optional
18//!    }
19//!    ```
20//!
21//! 2. **Store state** - Keep a `ScrollablePanel` in your component state
22//!
23//! 3. **On selection change** - Call `ensure_focused_visible()` to scroll the
24//!    focused item into view:
25//!    ```ignore
26//!    panel.ensure_focused_visible(&items, selected_index, sub_focus);
27//!    ```
28//!
29//! 4. **On render** - Update viewport, then call `render()` with a callback:
30//!    ```ignore
31//!    panel.set_viewport(available_height);
32//!    panel.update_content_height(&items);
33//!    let layout = panel.render(frame, area, &items, |f, rect, item, idx| {
34//!        render_my_item(f, rect, item, idx)
35//!    }, theme);
36//!    ```
37//!
38//! 5. **Use layout** - The returned `ScrollablePanelLayout` contains:
39//!    - `content_area` - Area used for content (excluding scrollbar)
40//!    - `scrollbar_area` - Scrollbar rect if visible (for drag hit testing)
41//!    - `item_layouts` - Per-item layout info from your render callback
42//!
43//! # Sub-focus
44//!
45//! For items with internal navigation (e.g., a list of strings), implement
46//! `focus_regions()` to return focusable sub-areas. Then pass the sub-focus
47//! ID to `ensure_focused_visible()` to scroll that specific region into view.
48
49use ratatui::layout::Rect;
50use ratatui::Frame;
51
52use super::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
53use crate::view::theme::Theme;
54
55/// A focusable region within an item
56#[derive(Debug, Clone, Copy)]
57pub struct FocusRegion {
58    /// Identifier for this region (e.g., row index within a TextList)
59    pub id: usize,
60    /// Y offset within the parent item
61    pub y_offset: u16,
62    /// Height of this region
63    pub height: u16,
64}
65
66/// Trait for items that can be displayed in a scrollable panel
67pub trait ScrollItem {
68    /// Total height of this item in terminal rows
69    fn height(&self) -> u16;
70
71    /// Optional: sub-focus regions within this item
72    /// Used for items with internal navigation (e.g., TextList rows)
73    fn focus_regions(&self) -> Vec<FocusRegion> {
74        Vec::new()
75    }
76}
77
78/// Pure scroll state - knows nothing about content
79#[derive(Debug, Clone, Copy, Default)]
80pub struct ScrollState {
81    /// Scroll offset in rows (not items)
82    pub offset: u16,
83    /// Viewport height
84    pub viewport: u16,
85    /// Total content height
86    pub content_height: u16,
87}
88
89impl ScrollState {
90    /// Create new scroll state
91    pub fn new(viewport: u16) -> Self {
92        Self {
93            offset: 0,
94            viewport,
95            content_height: 0,
96        }
97    }
98
99    /// Update viewport height
100    pub fn set_viewport(&mut self, height: u16) {
101        self.viewport = height;
102        self.clamp_offset();
103    }
104
105    /// Update content height (call when items change)
106    pub fn set_content_height(&mut self, height: u16) {
107        self.content_height = height;
108        self.clamp_offset();
109    }
110
111    /// Maximum scroll offset
112    pub fn max_offset(&self) -> u16 {
113        self.content_height.saturating_sub(self.viewport)
114    }
115
116    /// Clamp offset to valid range
117    fn clamp_offset(&mut self) {
118        self.offset = self.offset.min(self.max_offset());
119    }
120
121    /// Scroll to ensure a region is visible
122    /// If region is taller than viewport, shows the top
123    pub fn ensure_visible(&mut self, y: u16, height: u16) {
124        if y < self.offset {
125            // Region is above viewport - scroll up
126            self.offset = y;
127        } else if y + height > self.offset + self.viewport {
128            // Region is below viewport - scroll down
129            if height > self.viewport {
130                // Oversized item - show top
131                self.offset = y;
132            } else {
133                self.offset = y + height - self.viewport;
134            }
135        }
136        self.clamp_offset();
137    }
138
139    /// Scroll by delta rows (positive = down, negative = up)
140    pub fn scroll_by(&mut self, delta: i16) {
141        if delta < 0 {
142            self.offset = self.offset.saturating_sub((-delta) as u16);
143        } else {
144            self.offset = self.offset.saturating_add(delta as u16);
145        }
146        self.clamp_offset();
147    }
148
149    /// Scroll to a ratio (0.0 = top, 1.0 = bottom)
150    pub fn scroll_to_ratio(&mut self, ratio: f32) {
151        let ratio = ratio.clamp(0.0, 1.0);
152        self.offset = (ratio * self.max_offset() as f32) as u16;
153    }
154
155    /// Check if scrolling is needed
156    pub fn needs_scrollbar(&self) -> bool {
157        self.content_height > self.viewport
158    }
159
160    /// Convert to ScrollbarState for rendering
161    pub fn to_scrollbar_state(&self) -> ScrollbarState {
162        ScrollbarState::new(
163            self.content_height as usize,
164            self.viewport as usize,
165            self.offset as usize,
166        )
167    }
168}
169
170/// Layout info returned by ScrollablePanel::render
171#[derive(Debug, Clone)]
172pub struct ScrollablePanelLayout<L> {
173    /// Content area (excluding scrollbar)
174    pub content_area: Rect,
175    /// Scrollbar area (if visible)
176    pub scrollbar_area: Option<Rect>,
177    /// Per-item layouts with their indices and Y positions
178    pub item_layouts: Vec<ItemLayoutInfo<L>>,
179}
180
181/// Layout info for a single item
182#[derive(Debug, Clone)]
183pub struct ItemLayoutInfo<L> {
184    /// Item index
185    pub index: usize,
186    /// Y position in content coordinates (before scroll)
187    pub content_y: u16,
188    /// Rendered area on screen
189    pub area: Rect,
190    /// Custom layout data from render callback
191    pub layout: L,
192}
193
194/// Info passed to render callback for partial item rendering
195#[derive(Debug, Clone, Copy)]
196pub struct RenderInfo {
197    /// Screen area to render into
198    pub area: Rect,
199    /// Number of rows to skip at top of item (for partial visibility)
200    pub skip_top: u16,
201    /// Item index
202    pub index: usize,
203}
204
205/// Manages scrolling for a list of items
206#[derive(Debug, Clone, Default)]
207pub struct ScrollablePanel {
208    /// Scroll state
209    pub scroll: ScrollState,
210}
211
212impl ScrollablePanel {
213    /// Create new scrollable panel
214    pub fn new() -> Self {
215        Self {
216            scroll: ScrollState::default(),
217        }
218    }
219
220    /// Create with initial viewport height
221    pub fn with_viewport(viewport: u16) -> Self {
222        Self {
223            scroll: ScrollState::new(viewport),
224        }
225    }
226
227    /// Update scroll state for new viewport size
228    pub fn set_viewport(&mut self, height: u16) {
229        self.scroll.set_viewport(height);
230    }
231
232    /// Get current viewport height
233    pub fn viewport_height(&self) -> usize {
234        self.scroll.viewport as usize
235    }
236
237    /// Calculate total content height from items
238    pub fn update_content_height<I: ScrollItem>(&mut self, items: &[I]) {
239        let height: u16 = items.iter().map(|i| i.height()).sum();
240        self.scroll.set_content_height(height);
241    }
242
243    /// Get Y offset for an item by index
244    pub fn item_y_offset<I: ScrollItem>(&self, items: &[I], index: usize) -> u16 {
245        items[..index].iter().map(|i| i.height()).sum()
246    }
247
248    /// Ensure focused item (and optional sub-region) is visible
249    pub fn ensure_focused_visible<I: ScrollItem>(
250        &mut self,
251        items: &[I],
252        focused_index: usize,
253        sub_focus: Option<usize>,
254    ) {
255        if focused_index >= items.len() {
256            return;
257        }
258
259        // Calculate Y offset of focused item
260        let item_y = self.item_y_offset(items, focused_index);
261        let item = &items[focused_index];
262        let item_h = item.height();
263
264        // If sub-focus specified, use that region
265        let (focus_y, focus_h) = if let Some(sub_id) = sub_focus {
266            let regions = item.focus_regions();
267            if let Some(region) = regions.iter().find(|r| r.id == sub_id) {
268                (item_y + region.y_offset, region.height)
269            } else {
270                (item_y, item_h)
271            }
272        } else {
273            (item_y, item_h)
274        };
275
276        self.scroll.ensure_visible(focus_y, focus_h);
277    }
278
279    /// Render visible items and scrollbar
280    ///
281    /// # Arguments
282    /// * `frame` - The ratatui frame
283    /// * `area` - Total area for the panel (including scrollbar)
284    /// * `items` - Slice of items to render
285    /// * `render_item` - Callback to render each item, receives (frame, RenderInfo, item).
286    ///   RenderInfo contains area, skip_top (rows to skip for partial visibility), and index.
287    /// * `theme` - Theme for scrollbar colors
288    ///
289    /// # Returns
290    /// Layout info for hit testing
291    pub fn render<I, F, L>(
292        &self,
293        frame: &mut Frame,
294        area: Rect,
295        items: &[I],
296        render_item: F,
297        theme: &Theme,
298    ) -> ScrollablePanelLayout<L>
299    where
300        I: ScrollItem,
301        F: Fn(&mut Frame, RenderInfo, &I) -> L,
302    {
303        let scrollbar_width = if self.scroll.needs_scrollbar() { 1 } else { 0 };
304        let content_area = Rect::new(
305            area.x,
306            area.y,
307            area.width.saturating_sub(scrollbar_width),
308            area.height,
309        );
310
311        let mut layouts = Vec::new();
312        let mut content_y = 0u16; // Y in content coordinates
313        let mut render_y = area.y; // Y on screen
314
315        for (idx, item) in items.iter().enumerate() {
316            let item_h = item.height();
317
318            // Skip items entirely before scroll offset
319            if content_y + item_h <= self.scroll.offset {
320                content_y += item_h;
321                continue;
322            }
323
324            // Stop if we're past the viewport
325            if render_y >= area.y + area.height {
326                break;
327            }
328
329            // Calculate visible portion of item
330            let skip_top = self.scroll.offset.saturating_sub(content_y);
331            let available_h = (area.y + area.height).saturating_sub(render_y);
332            let visible_h = (item_h - skip_top).min(available_h);
333
334            if visible_h > 0 {
335                let item_area = Rect::new(content_area.x, render_y, content_area.width, visible_h);
336                let info = RenderInfo {
337                    area: item_area,
338                    skip_top,
339                    index: idx,
340                };
341                let layout = render_item(frame, info, item);
342                layouts.push(ItemLayoutInfo {
343                    index: idx,
344                    content_y,
345                    area: item_area,
346                    layout,
347                });
348            }
349
350            render_y += visible_h;
351            content_y += item_h;
352        }
353
354        // Render scrollbar if needed
355        let scrollbar_area = if self.scroll.needs_scrollbar() {
356            let sb_area = Rect::new(area.x + content_area.width, area.y, 1, area.height);
357            let scrollbar_state = self.scroll.to_scrollbar_state();
358            let scrollbar_colors = ScrollbarColors::from_theme(theme);
359            render_scrollbar(frame, sb_area, &scrollbar_state, &scrollbar_colors);
360            Some(sb_area)
361        } else {
362            None
363        };
364
365        ScrollablePanelLayout {
366            content_area,
367            scrollbar_area,
368            item_layouts: layouts,
369        }
370    }
371
372    /// Render without scrollbar (for when scrollbar is managed externally)
373    pub fn render_content_only<I, F, L>(
374        &self,
375        frame: &mut Frame,
376        area: Rect,
377        items: &[I],
378        render_item: F,
379    ) -> Vec<ItemLayoutInfo<L>>
380    where
381        I: ScrollItem,
382        F: Fn(&mut Frame, RenderInfo, &I) -> L,
383    {
384        let mut layouts = Vec::new();
385        let mut content_y = 0u16;
386        let mut render_y = area.y;
387
388        for (idx, item) in items.iter().enumerate() {
389            let item_h = item.height();
390
391            if content_y + item_h <= self.scroll.offset {
392                content_y += item_h;
393                continue;
394            }
395
396            if render_y >= area.y + area.height {
397                break;
398            }
399
400            let skip_top = self.scroll.offset.saturating_sub(content_y);
401            let available_h = (area.y + area.height).saturating_sub(render_y);
402            let visible_h = (item_h - skip_top).min(available_h);
403
404            if visible_h > 0 {
405                let item_area = Rect::new(area.x, render_y, area.width, visible_h);
406                let info = RenderInfo {
407                    area: item_area,
408                    skip_top,
409                    index: idx,
410                };
411                let layout = render_item(frame, info, item);
412                layouts.push(ItemLayoutInfo {
413                    index: idx,
414                    content_y,
415                    area: item_area,
416                    layout,
417                });
418            }
419
420            render_y += visible_h;
421            content_y += item_h;
422        }
423
424        layouts
425    }
426
427    // Scroll operations
428    pub fn scroll_up(&mut self, rows: u16) {
429        self.scroll.scroll_by(-(rows as i16));
430    }
431
432    pub fn scroll_down(&mut self, rows: u16) {
433        self.scroll.scroll_by(rows as i16);
434    }
435
436    pub fn scroll_to_ratio(&mut self, ratio: f32) {
437        self.scroll.scroll_to_ratio(ratio);
438    }
439
440    /// Get current scroll offset
441    pub fn offset(&self) -> u16 {
442        self.scroll.offset
443    }
444
445    /// Check if scrollbar is needed
446    pub fn needs_scrollbar(&self) -> bool {
447        self.scroll.needs_scrollbar()
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    struct TestItem {
456        height: u16,
457    }
458
459    impl ScrollItem for TestItem {
460        fn height(&self) -> u16 {
461            self.height
462        }
463    }
464
465    #[test]
466    fn test_scroll_state_basic() {
467        let mut state = ScrollState::new(10);
468        state.set_content_height(100);
469
470        assert_eq!(state.viewport, 10);
471        assert_eq!(state.content_height, 100);
472        assert_eq!(state.max_offset(), 90);
473        assert!(state.needs_scrollbar());
474    }
475
476    #[test]
477    fn test_scroll_state_no_scrollbar_needed() {
478        let mut state = ScrollState::new(100);
479        state.set_content_height(50);
480
481        assert!(!state.needs_scrollbar());
482        assert_eq!(state.max_offset(), 0);
483    }
484
485    #[test]
486    fn test_scroll_by() {
487        let mut state = ScrollState::new(10);
488        state.set_content_height(100);
489
490        state.scroll_by(5);
491        assert_eq!(state.offset, 5);
492
493        state.scroll_by(-3);
494        assert_eq!(state.offset, 2);
495
496        // Can't scroll past 0
497        state.scroll_by(-10);
498        assert_eq!(state.offset, 0);
499
500        // Can't scroll past max
501        state.scroll_by(200);
502        assert_eq!(state.offset, 90);
503    }
504
505    #[test]
506    fn test_ensure_visible_above_viewport() {
507        let mut state = ScrollState::new(10);
508        state.set_content_height(100);
509        state.offset = 50;
510
511        // Ensure item at y=20 (above viewport) is visible
512        state.ensure_visible(20, 5);
513        assert_eq!(state.offset, 20);
514    }
515
516    #[test]
517    fn test_ensure_visible_below_viewport() {
518        let mut state = ScrollState::new(10);
519        state.set_content_height(100);
520        state.offset = 0;
521
522        // Ensure item at y=50 is visible (need to scroll down)
523        state.ensure_visible(50, 5);
524        assert_eq!(state.offset, 45); // 50 + 5 - 10 = 45
525    }
526
527    #[test]
528    fn test_ensure_visible_oversized_item() {
529        let mut state = ScrollState::new(10);
530        state.set_content_height(100);
531        state.offset = 0;
532
533        // Ensure item at y=50 with height 20 (larger than viewport)
534        state.ensure_visible(50, 20);
535        assert_eq!(state.offset, 50); // Show top of item
536    }
537
538    #[test]
539    fn test_ensure_visible_already_visible() {
540        let mut state = ScrollState::new(10);
541        state.set_content_height(100);
542        state.offset = 20;
543
544        // Item at y=22 is already visible
545        state.ensure_visible(22, 3);
546        assert_eq!(state.offset, 20); // No change
547    }
548
549    #[test]
550    fn test_scroll_to_ratio() {
551        let mut state = ScrollState::new(10);
552        state.set_content_height(100);
553
554        state.scroll_to_ratio(0.0);
555        assert_eq!(state.offset, 0);
556
557        state.scroll_to_ratio(1.0);
558        assert_eq!(state.offset, 90);
559
560        state.scroll_to_ratio(0.5);
561        assert_eq!(state.offset, 45);
562    }
563
564    #[test]
565    fn test_panel_update_content_height() {
566        let mut panel = ScrollablePanel::new();
567        let items = vec![
568            TestItem { height: 3 },
569            TestItem { height: 5 },
570            TestItem { height: 2 },
571        ];
572
573        panel.update_content_height(&items);
574        assert_eq!(panel.scroll.content_height, 10);
575    }
576
577    #[test]
578    fn test_panel_item_y_offset() {
579        let panel = ScrollablePanel::new();
580        let items = vec![
581            TestItem { height: 3 },
582            TestItem { height: 5 },
583            TestItem { height: 2 },
584        ];
585
586        assert_eq!(panel.item_y_offset(&items, 0), 0);
587        assert_eq!(panel.item_y_offset(&items, 1), 3);
588        assert_eq!(panel.item_y_offset(&items, 2), 8);
589    }
590
591    #[test]
592    fn test_panel_ensure_focused_visible() {
593        let mut panel = ScrollablePanel::with_viewport(5);
594        let items = vec![
595            TestItem { height: 3 },
596            TestItem { height: 3 },
597            TestItem { height: 3 },
598            TestItem { height: 3 },
599        ];
600        panel.update_content_height(&items);
601
602        // Focus on item 2 (y=6, h=3) - needs scroll
603        panel.ensure_focused_visible(&items, 2, None);
604        // Item 2 ends at y=9, viewport=5, so offset should be 9-5=4
605        assert_eq!(panel.scroll.offset, 4);
606    }
607
608    struct TestItemWithRegions {
609        height: u16,
610        regions: Vec<FocusRegion>,
611    }
612
613    impl ScrollItem for TestItemWithRegions {
614        fn height(&self) -> u16 {
615            self.height
616        }
617
618        fn focus_regions(&self) -> Vec<FocusRegion> {
619            self.regions.clone()
620        }
621    }
622
623    #[test]
624    fn test_panel_ensure_focused_visible_with_subfocus() {
625        let mut panel = ScrollablePanel::with_viewport(5);
626        let items = vec![TestItemWithRegions {
627            height: 10,
628            regions: vec![
629                FocusRegion {
630                    id: 0,
631                    y_offset: 0,
632                    height: 1,
633                },
634                FocusRegion {
635                    id: 1,
636                    y_offset: 3,
637                    height: 1,
638                },
639                FocusRegion {
640                    id: 2,
641                    y_offset: 7,
642                    height: 1,
643                },
644            ],
645        }];
646        panel.update_content_height(&items);
647
648        // Focus on sub-region 2 (y_offset=7 within item, so absolute y=7)
649        panel.ensure_focused_visible(&items, 0, Some(2));
650        // Region at y=7, h=1, viewport=5, so offset should be 7+1-5=3
651        assert_eq!(panel.scroll.offset, 3);
652    }
653}