Skip to main content

agg_gui/widgets/
flex.rs

1//! Flex layout widgets: `FlexColumn` (vertical) and `FlexRow` (horizontal).
2//!
3//! # Y-up layout convention
4//!
5//! `FlexColumn` stacks children **top to bottom** visually, which in Y-up
6//! coordinates means the *first* child gets the *highest* Y values. The layout
7//! cursor starts at the top of the available area and moves downward.
8//!
9//! `FlexRow` stacks children **left to right**, as expected.
10//!
11//! # Flex algorithm
12//!
13//! Each child has a `flex` factor (stored in a parallel `Vec<f64>`):
14//! - `flex = 0.0` → "fixed": the child is laid out at its natural size on
15//!   the main axis.
16//! - `flex > 0.0` → "growing": the child receives a proportional share of
17//!   the remaining space after all fixed children are measured.
18//!
19//! Children with equal `flex` values split remaining space equally.
20//!
21//! # Child margin support
22//!
23//! Each child's `margin()` (logical units — DPI is applied once at the App
24//! paint boundary) contributes to the slot size on the main axis and is
25//! respected for cross-axis placement.
26//! Margins are **additive** — child A's `margin.top` and child B's
27//! `margin.bottom` both contribute gap space between those children (in
28//! addition to `self.gap`).
29//!
30//! # Cross-axis anchoring
31//!
32//! `FlexColumn` reads each child's `h_anchor()` to place it horizontally
33//! within the column's inner width.  `FlexRow` reads `v_anchor()` to place
34//! children vertically within the row's inner height.
35
36use crate::color::Color;
37use crate::draw_ctx::DrawCtx;
38use crate::event::{Event, EventResult};
39use crate::geometry::{Rect, Size};
40use crate::layout_props::{resolve_fit_or_stretch, HAnchor, Insets, VAnchor, WidgetBase};
41use crate::widget::Widget;
42
43// ---------------------------------------------------------------------------
44// Cross-axis placement helpers
45// ---------------------------------------------------------------------------
46
47/// Compute `(x, actual_width)` for a child in a `FlexColumn` (horizontal
48/// cross-axis placement).
49///
50/// - `pad_l`     — column's left inner-padding offset.
51/// - `inner_w`   — column's usable width (after padding, before margins).
52/// - `margin_l/r` — child's left/right margins (logical units).
53/// - `natural_w` — width returned by `child.layout()`.
54/// - `min_w/max_w` — child's min/max width constraints.
55fn place_cross_h(
56    anchor: HAnchor,
57    pad_l: f64,
58    inner_w: f64,
59    margin_l: f64,
60    margin_r: f64,
61    natural_w: f64,
62    min_w: f64,
63    max_w: f64,
64) -> (f64, f64) {
65    let slot_w = (inner_w - margin_l - margin_r).max(0.0);
66
67    // Determine width.
68    let actual_w = if anchor.is_stretch() {
69        // LEFT | RIGHT → fill slot
70        slot_w.clamp(min_w, max_w)
71    } else if anchor == HAnchor::MAX_FIT_OR_STRETCH {
72        resolve_fit_or_stretch(natural_w, slot_w, true).clamp(min_w, max_w)
73    } else if anchor == HAnchor::MIN_FIT_OR_STRETCH {
74        resolve_fit_or_stretch(natural_w, slot_w, false).clamp(min_w, max_w)
75    } else {
76        // FIT, LEFT, RIGHT, CENTER, ABSOLUTE — use natural width.
77        natural_w.clamp(min_w, max_w)
78    };
79
80    // Determine x position.
81    let x = if anchor.contains(HAnchor::RIGHT) && !anchor.contains(HAnchor::LEFT) {
82        // RIGHT only (not stretch): right-align within margin slot.
83        (pad_l + inner_w - margin_r - actual_w).max(pad_l)
84    } else if anchor.contains(HAnchor::CENTER) && !anchor.is_stretch() {
85        // CENTER: center within margin slot.
86        pad_l + margin_l + (slot_w - actual_w) * 0.5
87    } else {
88        // LEFT, STRETCH, FIT, ABSOLUTE, MIN/MAX_FIT_OR_STRETCH — left-align.
89        pad_l + margin_l
90    };
91
92    (x, actual_w)
93}
94
95// ---------------------------------------------------------------------------
96// FlexColumn
97// ---------------------------------------------------------------------------
98
99/// Stacks children top-to-bottom (first child = visually topmost).
100pub struct FlexColumn {
101    bounds: Rect,
102    children: Vec<Box<dyn Widget>>,
103    /// Parallel to `children`. 0.0 = fixed; >0 = flex fraction.
104    flex_factors: Vec<f64>,
105    base: WidgetBase,
106    pub gap: f64,
107    pub inner_padding: Insets,
108    pub background: Color,
109    /// When `true`, paint background using `ctx.visuals().panel_fill`
110    /// regardless of the stored `background` colour.
111    pub use_panel_bg: bool,
112    /// When `true`, `layout` reports the column's natural content
113    /// width (max over children, + horizontal padding) instead of the
114    /// full `available.width`.  Used by auto-sized ancestors that
115    /// want the column to shrink-to-content rather than stretch.
116    /// Off by default for backward compatibility.
117    pub fit_width: bool,
118    /// When `true`, children are anchored to the TOP of the column's
119    /// inner area, with any extra height appearing as whitespace at
120    /// the BOTTOM.  Off by default — legacy callers (e.g. ScrollView
121    /// content) rely on the natural-anchored layout where children
122    /// occupy the BOTTOM of their slot when oversized.
123    pub top_anchor: bool,
124}
125
126impl FlexColumn {
127    pub fn new() -> Self {
128        Self {
129            bounds: Rect::default(),
130            children: Vec::new(),
131            flex_factors: Vec::new(),
132            base: WidgetBase::new(),
133            gap: 0.0,
134            inner_padding: Insets::ZERO,
135            background: Color::rgba(0.0, 0.0, 0.0, 0.0),
136            use_panel_bg: false,
137            fit_width: false,
138            top_anchor: false,
139        }
140    }
141
142    pub fn with_gap(mut self, gap: f64) -> Self {
143        self.gap = gap;
144        self
145    }
146    pub fn with_padding(mut self, p: f64) -> Self {
147        self.inner_padding = Insets::all(p);
148        self
149    }
150    pub fn with_inner_padding(mut self, p: Insets) -> Self {
151        self.inner_padding = p;
152        self
153    }
154    pub fn with_background(mut self, c: Color) -> Self {
155        self.background = c;
156        self
157    }
158    /// Use `ctx.visuals().panel_fill` as background instead of the stored color.
159    pub fn with_panel_bg(mut self) -> Self {
160        self.use_panel_bg = true;
161        self
162    }
163
164    /// Opt into content-fit width — `layout` reports the widest
165    /// child's natural width (+ horizontal padding) instead of the
166    /// full available width.  Required when this column is the
167    /// content of an auto-sized `Window`; without it, wrapped Labels
168    /// claim the full available width and the window grows to the
169    /// canvas.  Matches egui's per-column shrink-to-content option.
170    pub fn with_fit_width(mut self, fit: bool) -> Self {
171        self.fit_width = fit;
172        self
173    }
174
175    /// Anchor children to the TOP of the inner area rather than the
176    /// bottom of the natural content extent.  Default is bottom (the
177    /// classic Y-up "natural-anchored" placement) so callers like
178    /// `ScrollView` whose layout pass uses `available.height ≈ ∞`
179    /// keep working — they need cursor_y to be derived from natural
180    /// extent, not from the supplied (huge) available.  Opt in for
181    /// containers placed inside a `Resize` widget or other oversized
182    /// slot where you want the visible content to start at the top
183    /// of the frame and any extra space to appear below.
184    pub fn with_top_anchor(mut self, on: bool) -> Self {
185        self.top_anchor = on;
186        self
187    }
188
189    pub fn with_margin(mut self, m: Insets) -> Self {
190        self.base.margin = m;
191        self
192    }
193    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
194        self.base.h_anchor = h;
195        self
196    }
197    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
198        self.base.v_anchor = v;
199        self
200    }
201    pub fn with_min_size(mut self, s: Size) -> Self {
202        self.base.min_size = s;
203        self
204    }
205    pub fn with_max_size(mut self, s: Size) -> Self {
206        self.base.max_size = s;
207        self
208    }
209
210    /// Add a fixed-size child (flex = 0).
211    pub fn add(mut self, child: Box<dyn Widget>) -> Self {
212        self.children.push(child);
213        self.flex_factors.push(0.0);
214        self
215    }
216
217    /// Add a flex child that expands proportionally.
218    pub fn add_flex(mut self, child: Box<dyn Widget>, flex: f64) -> Self {
219        self.children.push(child);
220        self.flex_factors.push(flex.max(0.0));
221        self
222    }
223
224    /// Push a child directly (for use without builder chaining).
225    pub fn push(&mut self, child: Box<dyn Widget>, flex: f64) {
226        self.children.push(child);
227        self.flex_factors.push(flex.max(0.0));
228    }
229}
230
231impl Default for FlexColumn {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237impl Widget for FlexColumn {
238    fn type_name(&self) -> &'static str {
239        "FlexColumn"
240    }
241    fn bounds(&self) -> Rect {
242        self.bounds
243    }
244    fn set_bounds(&mut self, b: Rect) {
245        self.bounds = b;
246    }
247    fn children(&self) -> &[Box<dyn Widget>] {
248        &self.children
249    }
250    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
251        &mut self.children
252    }
253
254    fn margin(&self) -> Insets {
255        self.base.margin
256    }
257    fn widget_base(&self) -> Option<&WidgetBase> {
258        Some(&self.base)
259    }
260    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
261        Some(&mut self.base)
262    }
263    fn padding(&self) -> Insets {
264        self.inner_padding
265    }
266    fn h_anchor(&self) -> HAnchor {
267        self.base.h_anchor
268    }
269    fn v_anchor(&self) -> VAnchor {
270        self.base.v_anchor
271    }
272    fn min_size(&self) -> Size {
273        self.base.min_size
274    }
275    fn max_size(&self) -> Size {
276        self.base.max_size
277    }
278
279    fn measure_min_height(&self, available_w: f64) -> f64 {
280        // Sum each child's required height (recursing through any
281        // FlexColumn / TextArea / Container chains) plus our own
282        // padding and inter-child gaps.  Used by ancestor
283        // `Window::tight_content_fit` to compute a content-bound
284        // height even when one of our children is a flex-fill widget
285        // whose `layout` would just return the available slot.
286        let pad_l = self.inner_padding.left;
287        let pad_r = self.inner_padding.right;
288        let pad_t = self.inner_padding.top;
289        let pad_b = self.inner_padding.bottom;
290        let inner_w = (available_w - pad_l - pad_r).max(0.0);
291        let mut total = 0.0_f64;
292        let mut visible_n = 0_usize;
293        for child in self.children.iter() {
294            // Hidden slots (collapsed `Conditional`s, self-hiding widgets)
295            // consume no height, margin, or gap.
296            if !child.is_visible() {
297                continue;
298            }
299            visible_n += 1;
300            let m = child.margin();
301            let slot_w = (inner_w - m.left - m.right).max(0.0);
302            total += child.measure_min_height(slot_w) + m.vertical();
303        }
304        total += pad_t + pad_b;
305        if visible_n > 1 {
306            total += self.gap * (visible_n - 1) as f64;
307        }
308        total.max(self.base.min_size.height)
309    }
310
311    fn layout(&mut self, available: Size) -> Size {
312        let pad_l = self.inner_padding.left;
313        let pad_r = self.inner_padding.right;
314        let pad_t = self.inner_padding.top;
315        let pad_b = self.inner_padding.bottom;
316        let gap = self.gap;
317        let n = self.children.len();
318        if n == 0 {
319            return available;
320        }
321
322        let inner_w = (available.width - pad_l - pad_r).max(0.0);
323        let inner_h = (available.height - pad_t - pad_b).max(0.0);
324
325        // Child margins (logical units end-to-end; DPI applied at paint).
326        let margins: Vec<Insets> = self.children.iter().map(|c| c.margin()).collect();
327
328        // -------------------------------------------------------------------
329        // Step 1: measure fixed children on the main (vertical) axis.
330        //
331        // The slot for each fixed child = content_h + margin_top + margin_bottom.
332        // Flex children contribute only their margins to the space budget.
333        // -------------------------------------------------------------------
334        let mut content_heights = vec![0.0f64; n];
335        let mut natural_widths = vec![0.0f64; n];
336        let mut total_fixed_with_margins = 0.0f64;
337        let mut total_flex = 0.0f64;
338        let mut total_flex_margin_v = 0.0f64;
339        let mut max_child_natural_w = 0.0f64;
340
341        for i in 0..n {
342            if self.flex_factors[i] == 0.0 {
343                let m = &margins[i];
344                let slot_w = (inner_w - m.left - m.right).max(0.0);
345                // Measure at natural height; pass inner_h as the available
346                // height so the child can self-report its natural size.
347                let desired = self.children[i].layout(Size::new(slot_w, inner_h));
348                content_heights[i] = desired.height.clamp(
349                    self.children[i].min_size().height,
350                    self.children[i].max_size().height,
351                );
352                natural_widths[i] = desired.width;
353            }
354        }
355
356        // Visibility is read AFTER measuring: a child that hides itself
357        // (collapsed `Conditional`, a results list with no rows) reports
358        // `is_visible() == false` from its fresh layout state and consumes
359        // no slot, margin, or gap — the contract documented on
360        // [`crate::widgets::Conditional`]. Without this, every hidden slot
361        // leaves `gap` px of dead space in the flow.
362        let visible: Vec<bool> = self.children.iter().map(|c| c.is_visible()).collect();
363        let visible_n = visible.iter().filter(|v| **v).count();
364        let total_gap = if visible_n > 1 {
365            gap * (visible_n - 1) as f64
366        } else {
367            0.0
368        };
369
370        for i in 0..n {
371            if !visible[i] {
372                continue;
373            }
374            let m = &margins[i];
375            if self.flex_factors[i] == 0.0 {
376                total_fixed_with_margins += content_heights[i] + m.vertical();
377                max_child_natural_w = max_child_natural_w.max(natural_widths[i] + m.horizontal());
378            } else {
379                total_flex += self.flex_factors[i];
380                total_flex_margin_v += m.vertical();
381            }
382        }
383
384        // -------------------------------------------------------------------
385        // Step 2: distribute remaining space to flex children.
386        // -------------------------------------------------------------------
387        let remaining =
388            (inner_h - total_fixed_with_margins - total_gap - total_flex_margin_v).max(0.0);
389        let flex_unit = if total_flex > 0.0 {
390            remaining / total_flex
391        } else {
392            0.0
393        };
394
395        for i in 0..n {
396            if self.flex_factors[i] > 0.0 && visible[i] {
397                let raw = self.flex_factors[i] * flex_unit;
398                content_heights[i] = raw.clamp(
399                    self.children[i].min_size().height,
400                    self.children[i].max_size().height,
401                );
402            }
403        }
404
405        // Natural content height (all-fixed case) determines the column's
406        // reported size when there are no flex children.
407        let natural_content_h = total_fixed_with_margins + total_gap;
408        let effective_h = if total_flex > 0.0 {
409            inner_h
410        } else {
411            natural_content_h
412        };
413
414        // -------------------------------------------------------------------
415        // Step 3: place children top-to-bottom.
416        //
417        // In Y-up coordinates "top" = high Y.  Two cursor seeds:
418        //
419        //   - **Default**: start at the top of the finite inner area,
420        //     matching egui's top-down layout.  When a parent passes a
421        //     deliberately huge height to measure natural content (the
422        //     `ScrollView` path), fall back to the natural-content extent
423        //     so children keep finite y-coordinates.
424        //
425        //   - **`top_anchor=true`**: always start at the top of the inner
426        //     area, even for very tall measurement slots.
427        let measuring_natural_height = available.height > 1.0e9;
428        let mut cursor_y = if self.top_anchor || !measuring_natural_height {
429            available.height - pad_t
430        } else {
431            pad_b + effective_h
432        };
433
434        for i in 0..n {
435            let m = &margins[i];
436            let slot_w = (inner_w - m.left - m.right).max(0.0);
437            let content_h = content_heights[i];
438
439            if !visible[i] {
440                // Hidden slot: zero-size bounds at the cursor, no margins,
441                // no gap, no cursor advance — as if the child weren't there.
442                self.children[i].set_bounds(Rect::new(pad_l + m.left, cursor_y, 0.0, 0.0));
443                continue;
444            }
445
446            // Subtract top margin first (moves cursor toward lower Y = downward).
447            cursor_y -= m.top;
448            let child_bottom = cursor_y - content_h;
449
450            // Layout child to obtain its natural width for cross-axis placement.
451            let desired = self.children[i].layout(Size::new(slot_w, content_h));
452            let natural_w = desired.width;
453            let h_anchor = self.children[i].h_anchor();
454            let min_w = self.children[i].min_size().width;
455            let max_w = self.children[i].max_size().width;
456
457            let (child_x, child_w) = place_cross_h(
458                h_anchor, pad_l, inner_w, m.left, m.right, natural_w, min_w, max_w,
459            );
460
461            // Round to integers so bitmap content (cached text, images) lands on
462            // exact pixel boundaries and isn't sub-pixel sampled into blur.
463            self.children[i].set_bounds(Rect::new(
464                child_x.round(),
465                child_bottom.round(),
466                child_w.round(),
467                content_h.round(),
468            ));
469
470            // Advance cursor past bottom margin and inter-child gap.
471            cursor_y = child_bottom - m.bottom - gap;
472        }
473
474        // Return natural size for all-fixed layouts so ScrollView can read
475        // the true content height from layout()'s return value.
476        //
477        // Width: by default we report the full available width (legacy
478        // behaviour many callers rely on).  `fit_width(true)` opts in
479        // to reporting the widest non-flex child's natural width +
480        // padding — NOT clamped to `available.width` so the parent
481        // (typically an auto-sized `Window`) can grow to fit content
482        // that exceeds the current slot.
483        let reported_w = if self.fit_width {
484            max_child_natural_w + pad_l + pad_r
485        } else {
486            available.width
487        };
488        if total_flex > 0.0 {
489            Size::new(reported_w, available.height)
490        } else {
491            Size::new(reported_w, natural_content_h + pad_t + pad_b)
492        }
493    }
494
495    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
496        let bg = if self.use_panel_bg {
497            Some(ctx.visuals().panel_fill)
498        } else if self.background.a > 0.001 {
499            Some(self.background)
500        } else {
501            None
502        };
503        if let Some(color) = bg {
504            let w = self.bounds.width;
505            let h = self.bounds.height;
506            ctx.set_fill_color(color);
507            ctx.begin_path();
508            ctx.rect(0.0, 0.0, w, h);
509            ctx.fill();
510        }
511    }
512
513    fn on_event(&mut self, _: &Event) -> EventResult {
514        EventResult::Ignored
515    }
516}