Skip to main content

oxframe_2d/
handles.rs

1use std::ops::Deref;
2
3use kurbo::{BezPath, Point};
4use std::future::Future;
5use std::pin::Pin;
6use std::task::{Context, Poll};
7
8use oxframe_core::{BlendMode, Color, Ease, Gradient, Seconds, Signal, Tween, Vec2, all};
9
10use crate::builder::{
11    self, CameraBuilder, CircleBuilder, CodeBuilder, GridBuilder, GroupBuilder, ImgBuilder,
12    LayoutBuilder, LineBuilder, NodeBuilder, ParticlesBuilder, PathBuilder, PolygonBuilder,
13    RectBuilder, ShaderBuilder, StarBuilder, SvgBuilder, TxtBuilder,
14};
15use crate::draw::{LineCap, LineJoin};
16use crate::graph::{NodeId, NodeKind, SceneGraph, with_graph};
17use crate::place::{Edge, Place, SafeArea, Side};
18use crate::text::RichSpan;
19
20/// How far [`Node::slide_in`] starts from the node's place, in pixels.
21pub const SLIDE_DISTANCE: f64 = 160.0;
22
23/// Untyped node handle with the properties every node has.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct Node(NodeId);
26
27macro_rules! base_signal {
28    ($($(#[$doc:meta])* $field:ident: $ty:ty),* $(,)?) => {
29        $(
30            $(#[$doc])*
31            #[doc = ""]
32            #[doc = concat!("The `", stringify!($field), "` property as an animatable signal.")]
33            pub fn $field(self) -> Signal<$ty> {
34                with_graph(|g| g.node(self.0).$field.clone())
35            }
36        )*
37    };
38}
39
40impl Node {
41    /// Index of the node in the scene graph.
42    pub fn id(self) -> NodeId {
43        self.0
44    }
45
46    /// Wraps an id from [`NodeBuilder::build`](crate::NodeBuilder::build).
47    ///
48    /// Other crates implement [`NodeBuilder`](crate::NodeBuilder) with this:
49    /// the `parent` argument is only an id, and handles are what `add` needs.
50    pub fn from_id(id: NodeId) -> Self {
51        Self(id)
52    }
53
54    base_signal! {
55        x: f64,
56        y: f64,
57        /// Degrees, clockwise.
58        rotation: f64,
59        scale: Vec2,
60        opacity: f64,
61        fill: Color,
62        stroke: Color,
63        line_width: f64,
64        /// Start of the drawn part of the outline (fraction of its length).
65        start: f64,
66        /// End of the drawn part of the outline.
67        end: f64,
68        fill_gradient: Option<Gradient>,
69        stroke_gradient: Option<Gradient>,
70        dash: Vec<f64>,
71        dash_offset: f64,
72        line_cap: LineCap,
73        line_join: LineJoin,
74        blur: f64,
75        shadow_color: Color,
76        shadow_blur: f64,
77        shadow_offset: Vec2,
78        clip: bool,
79        blend: BlendMode,
80        z_index: f64,
81        anchor: Vec2,
82        grow: f64,
83        shrink: f64,
84        basis: Option<f64>,
85        margin: f64,
86        align_self: Option<crate::LayoutAlign>,
87    }
88
89    /// Adds a child node; children are positioned relative to the parent.
90    ///
91    /// The call site is stored on the node (`oxframe check` and `oxframe
92    /// inspect` print it). `.name("title")` overrides the automatic label.
93    #[allow(clippy::should_implement_trait)] // `view.add(..)` mirrors Motion Canvas
94    #[track_caller]
95    pub fn add<B: NodeBuilder>(self, child: B) -> B::Handle {
96        let loc = std::panic::Location::caller();
97        let prev = crate::graph::push_add_at(loc.file(), loc.line());
98        let handle = builder::add(self.0, child);
99        crate::graph::pop_add_at(prev);
100        handle
101    }
102
103    /// Detaches the node from its parent; it stops being drawn.
104    pub fn remove(self) {
105        with_graph(|g| g.detach(self.0));
106    }
107
108    /// Fades the node in from transparent, over the theme's normal duration
109    /// with its enter easing.
110    pub fn fade_in(self) -> Tween<f64> {
111        let motion = crate::theme::current().motion;
112        self.opacity().set(0.0);
113        self.opacity().to(1.0, motion.normal).ease(motion.enter)
114    }
115
116    /// Grows the node from nothing to its current scale with a small
117    /// overshoot (the theme's emphasis easing, `OUT_BACK` by default).
118    pub fn pop_in(self) -> Tween<Vec2> {
119        let motion = crate::theme::current().motion;
120        let full = self.scale().peek();
121        self.scale().set(Vec2::ZERO);
122        self.scale().to(full, motion.normal).ease(motion.emphasis)
123    }
124
125    /// Draws the outline from start to end (`end` from 0 to 1), as if with a
126    /// pen. Works on every shape with an outline: paths, lines, rectangles,
127    /// circles, polygons.
128    pub fn draw_on(self) -> Tween<f64> {
129        let motion = crate::theme::current().motion;
130        self.end().set(0.0);
131        self.end().to(1.0, motion.slow).ease(motion.enter)
132    }
133
134    /// Slides the node into its place from `SLIDE_DISTANCE` pixels towards
135    /// `from`, fading in on the way.
136    pub fn slide_in(self, from: Side) -> impl std::future::Future<Output = ()> + 'static {
137        let motion = crate::theme::current().motion;
138        let (axis, offset) = match from {
139            Side::Left => (self.x(), -SLIDE_DISTANCE),
140            Side::Right => (self.x(), SLIDE_DISTANCE),
141            Side::Top => (self.y(), -SLIDE_DISTANCE),
142            Side::Bottom => (self.y(), SLIDE_DISTANCE),
143        };
144        let home = axis.peek();
145        axis.set(home + offset);
146        let moves = all((
147            axis.to(home, motion.normal).ease(motion.enter),
148            self.fade_in(),
149        ));
150        async move {
151            moves.await;
152        }
153    }
154
155    /// Sits just outside `other`. The gap is the theme's `Md` step, and the
156    /// position is recomputed every frame.
157    pub fn next_to(self, other: impl Into<Node>, side: Side) -> Self {
158        let other = other.into();
159        with_graph(|g| g.set_place(self.0, Place::next_to(other.id(), side)));
160        self
161    }
162
163    /// Lines this node's `edge` up with the same edge of `other`.
164    pub fn align_to(self, other: impl Into<Node>, edge: Edge) -> Self {
165        let other = other.into();
166        with_graph(|g| g.set_place(self.0, Place::align_to(other.id(), edge)));
167        self
168    }
169
170    /// Pins one edge to the safe area. The other axis is left as it is.
171    pub fn to_edge(self, edge: Edge) -> Self {
172        with_graph(|g| g.set_place(self.0, Place::to_edge(edge)));
173        self
174    }
175
176    /// Same center as `other`.
177    pub fn center_in(self, other: impl Into<Node>) -> Self {
178        let other = other.into();
179        with_graph(|g| g.set_place(self.0, Place::center_in(other.id())));
180        self
181    }
182
183    /// Fades the node out, over the theme's normal duration with its exit easing.
184    pub fn fade_out(self) -> Tween<f64> {
185        let motion = crate::theme::current().motion;
186        self.opacity().to(0.0, motion.normal).ease(motion.exit)
187    }
188
189    /// Moves the node under another parent.
190    pub fn reparent(self, parent: impl Into<Node>) {
191        let parent = parent.into();
192        with_graph(|g| g.reparent(self.0, parent.0));
193    }
194
195    /// Runs a shader effect on this node's pixels, children included.
196    ///
197    /// ```ignore
198    /// let ring = card.effect(shaders::ripple().strength(0.0));
199    /// ring.strength().to(1.0, 0.6).await;
200    /// ```
201    ///
202    /// A node has one effect; a second call replaces the first. The node
203    /// must not sit inside a group with opacity below 1, a blur, a clip, a
204    /// blend, a shadow or another effect: there its pixels are not
205    /// finished, and `check` reports `input_cycle`.
206    pub fn effect(self, effect: crate::builder::EffectBuilder) -> Effect {
207        with_graph(|g| g.set_effect(self.0, effect.build(self.0)));
208        Effect(self.0)
209    }
210
211    /// Direct children, in creation order.
212    pub fn children(self) -> Vec<Node> {
213        with_graph(|g| g.node(self.0).children.iter().map(|&c| Node(c)).collect())
214    }
215
216    /// Size of the node's box in its own coordinates.
217    pub fn size(self) -> (f64, f64) {
218        with_graph(|g| g.measure(self.0))
219    }
220
221    /// Position of the node's origin in scene coordinates.
222    pub fn absolute_position(self) -> Vec2 {
223        with_graph(|g| (g.world_transform(self.0).unwrap_or_default() * Point::ZERO).to_vec2())
224    }
225}
226
227macro_rules! typed_handle {
228    ($name:ident, $builder:ident) => {
229        #[doc = concat!("Handle to a `", stringify!($name), "` node in the scene: cheap to copy, its")]
230        #[doc = "methods return the node's properties as [`Signal`]s. Derefs to [`Node`]."]
231        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
232        pub struct $name(Node);
233
234        impl $name {
235            /// Starts a builder; add it with [`Node::add`] (e.g. `view.add(..)`).
236            #[allow(clippy::new_ret_no_self)]
237            pub fn new() -> $builder {
238                $builder::default()
239            }
240
241            pub(crate) fn from_id(id: NodeId) -> Self {
242                Self(Node(id))
243            }
244
245            /// The untyped node handle (same as `*self`).
246            pub fn node(self) -> Node {
247                self.0
248            }
249        }
250
251        impl Deref for $name {
252            type Target = Node;
253            fn deref(&self) -> &Node {
254                &self.0
255            }
256        }
257
258        impl From<$name> for Node {
259            fn from(h: $name) -> Node {
260                h.0
261            }
262        }
263    };
264}
265
266typed_handle!(Group, GroupBuilder);
267typed_handle!(Camera, CameraBuilder);
268typed_handle!(Rect, RectBuilder);
269typed_handle!(Circle, CircleBuilder);
270typed_handle!(Path, PathBuilder);
271typed_handle!(Line, LineBuilder);
272typed_handle!(Txt, TxtBuilder);
273typed_handle!(Img, ImgBuilder);
274typed_handle!(Svg, SvgBuilder);
275typed_handle!(Layout, LayoutBuilder);
276typed_handle!(Grid, GridBuilder);
277typed_handle!(Polygon, PolygonBuilder);
278typed_handle!(Star, StarBuilder);
279typed_handle!(Code, CodeBuilder);
280
281/// Handle to geometry drawn by a closure. Derefs to [`Node`].
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
283pub struct Custom(Node);
284
285impl Custom {
286    /// Starts a builder. `draw` is called every time the frame is painted,
287    /// with the frame time in seconds. It must depend only on that time, on
288    /// signals it captured, and on `ctx.rng(seed)`.
289    #[allow(clippy::new_ret_no_self)]
290    pub fn new(draw: impl Fn(&mut crate::draw::DrawCtx, f64) + 'static) -> crate::CustomBuilder {
291        crate::CustomBuilder::from_draw(draw)
292    }
293
294    pub(crate) fn from_id(id: NodeId) -> Self {
295        Self(Node(id))
296    }
297
298    /// The untyped node handle.
299    pub fn node(self) -> Node {
300        self.0
301    }
302}
303
304impl Deref for Custom {
305    type Target = Node;
306    fn deref(&self) -> &Node {
307        &self.0
308    }
309}
310
311/// Handle to a WGSL shader. Derefs to [`Node`].
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
313pub struct Shader(Node);
314
315impl Shader {
316    /// A shader from WGSL text. The text defines `struct Params`, the
317    /// uniform `p`, and `fn shade`. oxframe adds `ox` and `ShadeIn`.
318    ///
319    /// The waves preset is what gets painted. A custom `shade` is
320    /// type-checked on every draw.
321    #[allow(clippy::new_ret_no_self)]
322    pub fn source(wgsl: impl Into<String>) -> ShaderBuilder {
323        ShaderBuilder::new(crate::shader::ShaderCode::Wgsl(wgsl.into()))
324    }
325
326    /// A shader read from `path` on every draw, so saving the file updates
327    /// the running scene without a rebuild.
328    pub fn file(path: impl AsRef<std::path::Path>) -> ShaderBuilder {
329        ShaderBuilder::new(crate::shader::ShaderCode::File(path.as_ref().into()))
330    }
331
332    pub(crate) fn from_id(id: NodeId) -> Self {
333        Self(Node(id))
334    }
335
336    /// The untyped node handle.
337    pub fn node(self) -> Node {
338        self.0
339    }
340
341    /// The numeric uniform `name`.
342    ///
343    /// # Panics
344    ///
345    /// If the shader has no numeric uniform with that name. The message
346    /// lists the names it does have.
347    pub fn uniform(self, name: &str) -> Signal<f64> {
348        self.number(name)
349    }
350
351    /// Wave height of the waves preset.
352    pub fn amp(self) -> Signal<f64> {
353        self.number("amp")
354    }
355
356    /// Crest speed of the waves preset.
357    pub fn speed(self) -> Signal<f64> {
358        self.number("speed")
359    }
360
361    /// Water color of the waves preset.
362    pub fn deep(self) -> Signal<Color> {
363        self.color("deep")
364    }
365
366    /// Crest color of the waves preset.
367    pub fn foam(self) -> Signal<Color> {
368        self.color("foam")
369    }
370
371    /// How wet the lens of the lens_drops preset is, 0 to 1.
372    pub fn wetness(self) -> Signal<f64> {
373        self.number("wetness")
374    }
375
376    fn number(self, name: &str) -> Signal<f64> {
377        with_graph(|g| {
378            let NodeKind::Shader { uniforms, .. } = &g.node(self.id()).kind else {
379                unreachable!("handle does not point to a Shader");
380            };
381            for u in uniforms {
382                if u.name == name
383                    && let crate::shader::UniformValue::Num(v) = &u.value
384                {
385                    return v.clone();
386                }
387            }
388            panic!("no numeric uniform {name}. {}", uniform_names(uniforms));
389        })
390    }
391
392    fn color(self, name: &str) -> Signal<Color> {
393        with_graph(|g| {
394            let NodeKind::Shader { uniforms, .. } = &g.node(self.id()).kind else {
395                unreachable!("handle does not point to a Shader");
396            };
397            for u in uniforms {
398                if u.name == name
399                    && let crate::shader::UniformValue::Color(v) = &u.value
400                {
401                    return v.clone();
402                }
403            }
404            panic!("no color uniform {name}. {}", uniform_names(uniforms));
405        })
406    }
407}
408
409fn uniform_names(uniforms: &[crate::shader::Uniform]) -> String {
410    let names: Vec<&str> = uniforms.iter().map(|u| u.name.as_str()).collect();
411    format!("Available: {}.", names.join(", "))
412}
413
414/// Handle to a node's shader effect, from [`Node::effect`].
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
416pub struct Effect(NodeId);
417
418impl Effect {
419    /// The node the effect runs on.
420    pub fn node(self) -> Node {
421        Node(self.0)
422    }
423
424    /// 0 leaves the node alone, 1 is the full effect.
425    pub fn strength(self) -> Signal<f64> {
426        self.data().strength
427    }
428
429    /// How fast the effect moves.
430    pub fn speed(self) -> Signal<f64> {
431        self.data().speed
432    }
433
434    fn data(self) -> crate::post::EffectData {
435        with_graph(|g| {
436            g.node(self.0)
437                .effect
438                .clone()
439                .expect("an Effect handle always points to a node with an effect")
440        })
441    }
442}
443
444impl Deref for Shader {
445    type Target = Node;
446    fn deref(&self) -> &Node {
447        &self.0
448    }
449}
450
451impl From<Custom> for Node {
452    fn from(h: Custom) -> Node {
453        h.0
454    }
455}
456
457impl From<Shader> for Node {
458    fn from(h: Shader) -> Node {
459        h.0
460    }
461}
462
463typed_handle!(Particles, ParticlesBuilder);
464
465macro_rules! kind_signal {
466    ($handle:ident :: $kind:ident { $($(#[$doc:meta])* $field:ident: $ty:ty),* $(,)? }) => {
467        impl $handle {
468            $(
469                $(#[$doc])*
470                #[doc = ""]
471                #[doc = concat!("The `", stringify!($field), "` property as an animatable signal.")]
472                pub fn $field(self) -> Signal<$ty> {
473                    with_graph(|g| match &g.node(self.id()).kind {
474                        NodeKind::$kind { $field, .. } => $field.clone(),
475                        _ => unreachable!(concat!("handle does not point to a ", stringify!($kind))),
476                    })
477                }
478            )*
479        }
480    };
481}
482
483kind_signal!(Particles::Particles { gravity: f64 });
484
485impl Particles {
486    /// Emits `count` particles from `origin` at the current script time.
487    ///
488    /// The future is ready immediately: the burst is an event, and the
489    /// particles then move on their own. Seeking back before this time
490    /// removes them; seeking forward restores the same dots.
491    pub fn burst(self, origin: impl Into<Vec2>, count: u32) -> oxframe_core::WaitFor {
492        let origin = origin.into();
493        let time = oxframe_core::time();
494        with_graph(|g| {
495            if let NodeKind::Particles { state, .. } = &g.node(self.id()).kind {
496                state.bursts.borrow_mut().push(crate::sim::Burst {
497                    time,
498                    origin,
499                    count,
500                });
501            }
502        });
503        oxframe_core::wait_for(0.0)
504    }
505}
506
507kind_signal!(Rect::Rect {
508    width: f64,
509    height: f64,
510    radius: f64
511});
512kind_signal!(Circle::Circle {
513    /// Diameter.
514    size: f64,
515    start_angle: f64,
516    end_angle: f64,
517});
518kind_signal!(Polygon::Polygon {
519    sides: f64,
520    size: f64
521});
522kind_signal!(Star::Star {
523    points: f64,
524    size: f64,
525    inner: f64
526});
527kind_signal!(Path::Path { data: BezPath });
528kind_signal!(Camera::Camera {
529    /// 1 shows the world at its size, 2 twice as big.
530    zoom: f64
531});
532
533impl Camera {
534    /// Moves the camera so `node`'s center is in the middle of the frame.
535    pub fn center_on(
536        self,
537        node: impl Into<Node>,
538        duration: impl Seconds,
539    ) -> impl std::future::Future<Output = ()> + 'static {
540        let node = node.into();
541        let d = duration.seconds();
542        let target = with_graph(|g| g.box_in(node.id(), self.id())).map(|r| r.center());
543        let moves = target.map(|c| all((self.x().to(c.x, d), self.y().to(c.y, d))));
544        async move {
545            if let Some(m) = moves {
546                m.await;
547            }
548        }
549    }
550
551    /// Centers on `node` and zooms so all of it fits in the frame with
552    /// `padding` pixels to spare. Measures the node when called, so a layout
553    /// that is still growing fits at its current size.
554    pub fn fit(
555        self,
556        node: impl Into<Node>,
557        padding: f64,
558        duration: impl Seconds,
559    ) -> impl std::future::Future<Output = ()> + 'static {
560        let node = node.into();
561        let d = duration.seconds();
562        let (bounds, (fw, fh)) = with_graph(|g| (g.box_in(node.id(), self.id()), g.frame_size()));
563        let moves = bounds.map(|r| {
564            let zoom = ((fw - 2.0 * padding) / r.width().max(1e-6))
565                .min((fh - 2.0 * padding) / r.height().max(1e-6))
566                .max(1e-3);
567            let c = r.center();
568            all((
569                self.x().to(c.x, d),
570                self.y().to(c.y, d),
571                self.zoom().to(zoom, d),
572            ))
573        });
574        async move {
575            if let Some(m) = moves {
576                m.await;
577            }
578        }
579    }
580
581    /// Back to the start: looking at the origin, no zoom, no rotation.
582    pub fn reset(self, duration: impl Seconds) -> impl std::future::Future<Output = ()> + 'static {
583        let d = duration.seconds();
584        let moves = all((
585            self.x().to(0.0, d),
586            self.y().to(0.0, d),
587            self.zoom().to(1.0, d),
588            self.rotation().to(0.0, d),
589        ));
590        async move {
591            moves.await;
592        }
593    }
594}
595kind_signal!(Line::Line { points: Vec<Vec2>, arrow_size: f64 });
596kind_signal!(Txt::Txt { text: String, spans: Vec<RichSpan>, font_size: f64, weight: f64 });
597kind_signal!(Code::Code {
598    font_size: f64,
599    /// Focused lines as (first, last), 1-based; (0, 0) = all.
600    focus: Vec2,
601    /// Alpha of unfocused lines.
602    dim: f64,
603});
604
605impl Txt {
606    /// Morphs to `text` glyph by glyph: letters present in both strings
607    /// travel to their new places, the others fade.
608    pub fn morph_to(self, text: impl Into<String>, duration: f64) -> oxframe_core::BoxFuture {
609        let to = text.into();
610        let (txt, morph, t) = with_graph(|g| match &g.node(self.id()).kind {
611            NodeKind::Txt {
612                text,
613                morph,
614                morph_t,
615                ..
616            } => (text.clone(), morph.clone(), morph_t.clone()),
617            _ => unreachable!("handle does not point to a Txt"),
618        });
619        Box::pin(async move {
620            morph.set(Some((txt.get(), to.clone())));
621            t.set(0.0);
622            t.to(1.0, duration).await;
623            txt.set(to);
624            morph.set(None);
625        })
626    }
627}
628
629impl Code {
630    /// The source.
631    ///
632    /// `.to(next, seconds)` diffs the two versions: tokens that stay slide
633    /// to their new places, deleted tokens fade and collapse, new tokens
634    /// fade in. `Txt` types its text out; code does not. `.get()`, `.set()`
635    /// and `.bind()` still replace the source with no animation, and `.set`
636    /// cancels a diff that is already running.
637    pub fn code(self) -> CodeText {
638        with_graph(|g| match &g.node(self.id()).kind {
639            NodeKind::Code {
640                code,
641                edit_from,
642                edit_to,
643                editing,
644                edit_t,
645                edit_flash,
646                focus,
647                lang,
648                ..
649            } => CodeText {
650                code: code.clone(),
651                edit_from: edit_from.clone(),
652                edit_to: edit_to.clone(),
653                editing: editing.clone(),
654                edit_t: edit_t.clone(),
655                edit_flash: edit_flash.clone(),
656                focus: focus.clone(),
657                lang: *lang,
658            },
659            _ => unreachable!("handle does not point to a Code"),
660        })
661    }
662
663    /// Moves the focus to lines `first..=last` over `duration`.
664    ///
665    /// Lines are 1-based. While a diff is running, a focus that sits on
666    /// lines which shift rides with those lines, and lands on their new
667    /// numbers when the diff ends.
668    pub fn focus_lines(
669        self,
670        first: usize,
671        last: usize,
672        duration: f64,
673    ) -> oxframe_core::Tween<Vec2> {
674        self.focus()
675            .to(Vec2::new(first as f64, last as f64), duration)
676    }
677
678    /// Inserts `text` at `line` (1-based) and `col` (0-based characters).
679    ///
680    /// # Panics
681    ///
682    /// If the line or column is outside the current source. The line just
683    /// after the last one, column 0, is the end of the source.
684    pub fn insert(
685        self,
686        line: usize,
687        col: usize,
688        text: impl Into<String>,
689        duration: impl Seconds,
690    ) -> CodeTween {
691        let text = text.into();
692        let src = self.code().get();
693        let next = oxframe_text::insert_at(&src, line, col, &text)
694            .unwrap_or_else(|e| panic!("code.insert: {e}"));
695        self.code().to(next, duration)
696    }
697
698    /// Removes the half-open range `((start_line, start_col), (end_line, end_col))`.
699    ///
700    /// Lines are 1-based, columns are 0-based characters. `((5, 0), (6, 0))`
701    /// removes line 5.
702    ///
703    /// # Panics
704    ///
705    /// If a point is outside the source, or the end is before the start.
706    pub fn remove(
707        self,
708        range: ((usize, usize), (usize, usize)),
709        duration: impl Seconds,
710    ) -> CodeTween {
711        self.rewrite(range, String::new(), duration, "code.remove")
712    }
713
714    /// Replaces the half-open range with `text`. See [`remove`](Self::remove)
715    /// for how the range is counted.
716    ///
717    /// # Panics
718    ///
719    /// If a point is outside the source, or the end is before the start.
720    pub fn replace(
721        self,
722        range: ((usize, usize), (usize, usize)),
723        text: impl Into<String>,
724        duration: impl Seconds,
725    ) -> CodeTween {
726        self.rewrite(range, text.into(), duration, "code.replace")
727    }
728
729    fn rewrite(
730        self,
731        ((l0, c0), (l1, c1)): ((usize, usize), (usize, usize)),
732        text: String,
733        duration: impl Seconds,
734        what: &str,
735    ) -> CodeTween {
736        let src = self.code().get();
737        let next = oxframe_text::replace_range(
738            &src,
739            oxframe_text::Loc { line: l0, col: c0 },
740            oxframe_text::Loc { line: l1, col: c1 },
741            &text,
742        )
743        .unwrap_or_else(|e| panic!("{what}: {e}"));
744        self.code().to(next, duration)
745    }
746}
747
748/// The source of a [`Code`] node.
749///
750/// Dereferences to the underlying [`Signal`], so `.get()` reads it.
751/// [`.to()`](CodeText::to) runs a diff tween instead of the typewriter
752/// [`Signal::to`](Signal::to) uses for plain strings.
753#[derive(Clone)]
754pub struct CodeText {
755    code: Signal<String>,
756    edit_from: Signal<String>,
757    edit_to: Signal<String>,
758    editing: Signal<bool>,
759    edit_t: Signal<f64>,
760    edit_flash: Signal<bool>,
761    focus: Signal<Vec2>,
762    lang: oxframe_text::Lang,
763}
764
765impl std::ops::Deref for CodeText {
766    type Target = Signal<String>;
767    fn deref(&self) -> &Signal<String> {
768        &self.code
769    }
770}
771
772impl CodeText {
773    /// Replaces the source immediately and cancels a running diff.
774    pub fn set(&self, value: impl Into<String>) {
775        self.editing.set(false);
776        self.code.set(value.into());
777    }
778
779    /// Makes the source follow `f`, and cancels a running diff.
780    pub fn bind(&self, f: impl Fn() -> String + 'static) {
781        self.editing.set(false);
782        self.code.bind(f);
783    }
784
785    /// Diffs the current source into `target` over `duration`.
786    pub fn to(&self, target: impl Into<String>, duration: impl Seconds) -> CodeTween {
787        let target = target.into();
788        let from = self.code.get();
789        self.edit_from.set(from.clone());
790        self.edit_to.set(target.clone());
791        self.edit_flash.set(false);
792        self.editing.set(true);
793        self.edit_t.set(0.0);
794        CodeTween {
795            tween: self.edit_t.to(1.0, duration),
796            code: self.code.clone(),
797            editing: self.editing.clone(),
798            edit_flash: self.edit_flash.clone(),
799            focus: self.focus.clone(),
800            lang: self.lang,
801            from,
802            target,
803            finished: false,
804        }
805    }
806}
807
808/// Diff tween returned by [`CodeText::to`], [`Code::insert`], [`Code::remove`]
809/// and [`Code::replace`].
810#[must_use = "a tween does nothing unless awaited"]
811pub struct CodeTween {
812    tween: Tween<f64>,
813    code: Signal<String>,
814    editing: Signal<bool>,
815    edit_flash: Signal<bool>,
816    focus: Signal<Vec2>,
817    lang: oxframe_text::Lang,
818    from: String,
819    target: String,
820    finished: bool,
821}
822
823impl CodeTween {
824    /// Easing curve. The default is [`presets::DEFAULT`](oxframe_core::presets::DEFAULT).
825    pub fn ease(mut self, ease: impl Ease + 'static) -> Self {
826        self.tween = self.tween.ease(ease);
827        self
828    }
829
830    /// Changes the length. `f64` is seconds; a theme pace is a duration too.
831    pub fn duration(mut self, duration: impl Seconds) -> Self {
832        self.tween = self.tween.duration(duration);
833        self
834    }
835
836    /// Flashes a background behind text that this edit inserts.
837    pub fn flash(self) -> Self {
838        self.edit_flash.set(true);
839        self
840    }
841}
842
843impl Future for CodeTween {
844    type Output = ();
845
846    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
847        let this = self.get_mut();
848        if Pin::new(&mut this.tween).poll(cx).is_pending() {
849            return Poll::Pending;
850        }
851        if !this.finished {
852            this.finished = true;
853            let focus = this.focus.get();
854            let (a, b) = (focus.x.round() as i64, focus.y.round() as i64);
855            if a > 0 || b > 0 {
856                let diff = oxframe_text::diff_code(&this.from, &this.target, this.lang);
857                if let Some((n0, n1)) =
858                    oxframe_text::map_focus(&diff, a.max(0) as usize, b.max(0) as usize)
859                {
860                    this.focus.set(Vec2::new(n0 as f64, n1 as f64));
861                }
862            }
863            this.code.set(this.target.clone());
864            this.editing.set(false);
865        }
866        Poll::Ready(())
867    }
868}
869kind_signal!(Svg::Svg {
870    width: f64,
871    height: f64
872});
873
874impl Svg {
875    /// The node imported from the SVG element with this `id`.
876    ///
877    /// # Panics
878    ///
879    /// If the file has no element with that id.
880    pub fn find(self, id: &str) -> Node {
881        let id = with_graph(|g| g.descendant_named(self.id(), id))
882            .unwrap_or_else(|| panic!("svg has no element with id {id:?}"));
883        Node::from_id(id)
884    }
885
886    /// Every path from the file, in paint order.
887    pub fn paths(self) -> Vec<Path> {
888        with_graph(|g| {
889            g.descendant_paths(self.id())
890                .into_iter()
891                .map(Path::from_id)
892                .collect()
893        })
894    }
895}
896
897kind_signal!(Img::Img {
898    width: f64,
899    height: f64
900});
901kind_signal!(Layout::Layout {
902    gap: f64,
903    padding: f64,
904    radius: f64,
905    /// Fixed width; `None` sizes to content.
906    width: Option<f64>,
907    /// Fixed height; `None` sizes to content.
908    height: Option<f64>,
909});
910kind_signal!(Grid::Grid {
911    width: f64,
912    height: f64,
913    spacing: f64
914});
915
916/// Root of a scene. Origin is the center of the frame, y points down.
917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
918pub struct View(Node);
919
920impl View {
921    /// The view of the scene being executed.
922    pub fn current() -> Self {
923        Self(Node(SceneGraph::ROOT))
924    }
925
926    /// Frame size in pixels.
927    pub fn size(self) -> (u32, u32) {
928        with_graph(|g| g.size())
929    }
930
931    /// True when the frame is taller than it is wide.
932    pub fn is_portrait(self) -> bool {
933        let (w, h) = self.size();
934        h > w
935    }
936
937    /// The rectangle [`Node::to_edge`](crate::Node::to_edge) pins to, in scene
938    /// coordinates. A portrait frame leaves room for a vertical video's
939    /// status bar and buttons.
940    pub fn safe_area(self) -> SafeArea {
941        let (w, h) = self.size();
942        SafeArea::for_frame(f64::from(w), f64::from(h))
943    }
944}
945
946impl Deref for View {
947    type Target = Node;
948    fn deref(&self) -> &Node {
949        &self.0
950    }
951}
952
953impl From<View> for Node {
954    fn from(v: View) -> Node {
955        v.0
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use crate::Primitive;
963
964    fn scene(f: impl FnOnce(View)) -> std::rc::Rc<SceneGraph> {
965        let graph = SceneGraph::new(400, 400);
966        graph.enter(|| f(View::current()));
967        graph
968    }
969
970    /// Runs `scene` to the end inside a graph; returns the frames it took.
971    fn play(
972        scene_fn: impl FnOnce(View) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>,
973    ) -> u32 {
974        let graph = SceneGraph::new(400, 400);
975        graph.enter(|| {
976            let fut = scene_fn(View::current());
977            let mut ex =
978                oxframe_core::Executor::new(60.0, oxframe_protocol::Meta::default(), 0, fut);
979            let mut frames = 0;
980            while !ex.step() {
981                frames += 1;
982                assert!(frames < 10_000, "scene did not finish");
983            }
984            frames
985        })
986    }
987
988    /// Screen position of a node's origin.
989    fn screen(n: impl Into<Node>) -> (f64, f64) {
990        let p = n.into().absolute_position();
991        ((p.x * 1e6).round() / 1e6, (p.y * 1e6).round() / 1e6)
992    }
993
994    #[test]
995    fn camera_pans_zooms_and_turns_its_children_only() {
996        scene(|view| {
997            let cam = view.add(Camera::new());
998            let dot = cam.add(Circle::new().size(10.0).x(100.0).y(50.0));
999            let hud = view.add(Txt::new().text("HUD").x(-300.0).y(-200.0));
1000            // A fresh camera changes nothing.
1001            assert_eq!(screen(dot), (100.0, 50.0));
1002            // Looking at the dot brings it to the center.
1003            cam.x().set(100.0);
1004            cam.y().set(50.0);
1005            assert_eq!(screen(dot), (0.0, 0.0));
1006            // Zoom scales distances from the point looked at.
1007            cam.zoom().set(2.0);
1008            let other = cam.add(Circle::new().size(10.0).x(110.0).y(50.0));
1009            assert_eq!(screen(other), (20.0, 0.0));
1010            // Turning the camera clockwise turns the world the other way.
1011            cam.rotation().set(90.0);
1012            assert_eq!(screen(other), (0.0, -20.0));
1013            // Nodes outside the camera stay put.
1014            assert_eq!(screen(hud), (-300.0, -200.0));
1015        });
1016    }
1017
1018    #[test]
1019    fn camera_center_on_fit_and_reset() {
1020        play(|view| {
1021            let cam = view.add(Camera::new());
1022            let card = cam.add(Rect::new().width(200.0).height(100.0).x(300.0).y(-100.0));
1023            let row = cam.add(
1024                Layout::new()
1025                    .x(-500.0)
1026                    .gap(10.0)
1027                    .child(Rect::new().size(50.0))
1028                    .child(Rect::new().size(50.0)),
1029            );
1030            Box::pin(async move {
1031                cam.center_on(card, 1.0).await;
1032                assert_eq!((cam.x().get(), cam.y().get()), (300.0, -100.0));
1033                assert_eq!(screen(card), (0.0, 0.0));
1034                // 400×400 frame, 20 px padding: limited by width (360 / 200).
1035                cam.fit(card, 20.0, 0.0).await;
1036                assert!(
1037                    (cam.zoom().get() - 1.8).abs() < 1e-9,
1038                    "{}",
1039                    cam.zoom().get()
1040                );
1041                // A layout that grew is fitted at its current size.
1042                row.gap().to(110.0, 0.5).await;
1043                cam.fit(row, 0.0, 0.0).await;
1044                let (w, _) = row.size();
1045                assert!((w - 210.0).abs() < 1e-6, "layout width {w}");
1046                assert!((cam.zoom().get() - 400.0 / 210.0).abs() < 1e-9);
1047                assert!((cam.x().get() + 500.0).abs() < 1e-9);
1048                cam.reset(0.5).await;
1049                assert_eq!(
1050                    (
1051                        cam.x().get(),
1052                        cam.y().get(),
1053                        cam.zoom().get(),
1054                        cam.rotation().get()
1055                    ),
1056                    (0.0, 0.0, 1.0, 0.0)
1057                );
1058            })
1059        });
1060    }
1061
1062    #[test]
1063    fn entrance_helpers_start_at_once_and_end_in_place() {
1064        let normal = crate::theme::current().motion.normal;
1065        let expected = (normal * 60.0).round() as u32;
1066        let frames = play(|view| {
1067            let r = view.add(Rect::new().size(100.0).x(50.0));
1068            let pop = r.pop_in();
1069            // The start value is set when the helper is called, not awaited:
1070            // nothing flashes in its final state for a frame.
1071            assert_eq!(r.scale().get(), Vec2::ZERO);
1072            let slide = r.slide_in(Side::Left);
1073            assert_eq!(r.x().get(), 50.0 - SLIDE_DISTANCE);
1074            assert_eq!(r.opacity().get(), 0.0);
1075            let draw = r.draw_on();
1076            assert_eq!(r.end().get(), 0.0);
1077            Box::pin(async move {
1078                all((pop, slide)).await;
1079                assert_eq!(r.scale().get(), Vec2::new(1.0, 1.0));
1080                assert_eq!((r.x().get(), r.opacity().get()), (50.0, 1.0));
1081                draw.await;
1082                assert_eq!(r.end().get(), 1.0);
1083                r.fade_out().duration(0.5).await;
1084                assert_eq!(r.opacity().get(), 0.0);
1085            })
1086        });
1087        let slow = crate::theme::current().motion.slow;
1088        assert_eq!(frames, expected + (slow * 60.0).round() as u32 + 30);
1089    }
1090
1091    #[test]
1092    fn a_diff_lands_on_the_new_source_and_moves_the_focus() {
1093        play(|view| {
1094            let code = view.add(Code::new().code("a\nkeep\n").focus(2, 2));
1095            Box::pin(async move {
1096                code.insert(1, 0, "NEW\n", 0.2).flash().await;
1097                assert_eq!(code.code().get(), "NEW\na\nkeep\n");
1098                let f = code.focus().get();
1099                assert_eq!((f.x.round() as i32, f.y.round() as i32), (3, 3));
1100                code.replace(((3, 0), (3, 4)), "stay", 0.0).await;
1101                assert_eq!(code.code().get(), "NEW\na\nstay\n");
1102            })
1103        });
1104    }
1105
1106    #[test]
1107    fn add_records_its_call_site_and_scene_bounds() {
1108        let g = scene(|view| {
1109            view.add(Rect::new().size(100.0).name("box"));
1110            view.add(Rect::new().size(10.0).x(100.0));
1111        });
1112        let nodes = g.inspect_nodes();
1113        let named = nodes.iter().find(|n| n.name == "box").unwrap();
1114        assert_eq!(named.kind, "Rect");
1115        assert!(named.source.as_ref().unwrap().contains("handles.rs:"));
1116        assert!(
1117            (named.bounds[0] + 50.0).abs() < 1e-6 && (named.bounds[2] - 50.0).abs() < 1e-6,
1118            "{:?}",
1119            named.bounds
1120        );
1121        let auto = nodes
1122            .iter()
1123            .find(|n| n.name != "box" && n.name != "view")
1124            .unwrap();
1125        assert_eq!(auto.name, auto.source.clone().unwrap());
1126    }
1127
1128    #[test]
1129    fn builders_set_initial_values_and_handles_return_signals() {
1130        scene(|view| {
1131            let r = view.add(
1132                Rect::new()
1133                    .size(50.0)
1134                    .radius(5.0)
1135                    .x(-10.0)
1136                    .fill(Color::BLACK),
1137            );
1138            assert_eq!(r.width().get(), 50.0);
1139            assert_eq!(r.height().get(), 50.0);
1140            assert_eq!(r.radius().get(), 5.0);
1141            assert_eq!(r.x().get(), -10.0);
1142            assert_eq!(r.fill().get(), Color::BLACK);
1143            r.x().set(3.0);
1144            assert_eq!(r.x().get(), 3.0);
1145            let c = r.add(Circle::new().size(10.0));
1146            assert_eq!(r.children(), vec![c.node()]);
1147            assert_eq!(view.children(), vec![r.node()]);
1148            assert_eq!(c.size().name().as_deref(), Some("Circle#2.size"));
1149        });
1150    }
1151
1152    #[test]
1153    fn draw_list_composes_transforms() {
1154        let g = scene(|view| {
1155            let grp = view.add(Group::new().x(10.0).scale(2.0));
1156            grp.add(Circle::new().x(5.0));
1157        });
1158        let items = g.draw_list();
1159        assert_eq!(items.len(), 1);
1160        assert_eq!(items[0].transform * Point::ZERO, Point::new(20.0, 0.0));
1161    }
1162
1163    #[test]
1164    fn layout_places_children_in_a_row() {
1165        let g = scene(|view| {
1166            view.add(
1167                Layout::new()
1168                    .gap(10.0)
1169                    .padding(5.0)
1170                    .child(Rect::new().size(20.0))
1171                    .child(Rect::new().width(40.0).height(10.0)),
1172            );
1173        });
1174        let layout = NodeId(1);
1175        assert_eq!(g.measure(layout), (5.0 + 20.0 + 10.0 + 40.0 + 5.0, 30.0));
1176        let centers: Vec<_> = g
1177            .draw_list()
1178            .iter()
1179            .map(|i| i.transform * Point::ZERO)
1180            .collect();
1181        // Total 80 wide: first box spans -35..-15, second -5..35.
1182        assert_eq!(centers, vec![Point::new(-25.0, 0.0), Point::new(15.0, 0.0)]);
1183    }
1184
1185    #[test]
1186    fn column_with_fixed_size_and_alignment() {
1187        let g = scene(|view| {
1188            view.add(
1189                Layout::new()
1190                    .column()
1191                    .height(100.0)
1192                    .width(100.0)
1193                    .justify(crate::Justify::Start)
1194                    .align(crate::LayoutAlign::Start)
1195                    .child(Rect::new().size(20.0)),
1196            );
1197        });
1198        let c = g.draw_list()[0].transform * Point::ZERO;
1199        assert_eq!(c, Point::new(-40.0, -40.0));
1200    }
1201
1202    #[test]
1203    fn text_line_path_and_img_draw() {
1204        let png = {
1205            let mut v = Vec::new();
1206            image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 255]))
1207                .write_to(&mut std::io::Cursor::new(&mut v), image::ImageFormat::Png)
1208                .unwrap();
1209            v
1210        };
1211        let g = scene(|view| {
1212            let t = view.add(Txt::new().text("Hi").font_size(40.0));
1213            assert!(t.size().0 > 20.0);
1214            view.add(
1215                Line::new()
1216                    .points([(0.0, 0.0), (100.0, 0.0)])
1217                    .end_arrow(true),
1218            );
1219            view.add(Path::new().svg("M0 0 L10 0 L10 10 Z"));
1220            let img = view.add(Img::new().src(&png).width(40.0));
1221            assert_eq!(img.height().get(), 20.0);
1222        });
1223        let kinds: Vec<_> = g
1224            .draw_list()
1225            .iter()
1226            .map(|i| match i.prim {
1227                Primitive::Fill { .. } => "fill",
1228                Primitive::Stroke { .. } => "stroke",
1229                Primitive::Image { .. } => "image",
1230                Primitive::PushLayer(_) => "push",
1231                Primitive::PopLayer => "pop",
1232                Primitive::ReadFrame(_) => "frame",
1233                Primitive::BeginEffect(_) => "effect",
1234                Primitive::EndEffect => "end_effect",
1235            })
1236            .collect();
1237        assert_eq!(kinds, vec!["fill", "stroke", "fill", "fill", "image"]);
1238    }
1239
1240    #[test]
1241    fn trimming_and_pick() {
1242        let g = scene(|view| {
1243            view.add(Rect::new().size(100.0).name("back"));
1244            view.add(Circle::new().size(40.0).x(30.0).end(0.5));
1245        });
1246        assert_eq!(g.pick(Point::new(-40.0, 0.0)), Some(NodeId(1)));
1247        assert_eq!(g.pick(Point::new(300.0, 0.0)), None);
1248        // The half circle covers the right half of its disc.
1249        assert_eq!(g.pick(Point::new(45.0, 0.0)), Some(NodeId(2)));
1250    }
1251
1252    fn kinds(g: &SceneGraph) -> Vec<&'static str> {
1253        g.draw_list()
1254            .iter()
1255            .map(|i| match i.prim {
1256                Primitive::Fill { .. } => "fill",
1257                Primitive::Stroke { .. } => "stroke",
1258                Primitive::Image { .. } => "image",
1259                Primitive::PushLayer(_) => "push",
1260                Primitive::PopLayer => "pop",
1261                Primitive::ReadFrame(_) => "frame",
1262                Primitive::BeginEffect(_) => "effect",
1263                Primitive::EndEffect => "end_effect",
1264            })
1265            .collect()
1266    }
1267
1268    #[test]
1269    fn layers_only_when_needed_and_balanced() {
1270        // Translucent leaf: opacity folds into the paint, no layer.
1271        let g = scene(|v| {
1272            v.add(Rect::new().opacity(0.5));
1273        });
1274        assert_eq!(kinds(&g), ["fill"]);
1275        // Translucent group with children: one layer around them.
1276        let g = scene(|v| {
1277            v.add(
1278                Group::new()
1279                    .opacity(0.5)
1280                    .child(Rect::new())
1281                    .child(Circle::new()),
1282            );
1283        });
1284        assert_eq!(kinds(&g), ["push", "fill", "fill", "pop"]);
1285        // Clip wraps only the children; shadow wraps node + children.
1286        let g = scene(|v| {
1287            v.add(
1288                Circle::new()
1289                    .clip(true)
1290                    .shadow(Color::BLACK, 4.0, (2.0, 2.0))
1291                    .child(Rect::new()),
1292            );
1293        });
1294        assert_eq!(kinds(&g), ["push", "fill", "push", "fill", "pop", "pop"]);
1295    }
1296
1297    #[test]
1298    fn z_index_orders_siblings_and_anchor_moves_box() {
1299        let g = scene(|v| {
1300            v.add(Rect::new().name("a").z_index(2.0));
1301            v.add(Rect::new().name("b"));
1302            v.add(Rect::new().name("c").size(20.0).anchor((-1.0, -1.0)).x(5.0));
1303        });
1304        let items = g.draw_list();
1305        let order: Vec<u32> = items.iter().map(|i| i.node.0).collect();
1306        assert_eq!(order, vec![2, 3, 1]);
1307        // Anchored at the top-left: the box center sits at (5+10, 0+10).
1308        assert_eq!(items[1].transform * Point::ZERO, Point::new(15.0, 10.0));
1309    }
1310
1311    fn boxes(g: &SceneGraph) -> Vec<(Point, f64)> {
1312        g.draw_list()
1313            .iter()
1314            .filter_map(|i| match &i.prim {
1315                Primitive::Fill { path, .. } => {
1316                    let bb = kurbo::Shape::bounding_box(path);
1317                    Some((i.transform * Point::ZERO, bb.width()))
1318                }
1319                _ => None,
1320            })
1321            .collect()
1322    }
1323
1324    #[test]
1325    fn flex_grow_stretch_wrap_and_grid() {
1326        use crate::{Justify, LayoutAlign};
1327        // grow: a 200-wide row; the second child takes the free space.
1328        let g = scene(|v| {
1329            v.add(
1330                Layout::new()
1331                    .width(200.0)
1332                    .justify(Justify::Start)
1333                    .child(Rect::new().size(40.0))
1334                    .child(Rect::new().size(40.0).grow(1.0)),
1335            );
1336        });
1337        let b = boxes(&g);
1338        assert_eq!(b[0], (Point::new(-80.0, 0.0), 40.0));
1339        assert_eq!(b[1], (Point::new(20.0, 0.0), 160.0));
1340
1341        // stretch: children fill the cross axis.
1342        let g = scene(|v| {
1343            v.add(
1344                Layout::new()
1345                    .column()
1346                    .width(120.0)
1347                    .align(LayoutAlign::Stretch)
1348                    .child(Rect::new().size(30.0)),
1349            );
1350        });
1351        assert_eq!(boxes(&g)[0].1, 120.0);
1352
1353        // wrap: three 60-wide boxes in 130 → two lines.
1354        let g = scene(|v| {
1355            v.add(
1356                Layout::new()
1357                    .width(130.0)
1358                    .wrap(true)
1359                    .child(Rect::new().size(60.0))
1360                    .child(Rect::new().size(60.0))
1361                    .child(Rect::new().size(60.0)),
1362            );
1363        });
1364        let ys: Vec<f64> = boxes(&g).iter().map(|b| b.0.y).collect();
1365        assert_eq!(ys[0], ys[1]);
1366        assert!(ys[2] > ys[0]);
1367
1368        // grid: 2 columns of equal width.
1369        let g = scene(|v| {
1370            v.add(
1371                Layout::new()
1372                    .grid(2)
1373                    .width(200.0)
1374                    .align(LayoutAlign::Stretch)
1375                    .child(Rect::new().size(10.0))
1376                    .child(Rect::new().size(10.0))
1377                    .child(Rect::new().size(10.0)),
1378            );
1379        });
1380        let b = boxes(&g);
1381        assert_eq!(
1382            b.iter().map(|b| b.1).collect::<Vec<_>>(),
1383            vec![100.0, 100.0, 100.0]
1384        );
1385        assert_eq!(b[2].0.x, b[0].0.x);
1386        assert!(b[2].0.y > b[0].0.y);
1387    }
1388
1389    #[test]
1390    fn describe_lists_signals() {
1391        let g = scene(|view| {
1392            view.add(Txt::new().text("abc"));
1393        });
1394        let d = g.describe(NodeId(1));
1395        assert!(d.iter().any(|(k, v)| k == "text" && v == "abc"));
1396    }
1397}