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 area width.
246    /// Handles the circular dependency between scrollbar presence and item height.
247    pub fn update_content_height<I: ScrollItem>(&mut self, items: &[I], area_width: u16) {
248        // First pass: assume no scrollbar
249        let height1: u16 = items.iter().map(|i| i.height(area_width)).sum();
250        self.scroll.set_content_height(height1);
251
252        // If a scrollbar is needed, it reduces width, which might change height
253        if self.scroll.needs_scrollbar() && area_width > 0 {
254            let height2: u16 = items.iter().map(|i| i.height(area_width - 1)).sum();
255            self.scroll.set_content_height(height2);
256        }
257    }
258
259    /// Get the effective content width given an outer area width
260    pub fn content_width(&self, area_width: u16) -> u16 {
261        if self.scroll.needs_scrollbar() {
262            area_width.saturating_sub(1)
263        } else {
264            area_width
265        }
266    }
267
268    /// Get Y offset for an item by index at the given effective content width.
269    pub fn item_y_offset<I: ScrollItem>(
270        &self,
271        items: &[I],
272        index: usize,
273        content_width: u16,
274    ) -> u16 {
275        items[..index].iter().map(|i| i.height(content_width)).sum()
276    }
277
278    /// Ensure focused item (and optional sub-region) is visible at the given outer width.
279    pub fn ensure_focused_visible<I: ScrollItem>(
280        &mut self,
281        items: &[I],
282        focused_index: usize,
283        sub_focus: Option<usize>,
284        area_width: u16,
285    ) {
286        if focused_index >= items.len() {
287            return;
288        }
289
290        // Must sync content height first to know if scrollbar is present
291        self.update_content_height(items, area_width);
292
293        let content_width = self.content_width(area_width);
294
295        // Calculate Y offset of focused item
296        let item_y = self.item_y_offset(items, focused_index, content_width);
297        let item = &items[focused_index];
298        let item_h = item.height(content_width);
299
300        // If sub-focus specified, use that region
301        let (focus_y, focus_h) = if let Some(sub_id) = sub_focus {
302            let regions = item.focus_regions(content_width);
303            if let Some(region) = regions.iter().find(|r| r.id == sub_id) {
304                (item_y + region.y_offset, region.height)
305            } else {
306                (item_y, item_h)
307            }
308        } else {
309            (item_y, item_h)
310        };
311        self.scroll.ensure_visible(focus_y, focus_h);
312    }
313
314    /// Render visible items and scrollbar
315    ///
316    /// # Arguments
317    /// * `frame` - The ratatui frame
318    /// * `area` - Total area for the panel (including scrollbar)
319    /// * `items` - Slice of items to render
320    /// * `render_item` - Callback to render each item, receives (frame, RenderInfo, item).
321    ///   RenderInfo contains area, skip_top (rows to skip for partial visibility), and index.
322    /// * `theme` - Theme for scrollbar colors
323    ///
324    /// # Returns
325    /// Layout info for hit testing
326    pub fn render<I, F, L>(
327        &self,
328        frame: &mut Frame,
329        area: Rect,
330        items: &[I],
331        render_item: F,
332        theme: &Theme,
333    ) -> ScrollablePanelLayout<L>
334    where
335        I: ScrollItem,
336        F: Fn(&mut Frame, RenderInfo, &I) -> L,
337    {
338        let scrollbar_width = if self.scroll.needs_scrollbar() { 1 } else { 0 };
339        let content_area = Rect::new(
340            area.x,
341            area.y,
342            area.width.saturating_sub(scrollbar_width),
343            area.height,
344        );
345        let item_width = content_area.width;
346
347        let mut layouts = Vec::new();
348        let mut content_y = 0u16; // Y in content coordinates
349        let mut render_y = area.y; // Y on screen
350
351        for (idx, item) in items.iter().enumerate() {
352            let item_h = item.height(item_width);
353
354            // Skip items entirely before scroll offset
355            if content_y + item_h <= self.scroll.offset {
356                content_y += item_h;
357                continue;
358            }
359
360            // Stop if we're past the viewport
361            if render_y >= area.y + area.height {
362                break;
363            }
364
365            // Calculate visible portion of item
366            let skip_top = self.scroll.offset.saturating_sub(content_y);
367            let available_h = (area.y + area.height).saturating_sub(render_y);
368            let visible_h = (item_h - skip_top).min(available_h);
369
370            if visible_h > 0 {
371                let item_area = Rect::new(content_area.x, render_y, content_area.width, visible_h);
372                let info = RenderInfo {
373                    area: item_area,
374                    skip_top,
375                    index: idx,
376                };
377                let layout = render_item(frame, info, item);
378                layouts.push(ItemLayoutInfo {
379                    index: idx,
380                    content_y,
381                    area: item_area,
382                    layout,
383                });
384            }
385
386            render_y += visible_h;
387            content_y += item_h;
388        }
389
390        // Render scrollbar if needed
391        let scrollbar_area = if self.scroll.needs_scrollbar() {
392            let sb_area = Rect::new(area.x + content_area.width, area.y, 1, area.height);
393            let scrollbar_state = self.scroll.to_scrollbar_state();
394            let scrollbar_colors = ScrollbarColors::from_theme(theme);
395            render_scrollbar(frame, sb_area, &scrollbar_state, &scrollbar_colors);
396            Some(sb_area)
397        } else {
398            None
399        };
400
401        ScrollablePanelLayout {
402            content_area,
403            scrollbar_area,
404            item_layouts: layouts,
405        }
406    }
407
408    /// Render without scrollbar (for when scrollbar is managed externally)
409    pub fn render_content_only<I, F, L>(
410        &self,
411        frame: &mut Frame,
412        area: Rect,
413        items: &[I],
414        render_item: F,
415    ) -> Vec<ItemLayoutInfo<L>>
416    where
417        I: ScrollItem,
418        F: Fn(&mut Frame, RenderInfo, &I) -> L,
419    {
420        let mut layouts = Vec::new();
421        let mut content_y = 0u16;
422        let mut render_y = area.y;
423        let item_width = area.width;
424
425        for (idx, item) in items.iter().enumerate() {
426            let item_h = item.height(item_width);
427
428            if content_y + item_h <= self.scroll.offset {
429                content_y += item_h;
430                continue;
431            }
432
433            if render_y >= area.y + area.height {
434                break;
435            }
436
437            let skip_top = self.scroll.offset.saturating_sub(content_y);
438            let available_h = (area.y + area.height).saturating_sub(render_y);
439            let visible_h = (item_h - skip_top).min(available_h);
440
441            if visible_h > 0 {
442                let item_area = Rect::new(area.x, render_y, area.width, visible_h);
443                let info = RenderInfo {
444                    area: item_area,
445                    skip_top,
446                    index: idx,
447                };
448                let layout = render_item(frame, info, item);
449                layouts.push(ItemLayoutInfo {
450                    index: idx,
451                    content_y,
452                    area: item_area,
453                    layout,
454                });
455            }
456
457            render_y += visible_h;
458            content_y += item_h;
459        }
460
461        layouts
462    }
463
464    // Scroll operations
465    pub fn scroll_up(&mut self, rows: u16) {
466        self.scroll.scroll_by(-(rows as i16));
467    }
468
469    pub fn scroll_down(&mut self, rows: u16) {
470        self.scroll.scroll_by(rows as i16);
471    }
472
473    pub fn scroll_to_ratio(&mut self, ratio: f32) {
474        self.scroll.scroll_to_ratio(ratio);
475    }
476
477    /// Get current scroll offset
478    pub fn offset(&self) -> u16 {
479        self.scroll.offset
480    }
481
482    /// Check if scrollbar is needed
483    pub fn needs_scrollbar(&self) -> bool {
484        self.scroll.needs_scrollbar()
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    struct TestItem {
493        height: u16,
494    }
495
496    impl ScrollItem for TestItem {
497        fn height(&self, _width: u16) -> u16 {
498            self.height
499        }
500    }
501
502    impl ScrollItem for fn(u16) -> u16 {
503        fn height(&self, width: u16) -> u16 {
504            self(width)
505        }
506    }
507
508    #[test]
509    fn test_scroll_state_basic() {
510        let mut state = ScrollState::new(10);
511        state.set_content_height(100);
512
513        assert_eq!(state.viewport, 10);
514        assert_eq!(state.content_height, 100);
515        assert_eq!(state.max_offset(), 90);
516        assert!(state.needs_scrollbar());
517    }
518
519    #[test]
520    fn test_scroll_state_no_scrollbar_needed() {
521        let mut state = ScrollState::new(100);
522        state.set_content_height(50);
523
524        assert!(!state.needs_scrollbar());
525        assert_eq!(state.max_offset(), 0);
526    }
527
528    #[test]
529    fn test_scroll_by() {
530        let mut state = ScrollState::new(10);
531        state.set_content_height(100);
532
533        state.scroll_by(5);
534        assert_eq!(state.offset, 5);
535
536        state.scroll_by(-3);
537        assert_eq!(state.offset, 2);
538
539        // Can't scroll past 0
540        state.scroll_by(-10);
541        assert_eq!(state.offset, 0);
542
543        // Can't scroll past max
544        state.scroll_by(200);
545        assert_eq!(state.offset, 90);
546    }
547
548    #[test]
549    fn test_ensure_visible_above_viewport() {
550        let mut state = ScrollState::new(10);
551        state.set_content_height(100);
552        state.offset = 50;
553
554        // Ensure item at y=20 (above viewport) is visible
555        state.ensure_visible(20, 5);
556        assert_eq!(state.offset, 20);
557    }
558
559    #[test]
560    fn test_ensure_visible_below_viewport() {
561        let mut state = ScrollState::new(10);
562        state.set_content_height(100);
563        state.offset = 0;
564
565        // Ensure item at y=50 is visible (need to scroll down)
566        state.ensure_visible(50, 5);
567        assert_eq!(state.offset, 45); // 50 + 5 - 10 = 45
568    }
569
570    #[test]
571    fn test_ensure_visible_oversized_item() {
572        let mut state = ScrollState::new(10);
573        state.set_content_height(100);
574        state.offset = 0;
575
576        // Ensure item at y=50 with height 20 (larger than viewport)
577        state.ensure_visible(50, 20);
578        assert_eq!(state.offset, 50); // Show top of item
579    }
580
581    #[test]
582    fn test_ensure_visible_already_visible() {
583        let mut state = ScrollState::new(10);
584        state.set_content_height(100);
585        state.offset = 20;
586
587        // Item at y=22 is already visible
588        state.ensure_visible(22, 3);
589        assert_eq!(state.offset, 20); // No change
590    }
591
592    #[test]
593    fn test_scroll_to_ratio() {
594        let mut state = ScrollState::new(10);
595        state.set_content_height(100);
596
597        state.scroll_to_ratio(0.0);
598        assert_eq!(state.offset, 0);
599
600        state.scroll_to_ratio(1.0);
601        assert_eq!(state.offset, 90);
602
603        state.scroll_to_ratio(0.5);
604        assert_eq!(state.offset, 45);
605    }
606
607    /// Test items are width-agnostic, so the width arg is ignored — pass any value.
608    const TEST_WIDTH: u16 = 80;
609
610    #[test]
611    fn test_panel_update_content_height() {
612        let mut panel = ScrollablePanel::new();
613        let items = vec![
614            TestItem { height: 3 },
615            TestItem { height: 5 },
616            TestItem { height: 2 },
617        ];
618
619        panel.update_content_height(&items, TEST_WIDTH);
620        assert_eq!(panel.scroll.content_height, 10);
621    }
622
623    #[test]
624    fn test_panel_item_y_offset() {
625        let panel = ScrollablePanel::new();
626        let items = vec![
627            TestItem { height: 3 },
628            TestItem { height: 5 },
629            TestItem { height: 2 },
630        ];
631
632        assert_eq!(panel.item_y_offset(&items, 0, TEST_WIDTH), 0);
633        assert_eq!(panel.item_y_offset(&items, 1, TEST_WIDTH), 3);
634        assert_eq!(panel.item_y_offset(&items, 2, TEST_WIDTH), 8);
635    }
636
637    #[test]
638    fn test_panel_ensure_focused_visible() {
639        let mut panel = ScrollablePanel::with_viewport(5);
640        let items = vec![
641            TestItem { height: 3 },
642            TestItem { height: 3 },
643            TestItem { height: 3 },
644            TestItem { height: 3 },
645        ];
646        panel.update_content_height(&items, TEST_WIDTH);
647
648        // Focus on item 2 (y=6, h=3) - needs scroll
649        panel.ensure_focused_visible(&items, 2, None, TEST_WIDTH);
650        // Item 2 ends at y=9, viewport=5, so offset should be 9-5=4
651        assert_eq!(panel.scroll.offset, 4);
652    }
653
654    struct TestItemWithRegions {
655        height: u16,
656        regions: Vec<FocusRegion>,
657    }
658
659    impl ScrollItem for TestItemWithRegions {
660        fn height(&self, _width: u16) -> u16 {
661            self.height
662        }
663
664        fn focus_regions(&self, _width: u16) -> Vec<FocusRegion> {
665            self.regions.clone()
666        }
667    }
668
669    #[test]
670    fn test_panel_ensure_focused_visible_with_subfocus() {
671        let mut panel = ScrollablePanel::with_viewport(5);
672        let items = vec![TestItemWithRegions {
673            height: 10,
674            regions: vec![
675                FocusRegion {
676                    id: 0,
677                    y_offset: 0,
678                    height: 1,
679                },
680                FocusRegion {
681                    id: 1,
682                    y_offset: 3,
683                    height: 1,
684                },
685                FocusRegion {
686                    id: 2,
687                    y_offset: 7,
688                    height: 1,
689                },
690            ],
691        }];
692        panel.update_content_height(&items, TEST_WIDTH);
693
694        // Focus on sub-region 2 (y_offset=7 within item, so absolute y=7)
695        panel.ensure_focused_visible(&items, 0, Some(2), TEST_WIDTH);
696        // Region at y=7, h=1, viewport=5, so offset should be 7+1-5=3
697        assert_eq!(panel.scroll.offset, 3);
698    }
699
700    /// Regression test for the scrollbar-width mismatch bug.
701    ///
702    /// ## Background
703    ///
704    /// `ScrollablePanel::render()` reserves one column for the scrollbar when
705    /// `needs_scrollbar()` is true, so items are rendered at `area_width - 1`.
706    /// Before the fix, `ensure_focused_visible` computed Y-offsets using the
707    /// *full* `area_width`, not the narrower render width. When items are
708    /// taller at the narrow width (because description text wraps onto an
709    /// extra line), the cumulative Y position of items deep in the list drifts
710    /// below the offset that `ensure_focused_visible` calculated — leaving the
711    /// target item off-screen after the jump.
712    ///
713    /// ## Scenario
714    ///
715    /// - `area_width = 10`, `viewport = 5`
716    /// - 4 "wide" items: height 2 at w ≥ 10, height 3 at w < 10
717    /// - 1 "target" item (index 4): fixed height 2 at any width
718    ///
719    /// Content height at w=10:  4×2 + 2 = 10  →  needs_scrollbar (10 > 5)
720    /// Render width:            10 − 1 = 9
721    /// Content height at w=9:   4×3 + 2 = 14
722    ///
723    /// Y-position of item 4 at render width 9: 3+3+3+3 = **12**
724    ///
725    /// | Path          | offset computed | item 4 visible?            |
726    /// |---------------|-----------------|----------------------------|
727    /// | Bug (w=10)    | 8+2−5 = 5       | 12..14 ∉ [5..10) → **NO** |
728    /// | Fix (w=9)     | 12+2−5 = 9      | 12..14 ⊆ [9..14) → **YES**|
729    #[test]
730    fn test_ensure_focused_visible_uses_render_width_when_scrollbar_present() {
731        let area_width = 10u16;
732        let viewport = 5u16;
733
734        // 4 items that grow by 1 row when the scrollbar steals a column,
735        // plus the target item at index 4 (fixed height).
736        let mut items: Vec<fn(u16) -> u16> = vec![|w| if w >= 10 { 2 } else { 3 }; 4];
737        items.push(|_| 2);
738
739        let mut panel = ScrollablePanel::new();
740        panel.set_viewport(viewport);
741        panel.update_content_height(&items, area_width);
742
743        // The scrollbar must be active for the bug to manifest.
744        assert!(
745            panel.needs_scrollbar(),
746            "content height ({}) should exceed viewport ({}) to trigger the scrollbar",
747            panel.scroll.content_height,
748            viewport
749        );
750
751        // Ask the panel to make item 4 visible.
752        panel.ensure_focused_visible(&items, 4, None, area_width);
753
754        // The render engine will use (area_width - 1) = 9 for item heights.
755        let render_width = area_width - 1;
756        let item4_y: u16 = items[..4].iter().map(|i| i.height(render_width)).sum();
757        let item4_h = items[4].height(render_width);
758        let offset = panel.offset();
759
760        // Item 4 must lie fully within the rendered viewport.
761        assert!(
762            offset <= item4_y && item4_y + item4_h <= offset + viewport,
763            "Item 4 at render-y={item4_y}..{} must be visible in viewport \
764             [{offset}..{}), but offset was computed as {offset}",
765            item4_y + item4_h,
766            offset + viewport,
767        );
768    }
769}