Skip to main content

frus_widgets/
transitions.rs

1//! The **explicit** transitions: a widget driven by a value the *application* owns.
2//!
3//! The other family, in [`crate::animated`], is implicit: you change a target and the
4//! widget catches up on the framework's clock. These take the number itself — a progress
5//! the caller stepped, a spring's current value, a fraction of a gesture — so their timing
6//! is the application's, and two of them handed the same number move together.
7//!
8//! ## What they are, here
9//!
10//! In the reference this is a family of fifteen classes, because there a widget that
11//! animates has to *listen*: an `Animation<double>` is an object, and each transition
12//! subscribes to one and rebuilds when it ticks. There is no listening here and no retained
13//! tree — the view is rebuilt every frame and an animation is a number — so each of these
14//! is a **thin wrapper over a widget that already takes a value**, and that is not a
15//! shortcut but the shape of the thing.
16//!
17//! What the names buy is that the rule is written once. `SlideTransition::from_edge` is a
18//! sign and a subtraction that every application would otherwise get wrong once; the
19//! progress conventions (`0` = not started, `1` = arrived) are stated in one place; and a
20//! reader coming from the reference's vocabulary finds them under the names they know.
21//!
22//! ## The five here, and the ten that are not
23//!
24//! These three are what a **transition between two states of a screen** is made of, in the
25//! reference too: a fade, a slide, and a scale. The rest of that file maps onto this
26//! framework rather than being missing from it:
27//!
28//! - `RotationTransition` is [`crate::Transform::rotate`] on a value — or
29//!   [`crate::AnimatedRotation`] when the timing is not the caller's.
30//! - `PositionedTransition` and `RelativePositionedTransition` are [`crate::Positioned`]
31//!   with interpolated pins; `AlignTransition` is `Container::alignment`;
32//!   `MatrixTransition` is [`crate::Transform`]'s composed matrix.
33//! - `AnimatedBuilder`, `ListenableBuilder` and `AnimatedWidget` are **nothing** here, and
34//!   that is the whole difference between the two frameworks: they exist to rebuild a
35//!   subtree when a value changes, and here the subtree is rebuilt every frame anyway.
36//! - `SliverFadeTransition` waits on slivers, which this framework does not have.
37//! - `SizeTransition` animates a size it has to **measure**, and a widget cannot measure
38//!   its children here — see the entry in `ROADMAP.md` and issue #52. Not a wrapper, and
39//!   not this milestone.
40//! - [`DecoratedBoxTransition`] and [`DefaultTextStyleTransition`] are here since
41//!   milestone 501. They are not wrappers over one value but interpolations of a whole
42//!   decoration and a whole text style, each part by its own rule — see
43//!   `BoxDecoration::lerp` and `TextStyle::lerp`, which is where the real work is.
44
45use frus_core::{Alignment, BoxDecoration, Curve, Rect, Scene, TextAlign, TextOverflow, TextStyle};
46use frus_layout::Style;
47
48use crate::interaction::Status;
49use crate::theme::Theme;
50use crate::widget::Widget;
51
52/// **Fades** its child by a value the caller owns: `0.0` invisible, `1.0` solid.
53///
54/// The subtree is composited as one group, so overlapping children do not darken where
55/// they overlap — the difference between fading a picture and fading each of its parts.
56///
57/// ```
58/// use frus_widgets::{FadeTransition, Text};
59///
60/// let entering = 0.4_f32;
61/// let _fading: FadeTransition<()> = FadeTransition::new(entering, Text::new("Details"));
62/// ```
63///
64/// It is [`crate::Opacity`] with the name the reference uses, which is worth having: a
65/// reader looking for the explicit half of the animation library looks for this word.
66pub struct FadeTransition<Msg = crate::callback::Callback> {
67    inner: Box<dyn Widget<Msg>>,
68}
69
70impl<Msg: Clone + 'static> FadeTransition<Msg> {
71    /// Fades `child` to `opacity`, clamped to `0..=1`.
72    pub fn new(opacity: f32, child: impl Widget<Msg> + 'static) -> Self {
73        Self {
74            inner: Box::new(crate::Opacity::new(opacity.clamp(0.0, 1.0), child)),
75        }
76    }
77}
78
79impl<Msg> FadeTransition<Msg> {
80    /// A transparent wrapper: the box is the child's.
81    fn restyle(&self, base: Style) -> Style {
82        base
83    }
84}
85
86crate::transparent::forward_transparent!(FadeTransition {
87    /// Forwarded, all of them: a fade is not an identity, not a place, not a theme and
88    /// not a surface. It is its child, disappearing.
89    fn key(&self) -> Option<u64> {
90        self.inner.key()
91    }
92    fn positioned(&self) -> Option<crate::positioned::Positioning> {
93        self.inner.positioned()
94    }
95    fn theme_override(
96        &self,
97        inherited: &crate::theme::Theme,
98    ) -> Option<Box<crate::theme::Theme>> {
99        self.inner.theme_override(inherited)
100    }
101    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
102        self.inner.media_override(inherited)
103    }
104    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
105        self.inner.scaffold_override()
106    }
107    /// Forwarded: a wrapper around a form is still that form (milestone 512).
108    fn autofill_group(&self) -> bool {
109        self.inner.autofill_group()
110    }
111});
112
113/// The edge a [`SlideTransition::from_edge`] comes in from.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum SlideFrom {
116    /// From beyond the left edge, moving right.
117    Left,
118    /// From beyond the right edge, moving left.
119    Right,
120    /// From above, moving down.
121    Top,
122    /// From below, moving up.
123    Bottom,
124}
125
126/// **Slides** its child by a value the caller owns, stated as a **fraction of the child's
127/// own size**: `(1.0, 0.0)` is one whole width to the right.
128///
129/// A fraction rather than pixels because the width is the layout's to decide, and the
130/// number that puts a panel exactly off its own edge is `1` whatever that width turns out
131/// to be — which is what makes the same call work for a sheet, a drawer and a row.
132///
133/// Layout is untouched: the box stays where it was put and the neighbours do not move.
134///
135/// ```
136/// use frus_widgets::{SlideFrom, SlideTransition, Text};
137///
138/// let entering = 0.25_f32;
139/// // A quarter of the way in from the left: three quarters of a width still outside.
140/// let _panel: SlideTransition<()> =
141///     SlideTransition::from_edge(SlideFrom::Left, entering, Text::new("Filters"));
142/// ```
143///
144/// [`SlideTransition::from_edge`] is the reason this has a name at all. *Coming in from
145/// the left* is a negative offset that shrinks to nought as the progress grows, and every
146/// application that writes it by hand gets the sign wrong once.
147pub struct SlideTransition<Msg = crate::callback::Callback> {
148    inner: Box<dyn Widget<Msg>>,
149}
150
151impl<Msg: Clone + 'static> SlideTransition<Msg> {
152    /// Slides `child` by `fx` widths across and `fy` heights down.
153    pub fn new(fx: f32, fy: f32, child: impl Widget<Msg> + 'static) -> Self {
154        Self {
155            inner: Box::new(crate::FractionalTranslation::new(fx, fy).child(child)),
156        }
157    }
158
159    /// Slides `child` in from an edge: `progress` of `0` leaves it a whole size outside,
160    /// `1` puts it exactly where the layout laid it out. Values between the two are the
161    /// journey, and values outside them overshoot — which is what a spring does at the end
162    /// of its travel, and is deliberately not clamped away.
163    pub fn from_edge(edge: SlideFrom, progress: f32, child: impl Widget<Msg> + 'static) -> Self {
164        let away = 1.0 - progress;
165        let (fx, fy) = match edge {
166            SlideFrom::Left => (-away, 0.0),
167            SlideFrom::Right => (away, 0.0),
168            SlideFrom::Top => (0.0, -away),
169            SlideFrom::Bottom => (0.0, away),
170        };
171        Self::new(fx, fy, child)
172    }
173}
174
175impl<Msg> SlideTransition<Msg> {
176    /// A transparent wrapper: the box is the child's.
177    fn restyle(&self, base: Style) -> Style {
178        base
179    }
180}
181
182crate::transparent::forward_transparent!(SlideTransition {
183    /// Forwarded, all of them: a slide is not an identity, not a place, not a theme and
184    /// not a surface. It is its child, moving.
185    fn key(&self) -> Option<u64> {
186        self.inner.key()
187    }
188    fn positioned(&self) -> Option<crate::positioned::Positioning> {
189        self.inner.positioned()
190    }
191    fn theme_override(
192        &self,
193        inherited: &crate::theme::Theme,
194    ) -> Option<Box<crate::theme::Theme>> {
195        self.inner.theme_override(inherited)
196    }
197    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
198        self.inner.media_override(inherited)
199    }
200    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
201        self.inner.scaffold_override()
202    }
203    /// Forwarded: a wrapper around a form is still that form (milestone 512).
204    fn autofill_group(&self) -> bool {
205        self.inner.autofill_group()
206    }
207});
208
209/// **Scales** its child by a value the caller owns, about its centre or a pivot of the
210/// caller's choosing. `1.0` is neutral.
211///
212/// Paint only: the box does not change, so nothing around it moves and the child may spill
213/// past its own edges. That is what a control popping under a press wants, and what a
214/// dialog growing into place wants.
215///
216/// ```
217/// use frus_widgets::{ScaleTransition, Text};
218///
219/// let opening = 0.9_f32;
220/// let _dialog: ScaleTransition<()> = ScaleTransition::new(opening, Text::new("Delete?"));
221/// ```
222///
223/// The pivot is a choice of origin rather than a quantity, so it is an argument here and
224/// never a thing to interpolate — the same rule [`crate::AnimatedScale`] states.
225pub struct ScaleTransition<Msg = crate::callback::Callback> {
226    inner: Box<dyn Widget<Msg>>,
227}
228
229impl<Msg: Clone + 'static> ScaleTransition<Msg> {
230    /// Scales `child` by `scale`, about its centre.
231    pub fn new(scale: f32, child: impl Widget<Msg> + 'static) -> Self {
232        Self::about(scale, Alignment::CENTER, child)
233    }
234
235    /// The same, about a `pivot` — the corner or edge that stays put.
236    pub fn about(scale: f32, pivot: Alignment, child: impl Widget<Msg> + 'static) -> Self {
237        Self {
238            inner: Box::new(
239                crate::Transform::scale_xy_from(scale, scale, pivot)
240                    // No duration: an explicit transition is **already** where the caller
241                    // says it is. A tween here would chase the value the application is
242                    // itself stepping, and the two clocks would fight.
243                    .child(child),
244            ),
245        }
246    }
247}
248
249impl<Msg> ScaleTransition<Msg> {
250    /// A transparent wrapper: the box is the child's.
251    fn restyle(&self, base: Style) -> Style {
252        base
253    }
254}
255
256crate::transparent::forward_transparent!(ScaleTransition {
257    /// Forwarded, all of them: a scale is not an identity, not a place, not a theme and
258    /// not a surface. It is its child, growing.
259    fn key(&self) -> Option<u64> {
260        self.inner.key()
261    }
262    fn positioned(&self) -> Option<crate::positioned::Positioning> {
263        self.inner.positioned()
264    }
265    fn theme_override(
266        &self,
267        inherited: &crate::theme::Theme,
268    ) -> Option<Box<crate::theme::Theme>> {
269        self.inner.theme_override(inherited)
270    }
271    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
272        self.inner.media_override(inherited)
273    }
274    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
275        self.inner.scaffold_override()
276    }
277    /// Forwarded: a wrapper around a form is still that form (milestone 512).
278    fn autofill_group(&self) -> bool {
279        self.inner.autofill_group()
280    }
281});
282
283/// A curve applied to a progress, for a caller stepping one by hand: `curve.transform(t)`
284/// is the same call the framework's own animations make, so an explicit transition and an
285/// implicit one given the same curve are on the same shape.
286///
287/// It is here rather than left to the caller because reaching for
288/// [`frus_core::Curve::transform`] means knowing it exists, and the name of the thing you
289/// are looking for is *ease this progress*.
290pub fn eased(progress: f32, curve: &Curve) -> f32 {
291    curve.transform(progress.clamp(0.0, 1.0))
292}
293
294/// Where a [`DecoratedBoxTransition`] paints its decoration.
295#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
296pub enum DecorationPosition {
297    /// Behind the child: a fill, a card, a highlight under a row. The default.
298    #[default]
299    Background,
300    /// Over the child: a border around a photograph, a wash across a tile that is
301    /// becoming disabled.
302    Foreground,
303}
304
305/// **Paints a decoration that moves** by a value the caller owns — its fill, gradient,
306/// border, corners and shadow, each travelling by its own rule (see
307/// [`BoxDecoration::lerp`]).
308///
309/// ```
310/// use frus_core::{Border, BoxDecoration, Color};
311/// use frus_widgets::{DecoratedBoxTransition, Text};
312///
313/// let selected = 0.6_f32; // the application's own progress
314/// let _row: DecoratedBoxTransition<()> = DecoratedBoxTransition::between(
315///     BoxDecoration::default().radius(4.0),
316///     BoxDecoration::filled(Color::rgb(0.2, 0.4, 0.9))
317///         .radius(12.0)
318///         .border(Border::new(2.0, Color::rgb(0.1, 0.2, 0.6))),
319///     selected,
320///     Text::new("Reminders"),
321/// );
322/// ```
323///
324/// **Unlike the other three here, this is a node of its own**, with the child beneath it:
325/// a decoration is something painted, and something painted has a box. That box is the
326/// child's, and **the child is not inset by the border** — the same as the reference's
327/// `DecoratedBox`, and for a reason that matters more here than there: a line that
328/// thickens over a transition would otherwise push the content about on every frame of
329/// it. A border that must not overlap the content wants padding the caller chooses.
330///
331/// For a decoration that should catch up with a target on the framework's clock instead,
332/// [`crate::AnimatedContainer`] is the implicit half.
333pub struct DecoratedBoxTransition<Msg = crate::callback::Callback> {
334    children: Vec<Box<dyn Widget<Msg>>>,
335    decoration: BoxDecoration,
336    position: DecorationPosition,
337}
338
339impl<Msg> DecoratedBoxTransition<Msg> {
340    /// `child` over `decoration`, as it stands.
341    pub fn new(decoration: BoxDecoration, child: impl Widget<Msg> + 'static) -> Self {
342        Self {
343            children: vec![Box::new(child)],
344            decoration,
345            position: DecorationPosition::Background,
346        }
347    }
348
349    /// `child` over the decoration `progress` of the way from `from` to `to` — which is
350    /// what this is nearly always called with.
351    pub fn between(
352        from: BoxDecoration,
353        to: BoxDecoration,
354        progress: f32,
355        child: impl Widget<Msg> + 'static,
356    ) -> Self {
357        Self::new(from.lerp(to, progress), child)
358    }
359
360    /// Paints the decoration over the child instead of behind it.
361    pub fn position(mut self, position: DecorationPosition) -> Self {
362        self.position = position;
363        self
364    }
365}
366
367impl<Msg: Clone> Widget<Msg> for DecoratedBoxTransition<Msg> {
368    fn style(&self) -> Style {
369        // Nothing of its own: the box is the child's, and the border does not inset it.
370        Style::default()
371    }
372
373    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
374        &self.children
375    }
376
377    fn paint(&self, bounds: Rect, status: Status, _theme: &Theme, scene: &mut Scene) {
378        if self.position == DecorationPosition::Background {
379            self.decoration.paint_into(scene, bounds, status.opacity);
380        }
381    }
382
383    /// In front of the child, through the walk's one place for painting after a subtree.
384    fn foreground(&self, _theme: &Theme) -> Option<BoxDecoration> {
385        (self.position == DecorationPosition::Foreground).then_some(self.decoration)
386    }
387
388    fn on_click(&self) -> Option<Msg> {
389        None
390    }
391
392    fn debug_name(&self) -> &'static str {
393        "DecoratedBoxTransition"
394    }
395}
396
397/// **Hands a text style that moves down to everything below it**, by a value the caller
398/// owns. The explicit half of [`crate::AnimatedDefaultTextStyle`].
399///
400/// ```
401/// use frus_core::TextStyle;
402/// use frus_widgets::{DefaultTextStyleTransition, Text};
403///
404/// let arriving = 0.3_f32; // the application's own progress
405/// let _title: DefaultTextStyleTransition<()> = DefaultTextStyleTransition::between(
406///     TextStyle::NONE.size(20.0),
407///     TextStyle::NONE.size(24.0),
408///     arriving,
409///     Text::new("Groceries"),
410/// );
411/// ```
412///
413/// What travels, what holds and what swaps half-way is [`TextStyle::lerp`]'s business.
414/// Alignment, wrapping, overflow and the line count take effect at once, as they do on the
415/// implicit one: they are arrangements, not quantities.
416///
417/// **It is a transparent wrapper, which its implicit twin cannot be.** An implicit
418/// animation keeps a timeline in the runtime, and a timeline belongs to a node, so two of
419/// those nested have to be two nodes or they would share one. This keeps nothing — the
420/// caller's number is the whole of its state — so two nested fuse into one node and simply
421/// hand down both of what they say, the inner one asked by the outer as a nested theme is.
422///
423/// A text that names a field for itself keeps it: an inherited style answers a question,
424/// it does not overrule an answer.
425pub struct DefaultTextStyleTransition<Msg = crate::callback::Callback> {
426    handed: crate::widgettheme::DefaultTextStyle,
427    inner: Box<dyn Widget<Msg>>,
428}
429
430impl<Msg: Clone + 'static> DefaultTextStyleTransition<Msg> {
431    /// `child`'s subtree wearing `style`, as it stands.
432    pub fn new(style: TextStyle, child: impl Widget<Msg> + 'static) -> Self {
433        Self {
434            handed: crate::widgettheme::DefaultTextStyle::from_text_style(style),
435            inner: Box::new(child),
436        }
437    }
438
439    /// `child`'s subtree wearing the style `progress` of the way from `from` to `to`.
440    pub fn between(
441        from: TextStyle,
442        to: TextStyle,
443        progress: f32,
444        child: impl Widget<Msg> + 'static,
445    ) -> Self {
446        Self::new(from.lerp(to, progress), child)
447    }
448
449    /// Where the lines sit inside their box. **Not animated.**
450    pub fn align(mut self, align: TextAlign) -> Self {
451        self.handed.align = Some(align);
452        self
453    }
454
455    /// Whether the text wraps at the width it is given. **Not animated.**
456    pub fn soft_wrap(mut self, wrap: bool) -> Self {
457        self.handed.soft_wrap = Some(wrap);
458        self
459    }
460
461    /// What becomes of text that does not fit. **Not animated.**
462    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
463        self.handed.overflow = Some(overflow);
464        self
465    }
466
467    /// At most this many lines. **Not animated.**
468    pub fn max_lines(mut self, lines: usize) -> Self {
469        self.handed.max_lines = Some(lines);
470        self
471    }
472}
473
474impl<Msg> DefaultTextStyleTransition<Msg> {
475    /// A transparent wrapper: the box is the child's. A style reaches sizes through the
476    /// child's own `style_themed`, not through this.
477    fn restyle(&self, base: Style) -> Style {
478        base
479    }
480}
481
482crate::transparent::forward_transparent!(DefaultTextStyleTransition {
483    /// A style is not an identity, not a place and not a surface: forwarded.
484    fn key(&self) -> Option<u64> {
485        self.inner.key()
486    }
487    fn positioned(&self) -> Option<crate::positioned::Positioning> {
488        self.inner.positioned()
489    }
490
491    /// The one thing this does not delegate: the style it hands down, merged onto what it
492    /// inherits — and then the child asked in turn, because a transparent wrapper *is* its
493    /// child and a nested one would otherwise never be asked at all.
494    fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
495        let mut mine = inherited.clone();
496        mine.widgets.text = mine.widgets.text.merge(self.handed);
497        Some(
498            self.inner
499                .theme_override(&mine)
500                .unwrap_or_else(|| Box::new(mine)),
501        )
502    }
503
504    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
505        self.inner.media_override(inherited)
506    }
507    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
508        self.inner.scaffold_override()
509    }
510    /// Forwarded: a wrapper around a form is still that form (milestone 512).
511    fn autofill_group(&self) -> bool {
512        self.inner.autofill_group()
513    }
514});
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::{build_ui, Container, Runtime, Theme};
520    use frus_core::{Color, Primitive, Rect, Size};
521
522    const RED: Color = Color {
523        r: 1.0,
524        g: 0.0,
525        b: 0.0,
526        a: 1.0,
527    };
528
529    const BLUE: Color = Color {
530        r: 0.0,
531        g: 0.0,
532        b: 1.0,
533        a: 1.0,
534    };
535
536    fn mark() -> Container<()> {
537        Container::new().width(40.0).height(20.0).color(RED)
538    }
539
540    /// Where the blue neighbour was painted, through any layer above it.
541    fn blue_y(primitives: &[Primitive]) -> Option<f32> {
542        for primitive in primitives {
543            match primitive {
544                Primitive::Rect { rect, color, .. } if color.b > 0.5 && color.r < 0.5 => {
545                    return Some(rect.y)
546                }
547                Primitive::Layer { primitives, .. } => {
548                    if let Some(found) = blue_y(primitives) {
549                        return Some(found);
550                    }
551                }
552                _ => {}
553            }
554        }
555        None
556    }
557
558    /// The first red rectangle, and the opacity of the layer it is inside (`1.0` when
559    /// there is no layer).
560    fn painted(root: &dyn Widget<()>) -> (Rect, f32) {
561        let ui = build_ui(
562            root,
563            Size::new(100.0, 100.0),
564            &Runtime::default(),
565            &Theme::dark(),
566        );
567        fn walk(primitives: &[Primitive], opacity: f32) -> Option<(Rect, f32)> {
568            for primitive in primitives {
569                match primitive {
570                    Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
571                        return Some((*rect, opacity))
572                    }
573                    Primitive::Layer {
574                        primitives,
575                        opacity: layer,
576                        ..
577                    } => {
578                        if let Some(found) = walk(primitives, opacity * layer) {
579                            return Some(found);
580                        }
581                    }
582                    _ => {}
583                }
584            }
585            None
586        }
587        walk(ui.scene().primitives(), 1.0).expect("the mark")
588    }
589
590    /// **Nothing, half way, all of it** — the three each of these has to pin.
591    #[test]
592    fn a_fade_is_the_value_it_was_given() {
593        for (given, expected) in [(0.0, 0.0), (0.5, 0.5), (1.0, 1.0)] {
594            let root = FadeTransition::new(given, mark());
595            let (_, opacity) = painted(&root);
596            assert!(
597                (opacity - expected).abs() < 1e-3,
598                "at {given}: painted at {opacity}"
599            );
600        }
601    }
602
603    /// A slide is a multiple of the child's **own** box: a 40 px mark slid by half a width
604    /// has moved 20.
605    #[test]
606    fn a_slide_is_a_multiple_of_the_child_s_own_box() {
607        for (given, expected) in [(0.0, 0.0), (0.5, 20.0), (1.0, 40.0)] {
608            let root = crate::Flex::<()>::column().child(SlideTransition::new(given, 0.0, mark()));
609            let (rect, _) = painted(&root);
610            assert!(
611                (rect.x - expected).abs() < 0.5,
612                "at {given}: painted at {}",
613                rect.x
614            );
615        }
616    }
617
618    /// And the sign the name exists for: coming in from the left, nought is a whole width
619    /// outside and one is home.
620    #[test]
621    fn coming_in_from_an_edge_arrives_at_one() {
622        let at = |progress: f32| {
623            let root = crate::Flex::<()>::column().child(SlideTransition::from_edge(
624                SlideFrom::Left,
625                progress,
626                mark(),
627            ));
628            painted(&root).0.x
629        };
630        assert!((at(0.0) + 40.0).abs() < 0.5, "a whole width outside");
631        assert!((at(0.5) + 20.0).abs() < 0.5, "half of one, still outside");
632        assert!(at(1.0).abs() < 0.5, "and home");
633
634        // Each edge sends it the way its name says.
635        let mark_at = |edge: SlideFrom| {
636            let root =
637                crate::Flex::<()>::column().child(SlideTransition::from_edge(edge, 0.0, mark()));
638            let rect = painted(&root).0;
639            (rect.x, rect.y)
640        };
641        assert!(mark_at(SlideFrom::Left).0 < -1.0);
642        assert!(mark_at(SlideFrom::Right).0 > 1.0);
643        assert!(mark_at(SlideFrom::Top).1 < -1.0);
644        assert!(mark_at(SlideFrom::Bottom).1 > 1.0);
645    }
646
647    /// A scale is a **paint** transform: it reaches the screen as a matrix on a composited
648    /// layer, and the box underneath is untouched — so the neighbour below does not move
649    /// however big the child is drawn. A neutral scale costs no layer at all.
650    #[test]
651    fn a_scale_is_paint_and_not_layout() {
652        let built = |scale: f32| {
653            let root = crate::Flex::<()>::column()
654                .width(100.0)
655                .child(ScaleTransition::new(scale, mark()))
656                .child(Container::<()>::new().width(10.0).height(10.0).color(BLUE));
657            let ui = build_ui(
658                &root,
659                Size::new(100.0, 100.0),
660                &Runtime::default(),
661                &Theme::dark(),
662            );
663            let matrix = ui.scene().primitives().iter().find_map(|p| match p {
664                Primitive::Layer {
665                    transform: Some(t), ..
666                } => Some(t.affine.m[0]),
667                _ => None,
668            });
669            let neighbour = blue_y(ui.scene().primitives()).expect("the neighbour");
670            (matrix, neighbour)
671        };
672        for (given, expected) in [(0.5, Some(0.5)), (1.0, None), (2.0, Some(2.0))] {
673            let (matrix, neighbour) = built(given);
674            match (matrix, expected) {
675                (Some(m), Some(e)) => {
676                    assert!((m - e).abs() < 1e-3, "at {given}: painted at {m}")
677                }
678                (None, None) => {}
679                _ => panic!("at {given}: {matrix:?} where {expected:?} was expected"),
680            }
681            assert!(
682                (neighbour - 20.0).abs() < 0.5,
683                "the neighbour stayed where the layout put it: {neighbour}"
684            );
685        }
686    }
687
688    /// The curve is the framework's own, so the two families agree on what *ease-out, half
689    /// way* means.
690    #[test]
691    fn easing_a_progress_is_the_frameworks_own_curve() {
692        let curve = Curve::ease_out();
693        assert!(eased(0.0, &curve).abs() < 1e-3);
694        assert!((eased(1.0, &curve) - 1.0).abs() < 1e-3);
695        assert_eq!(eased(0.5, &curve), curve.transform(0.5));
696        assert!(
697            eased(0.5, &curve) > 0.5,
698            "an ease-out is ahead at the middle"
699        );
700        assert_eq!(
701            eased(2.0, &curve),
702            eased(1.0, &curve),
703            "and a progress past its end is its end"
704        );
705    }
706
707    // --- A decoration and a text style (milestone 501) ---
708
709    use frus_core::{Border, TextStyle};
710
711    /// Every rectangle painted, in paint order, with its colour — through any layer.
712    fn rects_of(root: &dyn Widget<()>) -> Vec<(Rect, Color)> {
713        let ui = build_ui(
714            root,
715            Size::new(200.0, 200.0),
716            &Runtime::default(),
717            &Theme::dark(),
718        );
719        fn walk(primitives: &[Primitive], out: &mut Vec<(Rect, Color)>) {
720            for primitive in primitives {
721                match primitive {
722                    Primitive::Rect { rect, color, .. } => out.push((*rect, *color)),
723                    Primitive::Layer { primitives, .. } => walk(primitives, out),
724                    _ => {}
725                }
726            }
727        }
728        let mut out = Vec::new();
729        walk(ui.scene().primitives(), &mut out);
730        out
731    }
732
733    /// Every run of words painted: its size and its colour.
734    fn texts_of(root: &dyn Widget<()>) -> Vec<(f32, Color)> {
735        let ui = build_ui(
736            root,
737            Size::new(200.0, 200.0),
738            &Runtime::default(),
739            &Theme::dark(),
740        );
741        fn walk(primitives: &[Primitive], out: &mut Vec<(f32, Color)>) {
742            for primitive in primitives {
743                match primitive {
744                    Primitive::Text { size, color, .. } => out.push((*size, *color)),
745                    Primitive::Layer { primitives, .. } => walk(primitives, out),
746                    _ => {}
747                }
748            }
749        }
750        let mut out = Vec::new();
751        walk(ui.scene().primitives(), &mut out);
752        out
753    }
754
755    fn close(a: Color, b: Color) -> bool {
756        (a.r - b.r).abs() < 1e-4
757            && (a.g - b.g).abs() < 1e-4
758            && (a.b - b.b).abs() < 1e-4
759            && (a.a - b.a).abs() < 1e-4
760    }
761
762    /// **At the start, half-way and at the end — read off the scene**, which is where a
763    /// decoration that moved correctly and never reached the screen would be caught.
764    #[test]
765    fn a_decoration_is_painted_as_it_stands() {
766        for (t, want) in [(0.0, RED), (0.5, RED.lerp(BLUE, 0.5)), (1.0, BLUE)] {
767            let tree = DecoratedBoxTransition::<()>::between(
768                BoxDecoration::filled(RED),
769                BoxDecoration::filled(BLUE),
770                t,
771                Container::new().width(40.0).height(20.0),
772            );
773            let rects = rects_of(&tree);
774            assert_eq!(rects.len(), 1, "at {t}: {rects:?}");
775            assert!(close(rects[0].1, want), "at {t}: {:?}", rects[0].1);
776        }
777    }
778
779    /// **A fill fading out keeps its hue on the screen too**, not only in the arithmetic.
780    #[test]
781    fn a_fading_fill_is_painted_in_its_own_colour() {
782        let tree = DecoratedBoxTransition::<()>::between(
783            BoxDecoration::filled(RED),
784            BoxDecoration::default(),
785            0.5,
786            Container::new().width(40.0).height(20.0),
787        );
788        let rects = rects_of(&tree);
789        assert_eq!(rects.len(), 1, "{rects:?}");
790        assert!(close(rects[0].1, RED.fade(0.5)), "{:?}", rects[0].1);
791    }
792
793    /// **A line that thickens does not move the content.** The child is not inset by the
794    /// border, so a border growing from nothing to eight pixels leaves it where it was on
795    /// every frame — which an inset would not.
796    #[test]
797    fn a_thickening_border_does_not_move_the_child() {
798        let child_at = |t: f32| {
799            let tree = DecoratedBoxTransition::<()>::between(
800                BoxDecoration::default(),
801                BoxDecoration::default().border(Border::new(8.0, BLUE)),
802                t,
803                mark(),
804            );
805            rects_of(&tree)
806                .into_iter()
807                .find(|(_, c)| close(*c, RED))
808                .expect("the child")
809                .0
810        };
811        let (start, end) = (child_at(0.0), child_at(1.0));
812        assert_eq!(
813            (start.x, start.y),
814            (end.x, end.y),
815            "the child stayed put: {start:?} then {end:?}"
816        );
817    }
818
819    /// **In front, it is painted over the child**; behind is the default.
820    #[test]
821    fn in_front_the_decoration_covers_the_child() {
822        let wash = || BoxDecoration::filled(BLUE.fade(0.3));
823        let order = |tree: &DecoratedBoxTransition<()>| {
824            let rects = rects_of(tree);
825            let child = rects.iter().position(|(_, c)| close(*c, RED));
826            let over = rects.iter().position(|(_, c)| c.b > 0.5);
827            (child.expect("the child"), over.expect("the wash"))
828        };
829        let (child, over) = order(
830            &DecoratedBoxTransition::new(wash(), mark()).position(DecorationPosition::Foreground),
831        );
832        assert!(child < over, "in front: the wash comes after the child");
833        let (child, under) = order(&DecoratedBoxTransition::new(wash(), mark()));
834        assert!(under < child, "behind, by default");
835    }
836
837    /// **The words are drawn at the style in flight** — start, half-way and end.
838    #[test]
839    fn a_text_style_is_handed_down_as_it_stands() {
840        for (t, want) in [(0.0, 12.0), (0.5, 18.0), (1.0, 24.0)] {
841            let tree = DefaultTextStyleTransition::<()>::between(
842                TextStyle::NONE.size(12.0),
843                TextStyle::NONE.size(24.0),
844                t,
845                crate::Text::new("Ag"),
846            );
847            let sizes: Vec<f32> = texts_of(&tree).into_iter().map(|(s, _)| s).collect();
848            assert_eq!(sizes, vec![want], "at {t}");
849        }
850    }
851
852    /// **Two nested fuse into one node and hand down both.** An outer one moving the
853    /// colour and an inner one the size: the words wear both, which means the outer asked
854    /// the inner — a transparent wrapper *is* its child, and the inner is never visited as a
855    /// node of its own.
856    #[test]
857    fn two_nested_hand_down_both_halves() {
858        let tree = DefaultTextStyleTransition::<()>::new(
859            TextStyle::NONE.color(BLUE),
860            DefaultTextStyleTransition::new(TextStyle::NONE.size(20.0), crate::Text::new("Ag")),
861        );
862        let texts = texts_of(&tree);
863        assert_eq!(texts.len(), 1, "{texts:?}");
864        assert_eq!(texts[0].0, 20.0, "the inner one's size");
865        assert!(close(texts[0].1, BLUE), "and the outer one's colour");
866    }
867
868    /// A text that names its own size keeps it: an inherited style answers a question, it
869    /// does not overrule an answer.
870    #[test]
871    fn a_text_that_names_its_size_keeps_it() {
872        let tree = DefaultTextStyleTransition::<()>::new(
873            TextStyle::NONE.size(30.0),
874            crate::Text::new("Ag").size(11.0),
875        );
876        let sizes: Vec<f32> = texts_of(&tree).into_iter().map(|(s, _)| s).collect();
877        assert_eq!(sizes, vec![11.0]);
878    }
879}