Skip to main content

rlvgl_widgets/
scroll_view.rs

1//! Scrollable viewport container (REND initiative).
2//!
3//! [`ScrollView`] owns children positioned in **content space** (origin at
4//! the content's top-left, independent of scroll state), exposes a
5//! per-pixel vertical scroll offset, and renders children translated and
6//! clipped to its own bounds via
7//! [`ClipRenderer`](rlvgl_core::renderer::ClipRenderer) — partially
8//! visible rows crop cleanly at the viewport's edges instead of bleeding
9//! over siblings.
10//!
11//! Normative contract: `docs/concepts/REND-00-CONCEPTS.md` §6. No kinetic
12//! scrolling and no scroll gesture recognition — consumers drive
13//! [`scroll_to`](ScrollView::scroll_to) / [`scroll_by`](ScrollView::scroll_by)
14//! from their own input handling, and draw custom scrollbars from the
15//! [`viewport`](ScrollView::viewport) / [`content_height`](ScrollView::content_height)
16//! / [`scroll_y`](ScrollView::scroll_y) / [`max_scroll`](ScrollView::max_scroll)
17//! query seam.
18
19use alloc::rc::Rc;
20use alloc::vec::Vec;
21use core::cell::RefCell;
22
23use rlvgl_core::draw::draw_widget_bg;
24use rlvgl_core::event::Event;
25use rlvgl_core::renderer::{ClipRenderer, Renderer};
26use rlvgl_core::style::Style;
27use rlvgl_core::widget::{Rect, Widget};
28
29/// Width in pixels of the optional built-in scrollbar thumb.
30const SCROLLBAR_WIDTH: i32 = 4;
31/// Gap between the scrollbar and the viewport's right edge.
32const SCROLLBAR_MARGIN: i32 = 2;
33/// Minimum thumb height so it stays grabbable/visible on long content.
34const SCROLLBAR_MIN_THUMB: i32 = 8;
35
36/// Vertical scrolling viewport over taller content (REND-00 §6).
37///
38/// Children live in content space: a child with bounds `y = 0` sits at
39/// the top of the *content*, which coincides with the top of the viewport
40/// only while `scroll_y == 0`.
41pub struct ScrollView {
42    bounds: Rect,
43    content_height: i32,
44    scroll_y: i32,
45    children: Vec<Rc<RefCell<dyn Widget>>>,
46    /// Visual style of the viewport background.
47    pub style: Style,
48    /// Draw the optional built-in proportional scrollbar thumb
49    /// (default `false`; the query seam is the supported contract for
50    /// custom scrollbars).
51    pub show_scrollbar: bool,
52    /// Set when the scroll offset changed since the last
53    /// [`take_dirty`](Self::take_dirty).
54    dirty: bool,
55}
56
57impl ScrollView {
58    /// Create a viewport at `bounds` over `content_height` pixels of
59    /// content. A content height ≤ the viewport height simply never
60    /// scrolls.
61    pub fn new(bounds: Rect, content_height: i32) -> Self {
62        Self {
63            bounds,
64            content_height: content_height.max(0),
65            scroll_y: 0,
66            children: Vec::new(),
67            style: Style::default(),
68            show_scrollbar: false,
69            dirty: false,
70        }
71    }
72
73    /// Add a child widget positioned in content space.
74    pub fn add_child(&mut self, child: Rc<RefCell<dyn Widget>>) {
75        self.children.push(child);
76    }
77
78    /// Replace the content extent (e.g. after adding rows). The current
79    /// offset is re-clamped; a clamp that moves it marks the view dirty.
80    pub fn set_content_height(&mut self, content_height: i32) {
81        self.content_height = content_height.max(0);
82        let clamped = self.scroll_y.clamp(0, self.max_scroll());
83        if clamped != self.scroll_y {
84            self.scroll_y = clamped;
85            self.dirty = true;
86        }
87    }
88
89    /// The viewport rect (the widget's own bounds, screen space).
90    pub fn viewport(&self) -> Rect {
91        self.bounds
92    }
93
94    /// Total content extent in pixels.
95    pub fn content_height(&self) -> i32 {
96        self.content_height
97    }
98
99    /// Current per-pixel scroll offset (`0..=max_scroll`).
100    pub fn scroll_y(&self) -> i32 {
101        self.scroll_y
102    }
103
104    /// Maximum reachable scroll offset.
105    pub fn max_scroll(&self) -> i32 {
106        (self.content_height - self.bounds.height).max(0)
107    }
108
109    /// Scroll to an absolute offset, clamped to `0..=max_scroll`.
110    /// Marks the view dirty only when the effective offset changes.
111    pub fn scroll_to(&mut self, y: i32) {
112        let clamped = y.clamp(0, self.max_scroll());
113        if clamped != self.scroll_y {
114            self.scroll_y = clamped;
115            self.dirty = true;
116        }
117    }
118
119    /// Scroll by a signed per-pixel delta (clamped; see
120    /// [`scroll_to`](Self::scroll_to)).
121    pub fn scroll_by(&mut self, dy: i32) {
122        self.scroll_to(self.scroll_y + dy);
123    }
124
125    /// The region invalidated by scroll-offset changes since the last
126    /// call: the viewport rect, once, then `None` until the next change
127    /// (REND-00 §6.6 — a scrolled panel invalidates its viewport, not
128    /// the whole frame).
129    pub fn take_dirty(&mut self) -> Option<Rect> {
130        if self.dirty {
131            self.dirty = false;
132            Some(self.bounds)
133        } else {
134            None
135        }
136    }
137
138    /// Bounds of the built-in scrollbar thumb, if it would be drawn.
139    fn scrollbar_thumb(&self) -> Option<Rect> {
140        if !self.show_scrollbar || self.content_height <= self.bounds.height {
141            return None;
142        }
143        let track_h = self.bounds.height;
144        let thumb_h = ((track_h as i64 * track_h as i64) / self.content_height as i64) as i32;
145        let thumb_h = thumb_h.clamp(SCROLLBAR_MIN_THUMB, track_h);
146        let travel = track_h - thumb_h;
147        let max = self.max_scroll();
148        let offset = if max > 0 {
149            ((self.scroll_y as i64 * travel as i64) / max as i64) as i32
150        } else {
151            0
152        };
153        Some(Rect {
154            x: self.bounds.x + self.bounds.width - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN,
155            y: self.bounds.y + offset,
156            width: SCROLLBAR_WIDTH,
157            height: thumb_h,
158        })
159    }
160
161    /// Translate a screen-space point into content space, or `None` when
162    /// it falls outside the viewport.
163    fn to_content(&self, x: i32, y: i32) -> Option<(i32, i32)> {
164        let b = self.bounds;
165        if x < b.x || x >= b.x + b.width || y < b.y || y >= b.y + b.height {
166            return None;
167        }
168        Some((x - b.x, y - b.y + self.scroll_y))
169    }
170
171    /// Translate a pointer-family event into content space, or `None`
172    /// when the event is outside the viewport (not delivered) or not a
173    /// pointer event (pass through untranslated).
174    fn translate_pointer(&self, event: &Event) -> Option<Option<Event>> {
175        let remap = |x: i32, y: i32, build: fn(i32, i32) -> Event| -> Option<Event> {
176            self.to_content(x, y).map(|(cx, cy)| build(cx, cy))
177        };
178        let translated = match *event {
179            Event::PointerDown { x, y } => remap(x, y, |x, y| Event::PointerDown { x, y }),
180            Event::PointerUp { x, y } => remap(x, y, |x, y| Event::PointerUp { x, y }),
181            Event::PointerMove { x, y } => remap(x, y, |x, y| Event::PointerMove { x, y }),
182            Event::PressDown { x, y } => remap(x, y, |x, y| Event::PressDown { x, y }),
183            Event::PressRelease { x, y } => remap(x, y, |x, y| Event::PressRelease { x, y }),
184            Event::DoubleTap { x, y } => remap(x, y, |x, y| Event::DoubleTap { x, y }),
185            _ => return None,
186        };
187        Some(translated)
188    }
189}
190
191impl Widget for ScrollView {
192    fn bounds(&self) -> Rect {
193        self.bounds
194    }
195
196    fn draw(&self, renderer: &mut dyn Renderer) {
197        draw_widget_bg(renderer, self.bounds, &self.style);
198        {
199            // Children draw in content space; the adapter shifts them to
200            // screen space and crops to the viewport (REND-00 §6.8).
201            let mut clipped = ClipRenderer::with_offset(
202                renderer,
203                self.bounds,
204                self.bounds.x,
205                self.bounds.y - self.scroll_y,
206            );
207            for child in &self.children {
208                child.borrow().draw(&mut clipped);
209            }
210        }
211        if let Some(thumb) = self.scrollbar_thumb() {
212            renderer.fill_rect(thumb, self.style.border_color);
213        }
214    }
215
216    fn handle_event(&mut self, event: &Event) -> bool {
217        match self.translate_pointer(event) {
218            // Pointer event inside the viewport: forward translated.
219            Some(Some(translated)) => {
220                for child in &self.children {
221                    if child.borrow_mut().handle_event(&translated) {
222                        return true;
223                    }
224                }
225                false
226            }
227            // Pointer event outside the viewport: children never see it.
228            Some(None) => false,
229            // Non-pointer event (Tick, keys, …): pass through unchanged.
230            None => {
231                for child in &self.children {
232                    if child.borrow_mut().handle_event(event) {
233                        return true;
234                    }
235                }
236                false
237            }
238        }
239    }
240}