Skip to main content

zest_widget/widget/
column.rs

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