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