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}