Skip to main content

zest_widget/widget/
list.rs

1//! Styled, vertically scrollable list of selectable rows.
2//!
3//! `List` wraps a scrollable [`Column`]: it collects rows added through its
4//! builders, lays them out with consistent padding and optional dividers,
5//! gives each row a pressed highlight, and reports a tapped row index through
6//! [`List::on_select`]. It exposes the same scroll surface as `Column` —
7//! [`List::scrollable`], [`List::scroll_state`], [`List::scrollbar`],
8//! [`List::snap`], and [`List::on_scroll`].
9//!
10//! ## Composition
11//!
12//! Internally the rows are wrapped in [`ListRow`] widgets (padding + pressed
13//! highlight + select callback) and pushed into a [`Column`] configured with
14//! the requested scroll settings. Every `Widget` method delegates to that
15//! inner column, so layout/touch/draw/scroll behavior matches `Column`.
16//!
17//! ## Row colors
18//!
19//! Rows are drawn with `theme.primary` colors: the resting background is
20//! `primary.base`, the pressed highlight is `theme.accent.base`, and an
21//! optional per-row divider uses `primary.divider`. Text leans on the
22//! theme's default font and `primary.on_base`.
23
24use super::{Widget, column::Column};
25use alloc::{boxed::Box, rc::Rc, string::String, vec::Vec};
26use core::marker::PhantomData;
27use embedded_graphics::{
28    pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
29};
30use zest_core::{
31    Constraints, Length, RenderError, Renderer, ScrollDirection, ScrollMsg, ScrollState,
32    ScrollbarMode, SnapMode, TouchPhase, UiAction, WidgetId,
33};
34use zest_theme::Theme;
35
36/// Default height (px) of a list row.
37pub const ROW_HEIGHT: u32 = 44;
38/// Horizontal padding (px) applied inside each row.
39pub const ROW_PADDING_X: u32 = 12;
40/// Gap (px) between a row's leading slot, label, and trailing slot.
41pub const ROW_GAP: u32 = 8;
42
43/// A single styled, selectable list row.
44///
45/// Holds optional leading/trailing text slots (for icon glyphs or accessory
46/// text) plus a main label. Draws a pressed highlight, optional bottom
47/// divider, and emits its owning [`List`]'s `on_select` message — carrying
48/// this row's index — on tap. The press/`mark_pressed` semantics mirror
49/// [`Button`](crate::Button) so drag-off-to-cancel and tap-vs-scroll work.
50pub struct ListRow<'a, C: PixelColor, M: Clone> {
51    rect: Rectangle,
52    id: Option<WidgetId>,
53    index: usize,
54    leading: Option<String>,
55    label: String,
56    trailing: Option<String>,
57    /// Shared select callback owned by the parent `List`.
58    on_select: Option<Rc<dyn Fn(usize) -> M + 'a>>,
59    /// Whether to draw a bottom divider under this row.
60    divider: bool,
61    /// Whether this row should render as selected (host-driven highlight).
62    selected: bool,
63    focused: bool,
64    pressed: bool,
65    width: Length,
66    height: Length,
67    _color: PhantomData<C>,
68}
69
70impl<'a, C: PixelColor, M: Clone> ListRow<'a, C, M> {
71    fn new(index: usize, label: impl Into<String>) -> Self {
72        Self {
73            rect: Rectangle::zero(),
74            id: None,
75            index,
76            leading: None,
77            label: label.into(),
78            trailing: None,
79            on_select: None,
80            divider: false,
81            selected: false,
82            focused: false,
83            pressed: false,
84            width: Length::Fill,
85            height: Length::Fixed(ROW_HEIGHT),
86            _color: PhantomData,
87        }
88    }
89
90    /// True iff a select callback is bound.
91    fn is_enabled(&self) -> bool {
92        self.on_select.is_some()
93    }
94
95    fn hit_test(&self, point: Point) -> bool {
96        let tl = self.rect.top_left;
97        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
98        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
99    }
100}
101
102impl<'a, C: PixelColor, M: Clone> Widget<C, M> for ListRow<'a, C, M> {
103    fn measure(&mut self, constraints: Constraints) -> Size {
104        let w = self
105            .width
106            .resolve(constraints.max.width, constraints.max.width);
107        let h = self.height.resolve(ROW_HEIGHT, constraints.max.height);
108        constraints.clamp(Size::new(w, h))
109    }
110
111    fn preferred_size(&self) -> (Length, Length) {
112        (self.width, self.height)
113    }
114
115    fn arrange(&mut self, rect: Rectangle) {
116        self.rect = rect;
117    }
118
119    fn rect(&self) -> Rectangle {
120        self.rect
121    }
122
123    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
124        if !self.is_enabled() || !self.hit_test(point) {
125            if matches!(phase, TouchPhase::Up | TouchPhase::Moved) {
126                self.pressed = false;
127            }
128            return None;
129        }
130        match phase {
131            TouchPhase::Down => {
132                self.pressed = true;
133                None
134            }
135            TouchPhase::Up => {
136                if self.pressed {
137                    self.pressed = false;
138                    self.on_select.as_ref().map(|cb| cb(self.index))
139                } else {
140                    None
141                }
142            }
143            TouchPhase::Moved => None,
144        }
145    }
146
147    fn mark_pressed(&mut self, point: Point) {
148        if self.is_enabled() && self.hit_test(point) {
149            self.pressed = true;
150        }
151    }
152
153    fn widget_id(&self) -> Option<WidgetId> {
154        self.id
155    }
156
157    fn is_focusable(&self) -> bool {
158        self.id.is_some() && self.is_enabled()
159    }
160
161    fn handle_action(&mut self, action: UiAction) -> Option<M> {
162        if !self.is_enabled() {
163            return None;
164        }
165
166        match action {
167            UiAction::Activate => self.on_select.as_ref().map(|cb| cb(self.index)),
168            _ => None,
169        }
170    }
171
172    fn sync_focus(&mut self, focused: Option<WidgetId>) {
173        self.focused = self.id.is_some() && self.id == focused;
174    }
175
176    fn focus_at(&self, point: Point) -> Option<WidgetId> {
177        if self.is_focusable() && self.hit_test(point) {
178            self.id
179        } else {
180            None
181        }
182    }
183
184    fn draw<'t>(
185        &self,
186        renderer: &mut dyn Renderer<C>,
187        theme: &Theme<'t, C>,
188    ) -> Result<(), RenderError> {
189        let font = theme.default_font();
190        // Background: pressed/selected highlight else the panel base.
191        if self.pressed {
192            renderer.fill_rect(self.rect, theme.accent.pressed)?;
193        } else if self.selected {
194            renderer.fill_rect(self.rect, theme.accent.base)?;
195        } else {
196            renderer.fill_rect(self.rect, theme.primary.base)?;
197        }
198        let border = if self.focused {
199            theme.accent.base
200        } else {
201            theme.primary.divider
202        };
203        renderer.stroke_rect(self.rect, border)?;
204
205        let text_color = if self.pressed || self.selected {
206            theme.accent.on_base
207        } else {
208            theme.primary.on_base
209        };
210
211        let glyph_h = font.character_size.height as i32;
212        let baseline_y = self.rect.top_left.y + self.rect.size.height as i32 / 2 + glyph_h / 3;
213        let left_x = self.rect.top_left.x + ROW_PADDING_X as i32;
214        let right_x = self.rect.top_left.x + self.rect.size.width as i32 - ROW_PADDING_X as i32;
215
216        // Leading slot (e.g. an icon glyph).
217        let mut label_x = left_x;
218        if let Some(leading) = &self.leading {
219            renderer.draw_text(
220                leading,
221                Point::new(left_x, baseline_y),
222                font,
223                text_color,
224                Alignment::Left,
225            )?;
226            let advance = font.character_size.width as i32 * leading.chars().count() as i32;
227            label_x = left_x + advance + ROW_GAP as i32;
228        }
229
230        // Main label.
231        renderer.draw_text(
232            &self.label,
233            Point::new(label_x, baseline_y),
234            font,
235            text_color,
236            Alignment::Left,
237        )?;
238
239        // Trailing slot, right-aligned.
240        if let Some(trailing) = &self.trailing {
241            renderer.draw_text(
242                trailing,
243                Point::new(right_x, baseline_y),
244                font,
245                text_color,
246                Alignment::Right,
247            )?;
248        }
249
250        // Bottom divider.
251        if self.divider {
252            let y = self.rect.top_left.y + self.rect.size.height as i32 - 1;
253            let divider = Rectangle::new(
254                Point::new(self.rect.top_left.x, y),
255                Size::new(self.rect.size.width, 1),
256            );
257            renderer.fill_rect(divider, theme.primary.divider)?;
258        }
259
260        Ok(())
261    }
262}
263
264/// Styled, scrollable vertical list of selectable rows.
265///
266/// Build it with [`List::new`], add rows via [`List::item`],
267/// [`List::item_with`], or [`List::push`], then enable scrolling with the
268/// same builders a [`Column`] exposes. A tapped row emits the
269/// [`List::on_select`] message carrying its zero-based index.
270pub struct List<'a, C: PixelColor, M: Clone> {
271    id: Option<WidgetId>,
272    /// Pending rows, materialized into the inner column at layout time.
273    rows: Vec<ListRow<'a, C, M>>,
274    /// Shared select callback handed to each row.
275    on_select: Option<Rc<dyn Fn(usize) -> M + 'a>>,
276    /// Index of a row to render highlighted (host-driven selection).
277    selected: Option<usize>,
278    /// Whether to draw a divider under every row.
279    dividers: bool,
280    spacing: u32,
281    width: Length,
282    height: Length,
283    // Scroll surface, forwarded verbatim onto the inner column.
284    scroll_dir: Option<ScrollDirection>,
285    scroll_state: Option<ScrollState>,
286    scrollbar: Option<ScrollbarMode>,
287    snap: Option<SnapMode>,
288    on_scroll: Option<Box<dyn Fn(ScrollMsg) -> M + 'a>>,
289    /// The composed scrollable column; built lazily in `arrange`.
290    inner: Option<Column<'a, C, M>>,
291}
292
293impl<'a, C: PixelColor + 'a, M: Clone + 'a> List<'a, C, M> {
294    /// Create a new empty list. Position and size are assigned by the parent
295    /// via `arrange`. Defaults to filling its container with 0 px spacing
296    /// between rows.
297    pub fn new() -> Self {
298        Self {
299            id: None,
300            rows: Vec::new(),
301            on_select: None,
302            selected: None,
303            dividers: false,
304            spacing: 0,
305            width: Length::Fill,
306            height: Length::Fill,
307            scroll_dir: None,
308            scroll_state: None,
309            scrollbar: None,
310            snap: None,
311            on_scroll: None,
312            inner: None,
313        }
314    }
315
316    /// Width sizing intent.
317    #[must_use]
318    pub fn width(mut self, width: impl Into<Length>) -> Self {
319        self.width = width.into();
320        self
321    }
322
323    /// Height sizing intent.
324    #[must_use]
325    pub fn height(mut self, height: impl Into<Length>) -> Self {
326        self.height = height.into();
327        self
328    }
329
330    /// Set a stable base id so rows can participate in focus traversal.
331    #[must_use]
332    pub fn id(mut self, id: WidgetId) -> Self {
333        self.id = Some(id);
334        self
335    }
336
337    /// Gap (px) between rows. Defaults to 0 (rows abut, suited to
338    /// dividers).
339    #[must_use]
340    pub fn spacing(mut self, spacing: u32) -> Self {
341        self.spacing = spacing;
342        self
343    }
344
345    /// Draw a thin divider under every row.
346    #[must_use]
347    pub fn dividers(mut self, on: bool) -> Self {
348        self.dividers = on;
349        self
350    }
351
352    /// Index of the row to render with the selected highlight.
353    /// Host-driven — typically the value the last [`List::on_select`] set.
354    #[must_use]
355    pub fn selected(mut self, index: usize) -> Self {
356        self.selected = Some(index);
357        self
358    }
359
360    /// Callback invoked with a tapped row's index. Without it the
361    /// rows are inert (they still draw, but emit no message).
362    #[must_use]
363    pub fn on_select<F>(mut self, f: F) -> Self
364    where
365        F: Fn(usize) -> M + 'a,
366    {
367        self.on_select = Some(Rc::new(f));
368        self
369    }
370
371    /// Append a row showing a single `label`.
372    #[must_use]
373    pub fn item(mut self, label: impl Into<String>) -> Self {
374        let index = self.rows.len();
375        self.rows.push(ListRow::new(index, label));
376        self
377    }
378
379    /// Append a row with an optional leading slot (e.g. an icon
380    /// glyph), a main `label`, and an optional trailing slot (e.g. an
381    /// accessory glyph or value text). Pass `""`/`None` for slots you don't
382    /// need.
383    #[must_use]
384    pub fn item_with(
385        mut self,
386        leading: Option<impl Into<String>>,
387        label: impl Into<String>,
388        trailing: Option<impl Into<String>>,
389    ) -> Self {
390        let index = self.rows.len();
391        let mut row = ListRow::new(index, label);
392        row.leading = leading.map(Into::into);
393        row.trailing = trailing.map(Into::into);
394        self.rows.push(row);
395        self
396    }
397
398    /// Append a pre-built [`ListRow`]. The row's index is reassigned
399    /// to its position in the list, and the list-wide divider/select settings
400    /// are applied at layout time.
401    #[must_use]
402    pub fn push(mut self, mut row: ListRow<'a, C, M>) -> Self {
403        row.index = self.rows.len();
404        self.rows.push(row);
405        self
406    }
407
408    /// Make this list scrollable on `dir`. Mirrors
409    /// [`Column::scrollable`]. Lists are vertical, so
410    /// [`ScrollDirection::Vertical`] is the usual choice.
411    #[must_use]
412    pub fn scrollable(mut self, dir: ScrollDirection) -> Self {
413        self.scroll_dir = Some(dir);
414        self
415    }
416
417    /// Supply the host-owned [`ScrollState`] read this frame.
418    /// Implies scrolling (vertical by default). Mirrors
419    /// [`Column::scroll_state`].
420    #[must_use]
421    pub fn scroll_state(mut self, state: &ScrollState) -> Self {
422        self.scroll_state = Some(*state);
423        if self.scroll_dir.is_none() {
424            self.scroll_dir = Some(ScrollDirection::Vertical);
425        }
426        self
427    }
428
429    /// When the scrollbar is drawn. Mirrors [`Column::scrollbar`].
430    #[must_use]
431    pub fn scrollbar(mut self, mode: ScrollbarMode) -> Self {
432        self.scrollbar = Some(mode);
433        if self.scroll_dir.is_none() {
434            self.scroll_dir = Some(ScrollDirection::Vertical);
435        }
436        self
437    }
438
439    /// Snapping mode. Mirrors [`Column::snap`].
440    #[must_use]
441    pub fn snap(mut self, mode: SnapMode) -> Self {
442        self.snap = Some(mode);
443        if self.scroll_dir.is_none() {
444            self.scroll_dir = Some(ScrollDirection::Vertical);
445        }
446        self
447    }
448
449    /// Callback mapping a [`ScrollMsg`] to the host message. Mirrors
450    /// [`Column::on_scroll`].
451    #[must_use]
452    pub fn on_scroll<F>(mut self, f: F) -> Self
453    where
454        F: Fn(ScrollMsg) -> M + 'a,
455    {
456        self.on_scroll = Some(Box::new(f));
457        if self.scroll_dir.is_none() {
458            self.scroll_dir = Some(ScrollDirection::Vertical);
459        }
460        self
461    }
462
463    /// Materialize the pending rows into the internal scrollable column,
464    /// applying the list-wide divider/select/selected settings. Consumes the
465    /// row vector, so this is called once per `arrange`.
466    fn build_inner(&mut self) -> Column<'a, C, M> {
467        let mut col = Column::new()
468            .width(self.width)
469            .height(self.height)
470            .spacing(self.spacing);
471
472        // Wire the scroll surface only when scrolling was requested.
473        if let Some(dir) = self.scroll_dir {
474            col = col.scrollable(dir);
475            if let Some(state) = self.scroll_state.as_ref() {
476                col = col.scroll_state(state);
477            }
478            if let Some(bar) = self.scrollbar {
479                col = col.scrollbar(bar);
480            }
481            if let Some(snap) = self.snap {
482                col = col.snap(snap);
483            }
484            if let Some(on_scroll) = self.on_scroll.take() {
485                col = col.on_scroll(move |sm| on_scroll(sm));
486            }
487        }
488
489        let dividers = self.dividers;
490        let selected = self.selected;
491        let id = self.id;
492        let on_select = self.on_select.clone();
493        for mut row in core::mem::take(&mut self.rows) {
494            row.id = id.map(|base| WidgetId::new(base.raw().wrapping_add(row.index as u64 + 1)));
495            row.divider = dividers;
496            row.selected = Some(row.index) == selected;
497            row.on_select = on_select.clone();
498            col = col.push(row);
499        }
500        col
501    }
502}
503
504impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for List<'a, C, M> {
505    fn default() -> Self {
506        Self::new()
507    }
508}
509
510impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for List<'a, C, M> {
511    fn measure(&mut self, constraints: Constraints) -> Size {
512        let w = self
513            .width
514            .resolve(constraints.max.width, constraints.max.width);
515        let h = self
516            .height
517            .resolve(constraints.max.height, constraints.max.height);
518        constraints.clamp(Size::new(w, h))
519    }
520
521    fn preferred_size(&self) -> (Length, Length) {
522        (self.width, self.height)
523    }
524
525    fn arrange(&mut self, rect: Rectangle) {
526        // Rebuild the composed column each frame (rows/scroll state are fresh)
527        // and arrange it into our slot.
528        let mut col = self.build_inner();
529        col.arrange(rect);
530        self.inner = Some(col);
531    }
532
533    fn rect(&self) -> Rectangle {
534        self.inner.as_ref().map_or(Rectangle::zero(), Widget::rect)
535    }
536
537    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
538        self.inner
539            .as_mut()
540            .and_then(|col| col.handle_touch(point, phase))
541    }
542
543    fn mark_pressed(&mut self, point: Point) {
544        if let Some(col) = self.inner.as_mut() {
545            col.mark_pressed(point);
546        }
547    }
548
549    fn draw<'t>(
550        &self,
551        renderer: &mut dyn Renderer<C>,
552        theme: &Theme<'t, C>,
553    ) -> Result<(), RenderError> {
554        if let Some(col) = self.inner.as_ref() {
555            col.draw(renderer, theme)?;
556        }
557        Ok(())
558    }
559}