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}