Skip to main content

frus_widgets/
transform.rs

1//! [`Transform`]: offsets its child at **paint** time, without touching layout, and
2//! [`FractionalTranslation`]: the same offset stated as a fraction of the child's own size.
3
4use frus_core::{Alignment, Curve, Rect, Scene};
5use frus_layout::Style;
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// Transforms its child **at paint time** (both rendering **and** hit-testing),
12/// without changing layout: the siblings do not move, and the child may overflow
13/// its box, since there is no clipping.
14///
15/// Two transformations, each combinable with a `Tween` read in `view()` to
16/// **animate**:
17/// - **`translate(dx, dy)`** — offsets the subtree (a dot in a corner, an entry
18///   that slides in, an error shake…).
19/// - **`scale(factor)`** / **`scale_xy(sx, sy)`** — scales the subtree about a
20///   pivot (the centre by default), uniformly or **per axis** (stretching,
21///   flattening): a button's "pop" effect, a thumbnail zoom.
22/// - **`rotate(radians)`** — rotates the subtree about a pivot (the centre by
23///   default): a needle, a chevron that flips, a spinner.
24///
25/// Scale and rotation (and their composition) are melted into **a single affine
26/// matrix** carried by a transformed composited layer; hit-testing applies the
27/// **inverse** matrix to the point. They **compose** within one widget through the
28/// chainers `and_translate`, `and_scale` / `and_scale_xy`, `and_rotate` — applied in
29/// the order translation → scale → rotation (the translation innermost, through the
30/// child offset). So `Transform::scale(1.5).and_rotate(0.2)` enlarges **and**
31/// rotates, exactly, with no composition approximation.
32pub struct Transform<Msg = crate::callback::Callback> {
33    dx: f32,
34    dy: f32,
35    /// `(sx, sy, pivot)` — `None` = no scaling.
36    scale: Option<(f32, f32, Alignment)>,
37    /// `(angle_radians, pivot)` — `None` = no rotation.
38    rotate: Option<(f32, Alignment)>,
39    /// `(duration, curve)` — `None` = the transform jumps to whatever it is told, which
40    /// is what every `Transform` did before this and still does unless asked otherwise.
41    anim: Option<(f32, Curve)>,
42    children: Vec<Box<dyn Widget<Msg>>>,
43}
44
45impl<Msg> Transform<Msg> {
46    /// Offsets the child by `(dx, dy)` logical pixels (x to the right, y downwards),
47    /// without touching layout.
48    pub fn translate(dx: f32, dy: f32) -> Self {
49        Self {
50            dx,
51            dy,
52            scale: None,
53            rotate: None,
54            anim: None,
55            children: Vec::new(),
56        }
57    }
58
59    /// Scales the child by `factor` **about its centre**, without touching layout
60    /// (`1.0` = neutral, `2.0` = double, `0.5` = half).
61    pub fn scale(factor: f32) -> Self {
62        Self::scale_xy_from(factor, factor, Alignment::CENTER)
63    }
64
65    /// Scales the child **per axis** (`sx` horizontal, `sy` vertical) about its
66    /// centre — stretching or flattening. `scale_xy(2.0, 1.0)` doubles the width
67    /// while keeping the height.
68    pub fn scale_xy(sx: f32, sy: f32) -> Self {
69        Self::scale_xy_from(sx, sy, Alignment::CENTER)
70    }
71
72    /// Like [`Transform::scale`], but about a `pivot` (an anchor within the box:
73    /// `Alignment::TOP_LEFT` pins the top-left corner, and so on).
74    pub fn scale_from(factor: f32, pivot: Alignment) -> Self {
75        Self::scale_xy_from(factor, factor, pivot)
76    }
77
78    /// Per-axis scaling about a `pivot` — the most general form.
79    pub fn scale_xy_from(sx: f32, sy: f32, pivot: Alignment) -> Self {
80        Self {
81            dx: 0.0,
82            dy: 0.0,
83            scale: Some((sx, sy, pivot)),
84            rotate: None,
85            anim: None,
86            children: Vec::new(),
87        }
88    }
89
90    /// Rotates the child by `radians` (clockwise) **about its centre**, without
91    /// touching layout.
92    pub fn rotate(radians: f32) -> Self {
93        Self::rotate_from(radians, Alignment::CENTER)
94    }
95
96    /// Like [`Transform::rotate`], but about a `pivot` (an anchor within the box).
97    pub fn rotate_from(radians: f32, pivot: Alignment) -> Self {
98        Self {
99            dx: 0.0,
100            dy: 0.0,
101            scale: None,
102            rotate: Some((radians, pivot)),
103            anim: None,
104            children: Vec::new(),
105        }
106    }
107
108    /// **Adds** a translation to the current transformation (composition):
109    /// `Transform::scale(1.2).and_translate(0, -4)` enlarges *and* moves up.
110    pub fn and_translate(mut self, dx: f32, dy: f32) -> Self {
111        self.dx = dx;
112        self.dy = dy;
113        self
114    }
115
116    /// **Adds** a uniform scaling (about the centre) to the current
117    /// transformation.
118    pub fn and_scale(self, factor: f32) -> Self {
119        self.and_scale_xy(factor, factor)
120    }
121
122    /// **Adds** a per-axis scaling (about the centre) to the current transformation.
123    pub fn and_scale_xy(mut self, sx: f32, sy: f32) -> Self {
124        self.scale = Some((sx, sy, Alignment::CENTER));
125        self
126    }
127
128    /// **Adds** a rotation (about the centre) to the current transformation:
129    /// `Transform::scale(1.5).and_rotate(0.2)` enlarges *and* rotates.
130    pub fn and_rotate(mut self, radians: f32) -> Self {
131        self.rotate = Some((radians, Alignment::CENTER));
132        self
133    }
134
135    /// **Moves to this transform rather than jumping to it**, over `duration` seconds
136    /// along `curve`.
137    ///
138    /// The widget does not time anything: it declares where it is going and the runtime
139    /// drives the scales and the turn there, which is how every implicit animation in
140    /// this framework works and why they all share one clock.
141    ///
142    /// The **pivot** does not animate. It is a choice of origin rather than a quantity,
143    /// and interpolating it would slide a shape across the screen while every number
144    /// describing the shape stood still.
145    ///
146    /// ```
147    /// use frus_core::Curve;
148    /// use frus_widgets::{Text, Transform};
149    ///
150    /// let pressed = true;
151    /// let _button: Transform<()> = Transform::scale(if pressed { 0.95 } else { 1.0 })
152    ///     .animated(0.12, Curve::ease_out())
153    ///     .child(Text::new("Tap"));
154    /// ```
155    #[must_use]
156    pub fn animated(mut self, duration: f32, curve: Curve) -> Self {
157        self.anim = Some((duration, curve));
158        self
159    }
160
161    /// Sets the transformed child.
162    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
163        self.children.clear();
164        self.children.push(Box::new(child));
165        self
166    }
167}
168
169impl<Msg: Clone> Widget<Msg> for Transform<Msg> {
170    fn style(&self) -> Style {
171        // A pass-through: the box takes its size from the context, like the child;
172        // the offset only acts at paint time (see `transform_translate`).
173        Style::default()
174    }
175
176    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
177        &self.children
178    }
179
180    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
181        // A pure transformation widget: no decoration of its own.
182    }
183
184    fn on_click(&self) -> Option<Msg> {
185        None
186    }
187
188    fn transform_translate(&self) -> Option<(f32, f32)> {
189        ((self.dx != 0.0) || (self.dy != 0.0)).then_some((self.dx, self.dy))
190    }
191
192    fn transform_scale(&self) -> Option<(f32, f32, Alignment)> {
193        self.scale
194    }
195
196    fn transform_rotate(&self) -> Option<(f32, Alignment)> {
197        self.rotate
198    }
199
200    /// Where the transform is heading, when it was told to animate. The **target** and
201    /// not the current value: `transform_scale` and `transform_rotate` above still
202    /// answer with it too, which is what an isolated frame — a test, a golden, the frame
203    /// before the loop has advanced anything — draws.
204    fn anim_transform(&self) -> Option<crate::runtime::TransformValues> {
205        self.anim.as_ref().map(|_| {
206            let (scale_x, scale_y) = self.scale.map_or((1.0, 1.0), |(x, y, _)| (x, y));
207            crate::runtime::TransformValues {
208                scale_x,
209                scale_y,
210                rotation: self.rotate.map_or(0.0, |(a, _)| a),
211            }
212        })
213    }
214
215    fn anim_duration(&self) -> f32 {
216        self.anim.as_ref().map_or(0.0, |(d, _)| *d)
217    }
218
219    fn anim_curve(&self) -> Curve {
220        self.anim.as_ref().map_or(Curve::Linear, |(_, c)| c.clone())
221    }
222}
223
224/// Offsets its child by a **fraction of the child's own size**, at paint time, without
225/// touching layout.
226///
227/// `FractionalTranslation::new(0.5, 0.0)` slides the subtree half its own width to the
228/// right; `(-1.0, 0.0)` slides it a whole width to the left, exactly clear of where it
229/// was. Like [`Transform::translate`], it moves the render **and** the hit-test, and it
230/// moves nothing else: the box stays where the layout put it and the siblings do not
231/// budge.
232///
233/// The reason it exists next to `Transform::translate` is that the number is not known
234/// where the widget is written. A panel that has to sit exactly off the edge of itself, a
235/// badge half outside its anchor, a row that slides its own width aside to uncover what is
236/// under it — each of those is a multiple of a size the layout decides, and a caller that
237/// had to pass pixels would be passing a guess.
238///
239/// ```ignore
240/// // A panel parked one full width off the left, sliding in as `open` runs 0 → 1.
241/// FractionalTranslation::new(open - 1.0, 0.0).child(panel())
242/// ```
243///
244/// In a right-to-left script the horizontal fraction follows the reading direction, as
245/// `Transform::translate` does: a positive `x` moves towards the end of the line.
246pub struct FractionalTranslation<Msg = crate::callback::Callback> {
247    fx: f32,
248    fy: f32,
249    /// `(duration, curve)` — `None` = the offset jumps to whatever it is told.
250    anim: Option<(f32, Curve)>,
251    children: Vec<Box<dyn Widget<Msg>>>,
252}
253
254impl<Msg> FractionalTranslation<Msg> {
255    /// A translation of `fx` widths across and `fy` heights down, with no child yet.
256    pub fn new(fx: f32, fy: f32) -> Self {
257        Self {
258            fx,
259            fy,
260            anim: None,
261            children: Vec::new(),
262        }
263    }
264
265    /// **Slides** to each new fraction over `duration` instead of jumping to it, on the
266    /// framework's own clock. See [`crate::AnimatedSlide`], which is this and a name.
267    pub fn animated(mut self, duration: f32, curve: Curve) -> Self {
268        self.anim = Some((duration, curve));
269        self
270    }
271
272    /// Sets the child, replacing any already there.
273    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
274        self.children.clear();
275        self.children.push(Box::new(child));
276        self
277    }
278}
279
280impl<Msg: Clone> Widget<Msg> for FractionalTranslation<Msg> {
281    fn style(&self) -> Style {
282        Style::default()
283    }
284
285    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
286        &self.children
287    }
288
289    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
290
291    fn on_click(&self) -> Option<Msg> {
292        None
293    }
294
295    fn translate_fraction(&self) -> Option<(f32, f32)> {
296        Some((self.fx, self.fy))
297    }
298
299    fn anim_offset(&self) -> Option<(f32, f32)> {
300        self.anim.as_ref().map(|_| (self.fx, self.fy))
301    }
302
303    fn anim_duration(&self) -> f32 {
304        self.anim.as_ref().map_or(0.0, |(d, _)| *d)
305    }
306
307    fn anim_curve(&self) -> Curve {
308        self.anim.as_ref().map_or(Curve::Linear, |(_, c)| c.clone())
309    }
310
311    fn debug_name(&self) -> &'static str {
312        "FractionalTranslation"
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::Container;
320    use frus_core::{Color, Primitive, Size};
321
322    /// `Transform::translate(30, 10)` offsets the (20×20) child at paint time: its
323    /// background, normally at the top left, is painted at about (30, 10).
324    #[test]
325    fn translate_offsets_the_child_at_paint() {
326        let red = Color::rgb(1.0, 0.0, 0.0);
327        let root = crate::Flex::<()>::column().width(100.0).child(
328            Transform::translate(30.0, 10.0)
329                .child(Container::new().width(20.0).height(20.0).color(red)),
330        );
331        let rt = crate::runtime::Runtime::default();
332        let theme = crate::Theme::dark();
333        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
334        let rect = ui
335            .scene()
336            .primitives()
337            .iter()
338            .find_map(|p| match p {
339                Primitive::Rect { rect, color, .. } if color.r > 0.5 => Some(*rect),
340                _ => None,
341            })
342            .expect("the child's red background");
343        assert!(
344            (rect.x - 30.0).abs() < 0.5 && (rect.y - 10.0).abs() < 0.5,
345            "offset to (30, 10): {rect:?}"
346        );
347    }
348
349    /// The offset is **purely visual**: a sibling placed after a transformed child
350    /// keeps its layout position (the Transform neither grows nor moves its box).
351    /// Here the 2nd child stays at `y = 20`, despite the 1st being offset 50
352    /// vertically.
353    #[test]
354    fn translate_does_not_affect_layout() {
355        let red = Color::rgb(1.0, 0.0, 0.0);
356        let green = Color::rgb(0.0, 1.0, 0.0);
357        let root = crate::Flex::<()>::column()
358            .width(100.0)
359            .child(
360                Transform::translate(0.0, 50.0)
361                    .child(Container::new().flex(1.0).height(20.0).color(red)),
362            )
363            .child(Container::new().height(20.0).color(green));
364        let rt = crate::runtime::Runtime::default();
365        let theme = crate::Theme::dark();
366        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
367        let green_y = ui
368            .scene()
369            .primitives()
370            .iter()
371            .find_map(|p| match p {
372                Primitive::Rect { rect, color, .. } if color.g > 0.5 && color.r < 0.5 => {
373                    Some(rect.y)
374                }
375                _ => None,
376            })
377            .expect("the 2nd child's green background");
378        // The 1st child takes 20px of height in layout (its offset of 50 is visual):
379        // the 2nd child follows at y = 20.
380        assert!(
381            (green_y - 20.0).abs() < 0.5,
382            "sibling in its layout place: y = {green_y}"
383        );
384    }
385
386    /// Extracts the [`Affine`] of the scene's transformed layer (the subtree is
387    /// painted flat *inside* that layer; the transformation is carried by its matrix).
388    fn layer_affine<Msg: Clone>(ui: &crate::ui::Ui<Msg>) -> frus_core::Affine {
389        ui.scene()
390            .primitives()
391            .iter()
392            .find_map(|p| match p {
393                Primitive::Layer {
394                    transform: Some(t), ..
395                } => Some(t.affine),
396                _ => None,
397            })
398            .expect("a transformed layer")
399    }
400
401    /// `Transform::scale(2.0)` carries a matrix that doubles (a linear part of 2) and
402    /// leaves **the child's centre (10, 10) fixed**.
403    #[test]
404    fn scale_grows_the_child_about_its_center() {
405        use frus_core::Point;
406        let red = Color::rgb(1.0, 0.0, 0.0);
407        let root = crate::Flex::<()>::column().width(100.0).child(
408            Transform::scale(2.0).child(Container::new().width(20.0).height(20.0).color(red)),
409        );
410        let rt = crate::runtime::Runtime::default();
411        let theme = crate::Theme::dark();
412        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
413        let m = layer_affine(&ui);
414        assert!(
415            (m.m[0] - 2.0).abs() < 1e-3 && (m.m[3] - 2.0).abs() < 1e-3,
416            "×2 : {:?}",
417            m.m
418        );
419        let c = m.apply(Point::new(10.0, 10.0));
420        assert!(
421            (c.x - 10.0).abs() < 0.5 && (c.y - 10.0).abs() < 0.5,
422            "centre fixe : {c:?}"
423        );
424    }
425
426    /// `scale_from(2.0, TOP_LEFT)` scales about the child's top-left corner: that
427    /// corner (0, 0) stays fixed.
428    #[test]
429    fn scale_from_pins_the_pivot_corner() {
430        use frus_core::{Alignment, Point};
431        let red = Color::rgb(1.0, 0.0, 0.0);
432        let root = crate::Flex::<()>::column().width(100.0).child(
433            Transform::scale_from(2.0, Alignment::TOP_LEFT)
434                .child(Container::new().width(20.0).height(20.0).color(red)),
435        );
436        let rt = crate::runtime::Runtime::default();
437        let theme = crate::Theme::dark();
438        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
439        let m = layer_affine(&ui);
440        assert!(
441            (m.m[0] - 2.0).abs() < 1e-3 && (m.m[3] - 2.0).abs() < 1e-3,
442            "×2 : {:?}",
443            m.m
444        );
445        let c = m.apply(Point::new(0.0, 0.0));
446        assert!(
447            c.x.abs() < 0.5 && c.y.abs() < 0.5,
448            "top-left corner unmoved: {c:?}"
449        );
450    }
451
452    /// `Transform::scale_xy(3.0, 1.0)` carries a **per-axis** scale matrix (×3 in x,
453    /// ×1 in y), with the centre fixed.
454    #[test]
455    fn scale_xy_stretches_per_axis() {
456        use frus_core::Point;
457        let red = Color::rgb(1.0, 0.0, 0.0);
458        let root = crate::Flex::<()>::column().width(200.0).child(
459            Transform::scale_xy(3.0, 1.0)
460                .child(Container::new().width(20.0).height(20.0).color(red)),
461        );
462        let rt = crate::runtime::Runtime::default();
463        let theme = crate::Theme::dark();
464        let ui = crate::ui::build_ui(&root, Size::new(200.0, 200.0), &rt, &theme);
465        let m = layer_affine(&ui);
466        assert!(
467            (m.m[0] - 3.0).abs() < 1e-3 && (m.m[3] - 1.0).abs() < 1e-3,
468            "×3 en x, ×1 en y : {:?}",
469            m.m
470        );
471        let c = m.apply(Point::new(10.0, 10.0));
472        assert!(
473            (c.x - 10.0).abs() < 0.5 && (c.y - 10.0).abs() < 0.5,
474            "centre fixe : {c:?}"
475        );
476    }
477
478    /// `Transform::rotate(π/2)` carries a pure rotation matrix (a linear part of
479    /// `[0, 1, -1, 0]`) that leaves the 40×20 child's centre fixed → (20, 10).
480    #[test]
481    fn rotate_emits_a_rotated_layer() {
482        use frus_core::Point;
483        use std::f32::consts::FRAC_PI_2;
484        let red = Color::rgb(1.0, 0.0, 0.0);
485        let root = crate::Flex::<()>::column().width(100.0).child(
486            Transform::rotate(FRAC_PI_2)
487                .child(Container::new().width(40.0).height(20.0).color(red)),
488        );
489        let rt = crate::runtime::Runtime::default();
490        let theme = crate::Theme::dark();
491        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
492        let m = layer_affine(&ui);
493        assert!(
494            m.m[0].abs() < 1e-3
495                && (m.m[1] - 1.0).abs() < 1e-3
496                && (m.m[2] + 1.0).abs() < 1e-3
497                && m.m[3].abs() < 1e-3,
498            "rotation +90° : {:?}",
499            m.m
500        );
501        let c = m.apply(Point::new(20.0, 10.0));
502        assert!(
503            (c.x - 20.0).abs() < 0.5 && (c.y - 10.0).abs() < 0.5,
504            "centre fixe : {c:?}"
505        );
506    }
507
508    /// **Composition**: `scale(2.0).and_rotate(π/2)` melts the two into **a single**
509    /// matrix = rotation ∘ scale: the linear part is `[0, 2, -2, 0]` (magnitude 2 =
510    /// the scaling, off-diagonal = the rotation).
511    #[test]
512    fn scale_and_rotate_compose() {
513        use std::f32::consts::FRAC_PI_2;
514        let red = Color::rgb(1.0, 0.0, 0.0);
515        let root = crate::Flex::<()>::column().width(100.0).child(
516            Transform::scale(2.0)
517                .and_rotate(FRAC_PI_2)
518                .child(Container::new().width(20.0).height(20.0).color(red)),
519        );
520        let rt = crate::runtime::Runtime::default();
521        let theme = crate::Theme::dark();
522        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
523        let m = layer_affine(&ui);
524        assert!(
525            m.m[0].abs() < 1e-3
526                && (m.m[1] - 2.0).abs() < 1e-3
527                && (m.m[2] + 2.0).abs() < 1e-3
528                && m.m[3].abs() < 1e-3,
529            "rotation ∘ scale: {:?}",
530            m.m
531        );
532    }
533
534    /// Under an **axis-aligned** transformation (pure scaling), the **focus** targets
535    /// follow too: a point outside the flat button but inside its enlarged image is
536    /// focusable, and its focus rectangle is about 2× wider.
537    #[test]
538    fn axis_aligned_transform_scales_the_focus_rect() {
539        use frus_core::Point;
540        let rt = crate::runtime::Runtime::default();
541        let theme = crate::Theme::dark();
542        let flat_ui = crate::ui::build_ui(
543            &crate::Flex::<i32>::column()
544                .width(200.0)
545                .child(crate::Button::new("Ok").on_press(1)),
546            Size::new(200.0, 200.0),
547            &rt,
548            &theme,
549        );
550        let flat = flat_ui
551            .focus_hit(Point::new(2.0, 2.0))
552            .expect("a focusable button")
553            .1;
554        let cy = flat.y + flat.height / 2.0;
555        // A point just right of the flat button (200 wide): outside its target.
556        let probe = Point::new(flat.x + flat.width + 2.0, cy);
557        assert!(
558            flat_ui.focus_hit(probe).is_none(),
559            "outside the flat button"
560        );
561
562        let scaled_ui = crate::ui::build_ui(
563            &crate::Flex::<i32>::column()
564                .width(200.0)
565                .child(Transform::scale(2.0).child(crate::Button::new("Ok").on_press(1))),
566            Size::new(200.0, 200.0),
567            &rt,
568            &theme,
569        );
570        let (_, r) = scaled_ui
571            .focus_hit(probe)
572            .expect("the button's enlarged image covers the point");
573        assert!(
574            (r.width - flat.width * 2.0).abs() < 1.0,
575            "rectangle de focus ~2× : {} vs {}",
576            r.width,
577            flat.width * 2.0
578        );
579    }
580
581    /// Hit-testing **counter-rotates** the point: a click at a clickable child's
582    /// *rotated* position reaches it, while its original (unrotated) position no
583    /// longer does. A 40×20 child rotated +90° about (20, 10): the internal point
584    /// (35, 10) appears on screen at (20, 25).
585    #[test]
586    fn rotate_hit_test_counter_rotates_the_point() {
587        use frus_core::Point;
588        use std::f32::consts::FRAC_PI_2;
589        let red = Color::rgb(1.0, 0.0, 0.0);
590        let root = crate::Flex::<i32>::column().width(100.0).child(
591            Transform::rotate(FRAC_PI_2).child(
592                Container::new()
593                    .width(40.0)
594                    .height(20.0)
595                    .color(red)
596                    .on_click(7),
597            ),
598        );
599        let rt = crate::runtime::Runtime::default();
600        let theme = crate::Theme::dark();
601        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
602        // On screen, the internal point (35, 10) is painted at (20, 25) after rotation.
603        assert!(
604            ui.hit(Point::new(20.0, 25.0)).is_some(),
605            "a click on the rotated position"
606        );
607        // The original (unrotated) position no longer covers the child.
608        assert!(
609            ui.hit(Point::new(35.0, 10.0)).is_none(),
610            "l'ancienne position rate"
611        );
612    }
613
614    /// A fraction is a multiple of the child's **own** box: half a width across and a
615    /// whole height down, from a 20 × 20 child, is (10, 20).
616    #[test]
617    fn a_fractional_translation_offsets_by_the_child_s_own_size() {
618        let red = Color::rgb(1.0, 0.0, 0.0);
619        let root = crate::Flex::<()>::column().width(100.0).child(
620            FractionalTranslation::new(0.5, 1.0)
621                .child(Container::new().width(20.0).height(20.0).color(red)),
622        );
623        let rt = crate::runtime::Runtime::default();
624        let theme = crate::Theme::dark();
625        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
626        let rect = ui
627            .scene()
628            .primitives()
629            .iter()
630            .find_map(|p| match p {
631                Primitive::Rect { rect, color, .. } if color.r > 0.5 => Some(*rect),
632                _ => None,
633            })
634            .expect("the child's red background");
635        assert!(
636            (rect.x - 10.0).abs() < 0.5 && (rect.y - 20.0).abs() < 0.5,
637            "half a width across, a height down: {rect:?}"
638        );
639    }
640
641    /// And it moves the **hit test** with the paint, as `Transform::translate` does: a
642    /// widget you can see somewhere and click somewhere else is the bug this rules out.
643    #[test]
644    fn a_fractional_translation_moves_the_input_too() {
645        let root = crate::Flex::<i32>::column().width(100.0).child(
646            FractionalTranslation::new(1.0, 0.0).child(
647                Container::<i32>::new()
648                    .width(20.0)
649                    .height(20.0)
650                    .color(Color::rgb(1.0, 0.0, 0.0))
651                    .on_click(7),
652            ),
653        );
654        let rt = crate::runtime::Runtime::default();
655        let theme = crate::Theme::dark();
656        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
657        let at = |x: f32| {
658            ui.hit(frus_core::Point::new(x, 10.0))
659                .and_then(|id| ui.msg_for(id))
660        };
661        assert_eq!(at(30.0), Some(7), "where it is painted");
662        assert_eq!(at(10.0), None, "not where it was laid out");
663    }
664
665    /// The offset is a paint offset: the box does not move, so the sibling below it does
666    /// not either.
667    #[test]
668    fn a_fractional_translation_moves_nothing_else() {
669        let blue = Color::rgb(0.0, 0.0, 1.0);
670        let root = crate::Flex::<()>::column()
671            .width(100.0)
672            .child(
673                FractionalTranslation::new(0.0, 2.0).child(
674                    Container::new()
675                        .width(20.0)
676                        .height(20.0)
677                        .color(Color::rgb(1.0, 0.0, 0.0)),
678                ),
679            )
680            .child(Container::new().width(20.0).height(20.0).color(blue));
681        let rt = crate::runtime::Runtime::default();
682        let theme = crate::Theme::dark();
683        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
684        let sibling = ui
685            .scene()
686            .primitives()
687            .iter()
688            .find_map(|p| match p {
689                Primitive::Rect { rect, color, .. } if color.b > 0.5 => Some(*rect),
690                _ => None,
691            })
692            .expect("the sibling");
693        assert!(
694            (sibling.y - 20.0).abs() < 0.5,
695            "the sibling stayed where the layout put it: {sibling:?}"
696        );
697    }
698}