Skip to main content

oxframe_2d/
handles.rs

1use std::ops::Deref;
2
3use kurbo::{BezPath, Point};
4use oxframe_core::{BlendMode, Color, Gradient, Signal, Tween, Vec2, all};
5
6use crate::builder::{
7    self, CircleBuilder, CodeBuilder, GridBuilder, GroupBuilder, ImgBuilder, LayoutBuilder,
8    LineBuilder, NodeBuilder, PathBuilder, PolygonBuilder, RectBuilder, StarBuilder, TxtBuilder,
9};
10use crate::draw::{LineCap, LineJoin};
11use crate::graph::{NodeId, NodeKind, SceneGraph, with_graph};
12use crate::place::{Edge, Place, SafeArea, Side};
13use crate::text::RichSpan;
14
15/// How far [`Node::slide_in`] starts from the node's place, in pixels.
16pub const SLIDE_DISTANCE: f64 = 160.0;
17
18/// Untyped node handle with the properties every node has.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub struct Node(NodeId);
21
22macro_rules! base_signal {
23    ($($(#[$doc:meta])* $field:ident: $ty:ty),* $(,)?) => {
24        $(
25            $(#[$doc])*
26            #[doc = ""]
27            #[doc = concat!("The `", stringify!($field), "` property as an animatable signal.")]
28            pub fn $field(self) -> Signal<$ty> {
29                with_graph(|g| g.node(self.0).$field.clone())
30            }
31        )*
32    };
33}
34
35impl Node {
36    /// Index of the node in the scene graph.
37    pub fn id(self) -> NodeId {
38        self.0
39    }
40
41    /// Wraps an id from [`NodeBuilder::build`](crate::NodeBuilder::build).
42    ///
43    /// Other crates implement [`NodeBuilder`](crate::NodeBuilder) with this:
44    /// the `parent` argument is only an id, and handles are what `add` needs.
45    pub fn from_id(id: NodeId) -> Self {
46        Self(id)
47    }
48
49    base_signal! {
50        x: f64,
51        y: f64,
52        /// Degrees, clockwise.
53        rotation: f64,
54        scale: Vec2,
55        opacity: f64,
56        fill: Color,
57        stroke: Color,
58        line_width: f64,
59        /// Start of the drawn part of the outline (fraction of its length).
60        start: f64,
61        /// End of the drawn part of the outline.
62        end: f64,
63        fill_gradient: Option<Gradient>,
64        stroke_gradient: Option<Gradient>,
65        dash: Vec<f64>,
66        dash_offset: f64,
67        line_cap: LineCap,
68        line_join: LineJoin,
69        blur: f64,
70        shadow_color: Color,
71        shadow_blur: f64,
72        shadow_offset: Vec2,
73        clip: bool,
74        blend: BlendMode,
75        z_index: f64,
76        anchor: Vec2,
77        grow: f64,
78        shrink: f64,
79        basis: Option<f64>,
80        margin: f64,
81        align_self: Option<crate::LayoutAlign>,
82    }
83
84    /// Adds a child node; children are positioned relative to the parent.
85    ///
86    /// The call site is stored on the node (`oxframe check` and `oxframe
87    /// inspect` print it). `.name("title")` overrides the automatic label.
88    #[allow(clippy::should_implement_trait)] // `view.add(..)` mirrors Motion Canvas
89    #[track_caller]
90    pub fn add<B: NodeBuilder>(self, child: B) -> B::Handle {
91        let loc = std::panic::Location::caller();
92        let prev = crate::graph::push_add_at(loc.file(), loc.line());
93        let handle = builder::add(self.0, child);
94        crate::graph::pop_add_at(prev);
95        handle
96    }
97
98    /// Detaches the node from its parent; it stops being drawn.
99    pub fn remove(self) {
100        with_graph(|g| g.detach(self.0));
101    }
102
103    /// Fades the node in from transparent, over the theme's normal duration
104    /// with its enter easing.
105    pub fn fade_in(self) -> Tween<f64> {
106        let motion = crate::theme::current().motion;
107        self.opacity().set(0.0);
108        self.opacity().to(1.0, motion.normal).ease(motion.enter)
109    }
110
111    /// Grows the node from nothing to its current scale with a small
112    /// overshoot (the theme's emphasis easing, `OUT_BACK` by default).
113    pub fn pop_in(self) -> Tween<Vec2> {
114        let motion = crate::theme::current().motion;
115        let full = self.scale().peek();
116        self.scale().set(Vec2::ZERO);
117        self.scale().to(full, motion.normal).ease(motion.emphasis)
118    }
119
120    /// Draws the outline from start to end (`end` from 0 to 1), as if with a
121    /// pen. Works on every shape with an outline: paths, lines, rectangles,
122    /// circles, polygons.
123    pub fn draw_on(self) -> Tween<f64> {
124        let motion = crate::theme::current().motion;
125        self.end().set(0.0);
126        self.end().to(1.0, motion.slow).ease(motion.enter)
127    }
128
129    /// Slides the node into its place from `SLIDE_DISTANCE` pixels towards
130    /// `from`, fading in on the way.
131    pub fn slide_in(self, from: Side) -> impl std::future::Future<Output = ()> + 'static {
132        let motion = crate::theme::current().motion;
133        let (axis, offset) = match from {
134            Side::Left => (self.x(), -SLIDE_DISTANCE),
135            Side::Right => (self.x(), SLIDE_DISTANCE),
136            Side::Top => (self.y(), -SLIDE_DISTANCE),
137            Side::Bottom => (self.y(), SLIDE_DISTANCE),
138        };
139        let home = axis.peek();
140        axis.set(home + offset);
141        let moves = all((
142            axis.to(home, motion.normal).ease(motion.enter),
143            self.fade_in(),
144        ));
145        async move {
146            moves.await;
147        }
148    }
149
150    /// Sits just outside `other`. The gap is the theme's `Md` step, and the
151    /// position is recomputed every frame.
152    pub fn next_to(self, other: impl Into<Node>, side: Side) -> Self {
153        let other = other.into();
154        with_graph(|g| g.set_place(self.0, Place::next_to(other.id(), side)));
155        self
156    }
157
158    /// Lines this node's `edge` up with the same edge of `other`.
159    pub fn align_to(self, other: impl Into<Node>, edge: Edge) -> Self {
160        let other = other.into();
161        with_graph(|g| g.set_place(self.0, Place::align_to(other.id(), edge)));
162        self
163    }
164
165    /// Pins one edge to the safe area. The other axis is left as it is.
166    pub fn to_edge(self, edge: Edge) -> Self {
167        with_graph(|g| g.set_place(self.0, Place::to_edge(edge)));
168        self
169    }
170
171    /// Same center as `other`.
172    pub fn center_in(self, other: impl Into<Node>) -> Self {
173        let other = other.into();
174        with_graph(|g| g.set_place(self.0, Place::center_in(other.id())));
175        self
176    }
177
178    /// Fades the node out, over the theme's normal duration with its exit easing.
179    pub fn fade_out(self) -> Tween<f64> {
180        let motion = crate::theme::current().motion;
181        self.opacity().to(0.0, motion.normal).ease(motion.exit)
182    }
183
184    /// Moves the node under another parent.
185    pub fn reparent(self, parent: impl Into<Node>) {
186        let parent = parent.into();
187        with_graph(|g| g.reparent(self.0, parent.0));
188    }
189
190    /// Direct children, in creation order.
191    pub fn children(self) -> Vec<Node> {
192        with_graph(|g| g.node(self.0).children.iter().map(|&c| Node(c)).collect())
193    }
194
195    /// Size of the node's box in its own coordinates.
196    pub fn size(self) -> (f64, f64) {
197        with_graph(|g| g.measure(self.0))
198    }
199
200    /// Position of the node's origin in scene coordinates.
201    pub fn absolute_position(self) -> Vec2 {
202        with_graph(|g| (g.world_transform(self.0).unwrap_or_default() * Point::ZERO).to_vec2())
203    }
204}
205
206macro_rules! typed_handle {
207    ($name:ident, $builder:ident) => {
208        #[doc = concat!("Handle to a `", stringify!($name), "` node in the scene: cheap to copy, its")]
209        #[doc = "methods return the node's properties as [`Signal`]s. Derefs to [`Node`]."]
210        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
211        pub struct $name(Node);
212
213        impl $name {
214            /// Starts a builder; add it with [`Node::add`] (e.g. `view.add(..)`).
215            #[allow(clippy::new_ret_no_self)]
216            pub fn new() -> $builder {
217                $builder::default()
218            }
219
220            pub(crate) fn from_id(id: NodeId) -> Self {
221                Self(Node(id))
222            }
223
224            /// The untyped node handle (same as `*self`).
225            pub fn node(self) -> Node {
226                self.0
227            }
228        }
229
230        impl Deref for $name {
231            type Target = Node;
232            fn deref(&self) -> &Node {
233                &self.0
234            }
235        }
236
237        impl From<$name> for Node {
238            fn from(h: $name) -> Node {
239                h.0
240            }
241        }
242    };
243}
244
245typed_handle!(Group, GroupBuilder);
246typed_handle!(Rect, RectBuilder);
247typed_handle!(Circle, CircleBuilder);
248typed_handle!(Path, PathBuilder);
249typed_handle!(Line, LineBuilder);
250typed_handle!(Txt, TxtBuilder);
251typed_handle!(Img, ImgBuilder);
252typed_handle!(Layout, LayoutBuilder);
253typed_handle!(Grid, GridBuilder);
254typed_handle!(Polygon, PolygonBuilder);
255typed_handle!(Star, StarBuilder);
256typed_handle!(Code, CodeBuilder);
257
258macro_rules! kind_signal {
259    ($handle:ident :: $kind:ident { $($(#[$doc:meta])* $field:ident: $ty:ty),* $(,)? }) => {
260        impl $handle {
261            $(
262                $(#[$doc])*
263                #[doc = ""]
264                #[doc = concat!("The `", stringify!($field), "` property as an animatable signal.")]
265                pub fn $field(self) -> Signal<$ty> {
266                    with_graph(|g| match &g.node(self.id()).kind {
267                        NodeKind::$kind { $field, .. } => $field.clone(),
268                        _ => unreachable!(concat!("handle does not point to a ", stringify!($kind))),
269                    })
270                }
271            )*
272        }
273    };
274}
275
276kind_signal!(Rect::Rect {
277    width: f64,
278    height: f64,
279    radius: f64
280});
281kind_signal!(Circle::Circle {
282    /// Diameter.
283    size: f64,
284    start_angle: f64,
285    end_angle: f64,
286});
287kind_signal!(Polygon::Polygon {
288    sides: f64,
289    size: f64
290});
291kind_signal!(Star::Star {
292    points: f64,
293    size: f64,
294    inner: f64
295});
296kind_signal!(Path::Path { data: BezPath });
297kind_signal!(Line::Line { points: Vec<Vec2>, arrow_size: f64 });
298kind_signal!(Txt::Txt { text: String, spans: Vec<RichSpan>, font_size: f64, weight: f64 });
299kind_signal!(Code::Code {
300    code: String,
301    font_size: f64,
302    /// Focused lines as (first, last), 1-based; (0, 0) = all.
303    focus: Vec2,
304    /// Alpha of unfocused lines.
305    dim: f64,
306});
307
308impl Txt {
309    /// Morphs to `text` glyph by glyph: letters present in both strings
310    /// travel to their new places, the others fade.
311    pub fn morph_to(self, text: impl Into<String>, duration: f64) -> oxframe_core::BoxFuture {
312        let to = text.into();
313        let (txt, morph, t) = with_graph(|g| match &g.node(self.id()).kind {
314            NodeKind::Txt {
315                text,
316                morph,
317                morph_t,
318                ..
319            } => (text.clone(), morph.clone(), morph_t.clone()),
320            _ => unreachable!("handle does not point to a Txt"),
321        });
322        Box::pin(async move {
323            morph.set(Some((txt.get(), to.clone())));
324            t.set(0.0);
325            t.to(1.0, duration).await;
326            txt.set(to);
327            morph.set(None);
328        })
329    }
330}
331
332impl Code {
333    /// Moves the focus to lines `first..=last` over `duration`.
334    pub fn focus_lines(
335        self,
336        first: usize,
337        last: usize,
338        duration: f64,
339    ) -> oxframe_core::Tween<Vec2> {
340        self.focus()
341            .to(Vec2::new(first as f64, last as f64), duration)
342    }
343}
344kind_signal!(Img::Img {
345    width: f64,
346    height: f64
347});
348kind_signal!(Layout::Layout {
349    gap: f64,
350    padding: f64,
351    radius: f64,
352    /// Fixed width; `None` sizes to content.
353    width: Option<f64>,
354    /// Fixed height; `None` sizes to content.
355    height: Option<f64>,
356});
357kind_signal!(Grid::Grid {
358    width: f64,
359    height: f64,
360    spacing: f64
361});
362
363/// Root of a scene. Origin is the center of the frame, y points down.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub struct View(Node);
366
367impl View {
368    /// The view of the scene being executed.
369    pub fn current() -> Self {
370        Self(Node(SceneGraph::ROOT))
371    }
372
373    /// Frame size in pixels.
374    pub fn size(self) -> (u32, u32) {
375        with_graph(|g| g.size())
376    }
377
378    /// True when the frame is taller than it is wide.
379    pub fn is_portrait(self) -> bool {
380        let (w, h) = self.size();
381        h > w
382    }
383
384    /// The rectangle [`Node::to_edge`](crate::Node::to_edge) pins to, in scene
385    /// coordinates. A portrait frame leaves room for a vertical video's
386    /// status bar and buttons.
387    pub fn safe_area(self) -> SafeArea {
388        let (w, h) = self.size();
389        SafeArea::for_frame(f64::from(w), f64::from(h))
390    }
391}
392
393impl Deref for View {
394    type Target = Node;
395    fn deref(&self) -> &Node {
396        &self.0
397    }
398}
399
400impl From<View> for Node {
401    fn from(v: View) -> Node {
402        v.0
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::Primitive;
410
411    fn scene(f: impl FnOnce(View)) -> std::rc::Rc<SceneGraph> {
412        let graph = SceneGraph::new(400, 400);
413        graph.enter(|| f(View::current()));
414        graph
415    }
416
417    /// Runs `scene` to the end inside a graph; returns the frames it took.
418    fn play(
419        scene_fn: impl FnOnce(View) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>,
420    ) -> u32 {
421        let graph = SceneGraph::new(400, 400);
422        graph.enter(|| {
423            let fut = scene_fn(View::current());
424            let mut ex =
425                oxframe_core::Executor::new(60.0, oxframe_protocol::Meta::default(), 0, fut);
426            let mut frames = 0;
427            while !ex.step() {
428                frames += 1;
429                assert!(frames < 10_000, "scene did not finish");
430            }
431            frames
432        })
433    }
434
435    #[test]
436    fn entrance_helpers_start_at_once_and_end_in_place() {
437        let normal = crate::theme::current().motion.normal;
438        let expected = (normal * 60.0).round() as u32;
439        let frames = play(|view| {
440            let r = view.add(Rect::new().size(100.0).x(50.0));
441            let pop = r.pop_in();
442            // The start value is set when the helper is called, not awaited:
443            // nothing flashes in its final state for a frame.
444            assert_eq!(r.scale().get(), Vec2::ZERO);
445            let slide = r.slide_in(Side::Left);
446            assert_eq!(r.x().get(), 50.0 - SLIDE_DISTANCE);
447            assert_eq!(r.opacity().get(), 0.0);
448            let draw = r.draw_on();
449            assert_eq!(r.end().get(), 0.0);
450            Box::pin(async move {
451                all((pop, slide)).await;
452                assert_eq!(r.scale().get(), Vec2::new(1.0, 1.0));
453                assert_eq!((r.x().get(), r.opacity().get()), (50.0, 1.0));
454                draw.await;
455                assert_eq!(r.end().get(), 1.0);
456                r.fade_out().duration(0.5).await;
457                assert_eq!(r.opacity().get(), 0.0);
458            })
459        });
460        let slow = crate::theme::current().motion.slow;
461        assert_eq!(frames, expected + (slow * 60.0).round() as u32 + 30);
462    }
463
464    #[test]
465    fn add_records_its_call_site_and_scene_bounds() {
466        let g = scene(|view| {
467            view.add(Rect::new().size(100.0).name("box"));
468            view.add(Rect::new().size(10.0).x(100.0));
469        });
470        let nodes = g.inspect_nodes();
471        let named = nodes.iter().find(|n| n.name == "box").unwrap();
472        assert_eq!(named.kind, "Rect");
473        assert!(named.source.as_ref().unwrap().contains("handles.rs:"));
474        assert!(
475            (named.bounds[0] + 50.0).abs() < 1e-6 && (named.bounds[2] - 50.0).abs() < 1e-6,
476            "{:?}",
477            named.bounds
478        );
479        let auto = nodes
480            .iter()
481            .find(|n| n.name != "box" && n.name != "view")
482            .unwrap();
483        assert_eq!(auto.name, auto.source.clone().unwrap());
484    }
485
486    #[test]
487    fn builders_set_initial_values_and_handles_return_signals() {
488        scene(|view| {
489            let r = view.add(
490                Rect::new()
491                    .size(50.0)
492                    .radius(5.0)
493                    .x(-10.0)
494                    .fill(Color::BLACK),
495            );
496            assert_eq!(r.width().get(), 50.0);
497            assert_eq!(r.height().get(), 50.0);
498            assert_eq!(r.radius().get(), 5.0);
499            assert_eq!(r.x().get(), -10.0);
500            assert_eq!(r.fill().get(), Color::BLACK);
501            r.x().set(3.0);
502            assert_eq!(r.x().get(), 3.0);
503            let c = r.add(Circle::new().size(10.0));
504            assert_eq!(r.children(), vec![c.node()]);
505            assert_eq!(view.children(), vec![r.node()]);
506            assert_eq!(c.size().name().as_deref(), Some("Circle#2.size"));
507        });
508    }
509
510    #[test]
511    fn draw_list_composes_transforms() {
512        let g = scene(|view| {
513            let grp = view.add(Group::new().x(10.0).scale(2.0));
514            grp.add(Circle::new().x(5.0));
515        });
516        let items = g.draw_list();
517        assert_eq!(items.len(), 1);
518        assert_eq!(items[0].transform * Point::ZERO, Point::new(20.0, 0.0));
519    }
520
521    #[test]
522    fn layout_places_children_in_a_row() {
523        let g = scene(|view| {
524            view.add(
525                Layout::new()
526                    .gap(10.0)
527                    .padding(5.0)
528                    .child(Rect::new().size(20.0))
529                    .child(Rect::new().width(40.0).height(10.0)),
530            );
531        });
532        let layout = NodeId(1);
533        assert_eq!(g.measure(layout), (5.0 + 20.0 + 10.0 + 40.0 + 5.0, 30.0));
534        let centers: Vec<_> = g
535            .draw_list()
536            .iter()
537            .map(|i| i.transform * Point::ZERO)
538            .collect();
539        // Total 80 wide: first box spans -35..-15, second -5..35.
540        assert_eq!(centers, vec![Point::new(-25.0, 0.0), Point::new(15.0, 0.0)]);
541    }
542
543    #[test]
544    fn column_with_fixed_size_and_alignment() {
545        let g = scene(|view| {
546            view.add(
547                Layout::new()
548                    .column()
549                    .height(100.0)
550                    .width(100.0)
551                    .justify(crate::Justify::Start)
552                    .align(crate::LayoutAlign::Start)
553                    .child(Rect::new().size(20.0)),
554            );
555        });
556        let c = g.draw_list()[0].transform * Point::ZERO;
557        assert_eq!(c, Point::new(-40.0, -40.0));
558    }
559
560    #[test]
561    fn text_line_path_and_img_draw() {
562        let png = {
563            let mut v = Vec::new();
564            image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 255]))
565                .write_to(&mut std::io::Cursor::new(&mut v), image::ImageFormat::Png)
566                .unwrap();
567            v
568        };
569        let g = scene(|view| {
570            let t = view.add(Txt::new().text("Hi").font_size(40.0));
571            assert!(t.size().0 > 20.0);
572            view.add(
573                Line::new()
574                    .points([(0.0, 0.0), (100.0, 0.0)])
575                    .end_arrow(true),
576            );
577            view.add(Path::new().svg("M0 0 L10 0 L10 10 Z"));
578            let img = view.add(Img::new().src(&png).width(40.0));
579            assert_eq!(img.height().get(), 20.0);
580        });
581        let kinds: Vec<_> = g
582            .draw_list()
583            .iter()
584            .map(|i| match i.prim {
585                Primitive::Fill { .. } => "fill",
586                Primitive::Stroke { .. } => "stroke",
587                Primitive::Image { .. } => "image",
588                Primitive::PushLayer(_) => "push",
589                Primitive::PopLayer => "pop",
590            })
591            .collect();
592        assert_eq!(kinds, vec!["fill", "stroke", "fill", "fill", "image"]);
593    }
594
595    #[test]
596    fn trimming_and_pick() {
597        let g = scene(|view| {
598            view.add(Rect::new().size(100.0).name("back"));
599            view.add(Circle::new().size(40.0).x(30.0).end(0.5));
600        });
601        assert_eq!(g.pick(Point::new(-40.0, 0.0)), Some(NodeId(1)));
602        assert_eq!(g.pick(Point::new(300.0, 0.0)), None);
603        // The half circle covers the right half of its disc.
604        assert_eq!(g.pick(Point::new(45.0, 0.0)), Some(NodeId(2)));
605    }
606
607    fn kinds(g: &SceneGraph) -> Vec<&'static str> {
608        g.draw_list()
609            .iter()
610            .map(|i| match i.prim {
611                Primitive::Fill { .. } => "fill",
612                Primitive::Stroke { .. } => "stroke",
613                Primitive::Image { .. } => "image",
614                Primitive::PushLayer(_) => "push",
615                Primitive::PopLayer => "pop",
616            })
617            .collect()
618    }
619
620    #[test]
621    fn layers_only_when_needed_and_balanced() {
622        // Translucent leaf: opacity folds into the paint, no layer.
623        let g = scene(|v| {
624            v.add(Rect::new().opacity(0.5));
625        });
626        assert_eq!(kinds(&g), ["fill"]);
627        // Translucent group with children: one layer around them.
628        let g = scene(|v| {
629            v.add(
630                Group::new()
631                    .opacity(0.5)
632                    .child(Rect::new())
633                    .child(Circle::new()),
634            );
635        });
636        assert_eq!(kinds(&g), ["push", "fill", "fill", "pop"]);
637        // Clip wraps only the children; shadow wraps node + children.
638        let g = scene(|v| {
639            v.add(
640                Circle::new()
641                    .clip(true)
642                    .shadow(Color::BLACK, 4.0, (2.0, 2.0))
643                    .child(Rect::new()),
644            );
645        });
646        assert_eq!(kinds(&g), ["push", "fill", "push", "fill", "pop", "pop"]);
647    }
648
649    #[test]
650    fn z_index_orders_siblings_and_anchor_moves_box() {
651        let g = scene(|v| {
652            v.add(Rect::new().name("a").z_index(2.0));
653            v.add(Rect::new().name("b"));
654            v.add(Rect::new().name("c").size(20.0).anchor((-1.0, -1.0)).x(5.0));
655        });
656        let items = g.draw_list();
657        let order: Vec<u32> = items.iter().map(|i| i.node.0).collect();
658        assert_eq!(order, vec![2, 3, 1]);
659        // Anchored at the top-left: the box center sits at (5+10, 0+10).
660        assert_eq!(items[1].transform * Point::ZERO, Point::new(15.0, 10.0));
661    }
662
663    fn boxes(g: &SceneGraph) -> Vec<(Point, f64)> {
664        g.draw_list()
665            .iter()
666            .filter_map(|i| match &i.prim {
667                Primitive::Fill { path, .. } => {
668                    let bb = kurbo::Shape::bounding_box(path);
669                    Some((i.transform * Point::ZERO, bb.width()))
670                }
671                _ => None,
672            })
673            .collect()
674    }
675
676    #[test]
677    fn flex_grow_stretch_wrap_and_grid() {
678        use crate::{Justify, LayoutAlign};
679        // grow: a 200-wide row; the second child takes the free space.
680        let g = scene(|v| {
681            v.add(
682                Layout::new()
683                    .width(200.0)
684                    .justify(Justify::Start)
685                    .child(Rect::new().size(40.0))
686                    .child(Rect::new().size(40.0).grow(1.0)),
687            );
688        });
689        let b = boxes(&g);
690        assert_eq!(b[0], (Point::new(-80.0, 0.0), 40.0));
691        assert_eq!(b[1], (Point::new(20.0, 0.0), 160.0));
692
693        // stretch: children fill the cross axis.
694        let g = scene(|v| {
695            v.add(
696                Layout::new()
697                    .column()
698                    .width(120.0)
699                    .align(LayoutAlign::Stretch)
700                    .child(Rect::new().size(30.0)),
701            );
702        });
703        assert_eq!(boxes(&g)[0].1, 120.0);
704
705        // wrap: three 60-wide boxes in 130 → two lines.
706        let g = scene(|v| {
707            v.add(
708                Layout::new()
709                    .width(130.0)
710                    .wrap(true)
711                    .child(Rect::new().size(60.0))
712                    .child(Rect::new().size(60.0))
713                    .child(Rect::new().size(60.0)),
714            );
715        });
716        let ys: Vec<f64> = boxes(&g).iter().map(|b| b.0.y).collect();
717        assert_eq!(ys[0], ys[1]);
718        assert!(ys[2] > ys[0]);
719
720        // grid: 2 columns of equal width.
721        let g = scene(|v| {
722            v.add(
723                Layout::new()
724                    .grid(2)
725                    .width(200.0)
726                    .align(LayoutAlign::Stretch)
727                    .child(Rect::new().size(10.0))
728                    .child(Rect::new().size(10.0))
729                    .child(Rect::new().size(10.0)),
730            );
731        });
732        let b = boxes(&g);
733        assert_eq!(
734            b.iter().map(|b| b.1).collect::<Vec<_>>(),
735            vec![100.0, 100.0, 100.0]
736        );
737        assert_eq!(b[2].0.x, b[0].0.x);
738        assert!(b[2].0.y > b[0].0.y);
739    }
740
741    #[test]
742    fn describe_lists_signals() {
743        let g = scene(|view| {
744            view.add(Txt::new().text("abc"));
745        });
746        let d = g.describe(NodeId(1));
747        assert!(d.iter().any(|(k, v)| k == "text" && v == "abc"));
748    }
749}