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