Skip to main content

frus_widgets/
align.rs

1//! [`Aligned`] and [`Center`]: a child placed somewhere in the room it is given.
2
3use frus_core::{AlignmentGeometry, Rect, Scene};
4use frus_layout::Style;
5
6use crate::container::Container;
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::{FillAxes, Widget};
10
11/// **Places its child** at `alignment` in the room it is given, at the child's own size.
12///
13/// The box takes all the room on offer, and the child sits at the anchor inside it: centred, in
14/// a corner, against an edge, or at any fraction between. The anchor can be
15/// [`Alignment`](frus_core::Alignment) or
16/// [`AlignmentDirectional`](frus_core::AlignmentDirectional), which mirrors in a right-to-left
17/// script.
18///
19/// ```
20/// use frus_core::Alignment;
21/// use frus_widgets::{Aligned, Text};
22///
23/// let _signature: Aligned<()> = Aligned::new(Alignment::BOTTOM_RIGHT, Text::new("— the team"));
24/// ```
25///
26/// **How much room it takes.** All of what its parent hands down: a whole page, a whole card,
27/// the whole cell of a grid. The exception is along a row's or a column's own direction when the
28/// line holds other children. The line is shared there, so the box is only as long as its child,
29/// and the child is centred across the line only. Wrap it in
30/// [`Expanded`](crate::Expanded) to give it the rest of the line.
31///
32/// **A size of its own.** [`Aligned::width_factor`] and [`Aligned::height_factor`] make the
33/// box a multiple of its child on that axis instead: `2.0` is twice as tall as the child, with
34/// the child at the anchor inside; `0.5` is half as tall, and the child spills past the box,
35/// which does not cut it. On an axis with no factor, the box still takes the room.
36///
37/// To animate a change of anchor, use [`AnimatedAlign`](crate::AnimatedAlign).
38///
39/// (Named for what it does to its child, as [`Positioned`](crate::Positioned) and
40/// [`Expanded`](crate::Expanded) are: `Align` is the cross-axis alignment of a row or a column.)
41pub struct Aligned<Msg = crate::callback::Callback> {
42    inner: Container<Msg>,
43    alignment: AlignmentGeometry,
44    /// `(across, down)`: a multiple of the child on that axis, or the room.
45    factors: (Option<f32>, Option<f32>),
46    /// Once a factor is given, `inner` moves here and is laid out apart: the box is then
47    /// measured from it, as the boxes that transform the space on offer are.
48    factored: Vec<Box<dyn Widget<Msg>>>,
49    /// How a change of anchor moves, for [`AnimatedAlign`](crate::AnimatedAlign)
50    /// (milestone 600).
51    anim: Option<(f32, frus_core::animation::Curve)>,
52}
53
54impl<Msg: Clone + 'static> Aligned<Msg> {
55    /// Places `child` at `alignment`.
56    pub fn new(alignment: impl Into<AlignmentGeometry>, child: impl Widget<Msg> + 'static) -> Self {
57        let alignment = alignment.into();
58        Self {
59            inner: Container::new().hugging().alignment(alignment).child(child),
60            alignment,
61            factors: (None, None),
62            factored: Vec::new(),
63            anim: None,
64        }
65    }
66
67    /// Places `child` at `alignment`, moving there over `duration` when the anchor changes.
68    ///
69    /// The container that places the child moves its anchor too: without a factor it is this
70    /// box's own node, and with one it places the child on the axes without a factor, while
71    /// the walk moves it on the others (milestone 600).
72    pub(crate) fn animated(
73        alignment: impl Into<AlignmentGeometry>,
74        duration: f32,
75        curve: frus_core::animation::Curve,
76        child: impl Widget<Msg> + 'static,
77    ) -> Self {
78        let alignment = alignment.into();
79        Self {
80            inner: Container::new()
81                .hugging()
82                .animated_alignment(alignment, duration, curve.clone())
83                .child(child),
84            alignment,
85            factors: (None, None),
86            factored: Vec::new(),
87            anim: Some((duration, curve)),
88        }
89    }
90
91    /// The box is `factor` times as wide as its child, rather than as wide as the room.
92    pub fn width_factor(mut self, factor: f32) -> Self {
93        self.factors.0 = Some(factor.max(0.0));
94        self.factor()
95    }
96
97    /// The box is `factor` times as tall as its child, rather than as tall as the room.
98    pub fn height_factor(mut self, factor: f32) -> Self {
99        self.factors.1 = Some(factor.max(0.0));
100        self.factor()
101    }
102
103    fn factor(mut self) -> Self {
104        if self.factored.is_empty() {
105            let inner = std::mem::take(&mut self.inner);
106            self.factored.push(Box::new(inner));
107        }
108        self
109    }
110}
111
112/// **Centres its child** in the room it is given — an [`Aligned`] at
113/// [`Alignment::CENTER`](frus_core::Alignment::CENTER), and the same room.
114///
115/// ```
116/// use frus_widgets::{Center, Text};
117///
118/// let _empty: Center<()> = Center::new(Text::new("Nothing here yet"));
119/// ```
120pub struct Center<Msg = crate::callback::Callback> {
121    inner: Aligned<Msg>,
122}
123
124impl<Msg: Clone + 'static> Center<Msg> {
125    /// Centres `child`.
126    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
127        Self {
128            inner: Aligned::new(frus_core::Alignment::CENTER, child),
129        }
130    }
131
132    /// The box is `factor` times as wide as its child. See [`Aligned::width_factor`].
133    pub fn width_factor(mut self, factor: f32) -> Self {
134        self.inner = self.inner.width_factor(factor);
135        self
136    }
137
138    /// The box is `factor` times as tall as its child. See [`Aligned::height_factor`].
139    pub fn height_factor(mut self, factor: f32) -> Self {
140        self.inner = self.inner.height_factor(factor);
141        self
142    }
143}
144
145impl<Msg: Clone> Aligned<Msg> {
146    fn is_factored(&self) -> bool {
147        !self.factored.is_empty()
148    }
149}
150
151impl<Msg: Clone> Widget<Msg> for Aligned<Msg> {
152    fn style(&self) -> Style {
153        match self.is_factored() {
154            true => Style::default(),
155            false => self.inner.style(),
156        }
157    }
158
159    fn style_themed(&self, theme: &Theme) -> Style {
160        match self.is_factored() {
161            true => Style::default(),
162            false => self.inner.style_themed(theme),
163        }
164    }
165
166    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
167        match self.is_factored() {
168            true => &self.factored,
169            false => self.inner.children(),
170        }
171    }
172
173    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
174        // A pure layout widget: it draws nothing of its own.
175    }
176
177    fn on_click(&self) -> Option<Msg> {
178        None
179    }
180
181    fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
182        Some(self.alignment)
183    }
184
185    fn anim_offset(&self) -> Option<(f32, f32)> {
186        self.anim.as_ref().map(|_| self.alignment.fractions())
187    }
188
189    fn anim_duration(&self) -> f32 {
190        self.anim.as_ref().map_or(0.0, |(duration, _)| *duration)
191    }
192
193    fn anim_curve(&self) -> frus_core::animation::Curve {
194        self.anim
195            .as_ref()
196            .map_or(frus_core::animation::Curve::Linear, |(_, curve)| {
197                curve.clone()
198            })
199    }
200
201    /// With a factor, the child is laid out apart: allowed the room, as the most it may take,
202    /// on the axes with a factor, and handed it on the others, where the container it sits in
203    /// places it. The box is then the child's size times the factors. Where the child sits on
204    /// an axis with a factor is the walk's to say, from the anchor and the reading direction.
205    fn constraints_transform(&self) -> Option<crate::constraints::ConstraintsTransform> {
206        use crate::constraints::AxisConstraint::{AsGiven, Loose};
207        if !self.is_factored() {
208            return None;
209        }
210        let rule = |factor: Option<f32>| match factor {
211            Some(_) => Loose,
212            None => AsGiven,
213        };
214        Some(crate::constraints::ConstraintsTransform {
215            width: rule(self.factors.0),
216            height: rule(self.factors.1),
217            alignment: self.alignment.resolve(frus_core::TextDirection::Ltr),
218            report: false,
219        })
220    }
221
222    fn size_factor(&self) -> Option<(f32, f32)> {
223        self.is_factored()
224            .then(|| (self.factors.0.unwrap_or(1.0), self.factors.1.unwrap_or(1.0)))
225    }
226
227    /// All the room on offer, on both axes. The layout gives a box that asks for this the
228    /// reference's answer: the whole of a bounded axis, and the child's length along a line it
229    /// shares with others. An axis with a factor asks for nothing: the child decides it.
230    fn fill_axes(&self, _theme: &Theme) -> FillAxes {
231        FillAxes {
232            horizontal: self.factors.0.is_none(),
233            vertical: self.factors.1.is_none(),
234        }
235    }
236
237    fn debug_name(&self) -> &'static str {
238        "Aligned"
239    }
240}
241
242impl<Msg: Clone> Widget<Msg> for Center<Msg> {
243    fn style(&self) -> Style {
244        self.inner.style()
245    }
246
247    fn style_themed(&self, theme: &Theme) -> Style {
248        self.inner.style_themed(theme)
249    }
250
251    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
252        self.inner.children()
253    }
254
255    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
256
257    fn on_click(&self) -> Option<Msg> {
258        None
259    }
260
261    fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
262        self.inner.alignment_geometry()
263    }
264
265    fn constraints_transform(&self) -> Option<crate::constraints::ConstraintsTransform> {
266        self.inner.constraints_transform()
267    }
268
269    fn size_factor(&self) -> Option<(f32, f32)> {
270        self.inner.size_factor()
271    }
272
273    fn fill_axes(&self, theme: &Theme) -> FillAxes {
274        self.inner.fill_axes(theme)
275    }
276
277    fn debug_name(&self) -> &'static str {
278        "Center"
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::{build_ui, Expanded, Flex, Runtime, Text};
286    use frus_core::{Alignment, Color, Primitive, Size};
287
288    const RED: Color = Color::rgb(1.0, 0.0, 0.0);
289
290    /// A 20×20 red square.
291    fn square() -> Container<()> {
292        Container::new().width(20.0).height(20.0).color(RED)
293    }
294
295    /// Where the red square landed.
296    fn red(root: &dyn Widget<()>, size: Size) -> Rect {
297        let ui = build_ui(root, size, &Runtime::default(), &Theme::dark());
298        ui.scene()
299            .primitives()
300            .iter()
301            .find_map(|p| match p {
302                Primitive::Rect { rect, color, .. } if *color == RED => Some(*rect),
303                _ => None,
304            })
305            .expect("the red square")
306    }
307
308    fn near(rect: Rect, x: f32, y: f32) -> bool {
309        (rect.x - x).abs() < 1.0 && (rect.y - y).abs() < 1.0
310    }
311
312    /// **Alone on a page, it centres on both axes**: the whole page is the room it is given,
313    /// without a size said anywhere.
314    #[test]
315    fn a_center_alone_centres_on_the_page() {
316        let at = red(&Center::new(square()), Size::new(200.0, 100.0));
317        assert!(near(at, 90.0, 40.0), "{at:?}");
318    }
319
320    /// **Inside a box with a size**, the same: the room is the box's.
321    #[test]
322    fn a_center_in_a_box_centres_in_the_box() {
323        let root = Container::<()>::new()
324            .width(100.0)
325            .height(60.0)
326            .child(Center::new(square()));
327        let at = red(&root, Size::new(300.0, 300.0));
328        assert!(near(at, 40.0, 20.0), "{at:?}");
329    }
330
331    /// **In a column beside other children**, the column's own direction is shared: the centre
332    /// takes its child's height and sits after the text, centred across the column only. Wrapped
333    /// in `Expanded`, it takes the rest of the column and centres in that.
334    #[test]
335    fn in_a_column_it_shares_the_line_unless_expanded() {
336        let size = Size::new(200.0, 200.0);
337        let column = Flex::<()>::column()
338            .child(Text::new("title").no_wrap())
339            .child(Center::new(square()));
340        let at = red(&column, size);
341        assert!((at.x - 90.0).abs() < 1.0, "centred across: {at:?}");
342        assert!(
343            at.y < 40.0,
344            "just below the title, not in the middle: {at:?}"
345        );
346
347        let column = Flex::<()>::column()
348            .height(200.0)
349            .child(Text::new("title").no_wrap())
350            .child(Expanded::new(Center::new(square())));
351        let at = red(&column, size);
352        assert!((at.x - 90.0).abs() < 1.0, "{at:?}");
353        assert!(at.y > 90.0, "centred in what the title leaves: {at:?}");
354    }
355
356    /// **In a row beside other children**, the other way round: centred down the row's height,
357    /// and as wide as its child, after the label.
358    #[test]
359    fn in_a_row_it_centres_across_the_row() {
360        let row = Flex::<()>::row()
361            .height(100.0)
362            .child(Text::new("label").no_wrap())
363            .child(Center::new(square()));
364        let at = red(&row, Size::new(300.0, 100.0));
365        assert!((at.y - 40.0).abs() < 1.0, "centred down the row: {at:?}");
366        assert!(at.x < 80.0, "just after the label: {at:?}");
367    }
368
369    /// **The body of a page**: the commonest place for it — an empty state, a spinner — centres
370    /// in what the bar leaves.
371    #[test]
372    fn a_center_as_a_page_body_centres_under_the_bar() {
373        let size = Size::new(200.0, 400.0);
374        let page = crate::MediaQuery::new(size).scope(|| {
375            crate::Scaffold::<()>::new()
376                .app_bar(
377                    crate::AppBar::<()>::new()
378                        .title(Text::new("Inbox"))
379                        .height(56.0)
380                        .build(),
381                )
382                .body(Center::new(square()))
383                .build()
384        });
385        let at = red(page.as_ref(), size);
386        assert!((at.x - 90.0).abs() < 1.0, "{at:?}");
387        assert!(
388            at.y > 190.0 && at.y < 230.0,
389            "centred below the bar: {at:?}"
390        );
391    }
392
393    /// **In a scroll**, which has no height to hand down, it is as tall as its child — the
394    /// reference's rule for an axis with no bound — and still centred across.
395    #[test]
396    fn in_a_scroll_it_is_as_tall_as_its_child() {
397        let scroll = crate::SingleChildScrollView::<()>::new()
398            .width(200.0)
399            .height(300.0)
400            .child(Center::new(square()));
401        let at = red(&scroll, Size::new(200.0, 300.0));
402        assert!((at.x - 90.0).abs() < 1.0, "{at:?}");
403        assert!(at.y < 5.0, "at the top: {at:?}");
404    }
405
406    /// **Any anchor**, physical or directional: `Aligned` in a corner, and at a fraction.
407    #[test]
408    fn aligned_places_at_any_anchor() {
409        let size = Size::new(100.0, 100.0);
410        let at = red(&Aligned::new(Alignment::BOTTOM_RIGHT, square()), size);
411        assert!(near(at, 80.0, 80.0), "{at:?}");
412        let at = red(&Aligned::new(Alignment::new(0.5, -0.5), square()), size);
413        assert!(near(at, 60.0, 20.0), "{at:?}");
414        let at = red(
415            &Aligned::new(frus_core::AlignmentDirectional::CENTER_START, square()),
416            size,
417        );
418        assert!(near(at, 0.0, 40.0), "{at:?}");
419    }
420
421    const BLUE: Color = Color::rgb(0.0, 0.0, 1.0);
422
423    /// Every rect of `color` painted, with the clip it was painted under.
424    fn painted(
425        root: &dyn Widget<()>,
426        size: Size,
427        theme: &Theme,
428        color: Color,
429    ) -> Vec<(Rect, Rect)> {
430        let ui = build_ui(root, size, &Runtime::default(), theme);
431        ui.scene()
432            .primitives()
433            .iter()
434            .filter_map(|p| match p {
435                Primitive::Rect {
436                    rect,
437                    clip,
438                    color: c,
439                    ..
440                } if *c == color => Some((*rect, *clip)),
441                _ => None,
442            })
443            .collect()
444    }
445
446    /// A column: the factored box, then a blue bar that starts where the box ends.
447    fn over_a_bar(anchor: impl Widget<()> + 'static) -> Flex<()> {
448        Flex::<()>::column()
449            .child(anchor)
450            .child(Container::new().width(10.0).height(10.0).color(BLUE))
451    }
452
453    fn bar_top(root: &dyn Widget<()>, size: Size) -> f32 {
454        painted(root, size, &Theme::dark(), BLUE)[0].0.y
455    }
456
457    /// **A height factor**: the box is that many times as tall as its child, with the child at
458    /// the anchor inside, and what follows starts below it. Across, the box is still the room,
459    /// and the child is centred in it — even a child that has no width of its own.
460    #[test]
461    fn a_height_factor_makes_the_box_a_multiple_of_the_child() {
462        let size = Size::new(200.0, 200.0);
463        let root = over_a_bar(Center::new(Container::new().child(square())).height_factor(3.0));
464        let at = red(&root, size);
465        assert!(near(at, 90.0, 20.0), "{at:?}");
466        assert!((bar_top(&root, size) - 60.0).abs() < 0.5);
467
468        let root = over_a_bar(Aligned::new(Alignment::BOTTOM_RIGHT, square()).height_factor(2.0));
469        let at = red(&root, size);
470        assert!(near(at, 180.0, 20.0), "{at:?}");
471        assert!((bar_top(&root, size) - 40.0).abs() < 0.5);
472
473        // Alone on the page, the height is on offer; the factor still asks the child, and the
474        // box is 40 tall at the top, not the page's height.
475        let at = red(&Center::new(square()).height_factor(2.0), size);
476        assert!(near(at, 90.0, 10.0), "{at:?}");
477    }
478
479    /// **A width factor**: across, the same, and down the box is still the room.
480    #[test]
481    fn a_width_factor_makes_the_box_a_multiple_across() {
482        let root = Flex::<()>::row()
483            .height(100.0)
484            .child(Center::new(square()).width_factor(3.0))
485            .child(Container::new().width(10.0).height(10.0).color(BLUE));
486        let size = Size::new(300.0, 100.0);
487        let at = red(&root, size);
488        assert!(near(at, 20.0, 40.0), "{at:?}");
489        let bar = painted(&root, size, &Theme::dark(), BLUE)[0].0;
490        assert!((bar.x - 60.0).abs() < 0.5, "{bar:?}");
491    }
492
493    /// **Both factors at one**: the box is exactly its child, wherever it is — the usual way to
494    /// stop a centre taking the page.
495    #[test]
496    fn factors_of_one_hug_the_child() {
497        let root = Container::<()>::new().child(over_a_bar(
498            Center::new(square()).width_factor(1.0).height_factor(1.0),
499        ));
500        let size = Size::new(200.0, 200.0);
501        let at = red(&root, size);
502        assert!(near(at, 0.0, 0.0), "{at:?}");
503        assert!((bar_top(&root, size) - 20.0).abs() < 0.5);
504    }
505
506    /// **Below one**, the box is smaller than the child, and the child is not cut: it spills,
507    /// as the reference's does.
508    #[test]
509    fn a_factor_below_one_lets_the_child_spill() {
510        let size = Size::new(200.0, 200.0);
511        let root = over_a_bar(Aligned::new(Alignment::TOP_CENTER, square()).height_factor(0.5));
512        let (at, clip) = painted(&root, size, &Theme::dark(), RED)[0];
513        assert!(near(at, 90.0, 0.0), "{at:?}");
514        assert!(clip.height >= 20.0, "not cut to the box: {clip:?}");
515        assert!((bar_top(&root, size) - 10.0).abs() < 0.5);
516    }
517
518    /// **A directional anchor** on an axis with a factor follows the script: the start is the
519    /// right in right-to-left.
520    #[test]
521    fn a_start_anchor_with_a_factor_follows_the_script() {
522        let size = Size::new(200.0, 100.0);
523        let root = Flex::<()>::row().width(200.0).height(100.0).child(
524            Aligned::new(frus_core::AlignmentDirectional::CENTER_START, square()).width_factor(4.0),
525        );
526        let ltr = painted(&root, size, &Theme::dark(), RED)[0].0;
527        assert!(near(ltr, 0.0, 40.0), "{ltr:?}");
528        let rtl = painted(&root, size, &Theme::dark().rtl(), RED)[0].0;
529        assert!(near(rtl, 180.0, 40.0), "{rtl:?}");
530    }
531
532    /// **A new factor is a new box**, on a frame that reuses the last one's layout.
533    #[test]
534    fn a_new_factor_is_a_new_box() {
535        let runtime = Runtime::default();
536        let size = Size::new(200.0, 200.0);
537        let tops: Vec<f32> = [2.0, 3.0]
538            .into_iter()
539            .map(|factor| {
540                let root = over_a_bar(Center::new(square()).height_factor(factor));
541                let ui = build_ui(&root, size, &runtime, &Theme::dark());
542                ui.scene()
543                    .primitives()
544                    .iter()
545                    .find_map(|p| match p {
546                        Primitive::Rect { rect, color, .. } if *color == BLUE => Some(rect.y),
547                        _ => None,
548                    })
549                    .expect("the bar")
550            })
551            .collect();
552        assert_eq!(tops, vec![40.0, 60.0]);
553    }
554
555    /// **A paragraph under a width factor wraps at the room**, as the reference's does, and the
556    /// box is its longest line: the room is the most it may take, not nothing (milestone 598).
557    #[test]
558    fn a_paragraph_under_a_width_factor_wraps_at_the_room() {
559        let size = Size::new(120.0, 400.0);
560        let words = "one two three four five six seven eight nine ten eleven twelve";
561        let height = |text: &str| {
562            bar_top(
563                &over_a_bar(Aligned::new(Alignment::TOP_LEFT, Text::new(text)).width_factor(1.0)),
564                size,
565            )
566        };
567        let line = height("one");
568        let paragraph = height(words);
569        assert!(
570            paragraph > 2.5 * line,
571            "wrapped: {paragraph} against {line}"
572        );
573    }
574
575    /// **Below one**, a paragraph is still laid out at the room it was measured in, not at the
576    /// smaller box: it wraps at 120 and spills past a 60-px box.
577    #[test]
578    fn a_paragraph_under_a_small_factor_keeps_its_lines() {
579        let words = "one two three four five six seven eight nine ten eleven twelve";
580        let root =
581            over_a_bar(Aligned::new(Alignment::TOP_LEFT, Text::new(words)).width_factor(0.5));
582        let ui = build_ui(
583            &root,
584            Size::new(120.0, 400.0),
585            &Runtime::default(),
586            &Theme::dark(),
587        );
588        let wraps_at = ui
589            .scene()
590            .primitives()
591            .iter()
592            .find_map(|p| match p {
593                Primitive::Text { max_width, .. } => Some(*max_width),
594                _ => None,
595            })
596            .expect("the paragraph");
597        assert_eq!(wraps_at, Some(120.0));
598    }
599}