Skip to main content

zest_widget/widget/
roller.rs

1//! Vertical wheel / drum selector — a scrollable list of option labels that
2//! settles each release so the chosen option lands centered under a fixed
3//! highlight band.
4//!
5//! A `Roller` is a self-contained scrollable widget (it does not wrap a
6//! [`Column`](super::column::Column)) so it can paint the center highlight
7//! band, dim the non-centered rows, and report the centered option to the
8//! host. It scrolls vertically with [`zest_core::SnapMode::Center`]: dragging spins the
9//! drum 1:1, and on release the spring settles the nearest option to the
10//! viewport center.
11//!
12//! ## Host ownership
13//!
14//! Like every scrollable widget in `zest`, the cross-frame scroll state lives
15//! on the host (widgets are transient — see [`scroll`](zest_core::scroll)).
16//! The host owns a [`ScrollState`] and the selected index, passing the state
17//! by reference each frame via [`Roller::scroll_state`]. The roller reads it
18//! during layout/draw and emits:
19//!
20//! - [`ScrollMsg`] through [`Roller::on_scroll`] — apply to the owned
21//!   [`ScrollState`] in `update()` exactly as for a scrollable
22//!   [`Column`](super::column::Column).
23//! - the centered option index through [`Roller::on_select`] — fires when the
24//!   item under the highlight band changes (during drag/fling/settle), so the
25//!   host can store the live selection.
26//!
27//! Leading and trailing padding equal to half the viewport (minus half a row)
28//! is folded into the content extent so the first and last options can reach
29//! the center.
30
31use super::{Widget, scroll_core};
32use alloc::{boxed::Box, string::String, vec::Vec};
33use core::marker::PhantomData;
34use embedded_graphics::{
35    mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle,
36    text::Alignment as EgAlignment,
37};
38use zest_core::{
39    Constraints, GesturePhase, Length, RenderError, Renderer, ScrollDirection, ScrollMsg,
40    ScrollState, TouchPhase, UiAction, WidgetId,
41};
42use zest_theme::Theme;
43
44/// Default pixel height of one option row.
45const DEFAULT_ITEM_HEIGHT: u32 = 36;
46/// Default number of rows shown in the viewport (kept odd so one row is
47/// unambiguously centered).
48const DEFAULT_VISIBLE: u32 = 5;
49
50/// Vertical wheel / drum selector.
51///
52/// Renders its options as a vertically scrollable drum with a fixed center
53/// highlight band. The host owns the [`ScrollState`] and the selected index;
54/// the roller reads the state, draws, and emits messages.
55pub struct Roller<'a, C: PixelColor, M: Clone> {
56    rect: Rectangle,
57    /// Option labels, in order.
58    options: Vec<String>,
59    /// Host-owned scroll state, read each frame.
60    state: ScrollState,
61    /// Height of one option row in pixels.
62    item_height: u32,
63    /// Number of rows the viewport shows (height = `visible * item_height`).
64    visible: u32,
65    /// Host's currently selected index (used to colour the centered row when
66    /// the drum is at rest and as a fallback before the first scroll).
67    selected: usize,
68    /// Stable id used for focus traversal.
69    id: Option<WidgetId>,
70    /// Optional font override (defaults to the theme body font).
71    font: Option<&'a MonoFont<'a>>,
72    /// Width sizing intent.
73    width: Length,
74    /// Callback turning a [`ScrollMsg`] into the host message.
75    on_scroll: Option<Box<dyn Fn(ScrollMsg) -> M + 'a>>,
76    /// Callback fired with the centered index when it changes.
77    on_select: Option<Box<dyn Fn(usize) -> M + 'a>>,
78    /// Callback fired with semantic actions while the roller is focused.
79    on_action: Option<Box<dyn Fn(UiAction) -> M + 'a>>,
80    /// Cached content height (rows + leading/trailing centering pads).
81    content_h: u32,
82    focused: bool,
83    _color: PhantomData<C>,
84}
85
86impl<'a, C: PixelColor + 'a, M: Clone + 'a> Roller<'a, C, M> {
87    /// Create an empty roller. Supply options via [`Roller::options`] or
88    /// [`Roller::option`], and the host state via [`Roller::scroll_state`].
89    /// Position and size are assigned by the parent container via `arrange`.
90    pub fn new() -> Self {
91        Self {
92            rect: Rectangle::zero(),
93            options: Vec::new(),
94            state: ScrollState::new(),
95            item_height: DEFAULT_ITEM_HEIGHT,
96            visible: DEFAULT_VISIBLE,
97            selected: 0,
98            id: None,
99            font: None,
100            width: Length::Fill,
101            on_scroll: None,
102            on_select: None,
103            on_action: None,
104            content_h: 0,
105            focused: false,
106            _color: PhantomData,
107        }
108    }
109
110    /// Replace all options from a slice of string slices.
111    #[must_use]
112    pub fn options(mut self, options: &[&str]) -> Self {
113        self.options = options.iter().map(|s| String::from(*s)).collect();
114        self
115    }
116
117    /// Append a single option label.
118    #[must_use]
119    pub fn option(mut self, label: impl Into<String>) -> Self {
120        self.options.push(label.into());
121        self
122    }
123
124    /// Supply the host-owned [`ScrollState`] read this frame.
125    #[must_use]
126    pub fn scroll_state(mut self, state: &ScrollState) -> Self {
127        self.state = *state;
128        self
129    }
130
131    /// The host's currently selected option index. Used to colour the
132    /// centered row at rest before any scroll has happened.
133    #[must_use]
134    pub fn selected(mut self, index: usize) -> Self {
135        self.selected = index;
136        self
137    }
138
139    /// Set a stable id so this roller can participate in focus traversal.
140    #[must_use]
141    pub fn id(mut self, id: WidgetId) -> Self {
142        self.id = Some(id);
143        self
144    }
145
146    /// Pixel height of one option row (default 36).
147    #[must_use]
148    pub fn item_height(mut self, height: u32) -> Self {
149        self.item_height = height.max(1);
150        self
151    }
152
153    /// Number of rows shown in the viewport (default 5). Kept odd so
154    /// exactly one row sits under the highlight band.
155    #[must_use]
156    pub fn visible_count(mut self, count: u32) -> Self {
157        self.visible = count.max(1);
158        self
159    }
160
161    /// Override the option font (defaults to `theme.typography.body`).
162    #[must_use]
163    pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
164        self.font = Some(font);
165        self
166    }
167
168    /// Width sizing intent (height is driven by `visible * item_height`).
169    #[must_use]
170    pub fn width(mut self, width: impl Into<Length>) -> Self {
171        self.width = width.into();
172        self
173    }
174
175    /// Callback mapping a [`ScrollMsg`] to the host message. Apply the
176    /// message to the owned [`ScrollState`] in `update()`.
177    #[must_use]
178    pub fn on_scroll<F>(mut self, f: F) -> Self
179    where
180        F: Fn(ScrollMsg) -> M + 'a,
181    {
182        self.on_scroll = Some(Box::new(f));
183        self
184    }
185
186    /// Callback fired with the centered option index whenever it
187    /// changes (during drag, fling, or settle).
188    #[must_use]
189    pub fn on_select<F>(mut self, f: F) -> Self
190    where
191        F: Fn(usize) -> M + 'a,
192    {
193        self.on_select = Some(Box::new(f));
194        self
195    }
196
197    /// Callback fired with semantic actions while the roller is focused.
198    #[must_use]
199    pub fn on_action<F>(mut self, f: F) -> Self
200    where
201        F: Fn(UiAction) -> M + 'a,
202    {
203        self.on_action = Some(Box::new(f));
204        self
205    }
206
207    /// Height (px) of the leading/trailing pad that lets the first and last
208    /// options reach the center. Equals half the viewport minus half a row.
209    fn pad(&self) -> i32 {
210        (self.rect.size.height as i32 - self.item_height as i32).max(0) / 2
211    }
212
213    /// Total content extent (leading pad + all rows + trailing pad).
214    fn content_height(&self) -> u32 {
215        let rows = self.item_height.saturating_mul(self.options.len() as u32);
216        rows.saturating_add((self.pad() as u32).saturating_mul(2))
217    }
218
219    /// Snap-line offsets (one per option) that center each option under the
220    /// band. With the leading pad of `pad`, option `i` is centered when the
221    /// scroll offset equals `i * item_height` (the pad cancels with the
222    /// center-mode `viewport/2 - item/2` term).
223    fn snap_lines(&self) -> Vec<i32> {
224        (0..self.options.len() as i32)
225            .map(|i| i * self.item_height as i32)
226            .collect()
227    }
228
229    /// The option index currently nearest the center, given the rendered
230    /// offset. Saturated to the valid range.
231    fn centered_index(&self) -> usize {
232        if self.options.is_empty() {
233            return 0;
234        }
235        let off = scroll_core::render_offset(self.state, ScrollDirection::Vertical).y;
236        let item_h = self.item_height as i32;
237        let raw = (off + item_h / 2).div_euclid(item_h);
238        raw.clamp(0, self.options.len() as i32 - 1) as usize
239    }
240}
241
242impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for Roller<'a, C, M> {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for Roller<'a, C, M> {
249    fn measure(&mut self, constraints: Constraints) -> Size {
250        let w = self
251            .width
252            .resolve(constraints.max.width, constraints.max.width);
253        let h = self.item_height.saturating_mul(self.visible);
254        constraints.clamp(Size::new(w, h))
255    }
256
257    fn preferred_size(&self) -> (Length, Length) {
258        (
259            self.width,
260            Length::Fixed(self.item_height.saturating_mul(self.visible)),
261        )
262    }
263
264    fn arrange(&mut self, rect: Rectangle) {
265        self.rect = rect;
266        self.content_h = self.content_height();
267    }
268
269    fn rect(&self) -> Rectangle {
270        self.rect
271    }
272
273    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
274        if self.options.is_empty() {
275            return None;
276        }
277        let viewport = self.rect;
278        let content = Size::new(self.rect.size.width, self.content_h);
279        let lines = self.snap_lines();
280        let on_scroll = self.on_scroll.as_deref();
281        // The roller has no interactive children; instead the `forward`
282        // closure interprets a tap (an `Up` that never crossed the scroll
283        // threshold) as "select the option the finger landed on" and emits the
284        // `on_select` message for that row. A drag never reaches this branch —
285        // route_touch only forwards `Up` while still `Pressing` — so 1:1
286        // panning and fling are untouched.
287        let off = scroll_core::render_offset(self.state, ScrollDirection::Vertical).y;
288        let band_top = viewport.top_left.y + self.pad();
289        let item_h = self.item_height as i32;
290        let on_select = self.on_select.as_deref();
291        let count = self.options.len();
292        scroll_core::route_touch(
293            self.state,
294            ScrollDirection::Vertical,
295            viewport,
296            content,
297            point,
298            phase,
299            &lines,
300            on_scroll,
301            |p, ph| {
302                if ph != TouchPhase::Up {
303                    return None;
304                }
305                // Convert the tap's y into an option index in content space.
306                let content_y = p.y - band_top + off;
307                if content_y < 0 {
308                    return None;
309                }
310                let idx = (content_y / item_h) as usize;
311                if idx >= count {
312                    return None;
313                }
314                on_select.map(|f| f(idx))
315            },
316        )
317    }
318
319    fn mark_pressed(&mut self, _point: Point) {}
320
321    fn widget_id(&self) -> Option<WidgetId> {
322        self.id
323    }
324
325    fn is_focusable(&self) -> bool {
326        self.id.is_some() && (!self.options.is_empty())
327    }
328
329    fn handle_action(&mut self, action: UiAction) -> Option<M> {
330        match action {
331            UiAction::Activate => self.on_select.as_ref().map(|cb| cb(self.centered_index())),
332            UiAction::Increment
333            | UiAction::Decrement
334            | UiAction::NavigateUp
335            | UiAction::NavigateDown => self.on_action.as_ref().map(|cb| cb(action)),
336            _ => None,
337        }
338    }
339
340    fn sync_focus(&mut self, focused: Option<WidgetId>) {
341        self.focused = self.id.is_some() && self.id == focused;
342    }
343
344    fn focus_at(&self, point: Point) -> Option<WidgetId> {
345        let top_left = self.rect.top_left;
346        let bottom_right =
347            top_left + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
348        if self.is_focusable()
349            && point.x >= top_left.x
350            && point.x < bottom_right.x
351            && point.y >= top_left.y
352            && point.y < bottom_right.y
353        {
354            self.id
355        } else {
356            None
357        }
358    }
359
360    fn draw<'t>(
361        &self,
362        renderer: &mut dyn Renderer<C>,
363        theme: &Theme<'t, C>,
364    ) -> Result<(), RenderError> {
365        let font = self.font.unwrap_or(theme.typography.body);
366        let viewport = self.rect;
367        let off = scroll_core::render_offset(self.state, ScrollDirection::Vertical).y;
368        let item_h = self.item_height as i32;
369        let glyph_h = font.character_size.height as i32;
370        let center_x = viewport.top_left.x + viewport.size.width as i32 / 2;
371        let band_top = viewport.top_left.y + self.pad();
372        let centered = self.centered_index();
373
374        renderer.push_clip(viewport);
375
376        // Center highlight band: a subtle fill behind the centered row, with a
377        // top and bottom rule framing it.
378        let band = Rectangle::new(
379            Point::new(viewport.top_left.x, band_top),
380            Size::new(viewport.size.width, self.item_height),
381        );
382        renderer.fill_rect(band, theme.secondary.base)?;
383        renderer.stroke_line(
384            Point::new(viewport.top_left.x, band_top),
385            Point::new(viewport.top_left.x + viewport.size.width as i32, band_top),
386            theme.accent.base,
387            1,
388        )?;
389        let band_bot = band_top + item_h;
390        renderer.stroke_line(
391            Point::new(viewport.top_left.x, band_bot),
392            Point::new(viewport.top_left.x + viewport.size.width as i32, band_bot),
393            theme.accent.base,
394            1,
395        )?;
396
397        // Each option's row top in screen space: leading pad, minus scroll.
398        for (i, label) in self.options.iter().enumerate() {
399            let row_top = band_top + i as i32 * item_h - off;
400            // Cull rows fully outside the viewport.
401            if row_top + item_h <= viewport.top_left.y
402                || row_top >= viewport.top_left.y + viewport.size.height as i32
403            {
404                continue;
405            }
406            let baseline_y = row_top + item_h / 2 + glyph_h / 3;
407            let color = if i == centered {
408                theme.background.on_base
409            } else {
410                // Non-centered rows use the muted text color.
411                theme.palette.neutral_2
412            };
413            renderer.draw_text(
414                label,
415                Point::new(center_x, baseline_y),
416                font,
417                color,
418                EgAlignment::Center,
419            )?;
420        }
421
422        renderer.pop_clip();
423        if self.focused {
424            renderer.stroke_rect(viewport, theme.accent.base)?;
425        }
426        Ok(())
427    }
428}
429
430impl<'a, C: PixelColor + 'a, M: Clone + 'a> Roller<'a, C, M> {
431    /// The option index currently under the highlight band for a given host
432    /// [`ScrollState`] and `item_height`. The host calls this in its `update()`
433    /// after applying a [`ScrollMsg`] (or after a [`ScrollState::tick`]) to
434    /// learn which option is centered, without rebuilding the widget tree.
435    ///
436    /// `item_height` must match the value configured on the widget (default:
437    /// 36 pixels).
438    #[must_use]
439    pub fn centered_for(state: &ScrollState, item_height: u32, count: usize) -> usize {
440        if count == 0 {
441            return 0;
442        }
443        let off = scroll_core::render_offset(*state, ScrollDirection::Vertical).y;
444        let item_h = item_height.max(1) as i32;
445        let raw = (off + item_h / 2).div_euclid(item_h);
446        raw.clamp(0, count as i32 - 1) as usize
447    }
448
449    /// Build the [`on_select`](Roller::on_select) message for the
450    /// currently-centered option when it differs from `previous`, or whenever
451    /// the drum has settled to rest. Returns `None` if no `on_select` callback
452    /// is set or the selection is unchanged mid-motion.
453    ///
454    /// The host typically calls this from `update()` after applying a
455    /// [`ScrollMsg`]/`tick` so the selection follows the drum as it spins and
456    /// commits when it settles.
457    #[must_use]
458    pub fn select_msg(&self, previous: usize) -> Option<M> {
459        let now = self.centered_index();
460        let settled = self.state.phase == GesturePhase::Idle;
461        if now != previous || settled {
462            self.on_select.as_ref().map(|f| f(now))
463        } else {
464            None
465        }
466    }
467}