Skip to main content

zest_widget/widget/
row.rs

1//! Horizontal layout container. Mirror of `Column` on the width axis.
2//! Children declare their slot intent via `.width(Length::...)`, e.g.
3//! `Length::FillPortion(n)` for weighted distribution.
4//!
5//! ## Scrolling
6//!
7//! A `Row` becomes scrollable via [`Row::scrollable`] plus
8//! [`Row::scroll_state`]. The host owns a [`ScrollState`] (because widgets
9//! are transient) and passes it by reference each frame; the row reads it
10//! during layout/draw and emits [`ScrollMsg`] through [`Row::on_scroll`].
11//! See [`scroll_core`] for the shared engine. When no
12//! scroll is configured the layout/touch/draw paths are identical to a plain
13//! `Row`.
14
15use super::{Widget, element::Element, scroll_core};
16use alloc::{boxed::Box, vec::Vec};
17use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
18use zest_core::{
19    Constraints, Length, RenderError, Renderer, ScrollDirection, ScrollMsg, ScrollState,
20    ScrollbarMode, SnapMode, TouchPhase, UiAction, WidgetId,
21};
22use zest_theme::Theme;
23
24/// Per-row scroll configuration (present only when scrolling is enabled).
25struct ScrollCore<'a, M> {
26    /// Host-owned scroll state, read each frame.
27    state: ScrollState,
28    /// Which axes scroll.
29    dir: ScrollDirection,
30    /// When the scrollbar is shown.
31    bar: ScrollbarMode,
32    /// How scrolling settles to child boundaries.
33    snap: SnapMode,
34    /// Callback turning a [`ScrollMsg`] into the host message.
35    on_scroll: Option<Box<dyn Fn(ScrollMsg) -> M + 'a>>,
36}
37
38/// Horizontal stack of widgets.
39pub struct Row<'a, C: PixelColor, M: Clone> {
40    rect: Rectangle,
41    children: Vec<Element<'a, C, M>>,
42    spacing: u32,
43    width: Length,
44    height: Length,
45    scroll: Option<ScrollCore<'a, M>>,
46    /// Measured total content width (only meaningful when scrollable).
47    content_w: u32,
48    /// Un-scrolled child top-left positions, captured for snap lines.
49    child_origins: Vec<Point>,
50    /// Un-scrolled child sizes, captured for snap lines.
51    child_sizes: Vec<Size>,
52}
53
54impl<'a, C: PixelColor + 'a, M: Clone + 'a> Row<'a, C, M> {
55    /// Create a new empty row. Position and size are assigned by the
56    /// parent via `arrange`.
57    pub fn new() -> Self {
58        Self {
59            rect: Rectangle::zero(),
60            children: Vec::new(),
61            spacing: 0,
62            width: Length::Fill,
63            height: Length::Fill,
64            scroll: None,
65            content_w: 0,
66            child_origins: Vec::new(),
67            child_sizes: Vec::new(),
68        }
69    }
70
71    /// Gap between children.
72    #[must_use]
73    pub fn spacing(mut self, spacing: u32) -> Self {
74        self.spacing = spacing;
75        self
76    }
77
78    /// Width sizing intent.
79    #[must_use]
80    pub fn width(mut self, width: impl Into<Length>) -> Self {
81        self.width = width.into();
82        self
83    }
84
85    /// Height sizing intent.
86    #[must_use]
87    pub fn height(mut self, height: impl Into<Length>) -> Self {
88        self.height = height.into();
89        self
90    }
91
92    /// Add a child.
93    #[must_use]
94    pub fn push<W>(mut self, child: W) -> Self
95    where
96        W: Widget<C, M> + 'a,
97    {
98        self.children.push(Element::new(child));
99        self
100    }
101
102    /// Make this row scrollable on `dir`. Defaults the scrollbar
103    /// to [`ScrollbarMode::Auto`] and no snapping. Pair with
104    /// [`Row::scroll_state`] to supply the host's [`ScrollState`].
105    #[must_use]
106    pub fn scrollable(mut self, dir: ScrollDirection) -> Self {
107        let core = self.scroll.get_or_insert(ScrollCore {
108            state: ScrollState::new(),
109            dir,
110            bar: ScrollbarMode::Auto,
111            snap: SnapMode::None,
112            on_scroll: None,
113        });
114        core.dir = dir;
115        self
116    }
117
118    /// Supply the host-owned [`ScrollState`] read this frame.
119    /// Implies scrolling (defaults to [`ScrollDirection::Horizontal`] if
120    /// [`Row::scrollable`] was not called first).
121    #[must_use]
122    pub fn scroll_state(mut self, state: &ScrollState) -> Self {
123        let core = self.scroll.get_or_insert(ScrollCore {
124            state: *state,
125            dir: ScrollDirection::Horizontal,
126            bar: ScrollbarMode::Auto,
127            snap: SnapMode::None,
128            on_scroll: None,
129        });
130        core.state = *state;
131        self
132    }
133
134    /// When the scrollbar is drawn. Implies scrolling.
135    #[must_use]
136    pub fn scrollbar(mut self, mode: ScrollbarMode) -> Self {
137        let core = self.scroll.get_or_insert(ScrollCore {
138            state: ScrollState::new(),
139            dir: ScrollDirection::Horizontal,
140            bar: mode,
141            snap: SnapMode::None,
142            on_scroll: None,
143        });
144        core.bar = mode;
145        self
146    }
147
148    /// Snapping mode. Implies scrolling.
149    #[must_use]
150    pub fn snap(mut self, mode: SnapMode) -> Self {
151        let core = self.scroll.get_or_insert(ScrollCore {
152            state: ScrollState::new(),
153            dir: ScrollDirection::Horizontal,
154            bar: ScrollbarMode::Auto,
155            snap: mode,
156            on_scroll: None,
157        });
158        core.snap = mode;
159        self
160    }
161
162    /// Callback mapping a [`ScrollMsg`] to the host message. Implies
163    /// scrolling.
164    #[must_use]
165    pub fn on_scroll<F>(mut self, f: F) -> Self
166    where
167        F: Fn(ScrollMsg) -> M + 'a,
168    {
169        let core = self.scroll.get_or_insert(ScrollCore {
170            state: ScrollState::new(),
171            dir: ScrollDirection::Horizontal,
172            bar: ScrollbarMode::Auto,
173            snap: SnapMode::None,
174            on_scroll: None,
175        });
176        core.on_scroll = Some(Box::new(f));
177        self
178    }
179
180    /// Snap-line candidates for the current layout, in offset space. Empty
181    /// when not snapping.
182    fn snap_lines(&self) -> Vec<i32> {
183        match &self.scroll {
184            Some(core) if core.snap != SnapMode::None => {
185                let rects: Vec<Rectangle> = self
186                    .child_origins
187                    .iter()
188                    .zip(self.child_sizes.iter())
189                    .map(|(p, s)| Rectangle::new(*p, *s))
190                    .collect();
191                let offset = scroll_core::render_offset(core.state, core.dir);
192                scroll_core::snap_lines(
193                    &rects,
194                    self.rect.top_left,
195                    offset,
196                    self.rect.size,
197                    core.dir,
198                    core.snap,
199                )
200            }
201            _ => Vec::new(),
202        }
203    }
204
205    // ---- non-scrolling layout ------
206
207    fn relayout(&mut self) {
208        let n = self.children.len();
209        if n == 0 {
210            return;
211        }
212        let avail_h = self.rect.size.height;
213        let total_spacing = self.spacing.saturating_mul(n as u32 - 1);
214        let avail_w = self.rect.size.width.saturating_sub(total_spacing);
215
216        let cross = Constraints::loose(Size::new(avail_w, avail_h));
217        let mut widths: Vec<u32> = Vec::with_capacity(n);
218        let mut fixed_total: u32 = 0;
219        let mut shrink_total: u32 = 0;
220        let mut portion_total: u32 = 0;
221
222        for child in &mut self.children {
223            let (w_intent, _) = child.preferred_size();
224            let w = match w_intent {
225                Length::Fixed(px) => {
226                    fixed_total = fixed_total.saturating_add(px);
227                    px
228                }
229                Length::Shrink => {
230                    let m = child.measure(cross).width;
231                    shrink_total = shrink_total.saturating_add(m);
232                    m
233                }
234                Length::Fill | Length::FillPortion(_) => {
235                    portion_total = portion_total.saturating_add(w_intent.portion());
236                    0
237                }
238            };
239            widths.push(w);
240        }
241
242        let consumed = fixed_total.saturating_add(shrink_total);
243        let remaining = avail_w.saturating_sub(consumed);
244        if portion_total > 0 {
245            let unit = remaining / portion_total;
246            for (child, w) in self.children.iter_mut().zip(widths.iter_mut()) {
247                let (w_intent, _) = child.preferred_size();
248                if w_intent == Length::Fill || matches!(w_intent, Length::FillPortion(_)) {
249                    *w = unit.saturating_mul(w_intent.portion());
250                }
251            }
252        }
253
254        let mut x = self.rect.top_left.x;
255        for (child, w) in self.children.iter_mut().zip(widths.iter()) {
256            let cell = Rectangle::new(Point::new(x, self.rect.top_left.y), Size::new(*w, avail_h));
257            child.arrange(cell);
258            x += *w as i32 + self.spacing as i32;
259        }
260    }
261
262    // ---- scrolling layout ---------------------------------------------
263
264    fn relayout_scroll(&mut self, dir: ScrollDirection) {
265        let n = self.children.len();
266        self.child_origins.clear();
267        self.child_sizes.clear();
268        if n == 0 {
269            self.content_w = 0;
270            return;
271        }
272        let scrolls_x = dir.scrolls_x();
273        // The scrollbar overlays the content edge (drawn after pop_clip),
274        // LVGL-style, so children use the full viewport height.
275        let avail_h = self.rect.size.height;
276        let spacing = self.spacing;
277
278        // On the scroll axis, measure each child against UNBOUNDED so the
279        // row learns its intrinsic content extent (mirrors scrollable.rs).
280        let measure_w = if scrolls_x {
281            zest_core::UNBOUNDED
282        } else {
283            self.rect
284                .size
285                .width
286                .saturating_sub(spacing.saturating_mul(n as u32 - 1))
287        };
288        let cross = Constraints::loose(Size::new(measure_w, avail_h));
289
290        let mut widths: Vec<u32> = Vec::with_capacity(n);
291        for child in &mut self.children {
292            let (w_intent, _) = child.preferred_size();
293            let w = match w_intent {
294                Length::Fixed(px) => px,
295                _ => child.measure(cross).width,
296            };
297            widths.push(w);
298        }
299
300        let total_spacing = spacing.saturating_mul(n as u32 - 1);
301        let content_w: u32 = widths
302            .iter()
303            .copied()
304            .sum::<u32>()
305            .saturating_add(total_spacing);
306        self.content_w = content_w;
307
308        // Resolve the render offset from the host-owned state.
309        let off = self
310            .scroll
311            .as_ref()
312            .map_or(Point::zero(), |c| scroll_core::render_offset(c.state, dir));
313
314        let mut x = self.rect.top_left.x - off.x;
315        let y = self.rect.top_left.y - off.y;
316        for (child, w) in self.children.iter_mut().zip(widths.iter()) {
317            let origin = Point::new(x, y);
318            let size = Size::new(*w, avail_h);
319            child.arrange(Rectangle::new(origin, size));
320            self.child_origins.push(origin);
321            self.child_sizes.push(size);
322            x += *w as i32 + spacing as i32;
323        }
324    }
325}
326
327impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for Row<'a, C, M> {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for Row<'a, C, M> {
334    fn measure(&mut self, constraints: Constraints) -> Size {
335        let w = self
336            .width
337            .resolve(constraints.max.width, constraints.max.width);
338        let h = self
339            .height
340            .resolve(constraints.max.height, constraints.max.height);
341        constraints.clamp(Size::new(w, h))
342    }
343
344    fn preferred_size(&self) -> (Length, Length) {
345        (self.width, self.height)
346    }
347
348    fn arrange(&mut self, rect: Rectangle) {
349        self.rect = rect;
350        match self.scroll.as_ref().map(|c| c.dir) {
351            Some(dir) if dir != ScrollDirection::None => self.relayout_scroll(dir),
352            _ => self.relayout(),
353        }
354    }
355
356    fn rect(&self) -> Rectangle {
357        self.rect
358    }
359
360    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
361        match self.scroll.as_ref() {
362            Some(core) if core.dir != ScrollDirection::None => {
363                let dir = core.dir;
364                let state = core.state;
365                let viewport = self.rect;
366                let content = Size::new(self.content_w, self.rect.size.height);
367                let lines = self.snap_lines();
368                // Take the callback out so the closure can borrow children
369                // mutably without aliasing `self.scroll`.
370                let on_scroll = self.scroll.as_ref().and_then(|c| c.on_scroll.as_deref());
371                let children = &mut self.children;
372                scroll_core::route_touch(
373                    state,
374                    dir,
375                    viewport,
376                    content,
377                    point,
378                    phase,
379                    &lines,
380                    on_scroll,
381                    |p, ph| {
382                        for child in children.iter_mut().rev() {
383                            if let Some(msg) = child.handle_touch(p, ph) {
384                                return Some(msg);
385                            }
386                        }
387                        None
388                    },
389                )
390            }
391            _ => {
392                for child in self.children.iter_mut().rev() {
393                    if let Some(msg) = child.handle_touch(point, phase) {
394                        return Some(msg);
395                    }
396                }
397                None
398            }
399        }
400    }
401
402    fn mark_pressed(&mut self, point: Point) {
403        // While dragging/flinging/springing, stop re-asserting a child press
404        // so any button highlighted on Down is cancelled mid-drag.
405        if let Some(core) = self.scroll.as_ref() {
406            if matches!(
407                core.state.phase,
408                zest_core::GesturePhase::Dragging
409                    | zest_core::GesturePhase::Flinging
410                    | zest_core::GesturePhase::Springing
411            ) {
412                return;
413            }
414        }
415        for child in &mut self.children {
416            child.mark_pressed(point);
417        }
418    }
419
420    fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
421        for child in &self.children {
422            child.collect_focusable(out);
423        }
424    }
425
426    fn sync_focus(&mut self, focused: Option<WidgetId>) {
427        for child in &mut self.children {
428            child.sync_focus(focused);
429        }
430    }
431
432    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
433        for child in &mut self.children {
434            if let Some(msg) = child.route_action(target, action) {
435                return Some(msg);
436            }
437        }
438        None
439    }
440
441    fn navigate_focus(&self, target: WidgetId, action: UiAction) -> Option<WidgetId> {
442        for child in &self.children {
443            if let Some(next) = child.navigate_focus(target, action) {
444                return Some(next);
445            }
446        }
447        None
448    }
449
450    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
451        for child in &self.children {
452            if let Some(rect) = child.focus_rect(target) {
453                return Some(rect);
454            }
455        }
456        None
457    }
458
459    fn focus_at(&self, point: Point) -> Option<WidgetId> {
460        for child in self.children.iter().rev() {
461            if let Some(id) = child.focus_at(point) {
462                return Some(id);
463            }
464        }
465        None
466    }
467
468    fn draw<'t>(
469        &self,
470        renderer: &mut dyn Renderer<C>,
471        theme: &Theme<'t, C>,
472    ) -> Result<(), RenderError> {
473        match self.scroll.as_ref() {
474            Some(core) if core.dir != ScrollDirection::None => {
475                let viewport = self.rect;
476                renderer.push_clip(viewport);
477                for child in &self.children {
478                    child.draw(renderer, theme)?;
479                }
480                renderer.pop_clip();
481                let content = Size::new(self.content_w, self.rect.size.height);
482                scroll_core::draw_scrollbars(
483                    renderer, theme, core.state, core.bar, core.dir, viewport, content,
484                )?;
485                Ok(())
486            }
487            _ => {
488                for child in &self.children {
489                    child.draw(renderer, theme)?;
490                }
491                Ok(())
492            }
493        }
494    }
495}