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