Skip to main content

frust_widgets/
align.rs

1//! Align layout container: positions a single child within the
2//! space the align itself is given.
3//!
4//! [`AlignView`]/[`AlignWidget`] loosen the incoming constraints for the child
5//! (so it takes its natural size), grow to fill the available (max) space when it
6//! is bounded, and position the child within that box per an [`Alignment`].
7//!
8//! # Flutter-parity contract
9//!
10//! Sizing matches Flutter's `RenderPositionedBox`/`RenderAligningShiftedBox`
11//! (`packages/flutter/lib/src/rendering/shifted_box.dart`, retrieved
12//! 2026-07-19): with no width/height *factor* (Frust does not expose one —
13//! do not add it), the align sizes **per axis** to `constraints.biggest` on a
14//! **bounded** axis (fill) and to the **child's** extent on an **unbounded**
15//! axis (shrink-wrap). This is decided independently on each axis, so a
16//! bounded-width / unbounded-height constraint fills horizontally and
17//! shrink-wraps vertically. The child is then positioned in the free space per
18//! the [`Alignment`] fractions.
19//!
20//! The practical consequence (the huddle avatar bug shape): centering a small
21//! child over a fixed box — `Stack`/`SizedBox` bounding the align, then
22//! `Align(CENTER, child)` — centers only because the incoming constraint is
23//! *bounded* (the bounding parent supplies the max). Under an *unbounded*
24//! constraint the align shrink-wraps to the child and there is no free space to
25//! center within, so the child lands at the origin. Give the align a bounded
26//! box (e.g. wrap in [`crate::SizedBox`]) when centered content over a larger
27//! region is the intent — matching Flutter, where an unbounded `Align` is
28//! likewise a no-op for positioning.
29
30use frust_core::{
31    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
32    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
33};
34use kurbo::{Point, Size};
35
36/// A relative alignment within a box: each axis runs `-1.0` (start) through
37/// `0.0` (center) to `1.0` (end).
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct Alignment {
40    /// Horizontal position: `-1.0` = left, `0.0` = center, `1.0` = right.
41    pub x: f64,
42    /// Vertical position: `-1.0` = top, `0.0` = center, `1.0` = bottom.
43    pub y: f64,
44}
45
46impl Alignment {
47    /// Center of the box.
48    pub const CENTER: Self = Self { x: 0.0, y: 0.0 };
49    /// Top-left corner.
50    pub const TOP_LEFT: Self = Self { x: -1.0, y: -1.0 };
51    /// Top-right corner.
52    pub const TOP_RIGHT: Self = Self { x: 1.0, y: -1.0 };
53    /// Bottom-left corner.
54    pub const BOTTOM_LEFT: Self = Self { x: -1.0, y: 1.0 };
55    /// Bottom-right corner.
56    pub const BOTTOM_RIGHT: Self = Self { x: 1.0, y: 1.0 };
57
58    /// A custom alignment with `x`/`y` each in `-1.0..=1.0`.
59    pub fn new(x: f64, y: f64) -> Self {
60        Self { x, y }
61    }
62
63    /// Map an alignment component in `-1.0..=1.0` to a `0.0..=1.0` fraction of
64    /// the free space (`-1 → 0`, `0 → 0.5`, `1 → 1`).
65    fn fraction(component: f64) -> f64 {
66        (component + 1.0) / 2.0
67    }
68}
69
70/// A declarative alignment container. See the [module docs](self).
71pub struct AlignView<State: 'static> {
72    alignment: Alignment,
73    child: AnyView<State>,
74}
75
76/// Position `child` within the align's box per `alignment`.
77#[allow(non_snake_case)]
78pub fn Align<State: 'static, V: View<State>>(alignment: Alignment, child: V) -> AlignView<State> {
79    AlignView {
80        alignment,
81        child: any(child),
82    }
83}
84
85/// The retained widget for an [`AlignView`].
86pub struct AlignWidget {
87    alignment: Alignment,
88    child: ChildPod,
89}
90
91impl<State: 'static> View<State> for AlignView<State> {
92    type Element = AlignWidget;
93
94    fn build(&self, ctx: &mut BuildCtx<'_>) -> AlignWidget {
95        AlignWidget {
96            alignment: self.alignment,
97            child: crate::authoring::build_child(&self.child, ctx),
98        }
99    }
100
101    fn rebuild(
102        &self,
103        prev: &Self,
104        element: &mut AlignWidget,
105        ctx: &mut BuildCtx<'_>,
106    ) -> ChangeFlags {
107        let mut flags = ChangeFlags::NONE;
108        if prev.alignment != self.alignment {
109            element.alignment = self.alignment;
110            flags |= ChangeFlags::LAYOUT;
111        }
112        flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
113        flags
114    }
115
116    fn teardown(&self, element: &mut AlignWidget, ctx: &mut BuildCtx<'_>) {
117        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
118    }
119}
120
121impl Widget for AlignWidget {
122    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
123        // The child takes its natural size under loosened constraints.
124        let child_size = self.child.layout_child(ctx, &bc.loosen());
125        // Flutter parity (shifted_box.dart's RenderPositionedBox, retrieved
126        // 2026-07-19; see module docs): fill the available space on each bounded
127        // axis, shrink-wrap an unbounded one to the child — decided per axis.
128        let width = if bc.max().width.is_finite() {
129            bc.max().width
130        } else {
131            child_size.width
132        };
133        let height = if bc.max().height.is_finite() {
134            bc.max().height
135        } else {
136            child_size.height
137        };
138        let size = bc.constrain(Size::new(width, height));
139        // Position the child within the free space per the alignment fractions.
140        let x = (size.width - child_size.width) * Alignment::fraction(self.alignment.x);
141        let y = (size.height - child_size.height) * Alignment::fraction(self.alignment.y);
142        self.child.set_origin(Point::new(x, y));
143        size
144    }
145
146    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
147        self.child.paint_child(ctx, scene);
148    }
149
150    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
151        crate::authoring::route_event_single(&mut self.child, ctx, event)
152    }
153
154    fn semantics(&self, ctx: &mut SemanticsCtx) {
155        // Transparent positioning wrapper: forward to the single child.
156        self.child.semantics_child(ctx);
157    }
158
159    crate::authoring::visit_children!(child);
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::test_support::{RecordingScene, leaf, leaf_any};
166    use frust_core::{BuildCtx, PointerButton, PointerEvent, PointerPhase};
167    use std::any::Any;
168
169    fn build<S: 'static>(view: &AlignView<S>) -> AlignWidget {
170        let mut counter = 0u64;
171        view.build(&mut BuildCtx::new(&mut counter))
172    }
173
174    fn placed(alignment: Alignment) -> (Size, Point) {
175        // 20x20 child inside a 100x100 align box.
176        let view: AlignView<()> = Align(alignment, leaf(20.0, 20.0));
177        let mut w = build(&view);
178        let mut lctx = LayoutCtx::new();
179        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 100.0)));
180        (size, w.child.origin())
181    }
182
183    #[test]
184    fn fills_box_and_centers_child() {
185        let (size, origin) = placed(Alignment::CENTER);
186        assert_eq!(size, Size::new(100.0, 100.0));
187        // (100 - 20) * 0.5 = 40 on each axis.
188        assert_eq!(origin, Point::new(40.0, 40.0));
189    }
190
191    #[test]
192    fn positions_child_at_corners() {
193        assert_eq!(placed(Alignment::TOP_LEFT).1, Point::new(0.0, 0.0));
194        assert_eq!(placed(Alignment::TOP_RIGHT).1, Point::new(80.0, 0.0));
195        assert_eq!(placed(Alignment::BOTTOM_LEFT).1, Point::new(0.0, 80.0));
196        assert_eq!(placed(Alignment::BOTTOM_RIGHT).1, Point::new(80.0, 80.0));
197    }
198
199    // -- Flutter-parity sizing (shifted_box.dart's RenderPositionedBox): fill a
200    // bounded axis, shrink-wrap an unbounded one, per axis. --
201
202    /// Lay a 20x20 child inside an align under arbitrary `bc` and report the
203    /// align's chosen size and the child's origin within it.
204    fn placed_bc(alignment: Alignment, bc: &BoxConstraints) -> (Size, Point) {
205        let view: AlignView<()> = Align(alignment, leaf(20.0, 20.0));
206        let mut w = build(&view);
207        let mut lctx = LayoutCtx::new();
208        let size = w.layout(&mut lctx, bc);
209        (size, w.child.origin())
210    }
211
212    #[test]
213    fn bounded_loose_fills_and_centers_child() {
214        // A *loose* (min 0) but bounded constraint must fill to the max on both
215        // axes and center the child — the case that bit the huddle avatars.
216        let (size, origin) = placed_bc(
217            Alignment::CENTER,
218            &BoxConstraints::loose(Size::new(100.0, 100.0)),
219        );
220        assert_eq!(size, Size::new(100.0, 100.0), "bounded axes fill to max");
221        assert_eq!(origin, Point::new(40.0, 40.0), "(100-20)*0.5 on each axis");
222    }
223
224    #[test]
225    fn unbounded_shrink_wraps_to_child() {
226        // Both axes unbounded: the align shrink-wraps to the child, leaving no
227        // free space, so the child sits at the origin regardless of alignment.
228        let bc = BoxConstraints::loose(Size::new(f64::INFINITY, f64::INFINITY));
229        let (size, origin) = placed_bc(Alignment::CENTER, &bc);
230        assert_eq!(
231            size,
232            Size::new(20.0, 20.0),
233            "unbounded axes shrink to child"
234        );
235        assert_eq!(origin, Point::ZERO, "no free space to align within");
236    }
237
238    #[test]
239    fn mixed_axis_fills_bounded_shrinks_unbounded() {
240        // Bounded width, unbounded height: fill horizontally (100), shrink-wrap
241        // vertically (20). The decision is independent per axis.
242        let bc = BoxConstraints::new(Size::ZERO, Size::new(100.0, f64::INFINITY));
243        let (size, origin) = placed_bc(Alignment::CENTER, &bc);
244        assert_eq!(size, Size::new(100.0, 20.0));
245        // x centered over the 100px width; y has no slack to center within.
246        assert_eq!(origin, Point::new(40.0, 0.0));
247    }
248
249    #[test]
250    fn tight_passes_through() {
251        // A tight constraint forces the align's size exactly, both axes bounded.
252        let (size, origin) = placed_bc(
253            Alignment::CENTER,
254            &BoxConstraints::tight(Size::new(60.0, 60.0)),
255        );
256        assert_eq!(size, Size::new(60.0, 60.0));
257        assert_eq!(origin, Point::new(20.0, 20.0), "(60-20)*0.5");
258    }
259
260    #[test]
261    fn stack_align_center_positions_small_child_over_larger_sibling() {
262        // Regression for the huddle avatar bug shape: a Stack bounded to the
263        // "circle" size (40x40, as a SizedBox parent would supply) overlays a
264        // 40x40 sibling with `Align(CENTER, <20x20 child>)`. Because the Stack
265        // hands its children a *bounded* loose constraint, the align fills 40x40
266        // and centers the child at (10,10) — not shrink-wrapped to the origin,
267        // which is what pinned the avatar initials to the corner.
268        let view: crate::StackView<()> = crate::stack()
269            .child(leaf_any(40.0, 40.0))
270            .child(Align(Alignment::CENTER, leaf(20.0, 20.0)));
271        let mut counter = 0u64;
272        let mut w = view.build(&mut BuildCtx::new(&mut counter));
273        let mut lctx = LayoutCtx::new();
274        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(40.0, 40.0)));
275        assert_eq!(size, Size::new(40.0, 40.0));
276
277        // Paint into a recording scene: each leaf fills a rect at its absolute
278        // origin, so the child's centered placement is observable end-to-end
279        // through the real Stack -> Align -> child origin threading.
280        let mut scene = RecordingScene::default();
281        let mut pctx = PaintCtx::new(Point::ZERO, size);
282        w.paint(&mut pctx, &mut scene);
283
284        assert!(
285            scene
286                .rects
287                .contains(&(Point::new(10.0, 10.0), Size::new(20.0, 20.0))),
288            "the 20x20 child must center at (10,10) over the 40x40 box, not sit \
289             at the origin; recorded rects: {:?}",
290            scene.rects,
291        );
292    }
293
294    // -- Capture routing (a captured child must keep receiving
295    // events regardless of hit geometry, not just while the point is still
296    // over it) --
297
298    #[derive(Default)]
299    struct Counter {
300        presses: u32,
301    }
302
303    fn pointer_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
304        InputEvent::Pointer(PointerEvent {
305            phase,
306            position: Point::new(x, y),
307            button: PointerButton::Primary,
308        })
309    }
310
311    /// Build a `Align(CENTER, Button)` inside a generous box, so the child is
312    /// surrounded by free space it does not occupy.
313    fn button_align() -> AlignWidget {
314        let view: AlignView<Counter> = Align(
315            Alignment::CENTER,
316            crate::button::<Counter, _>("go", |s: &mut Counter| s.presses += 1),
317        );
318        let mut w = build(&view);
319        let mut text_ctx = frust_text::TextContext::new();
320        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
321        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(500.0, 500.0)));
322        w
323    }
324
325    fn dispatch<S: 'static>(w: &mut AlignWidget, state: &mut S, event: &InputEvent) -> EventResult {
326        let state_any: &mut dyn Any = state;
327        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(500.0, 500.0));
328        w.event(&mut ctx, event)
329    }
330
331    #[test]
332    fn captured_button_receives_move_and_up_outside_its_bounds() {
333        let mut w = button_align();
334        let mut state = Counter::default();
335        let origin = w.child.origin();
336        assert!(
337            origin.x > 0.0 && origin.y > 0.0,
338            "the centered button should have free space around it"
339        );
340
341        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
342        assert_eq!(
343            dispatch(
344                &mut w,
345                &mut state,
346                &pointer_ev(PointerPhase::Down, inside.x, inside.y)
347            ),
348            EventResult::Handled
349        );
350        assert!(w.child.is_active(), "down captures the pointer");
351
352        // Move to the corner of the 500x500 box — well outside the centered
353        // child — the capture must still route it there.
354        let outside = Point::new(1.0, 1.0);
355        assert_eq!(
356            dispatch(
357                &mut w,
358                &mut state,
359                &pointer_ev(PointerPhase::Move, outside.x, outside.y)
360            ),
361            EventResult::Handled,
362            "a captured Move outside the child's bounds must still route to it"
363        );
364
365        assert_eq!(
366            dispatch(
367                &mut w,
368                &mut state,
369                &pointer_ev(PointerPhase::Up, outside.x, outside.y)
370            ),
371            EventResult::Handled,
372            "the capturing Up must still route to the child even though it's outside"
373        );
374        assert_eq!(
375            state.presses, 0,
376            "up outside the button must not fire on_press"
377        );
378        assert!(!w.child.is_active(), "capture releases on Up");
379    }
380
381    #[test]
382    fn captured_button_fires_on_move_back_inside_then_up() {
383        let mut w = button_align();
384        let mut state = Counter::default();
385        let origin = w.child.origin();
386
387        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
388        dispatch(
389            &mut w,
390            &mut state,
391            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
392        );
393
394        let outside = Point::new(1.0, 1.0);
395        dispatch(
396            &mut w,
397            &mut state,
398            &pointer_ev(PointerPhase::Move, outside.x, outside.y),
399        );
400
401        let back_inside = Point::new(origin.x + 4.0, origin.y + 4.0);
402        dispatch(
403            &mut w,
404            &mut state,
405            &pointer_ev(PointerPhase::Move, back_inside.x, back_inside.y),
406        );
407
408        dispatch(
409            &mut w,
410            &mut state,
411            &pointer_ev(PointerPhase::Up, back_inside.x, back_inside.y),
412        );
413        assert_eq!(state.presses, 1, "up back inside must fire on_press");
414    }
415
416    #[test]
417    fn active_clears_on_up_so_a_later_down_elsewhere_is_not_routed() {
418        let mut w = button_align();
419        let mut state = Counter::default();
420        let origin = w.child.origin();
421
422        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
423        dispatch(
424            &mut w,
425            &mut state,
426            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
427        );
428        dispatch(
429            &mut w,
430            &mut state,
431            &pointer_ev(PointerPhase::Up, inside.x, inside.y),
432        );
433        assert!(!w.child.is_active(), "capture releases on Up");
434
435        // A Down far away, outside the child's bounds, must now be ignored —
436        // not routed to the (no-longer-active) child.
437        assert_eq!(
438            dispatch(
439                &mut w,
440                &mut state,
441                &pointer_ev(PointerPhase::Down, 1.0, 1.0)
442            ),
443            EventResult::Ignored
444        );
445    }
446
447    // -- Type-swap capture clearing (`rebuild_child` must
448    // clear a stale capture on an AnyView type swap, matching
449    // `rebuild_children`'s semantics for `Flex`/`Stack`) --
450
451    #[test]
452    fn type_swap_at_captured_child_clears_active_and_stops_routing() {
453        let mut counter = 0u64;
454        let prev: AlignView<Counter> = Align(
455            Alignment::CENTER,
456            crate::button::<Counter, _>("go", |s: &mut Counter| s.presses += 1),
457        );
458        let mut w = prev.build(&mut BuildCtx::new(&mut counter));
459        let mut text_ctx = frust_text::TextContext::new();
460        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
461        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(500.0, 500.0)));
462
463        let mut state = Counter::default();
464        let origin = w.child.origin();
465        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
466        dispatch(
467            &mut w,
468            &mut state,
469            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
470        );
471        assert!(w.child.is_active(), "down captures the pointer");
472
473        // Rebuild, swapping the child's concrete type (Button -> Checkbox).
474        let swapped: AlignView<Counter> = Align(
475            Alignment::CENTER,
476            crate::checkbox::<Counter, _>(false, "swapped", |_s: &mut Counter, _v: bool| {}),
477        );
478        swapped.rebuild(&prev, &mut w, &mut BuildCtx::new(&mut counter));
479        assert!(
480            !w.child.is_active(),
481            "the type swap must clear the stale capture"
482        );
483
484        // A Down outside the child's bounds must now be Ignored — not routed
485        // to the fresh widget via a stale `active` flag.
486        let outside = Point::new(1.0, 1.0);
487        assert_eq!(
488            dispatch(
489                &mut w,
490                &mut state,
491                &pointer_ev(PointerPhase::Down, outside.x, outside.y)
492            ),
493            EventResult::Ignored,
494            "a swap must clear active so an out-of-bounds Down is not delivered"
495        );
496    }
497}