Skip to main content

zest_widget/widget/
table.rs

1//! Vertically scrollable grid of text cells with an optional header row.
2//!
3//! `Table` lays out borrowed rows of string cells in evenly-sized columns,
4//! draws an optional pinned-looking header (styled with accent colors), and
5//! scrolls the body via the same engine as [`Column`] — exposing the same
6//! scroll surface ([`Table::scrollable`], [`Table::scroll_state`],
7//! [`Table::scrollbar`], [`Table::snap`], [`Table::on_scroll`]).
8//!
9//! ## Data model
10//!
11//! Cell data is borrowed: a table is built from `&'a [&'a [&'a str]]` via
12//! [`Table::rows`], or one row at a time with [`Table::row`] (each row a
13//! `&'a [&'a str]`). Columns are sized evenly across the table width. An
14//! optional header row is supplied with [`Table::header`].
15//!
16//! ## Composition
17//!
18//! The body is a scrollable [`Column`] of [`TableRow`] widgets; the header
19//! (when present) is drawn above the scroll viewport and excluded from it, so
20//! it stays put while the body scrolls. Each body row can report a tapped
21//! `(row, col)` through [`Table::on_select`].
22//!
23//! ## Colors
24//!
25//! Header uses `theme.accent` (base background, `on_base` text). Body rows
26//! alternate `theme.primary.base` and a subtly shaded variant for readability,
27//! a pressed/selected cell uses `theme.accent.pressed`, cell text uses
28//! `theme.primary.on_base`, and grid lines use `theme.primary.divider`.
29
30use super::{Widget, column::Column};
31use alloc::{boxed::Box, rc::Rc, vec::Vec};
32use core::marker::PhantomData;
33use embedded_graphics::{
34    pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
35};
36use zest_core::{
37    Constraints, Length, RenderError, Renderer, ScrollDirection, ScrollMsg, ScrollState,
38    ScrollbarMode, SnapMode, TouchPhase, UiAction, WidgetId,
39};
40use zest_theme::Theme;
41
42/// Default height (px) of a table row (header and body).
43pub const TABLE_ROW_HEIGHT: u32 = 32;
44/// Horizontal padding (px) inside each cell.
45pub const CELL_PADDING_X: u32 = 6;
46
47/// A single table row of borrowed string cells.
48///
49/// Drawn as evenly-spaced cells with vertical grid lines between columns and
50/// a bottom divider. Reports a tapped `(row, col)` through the shared select
51/// callback owned by the parent [`Table`]. Press/`mark_pressed` semantics
52/// mirror [`Button`](crate::Button) for tap-vs-scroll behavior.
53pub struct TableRow<'a, C: PixelColor, M: Clone> {
54    rect: Rectangle,
55    /// This row's index within the table body.
56    row: usize,
57    /// Stable id base for this row's focusable cells.
58    base_id: Option<WidgetId>,
59    /// Borrowed cell strings, one per column.
60    cells: &'a [&'a str],
61    /// Total column count (so short rows still align to the grid).
62    columns: usize,
63    /// Shared select callback owned by the parent table.
64    on_select: Option<Rc<dyn Fn(usize, usize) -> M + 'a>>,
65    /// Whether this row should render with the alternate (zebra) shade.
66    alternate: bool,
67    /// `(row, col)` of a host-selected cell to highlight, if it lies here.
68    selected_col: Option<usize>,
69    /// Column index currently focused.
70    focused_col: Option<usize>,
71    /// Column index currently pressed (set on Down, cleared on Up/cancel).
72    pressed_col: Option<usize>,
73    width: Length,
74    height: Length,
75    _color: PhantomData<C>,
76}
77
78impl<'a, C: PixelColor, M: Clone> TableRow<'a, C, M> {
79    fn new(row: usize, cells: &'a [&'a str], columns: usize) -> Self {
80        Self {
81            rect: Rectangle::zero(),
82            row,
83            base_id: None,
84            cells,
85            columns,
86            on_select: None,
87            alternate: false,
88            selected_col: None,
89            focused_col: None,
90            pressed_col: None,
91            width: Length::Fill,
92            height: Length::Fixed(TABLE_ROW_HEIGHT),
93            _color: PhantomData,
94        }
95    }
96
97    fn is_enabled(&self) -> bool {
98        self.on_select.is_some()
99    }
100
101    /// Width (px) of one column given the row's current width.
102    fn col_width(&self) -> u32 {
103        let cols = self.columns.max(1) as u32;
104        self.rect.size.width / cols
105    }
106
107    /// Column index hit by `point`, if `point` is inside the row.
108    fn col_at(&self, point: Point) -> Option<usize> {
109        let tl = self.rect.top_left;
110        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
111        if point.x < tl.x || point.x >= br.x || point.y < tl.y || point.y >= br.y {
112            return None;
113        }
114        let col_w = self.col_width().max(1) as i32;
115        let col = ((point.x - tl.x) / col_w) as usize;
116        Some(col.min(self.columns.saturating_sub(1)))
117    }
118
119    fn cell_id(&self, col: usize) -> Option<WidgetId> {
120        self.base_id
121            .map(|base| WidgetId::new(base.raw().wrapping_add(col as u64)))
122    }
123}
124
125impl<'a, C: PixelColor, M: Clone> Widget<C, M> for TableRow<'a, C, M> {
126    fn measure(&mut self, constraints: Constraints) -> Size {
127        let w = self
128            .width
129            .resolve(constraints.max.width, constraints.max.width);
130        let h = self
131            .height
132            .resolve(TABLE_ROW_HEIGHT, constraints.max.height);
133        constraints.clamp(Size::new(w, h))
134    }
135
136    fn preferred_size(&self) -> (Length, Length) {
137        (self.width, self.height)
138    }
139
140    fn arrange(&mut self, rect: Rectangle) {
141        self.rect = rect;
142    }
143
144    fn rect(&self) -> Rectangle {
145        self.rect
146    }
147
148    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
149        if !self.is_enabled() {
150            return None;
151        }
152        match phase {
153            TouchPhase::Down => {
154                self.pressed_col = self.col_at(point);
155                None
156            }
157            TouchPhase::Up => {
158                let hit = self.col_at(point);
159                let fired = match (self.pressed_col, hit) {
160                    (Some(p), Some(h)) if p == h => {
161                        self.on_select.as_ref().map(|cb| cb(self.row, h))
162                    }
163                    _ => None,
164                };
165                self.pressed_col = None;
166                fired
167            }
168            TouchPhase::Moved => {
169                if self.col_at(point) != self.pressed_col {
170                    self.pressed_col = None;
171                }
172                None
173            }
174        }
175    }
176
177    fn mark_pressed(&mut self, point: Point) {
178        if self.is_enabled() {
179            self.pressed_col = self.col_at(point);
180        }
181    }
182
183    fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
184        if !self.is_enabled() {
185            return;
186        }
187        for col in 0..self.columns {
188            if let Some(id) = self.cell_id(col) {
189                out.push(id);
190            }
191        }
192    }
193
194    fn sync_focus(&mut self, focused: Option<WidgetId>) {
195        self.focused_col = focused
196            .and_then(|target| (0..self.columns).find(|col| self.cell_id(*col) == Some(target)));
197    }
198
199    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
200        let col = (0..self.columns).find(|candidate| self.cell_id(*candidate) == Some(target))?;
201        match action {
202            UiAction::Activate => self.on_select.as_ref().map(|cb| cb(self.row, col)),
203            _ => None,
204        }
205    }
206
207    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
208        let col = (0..self.columns).find(|candidate| self.cell_id(*candidate) == Some(target))?;
209        let col_w = self.col_width();
210        Some(Rectangle::new(
211            Point::new(
212                self.rect.top_left.x + (col_w * col as u32) as i32,
213                self.rect.top_left.y,
214            ),
215            Size::new(col_w, self.rect.size.height),
216        ))
217    }
218
219    fn focus_at(&self, point: Point) -> Option<WidgetId> {
220        self.col_at(point).and_then(|col| self.cell_id(col))
221    }
222
223    fn draw<'t>(
224        &self,
225        renderer: &mut dyn Renderer<C>,
226        theme: &Theme<'t, C>,
227    ) -> Result<(), RenderError> {
228        let font = theme.default_font();
229        // Row background (zebra striping for readability).
230        let bg = if self.alternate {
231            theme.secondary.base
232        } else {
233            theme.primary.base
234        };
235        renderer.fill_rect(self.rect, bg)?;
236
237        let col_w = self.col_width();
238        let glyph_h = font.character_size.height as i32;
239        let baseline_y = self.rect.top_left.y + self.rect.size.height as i32 / 2 + glyph_h / 3;
240
241        for col in 0..self.columns {
242            let cell_x = self.rect.top_left.x + (col_w * col as u32) as i32;
243            let cell_rect = Rectangle::new(
244                Point::new(cell_x, self.rect.top_left.y),
245                Size::new(col_w, self.rect.size.height),
246            );
247
248            // Pressed/selected cell highlight.
249            let highlighted = self.pressed_col == Some(col) || self.selected_col == Some(col);
250            let text_color = if highlighted {
251                renderer.fill_rect(cell_rect, theme.accent.pressed)?;
252                theme.accent.on_base
253            } else {
254                theme.primary.on_base
255            };
256            if self.focused_col == Some(col) {
257                renderer.stroke_rect(cell_rect, theme.accent.base)?;
258            }
259
260            if let Some(text) = self.cells.get(col) {
261                renderer.draw_text(
262                    text,
263                    Point::new(cell_x + CELL_PADDING_X as i32, baseline_y),
264                    font,
265                    text_color,
266                    Alignment::Left,
267                )?;
268            }
269
270            // Vertical grid line between columns (not before the first).
271            if col > 0 {
272                let line = Rectangle::new(
273                    Point::new(cell_x, self.rect.top_left.y),
274                    Size::new(1, self.rect.size.height),
275                );
276                renderer.fill_rect(line, theme.primary.divider)?;
277            }
278        }
279
280        // Bottom divider.
281        let y = self.rect.top_left.y + self.rect.size.height as i32 - 1;
282        let divider = Rectangle::new(
283            Point::new(self.rect.top_left.x, y),
284            Size::new(self.rect.size.width, 1),
285        );
286        renderer.fill_rect(divider, theme.primary.divider)?;
287
288        Ok(())
289    }
290}
291
292/// Vertically scrollable table of borrowed text cells with an optional header.
293///
294/// Build it with [`Table::new`], set columns/data with [`Table::rows`] or
295/// [`Table::row`], optionally add a [`Table::header`], then enable scrolling
296/// with the same builders [`Column`] exposes. A tapped body cell emits the
297/// [`Table::on_select`] message carrying `(row, col)`.
298pub struct Table<'a, C: PixelColor, M: Clone> {
299    /// Stable base id for body cells.
300    id: Option<WidgetId>,
301    /// Optional header cells (drawn above the scrolling body).
302    header: Option<&'a [&'a str]>,
303    /// Body rows, each a borrowed slice of cell strings.
304    body: Vec<&'a [&'a str]>,
305    /// Column count; defaults to the widest row / header seen.
306    columns: usize,
307    /// Shared select callback handed to each body row.
308    on_select: Option<Rc<dyn Fn(usize, usize) -> M + 'a>>,
309    /// `(row, col)` of the host-selected cell to highlight.
310    selected: Option<(usize, usize)>,
311    /// Whether to zebra-stripe alternating body rows.
312    striped: bool,
313    width: Length,
314    height: Length,
315    // Scroll surface, forwarded onto the inner body column.
316    scroll_dir: Option<ScrollDirection>,
317    scroll_state: Option<ScrollState>,
318    scrollbar: Option<ScrollbarMode>,
319    snap: Option<SnapMode>,
320    on_scroll: Option<Box<dyn Fn(ScrollMsg) -> M + 'a>>,
321    /// Cached arranged rect.
322    rect: Rectangle,
323    /// The composed scrollable body column; built lazily in `arrange`.
324    inner: Option<Column<'a, C, M>>,
325    /// Header rect captured in `arrange` for drawing.
326    header_rect: Rectangle,
327}
328
329impl<'a, C: PixelColor + 'a, M: Clone + 'a> Table<'a, C, M> {
330    /// Create a new empty table. Position and size are assigned by the parent
331    /// via `arrange`.
332    pub fn new() -> Self {
333        Self {
334            header: None,
335            body: Vec::new(),
336            columns: 0,
337            id: None,
338            on_select: None,
339            selected: None,
340            striped: true,
341            width: Length::Fill,
342            height: Length::Fill,
343            scroll_dir: None,
344            scroll_state: None,
345            scrollbar: None,
346            snap: None,
347            on_scroll: None,
348            rect: Rectangle::zero(),
349            inner: None,
350            header_rect: Rectangle::zero(),
351        }
352    }
353
354    /// Width sizing intent.
355    #[must_use]
356    pub fn width(mut self, width: impl Into<Length>) -> Self {
357        self.width = width.into();
358        self
359    }
360
361    /// Height sizing intent.
362    #[must_use]
363    pub fn height(mut self, height: impl Into<Length>) -> Self {
364        self.height = height.into();
365        self
366    }
367
368    /// Set a stable base id so body cells can participate in focus traversal.
369    #[must_use]
370    pub fn id(mut self, id: WidgetId) -> Self {
371        self.id = Some(id);
372        self
373    }
374
375    /// The header row, drawn above (and outside) the scrolling body.
376    #[must_use]
377    pub fn header(mut self, cells: &'a [&'a str]) -> Self {
378        self.columns = self.columns.max(cells.len());
379        self.header = Some(cells);
380        self
381    }
382
383    /// Replace all body rows at once from a borrowed 2-D slice.
384    #[must_use]
385    pub fn rows(mut self, rows: &'a [&'a [&'a str]]) -> Self {
386        self.body.clear();
387        for r in rows {
388            self.columns = self.columns.max(r.len());
389            self.body.push(r);
390        }
391        self
392    }
393
394    /// Append a single body row.
395    #[must_use]
396    pub fn row(mut self, cells: &'a [&'a str]) -> Self {
397        self.columns = self.columns.max(cells.len());
398        self.body.push(cells);
399        self
400    }
401
402    /// Explicit column count. Overrides the auto-derived width;
403    /// useful when some rows are short.
404    #[must_use]
405    pub fn columns(mut self, columns: usize) -> Self {
406        self.columns = columns;
407        self
408    }
409
410    /// Zebra-stripe alternating rows (default `true`).
411    #[must_use]
412    pub fn striped(mut self, on: bool) -> Self {
413        self.striped = on;
414        self
415    }
416
417    /// `(row, col)` of the cell to render highlighted. Host-driven —
418    /// typically the value the last [`Table::on_select`] set.
419    #[must_use]
420    pub fn selected(mut self, row: usize, col: usize) -> Self {
421        self.selected = Some((row, col));
422        self
423    }
424
425    /// Callback invoked with a tapped body cell's `(row, col)`.
426    /// Without it the body cells are inert.
427    #[must_use]
428    pub fn on_select<F>(mut self, f: F) -> Self
429    where
430        F: Fn(usize, usize) -> M + 'a,
431    {
432        self.on_select = Some(Rc::new(f));
433        self
434    }
435
436    /// Make this table scrollable on `dir`. Tables scroll
437    /// vertically, so [`ScrollDirection::Vertical`] is the usual choice.
438    #[must_use]
439    pub fn scrollable(mut self, dir: ScrollDirection) -> Self {
440        self.scroll_dir = Some(dir);
441        self
442    }
443
444    /// Supply the host-owned [`ScrollState`] read this frame.
445    /// Implies scrolling (vertical by default).
446    #[must_use]
447    pub fn scroll_state(mut self, state: &ScrollState) -> Self {
448        self.scroll_state = Some(*state);
449        if self.scroll_dir.is_none() {
450            self.scroll_dir = Some(ScrollDirection::Vertical);
451        }
452        self
453    }
454
455    /// When the scrollbar is drawn.
456    #[must_use]
457    pub fn scrollbar(mut self, mode: ScrollbarMode) -> Self {
458        self.scrollbar = Some(mode);
459        if self.scroll_dir.is_none() {
460            self.scroll_dir = Some(ScrollDirection::Vertical);
461        }
462        self
463    }
464
465    /// Snapping mode.
466    #[must_use]
467    pub fn snap(mut self, mode: SnapMode) -> Self {
468        self.snap = Some(mode);
469        if self.scroll_dir.is_none() {
470            self.scroll_dir = Some(ScrollDirection::Vertical);
471        }
472        self
473    }
474
475    /// Callback mapping a [`ScrollMsg`] to the host message.
476    #[must_use]
477    pub fn on_scroll<F>(mut self, f: F) -> Self
478    where
479        F: Fn(ScrollMsg) -> M + 'a,
480    {
481        self.on_scroll = Some(Box::new(f));
482        if self.scroll_dir.is_none() {
483            self.scroll_dir = Some(ScrollDirection::Vertical);
484        }
485        self
486    }
487
488    /// Build the scrollable body column from the borrowed rows, applying
489    /// striping/select settings.
490    fn build_body(&mut self) -> Column<'a, C, M> {
491        let mut col = Column::new()
492            .width(self.width)
493            .height(Length::Fill)
494            .spacing(0);
495
496        if let Some(dir) = self.scroll_dir {
497            col = col.scrollable(dir);
498            if let Some(state) = self.scroll_state.as_ref() {
499                col = col.scroll_state(state);
500            }
501            if let Some(bar) = self.scrollbar {
502                col = col.scrollbar(bar);
503            }
504            if let Some(snap) = self.snap {
505                col = col.snap(snap);
506            }
507            if let Some(on_scroll) = self.on_scroll.take() {
508                col = col.on_scroll(move |sm| on_scroll(sm));
509            }
510        }
511
512        let columns = self.columns.max(1);
513        let striped = self.striped;
514        let selected = self.selected;
515        let on_select = self.on_select.clone();
516        for (i, cells) in self.body.iter().copied().enumerate() {
517            let mut row = TableRow::new(i, cells, columns);
518            row.base_id = self.row_base_id(i);
519            row.alternate = striped && (i % 2 == 1);
520            row.selected_col = match selected {
521                Some((r, c)) if r == i => Some(c),
522                _ => None,
523            };
524            row.on_select = on_select.clone();
525            col = col.push(row);
526        }
527        col
528    }
529
530    fn row_base_id(&self, row: usize) -> Option<WidgetId> {
531        let columns = self.columns.max(1) as u64;
532        self.id.map(|base| {
533            WidgetId::new(
534                base.raw()
535                    .wrapping_add(1)
536                    .wrapping_add(row as u64 * columns),
537            )
538        })
539    }
540
541    fn cell_id(&self, row: usize, col: usize) -> Option<WidgetId> {
542        self.row_base_id(row)
543            .map(|base| WidgetId::new(base.raw().wrapping_add(col as u64)))
544    }
545
546    fn coords_for(&self, target: WidgetId) -> Option<(usize, usize)> {
547        let columns = self.columns.max(1);
548        for row in 0..self.body.len() {
549            for col in 0..columns {
550                if self.cell_id(row, col) == Some(target) {
551                    return Some((row, col));
552                }
553            }
554        }
555        None
556    }
557}
558
559impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for Table<'a, C, M> {
560    fn default() -> Self {
561        Self::new()
562    }
563}
564
565impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for Table<'a, C, M> {
566    fn measure(&mut self, constraints: Constraints) -> Size {
567        let w = self
568            .width
569            .resolve(constraints.max.width, constraints.max.width);
570        let h = self
571            .height
572            .resolve(constraints.max.height, constraints.max.height);
573        constraints.clamp(Size::new(w, h))
574    }
575
576    fn preferred_size(&self) -> (Length, Length) {
577        (self.width, self.height)
578    }
579
580    fn arrange(&mut self, rect: Rectangle) {
581        self.rect = rect;
582        // Reserve a header strip at the top; the body fills the remainder.
583        let header_h = if self.header.is_some() {
584            TABLE_ROW_HEIGHT.min(rect.size.height)
585        } else {
586            0
587        };
588        self.header_rect = Rectangle::new(rect.top_left, Size::new(rect.size.width, header_h));
589        let body_rect = Rectangle::new(
590            Point::new(rect.top_left.x, rect.top_left.y + header_h as i32),
591            Size::new(rect.size.width, rect.size.height.saturating_sub(header_h)),
592        );
593
594        let mut body = self.build_body();
595        body.arrange(body_rect);
596        self.inner = Some(body);
597    }
598
599    fn rect(&self) -> Rectangle {
600        self.rect
601    }
602
603    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
604        self.inner
605            .as_mut()
606            .and_then(|body| body.handle_touch(point, phase))
607    }
608
609    fn mark_pressed(&mut self, point: Point) {
610        if let Some(body) = self.inner.as_mut() {
611            body.mark_pressed(point);
612        }
613    }
614
615    fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
616        if let Some(body) = self.inner.as_ref() {
617            body.collect_focusable(out);
618        }
619    }
620
621    fn sync_focus(&mut self, focused: Option<WidgetId>) {
622        if let Some(body) = self.inner.as_mut() {
623            body.sync_focus(focused);
624        }
625    }
626
627    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
628        self.inner
629            .as_mut()
630            .and_then(|body| body.route_action(target, action))
631    }
632
633    fn navigate_focus(&self, target: WidgetId, action: UiAction) -> Option<WidgetId> {
634        let (row, col) = self.coords_for(target)?;
635        let columns = self.columns.max(1);
636        let (next_row, next_col) = match action {
637            UiAction::NavigateLeft => (row, col.saturating_sub(1)),
638            UiAction::NavigateRight => (row, (col + 1).min(columns.saturating_sub(1))),
639            UiAction::NavigateUp => (row.saturating_sub(1), col),
640            UiAction::NavigateDown => ((row + 1).min(self.body.len().saturating_sub(1)), col),
641            _ => return None,
642        };
643        self.cell_id(next_row, next_col)
644    }
645
646    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
647        self.inner.as_ref().and_then(|body| body.focus_rect(target))
648    }
649
650    fn focus_at(&self, point: Point) -> Option<WidgetId> {
651        self.inner.as_ref().and_then(|body| body.focus_at(point))
652    }
653
654    fn draw<'t>(
655        &self,
656        renderer: &mut dyn Renderer<C>,
657        theme: &Theme<'t, C>,
658    ) -> Result<(), RenderError> {
659        // Body first (scrolls under the header strip).
660        if let Some(body) = self.inner.as_ref() {
661            body.draw(renderer, theme)?;
662        }
663
664        // Header on top, so the scrolling body never overlaps it.
665        if let Some(cells) = self.header {
666            let r = self.header_rect;
667            renderer.fill_rect(r, theme.accent.base)?;
668            let font = theme.default_font();
669            let cols = self.columns.max(1) as u32;
670            let col_w = r.size.width / cols;
671            let glyph_h = font.character_size.height as i32;
672            let baseline_y = r.top_left.y + r.size.height as i32 / 2 + glyph_h / 3;
673            for (col, text) in cells.iter().enumerate() {
674                let cell_x = r.top_left.x + (col_w * col as u32) as i32;
675                renderer.draw_text(
676                    text,
677                    Point::new(cell_x + CELL_PADDING_X as i32, baseline_y),
678                    font,
679                    theme.accent.on_base,
680                    Alignment::Left,
681                )?;
682                if col > 0 {
683                    let line = Rectangle::new(
684                        Point::new(cell_x, r.top_left.y),
685                        Size::new(1, r.size.height),
686                    );
687                    renderer.fill_rect(line, theme.accent.border)?;
688                }
689            }
690            // Bottom edge of the header.
691            let y = r.top_left.y + r.size.height as i32 - 1;
692            let edge = Rectangle::new(Point::new(r.top_left.x, y), Size::new(r.size.width, 1));
693            renderer.fill_rect(edge, theme.accent.border)?;
694        }
695
696        Ok(())
697    }
698}