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(vec![
269            leaf_any(40.0, 40.0),
270            any(Align(Alignment::CENTER, leaf(20.0, 20.0))),
271        ]);
272        let mut counter = 0u64;
273        let mut w = view.build(&mut BuildCtx::new(&mut counter));
274        let mut lctx = LayoutCtx::new();
275        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(40.0, 40.0)));
276        assert_eq!(size, Size::new(40.0, 40.0));
277
278        // Paint into a recording scene: each leaf fills a rect at its absolute
279        // origin, so the child's centered placement is observable end-to-end
280        // through the real Stack -> Align -> child origin threading.
281        let mut scene = RecordingScene::default();
282        let mut pctx = PaintCtx::new(Point::ZERO, size);
283        w.paint(&mut pctx, &mut scene);
284
285        assert!(
286            scene
287                .rects
288                .contains(&(Point::new(10.0, 10.0), Size::new(20.0, 20.0))),
289            "the 20x20 child must center at (10,10) over the 40x40 box, not sit \
290             at the origin; recorded rects: {:?}",
291            scene.rects,
292        );
293    }
294
295    // -- Capture routing (a captured child must keep receiving
296    // events regardless of hit geometry, not just while the point is still
297    // over it) --
298
299    #[derive(Default)]
300    struct Counter {
301        presses: u32,
302    }
303
304    fn pointer_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
305        InputEvent::Pointer(PointerEvent {
306            phase,
307            position: Point::new(x, y),
308            button: PointerButton::Primary,
309        })
310    }
311
312    /// Build a `Align(CENTER, Button)` inside a generous box, so the child is
313    /// surrounded by free space it does not occupy.
314    fn button_align() -> AlignWidget {
315        let view: AlignView<Counter> = Align(
316            Alignment::CENTER,
317            crate::button::<Counter, _>("go", |s: &mut Counter| s.presses += 1),
318        );
319        let mut w = build(&view);
320        let mut text_ctx = frust_text::TextContext::new();
321        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
322        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(500.0, 500.0)));
323        w
324    }
325
326    fn dispatch<S: 'static>(w: &mut AlignWidget, state: &mut S, event: &InputEvent) -> EventResult {
327        let state_any: &mut dyn Any = state;
328        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(500.0, 500.0));
329        w.event(&mut ctx, event)
330    }
331
332    #[test]
333    fn captured_button_receives_move_and_up_outside_its_bounds() {
334        let mut w = button_align();
335        let mut state = Counter::default();
336        let origin = w.child.origin();
337        assert!(
338            origin.x > 0.0 && origin.y > 0.0,
339            "the centered button should have free space around it"
340        );
341
342        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
343        assert_eq!(
344            dispatch(
345                &mut w,
346                &mut state,
347                &pointer_ev(PointerPhase::Down, inside.x, inside.y)
348            ),
349            EventResult::Handled
350        );
351        assert!(w.child.is_active(), "down captures the pointer");
352
353        // Move to the corner of the 500x500 box — well outside the centered
354        // child — the capture must still route it there.
355        let outside = Point::new(1.0, 1.0);
356        assert_eq!(
357            dispatch(
358                &mut w,
359                &mut state,
360                &pointer_ev(PointerPhase::Move, outside.x, outside.y)
361            ),
362            EventResult::Handled,
363            "a captured Move outside the child's bounds must still route to it"
364        );
365
366        assert_eq!(
367            dispatch(
368                &mut w,
369                &mut state,
370                &pointer_ev(PointerPhase::Up, outside.x, outside.y)
371            ),
372            EventResult::Handled,
373            "the capturing Up must still route to the child even though it's outside"
374        );
375        assert_eq!(
376            state.presses, 0,
377            "up outside the button must not fire on_press"
378        );
379        assert!(!w.child.is_active(), "capture releases on Up");
380    }
381
382    #[test]
383    fn captured_button_fires_on_move_back_inside_then_up() {
384        let mut w = button_align();
385        let mut state = Counter::default();
386        let origin = w.child.origin();
387
388        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
389        dispatch(
390            &mut w,
391            &mut state,
392            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
393        );
394
395        let outside = Point::new(1.0, 1.0);
396        dispatch(
397            &mut w,
398            &mut state,
399            &pointer_ev(PointerPhase::Move, outside.x, outside.y),
400        );
401
402        let back_inside = Point::new(origin.x + 4.0, origin.y + 4.0);
403        dispatch(
404            &mut w,
405            &mut state,
406            &pointer_ev(PointerPhase::Move, back_inside.x, back_inside.y),
407        );
408
409        dispatch(
410            &mut w,
411            &mut state,
412            &pointer_ev(PointerPhase::Up, back_inside.x, back_inside.y),
413        );
414        assert_eq!(state.presses, 1, "up back inside must fire on_press");
415    }
416
417    #[test]
418    fn active_clears_on_up_so_a_later_down_elsewhere_is_not_routed() {
419        let mut w = button_align();
420        let mut state = Counter::default();
421        let origin = w.child.origin();
422
423        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
424        dispatch(
425            &mut w,
426            &mut state,
427            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
428        );
429        dispatch(
430            &mut w,
431            &mut state,
432            &pointer_ev(PointerPhase::Up, inside.x, inside.y),
433        );
434        assert!(!w.child.is_active(), "capture releases on Up");
435
436        // A Down far away, outside the child's bounds, must now be ignored —
437        // not routed to the (no-longer-active) child.
438        assert_eq!(
439            dispatch(
440                &mut w,
441                &mut state,
442                &pointer_ev(PointerPhase::Down, 1.0, 1.0)
443            ),
444            EventResult::Ignored
445        );
446    }
447
448    // -- Type-swap capture clearing (`rebuild_child` must
449    // clear a stale capture on an AnyView type swap, matching
450    // `rebuild_children`'s semantics for `Flex`/`Stack`) --
451
452    #[test]
453    fn type_swap_at_captured_child_clears_active_and_stops_routing() {
454        let mut counter = 0u64;
455        let prev: AlignView<Counter> = Align(
456            Alignment::CENTER,
457            crate::button::<Counter, _>("go", |s: &mut Counter| s.presses += 1),
458        );
459        let mut w = prev.build(&mut BuildCtx::new(&mut counter));
460        let mut text_ctx = frust_text::TextContext::new();
461        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
462        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(500.0, 500.0)));
463
464        let mut state = Counter::default();
465        let origin = w.child.origin();
466        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
467        dispatch(
468            &mut w,
469            &mut state,
470            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
471        );
472        assert!(w.child.is_active(), "down captures the pointer");
473
474        // Rebuild, swapping the child's concrete type (Button -> Checkbox).
475        let swapped: AlignView<Counter> = Align(
476            Alignment::CENTER,
477            crate::checkbox::<Counter, _>(false, "swapped", |_s: &mut Counter, _v: bool| {}),
478        );
479        swapped.rebuild(&prev, &mut w, &mut BuildCtx::new(&mut counter));
480        assert!(
481            !w.child.is_active(),
482            "the type swap must clear the stale capture"
483        );
484
485        // A Down outside the child's bounds must now be Ignored — not routed
486        // to the fresh widget via a stale `active` flag.
487        let outside = Point::new(1.0, 1.0);
488        assert_eq!(
489            dispatch(
490                &mut w,
491                &mut state,
492                &pointer_ev(PointerPhase::Down, outside.x, outside.y)
493            ),
494            EventResult::Ignored,
495            "a swap must clear active so an out-of-bounds Down is not delivered"
496        );
497    }
498}