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}