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, 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    /// Direct children, in creation order.
196    pub fn children(self) -> Vec<Node> {
197        with_graph(|g| g.node(self.0).children.iter().map(|&c| Node(c)).collect())
198    }
199
200    /// Size of the node's box in its own coordinates.
201    pub fn size(self) -> (f64, f64) {
202        with_graph(|g| g.measure(self.0))
203    }
204
205    /// Position of the node's origin in scene coordinates.
206    pub fn absolute_position(self) -> Vec2 {
207        with_graph(|g| (g.world_transform(self.0).unwrap_or_default() * Point::ZERO).to_vec2())
208    }
209}
210
211macro_rules! typed_handle {
212    ($name:ident, $builder:ident) => {
213        #[doc = concat!("Handle to a `", stringify!($name), "` node in the scene: cheap to copy, its")]
214        #[doc = "methods return the node's properties as [`Signal`]s. Derefs to [`Node`]."]
215        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
216        pub struct $name(Node);
217
218        impl $name {
219            /// Starts a builder; add it with [`Node::add`] (e.g. `view.add(..)`).
220            #[allow(clippy::new_ret_no_self)]
221            pub fn new() -> $builder {
222                $builder::default()
223            }
224
225            pub(crate) fn from_id(id: NodeId) -> Self {
226                Self(Node(id))
227            }
228
229            /// The untyped node handle (same as `*self`).
230            pub fn node(self) -> Node {
231                self.0
232            }
233        }
234
235        impl Deref for $name {
236            type Target = Node;
237            fn deref(&self) -> &Node {
238                &self.0
239            }
240        }
241
242        impl From<$name> for Node {
243            fn from(h: $name) -> Node {
244                h.0
245            }
246        }
247    };
248}
249
250typed_handle!(Group, GroupBuilder);
251typed_handle!(Camera, CameraBuilder);
252typed_handle!(Rect, RectBuilder);
253typed_handle!(Circle, CircleBuilder);
254typed_handle!(Path, PathBuilder);
255typed_handle!(Line, LineBuilder);
256typed_handle!(Txt, TxtBuilder);
257typed_handle!(Img, ImgBuilder);
258typed_handle!(Svg, SvgBuilder);
259typed_handle!(Layout, LayoutBuilder);
260typed_handle!(Grid, GridBuilder);
261typed_handle!(Polygon, PolygonBuilder);
262typed_handle!(Star, StarBuilder);
263typed_handle!(Code, CodeBuilder);
264
265/// Handle to geometry drawn by a closure. Derefs to [`Node`].
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
267pub struct Custom(Node);
268
269impl Custom {
270    /// Starts a builder. `draw` is called every time the frame is painted,
271    /// with the frame time in seconds. It must depend only on that time, on
272    /// signals it captured, and on `ctx.rng(seed)`.
273    #[allow(clippy::new_ret_no_self)]
274    pub fn new(draw: impl Fn(&mut crate::draw::DrawCtx, f64) + 'static) -> crate::CustomBuilder {
275        crate::CustomBuilder::from_draw(draw)
276    }
277
278    pub(crate) fn from_id(id: NodeId) -> Self {
279        Self(Node(id))
280    }
281
282    /// The untyped node handle.
283    pub fn node(self) -> Node {
284        self.0
285    }
286}
287
288impl Deref for Custom {
289    type Target = Node;
290    fn deref(&self) -> &Node {
291        &self.0
292    }
293}
294
295impl From<Custom> for Node {
296    fn from(h: Custom) -> Node {
297        h.0
298    }
299}
300
301typed_handle!(Particles, ParticlesBuilder);
302
303macro_rules! kind_signal {
304    ($handle:ident :: $kind:ident { $($(#[$doc:meta])* $field:ident: $ty:ty),* $(,)? }) => {
305        impl $handle {
306            $(
307                $(#[$doc])*
308                #[doc = ""]
309                #[doc = concat!("The `", stringify!($field), "` property as an animatable signal.")]
310                pub fn $field(self) -> Signal<$ty> {
311                    with_graph(|g| match &g.node(self.id()).kind {
312                        NodeKind::$kind { $field, .. } => $field.clone(),
313                        _ => unreachable!(concat!("handle does not point to a ", stringify!($kind))),
314                    })
315                }
316            )*
317        }
318    };
319}
320
321kind_signal!(Particles::Particles { gravity: f64 });
322
323impl Particles {
324    /// Emits `count` particles from `origin` at the current script time.
325    ///
326    /// The future is ready immediately: the burst is an event, and the
327    /// particles then move on their own. Seeking back before this time
328    /// removes them; seeking forward restores the same dots.
329    pub fn burst(self, origin: impl Into<Vec2>, count: u32) -> oxframe_core::WaitFor {
330        let origin = origin.into();
331        let time = oxframe_core::time();
332        with_graph(|g| {
333            if let NodeKind::Particles { state, .. } = &g.node(self.id()).kind {
334                state.bursts.borrow_mut().push(crate::sim::Burst {
335                    time,
336                    origin,
337                    count,
338                });
339            }
340        });
341        oxframe_core::wait_for(0.0)
342    }
343}
344
345kind_signal!(Rect::Rect {
346    width: f64,
347    height: f64,
348    radius: f64
349});
350kind_signal!(Circle::Circle {
351    /// Diameter.
352    size: f64,
353    start_angle: f64,
354    end_angle: f64,
355});
356kind_signal!(Polygon::Polygon {
357    sides: f64,
358    size: f64
359});
360kind_signal!(Star::Star {
361    points: f64,
362    size: f64,
363    inner: f64
364});
365kind_signal!(Path::Path { data: BezPath });
366kind_signal!(Camera::Camera {
367    /// 1 shows the world at its size, 2 twice as big.
368    zoom: f64
369});
370
371impl Camera {
372    /// Moves the camera so `node`'s center is in the middle of the frame.
373    pub fn center_on(
374        self,
375        node: impl Into<Node>,
376        duration: impl Seconds,
377    ) -> impl std::future::Future<Output = ()> + 'static {
378        let node = node.into();
379        let d = duration.seconds();
380        let target = with_graph(|g| g.box_in(node.id(), self.id())).map(|r| r.center());
381        let moves = target.map(|c| all((self.x().to(c.x, d), self.y().to(c.y, d))));
382        async move {
383            if let Some(m) = moves {
384                m.await;
385            }
386        }
387    }
388
389    /// Centers on `node` and zooms so all of it fits in the frame with
390    /// `padding` pixels to spare. Measures the node when called, so a layout
391    /// that is still growing fits at its current size.
392    pub fn fit(
393        self,
394        node: impl Into<Node>,
395        padding: f64,
396        duration: impl Seconds,
397    ) -> impl std::future::Future<Output = ()> + 'static {
398        let node = node.into();
399        let d = duration.seconds();
400        let (bounds, (fw, fh)) = with_graph(|g| (g.box_in(node.id(), self.id()), g.frame_size()));
401        let moves = bounds.map(|r| {
402            let zoom = ((fw - 2.0 * padding) / r.width().max(1e-6))
403                .min((fh - 2.0 * padding) / r.height().max(1e-6))
404                .max(1e-3);
405            let c = r.center();
406            all((
407                self.x().to(c.x, d),
408                self.y().to(c.y, d),
409                self.zoom().to(zoom, d),
410            ))
411        });
412        async move {
413            if let Some(m) = moves {
414                m.await;
415            }
416        }
417    }
418
419    /// Back to the start: looking at the origin, no zoom, no rotation.
420    pub fn reset(self, duration: impl Seconds) -> impl std::future::Future<Output = ()> + 'static {
421        let d = duration.seconds();
422        let moves = all((
423            self.x().to(0.0, d),
424            self.y().to(0.0, d),
425            self.zoom().to(1.0, d),
426            self.rotation().to(0.0, d),
427        ));
428        async move {
429            moves.await;
430        }
431    }
432}
433kind_signal!(Line::Line { points: Vec<Vec2>, arrow_size: f64 });
434kind_signal!(Txt::Txt { text: String, spans: Vec<RichSpan>, font_size: f64, weight: f64 });
435kind_signal!(Code::Code {
436    font_size: f64,
437    /// Focused lines as (first, last), 1-based; (0, 0) = all.
438    focus: Vec2,
439    /// Alpha of unfocused lines.
440    dim: f64,
441});
442
443impl Txt {
444    /// Morphs to `text` glyph by glyph: letters present in both strings
445    /// travel to their new places, the others fade.
446    pub fn morph_to(self, text: impl Into<String>, duration: f64) -> oxframe_core::BoxFuture {
447        let to = text.into();
448        let (txt, morph, t) = with_graph(|g| match &g.node(self.id()).kind {
449            NodeKind::Txt {
450                text,
451                morph,
452                morph_t,
453                ..
454            } => (text.clone(), morph.clone(), morph_t.clone()),
455            _ => unreachable!("handle does not point to a Txt"),
456        });
457        Box::pin(async move {
458            morph.set(Some((txt.get(), to.clone())));
459            t.set(0.0);
460            t.to(1.0, duration).await;
461            txt.set(to);
462            morph.set(None);
463        })
464    }
465}
466
467impl Code {
468    /// The source.
469    ///
470    /// `.to(next, seconds)` diffs the two versions: tokens that stay slide
471    /// to their new places, deleted tokens fade and collapse, new tokens
472    /// fade in. `Txt` types its text out; code does not. `.get()`, `.set()`
473    /// and `.bind()` still replace the source with no animation, and `.set`
474    /// cancels a diff that is already running.
475    pub fn code(self) -> CodeText {
476        with_graph(|g| match &g.node(self.id()).kind {
477            NodeKind::Code {
478                code,
479                edit_from,
480                edit_to,
481                editing,
482                edit_t,
483                edit_flash,
484                focus,
485                lang,
486                ..
487            } => CodeText {
488                code: code.clone(),
489                edit_from: edit_from.clone(),
490                edit_to: edit_to.clone(),
491                editing: editing.clone(),
492                edit_t: edit_t.clone(),
493                edit_flash: edit_flash.clone(),
494                focus: focus.clone(),
495                lang: *lang,
496            },
497            _ => unreachable!("handle does not point to a Code"),
498        })
499    }
500
501    /// Moves the focus to lines `first..=last` over `duration`.
502    ///
503    /// Lines are 1-based. While a diff is running, a focus that sits on
504    /// lines which shift rides with those lines, and lands on their new
505    /// numbers when the diff ends.
506    pub fn focus_lines(
507        self,
508        first: usize,
509        last: usize,
510        duration: f64,
511    ) -> oxframe_core::Tween<Vec2> {
512        self.focus()
513            .to(Vec2::new(first as f64, last as f64), duration)
514    }
515
516    /// Inserts `text` at `line` (1-based) and `col` (0-based characters).
517    ///
518    /// # Panics
519    ///
520    /// If the line or column is outside the current source. The line just
521    /// after the last one, column 0, is the end of the source.
522    pub fn insert(
523        self,
524        line: usize,
525        col: usize,
526        text: impl Into<String>,
527        duration: impl Seconds,
528    ) -> CodeTween {
529        let text = text.into();
530        let src = self.code().get();
531        let next = oxframe_text::insert_at(&src, line, col, &text)
532            .unwrap_or_else(|e| panic!("code.insert: {e}"));
533        self.code().to(next, duration)
534    }
535
536    /// Removes the half-open range `((start_line, start_col), (end_line, end_col))`.
537    ///
538    /// Lines are 1-based, columns are 0-based characters. `((5, 0), (6, 0))`
539    /// removes line 5.
540    ///
541    /// # Panics
542    ///
543    /// If a point is outside the source, or the end is before the start.
544    pub fn remove(
545        self,
546        range: ((usize, usize), (usize, usize)),
547        duration: impl Seconds,
548    ) -> CodeTween {
549        self.rewrite(range, String::new(), duration, "code.remove")
550    }
551
552    /// Replaces the half-open range with `text`. See [`remove`](Self::remove)
553    /// for how the range is counted.
554    ///
555    /// # Panics
556    ///
557    /// If a point is outside the source, or the end is before the start.
558    pub fn replace(
559        self,
560        range: ((usize, usize), (usize, usize)),
561        text: impl Into<String>,
562        duration: impl Seconds,
563    ) -> CodeTween {
564        self.rewrite(range, text.into(), duration, "code.replace")
565    }
566
567    fn rewrite(
568        self,
569        ((l0, c0), (l1, c1)): ((usize, usize), (usize, usize)),
570        text: String,
571        duration: impl Seconds,
572        what: &str,
573    ) -> CodeTween {
574        let src = self.code().get();
575        let next = oxframe_text::replace_range(
576            &src,
577            oxframe_text::Loc { line: l0, col: c0 },
578            oxframe_text::Loc { line: l1, col: c1 },
579            &text,
580        )
581        .unwrap_or_else(|e| panic!("{what}: {e}"));
582        self.code().to(next, duration)
583    }
584}
585
586/// The source of a [`Code`] node.
587///
588/// Dereferences to the underlying [`Signal`], so `.get()` reads it.
589/// [`.to()`](CodeText::to) runs a diff tween instead of the typewriter
590/// [`Signal::to`](Signal::to) uses for plain strings.
591#[derive(Clone)]
592pub struct CodeText {
593    code: Signal<String>,
594    edit_from: Signal<String>,
595    edit_to: Signal<String>,
596    editing: Signal<bool>,
597    edit_t: Signal<f64>,
598    edit_flash: Signal<bool>,
599    focus: Signal<Vec2>,
600    lang: oxframe_text::Lang,
601}
602
603impl std::ops::Deref for CodeText {
604    type Target = Signal<String>;
605    fn deref(&self) -> &Signal<String> {
606        &self.code
607    }
608}
609
610impl CodeText {
611    /// Replaces the source immediately and cancels a running diff.
612    pub fn set(&self, value: impl Into<String>) {
613        self.editing.set(false);
614        self.code.set(value.into());
615    }
616
617    /// Makes the source follow `f`, and cancels a running diff.
618    pub fn bind(&self, f: impl Fn() -> String + 'static) {
619        self.editing.set(false);
620        self.code.bind(f);
621    }
622
623    /// Diffs the current source into `target` over `duration`.
624    pub fn to(&self, target: impl Into<String>, duration: impl Seconds) -> CodeTween {
625        let target = target.into();
626        let from = self.code.get();
627        self.edit_from.set(from.clone());
628        self.edit_to.set(target.clone());
629        self.edit_flash.set(false);
630        self.editing.set(true);
631        self.edit_t.set(0.0);
632        CodeTween {
633            tween: self.edit_t.to(1.0, duration),
634            code: self.code.clone(),
635            editing: self.editing.clone(),
636            edit_flash: self.edit_flash.clone(),
637            focus: self.focus.clone(),
638            lang: self.lang,
639            from,
640            target,
641            finished: false,
642        }
643    }
644}
645
646/// Diff tween returned by [`CodeText::to`], [`Code::insert`], [`Code::remove`]
647/// and [`Code::replace`].
648#[must_use = "a tween does nothing unless awaited"]
649pub struct CodeTween {
650    tween: Tween<f64>,
651    code: Signal<String>,
652    editing: Signal<bool>,
653    edit_flash: Signal<bool>,
654    focus: Signal<Vec2>,
655    lang: oxframe_text::Lang,
656    from: String,
657    target: String,
658    finished: bool,
659}
660
661impl CodeTween {
662    /// Easing curve. The default is [`presets::DEFAULT`](oxframe_core::presets::DEFAULT).
663    pub fn ease(mut self, ease: impl Ease + 'static) -> Self {
664        self.tween = self.tween.ease(ease);
665        self
666    }
667
668    /// Changes the length. `f64` is seconds; a theme pace is a duration too.
669    pub fn duration(mut self, duration: impl Seconds) -> Self {
670        self.tween = self.tween.duration(duration);
671        self
672    }
673
674    /// Flashes a background behind text that this edit inserts.
675    pub fn flash(self) -> Self {
676        self.edit_flash.set(true);
677        self
678    }
679}
680
681impl Future for CodeTween {
682    type Output = ();
683
684    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
685        let this = self.get_mut();
686        if Pin::new(&mut this.tween).poll(cx).is_pending() {
687            return Poll::Pending;
688        }
689        if !this.finished {
690            this.finished = true;
691            let focus = this.focus.get();
692            let (a, b) = (focus.x.round() as i64, focus.y.round() as i64);
693            if a > 0 || b > 0 {
694                let diff = oxframe_text::diff_code(&this.from, &this.target, this.lang);
695                if let Some((n0, n1)) =
696                    oxframe_text::map_focus(&diff, a.max(0) as usize, b.max(0) as usize)
697                {
698                    this.focus.set(Vec2::new(n0 as f64, n1 as f64));
699                }
700            }
701            this.code.set(this.target.clone());
702            this.editing.set(false);
703        }
704        Poll::Ready(())
705    }
706}
707kind_signal!(Svg::Svg {
708    width: f64,
709    height: f64
710});
711
712impl Svg {
713    /// The node imported from the SVG element with this `id`.
714    ///
715    /// # Panics
716    ///
717    /// If the file has no element with that id.
718    pub fn find(self, id: &str) -> Node {
719        let id = with_graph(|g| g.descendant_named(self.id(), id))
720            .unwrap_or_else(|| panic!("svg has no element with id {id:?}"));
721        Node::from_id(id)
722    }
723
724    /// Every path from the file, in paint order.
725    pub fn paths(self) -> Vec<Path> {
726        with_graph(|g| {
727            g.descendant_paths(self.id())
728                .into_iter()
729                .map(Path::from_id)
730                .collect()
731        })
732    }
733}
734
735kind_signal!(Img::Img {
736    width: f64,
737    height: f64
738});
739kind_signal!(Layout::Layout {
740    gap: f64,
741    padding: f64,
742    radius: f64,
743    /// Fixed width; `None` sizes to content.
744    width: Option<f64>,
745    /// Fixed height; `None` sizes to content.
746    height: Option<f64>,
747});
748kind_signal!(Grid::Grid {
749    width: f64,
750    height: f64,
751    spacing: f64
752});
753
754/// Root of a scene. Origin is the center of the frame, y points down.
755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
756pub struct View(Node);
757
758impl View {
759    /// The view of the scene being executed.
760    pub fn current() -> Self {
761        Self(Node(SceneGraph::ROOT))
762    }
763
764    /// Frame size in pixels.
765    pub fn size(self) -> (u32, u32) {
766        with_graph(|g| g.size())
767    }
768
769    /// True when the frame is taller than it is wide.
770    pub fn is_portrait(self) -> bool {
771        let (w, h) = self.size();
772        h > w
773    }
774
775    /// The rectangle [`Node::to_edge`](crate::Node::to_edge) pins to, in scene
776    /// coordinates. A portrait frame leaves room for a vertical video's
777    /// status bar and buttons.
778    pub fn safe_area(self) -> SafeArea {
779        let (w, h) = self.size();
780        SafeArea::for_frame(f64::from(w), f64::from(h))
781    }
782}
783
784impl Deref for View {
785    type Target = Node;
786    fn deref(&self) -> &Node {
787        &self.0
788    }
789}
790
791impl From<View> for Node {
792    fn from(v: View) -> Node {
793        v.0
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::Primitive;
801
802    fn scene(f: impl FnOnce(View)) -> std::rc::Rc<SceneGraph> {
803        let graph = SceneGraph::new(400, 400);
804        graph.enter(|| f(View::current()));
805        graph
806    }
807
808    /// Runs `scene` to the end inside a graph; returns the frames it took.
809    fn play(
810        scene_fn: impl FnOnce(View) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>,
811    ) -> u32 {
812        let graph = SceneGraph::new(400, 400);
813        graph.enter(|| {
814            let fut = scene_fn(View::current());
815            let mut ex =
816                oxframe_core::Executor::new(60.0, oxframe_protocol::Meta::default(), 0, fut);
817            let mut frames = 0;
818            while !ex.step() {
819                frames += 1;
820                assert!(frames < 10_000, "scene did not finish");
821            }
822            frames
823        })
824    }
825
826    /// Screen position of a node's origin.
827    fn screen(n: impl Into<Node>) -> (f64, f64) {
828        let p = n.into().absolute_position();
829        ((p.x * 1e6).round() / 1e6, (p.y * 1e6).round() / 1e6)
830    }
831
832    #[test]
833    fn camera_pans_zooms_and_turns_its_children_only() {
834        scene(|view| {
835            let cam = view.add(Camera::new());
836            let dot = cam.add(Circle::new().size(10.0).x(100.0).y(50.0));
837            let hud = view.add(Txt::new().text("HUD").x(-300.0).y(-200.0));
838            // A fresh camera changes nothing.
839            assert_eq!(screen(dot), (100.0, 50.0));
840            // Looking at the dot brings it to the center.
841            cam.x().set(100.0);
842            cam.y().set(50.0);
843            assert_eq!(screen(dot), (0.0, 0.0));
844            // Zoom scales distances from the point looked at.
845            cam.zoom().set(2.0);
846            let other = cam.add(Circle::new().size(10.0).x(110.0).y(50.0));
847            assert_eq!(screen(other), (20.0, 0.0));
848            // Turning the camera clockwise turns the world the other way.
849            cam.rotation().set(90.0);
850            assert_eq!(screen(other), (0.0, -20.0));
851            // Nodes outside the camera stay put.
852            assert_eq!(screen(hud), (-300.0, -200.0));
853        });
854    }
855
856    #[test]
857    fn camera_center_on_fit_and_reset() {
858        play(|view| {
859            let cam = view.add(Camera::new());
860            let card = cam.add(Rect::new().width(200.0).height(100.0).x(300.0).y(-100.0));
861            let row = cam.add(
862                Layout::new()
863                    .x(-500.0)
864                    .gap(10.0)
865                    .child(Rect::new().size(50.0))
866                    .child(Rect::new().size(50.0)),
867            );
868            Box::pin(async move {
869                cam.center_on(card, 1.0).await;
870                assert_eq!((cam.x().get(), cam.y().get()), (300.0, -100.0));
871                assert_eq!(screen(card), (0.0, 0.0));
872                // 400×400 frame, 20 px padding: limited by width (360 / 200).
873                cam.fit(card, 20.0, 0.0).await;
874                assert!(
875                    (cam.zoom().get() - 1.8).abs() < 1e-9,
876                    "{}",
877                    cam.zoom().get()
878                );
879                // A layout that grew is fitted at its current size.
880                row.gap().to(110.0, 0.5).await;
881                cam.fit(row, 0.0, 0.0).await;
882                let (w, _) = row.size();
883                assert!((w - 210.0).abs() < 1e-6, "layout width {w}");
884                assert!((cam.zoom().get() - 400.0 / 210.0).abs() < 1e-9);
885                assert!((cam.x().get() + 500.0).abs() < 1e-9);
886                cam.reset(0.5).await;
887                assert_eq!(
888                    (
889                        cam.x().get(),
890                        cam.y().get(),
891                        cam.zoom().get(),
892                        cam.rotation().get()
893                    ),
894                    (0.0, 0.0, 1.0, 0.0)
895                );
896            })
897        });
898    }
899
900    #[test]
901    fn entrance_helpers_start_at_once_and_end_in_place() {
902        let normal = crate::theme::current().motion.normal;
903        let expected = (normal * 60.0).round() as u32;
904        let frames = play(|view| {
905            let r = view.add(Rect::new().size(100.0).x(50.0));
906            let pop = r.pop_in();
907            // The start value is set when the helper is called, not awaited:
908            // nothing flashes in its final state for a frame.
909            assert_eq!(r.scale().get(), Vec2::ZERO);
910            let slide = r.slide_in(Side::Left);
911            assert_eq!(r.x().get(), 50.0 - SLIDE_DISTANCE);
912            assert_eq!(r.opacity().get(), 0.0);
913            let draw = r.draw_on();
914            assert_eq!(r.end().get(), 0.0);
915            Box::pin(async move {
916                all((pop, slide)).await;
917                assert_eq!(r.scale().get(), Vec2::new(1.0, 1.0));
918                assert_eq!((r.x().get(), r.opacity().get()), (50.0, 1.0));
919                draw.await;
920                assert_eq!(r.end().get(), 1.0);
921                r.fade_out().duration(0.5).await;
922                assert_eq!(r.opacity().get(), 0.0);
923            })
924        });
925        let slow = crate::theme::current().motion.slow;
926        assert_eq!(frames, expected + (slow * 60.0).round() as u32 + 30);
927    }
928
929    #[test]
930    fn a_diff_lands_on_the_new_source_and_moves_the_focus() {
931        play(|view| {
932            let code = view.add(Code::new().code("a\nkeep\n").focus(2, 2));
933            Box::pin(async move {
934                code.insert(1, 0, "NEW\n", 0.2).flash().await;
935                assert_eq!(code.code().get(), "NEW\na\nkeep\n");
936                let f = code.focus().get();
937                assert_eq!((f.x.round() as i32, f.y.round() as i32), (3, 3));
938                code.replace(((3, 0), (3, 4)), "stay", 0.0).await;
939                assert_eq!(code.code().get(), "NEW\na\nstay\n");
940            })
941        });
942    }
943
944    #[test]
945    fn add_records_its_call_site_and_scene_bounds() {
946        let g = scene(|view| {
947            view.add(Rect::new().size(100.0).name("box"));
948            view.add(Rect::new().size(10.0).x(100.0));
949        });
950        let nodes = g.inspect_nodes();
951        let named = nodes.iter().find(|n| n.name == "box").unwrap();
952        assert_eq!(named.kind, "Rect");
953        assert!(named.source.as_ref().unwrap().contains("handles.rs:"));
954        assert!(
955            (named.bounds[0] + 50.0).abs() < 1e-6 && (named.bounds[2] - 50.0).abs() < 1e-6,
956            "{:?}",
957            named.bounds
958        );
959        let auto = nodes
960            .iter()
961            .find(|n| n.name != "box" && n.name != "view")
962            .unwrap();
963        assert_eq!(auto.name, auto.source.clone().unwrap());
964    }
965
966    #[test]
967    fn builders_set_initial_values_and_handles_return_signals() {
968        scene(|view| {
969            let r = view.add(
970                Rect::new()
971                    .size(50.0)
972                    .radius(5.0)
973                    .x(-10.0)
974                    .fill(Color::BLACK),
975            );
976            assert_eq!(r.width().get(), 50.0);
977            assert_eq!(r.height().get(), 50.0);
978            assert_eq!(r.radius().get(), 5.0);
979            assert_eq!(r.x().get(), -10.0);
980            assert_eq!(r.fill().get(), Color::BLACK);
981            r.x().set(3.0);
982            assert_eq!(r.x().get(), 3.0);
983            let c = r.add(Circle::new().size(10.0));
984            assert_eq!(r.children(), vec![c.node()]);
985            assert_eq!(view.children(), vec![r.node()]);
986            assert_eq!(c.size().name().as_deref(), Some("Circle#2.size"));
987        });
988    }
989
990    #[test]
991    fn draw_list_composes_transforms() {
992        let g = scene(|view| {
993            let grp = view.add(Group::new().x(10.0).scale(2.0));
994            grp.add(Circle::new().x(5.0));
995        });
996        let items = g.draw_list();
997        assert_eq!(items.len(), 1);
998        assert_eq!(items[0].transform * Point::ZERO, Point::new(20.0, 0.0));
999    }
1000
1001    #[test]
1002    fn layout_places_children_in_a_row() {
1003        let g = scene(|view| {
1004            view.add(
1005                Layout::new()
1006                    .gap(10.0)
1007                    .padding(5.0)
1008                    .child(Rect::new().size(20.0))
1009                    .child(Rect::new().width(40.0).height(10.0)),
1010            );
1011        });
1012        let layout = NodeId(1);
1013        assert_eq!(g.measure(layout), (5.0 + 20.0 + 10.0 + 40.0 + 5.0, 30.0));
1014        let centers: Vec<_> = g
1015            .draw_list()
1016            .iter()
1017            .map(|i| i.transform * Point::ZERO)
1018            .collect();
1019        // Total 80 wide: first box spans -35..-15, second -5..35.
1020        assert_eq!(centers, vec![Point::new(-25.0, 0.0), Point::new(15.0, 0.0)]);
1021    }
1022
1023    #[test]
1024    fn column_with_fixed_size_and_alignment() {
1025        let g = scene(|view| {
1026            view.add(
1027                Layout::new()
1028                    .column()
1029                    .height(100.0)
1030                    .width(100.0)
1031                    .justify(crate::Justify::Start)
1032                    .align(crate::LayoutAlign::Start)
1033                    .child(Rect::new().size(20.0)),
1034            );
1035        });
1036        let c = g.draw_list()[0].transform * Point::ZERO;
1037        assert_eq!(c, Point::new(-40.0, -40.0));
1038    }
1039
1040    #[test]
1041    fn text_line_path_and_img_draw() {
1042        let png = {
1043            let mut v = Vec::new();
1044            image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 255]))
1045                .write_to(&mut std::io::Cursor::new(&mut v), image::ImageFormat::Png)
1046                .unwrap();
1047            v
1048        };
1049        let g = scene(|view| {
1050            let t = view.add(Txt::new().text("Hi").font_size(40.0));
1051            assert!(t.size().0 > 20.0);
1052            view.add(
1053                Line::new()
1054                    .points([(0.0, 0.0), (100.0, 0.0)])
1055                    .end_arrow(true),
1056            );
1057            view.add(Path::new().svg("M0 0 L10 0 L10 10 Z"));
1058            let img = view.add(Img::new().src(&png).width(40.0));
1059            assert_eq!(img.height().get(), 20.0);
1060        });
1061        let kinds: Vec<_> = g
1062            .draw_list()
1063            .iter()
1064            .map(|i| match i.prim {
1065                Primitive::Fill { .. } => "fill",
1066                Primitive::Stroke { .. } => "stroke",
1067                Primitive::Image { .. } => "image",
1068                Primitive::PushLayer(_) => "push",
1069                Primitive::PopLayer => "pop",
1070            })
1071            .collect();
1072        assert_eq!(kinds, vec!["fill", "stroke", "fill", "fill", "image"]);
1073    }
1074
1075    #[test]
1076    fn trimming_and_pick() {
1077        let g = scene(|view| {
1078            view.add(Rect::new().size(100.0).name("back"));
1079            view.add(Circle::new().size(40.0).x(30.0).end(0.5));
1080        });
1081        assert_eq!(g.pick(Point::new(-40.0, 0.0)), Some(NodeId(1)));
1082        assert_eq!(g.pick(Point::new(300.0, 0.0)), None);
1083        // The half circle covers the right half of its disc.
1084        assert_eq!(g.pick(Point::new(45.0, 0.0)), Some(NodeId(2)));
1085    }
1086
1087    fn kinds(g: &SceneGraph) -> Vec<&'static str> {
1088        g.draw_list()
1089            .iter()
1090            .map(|i| match i.prim {
1091                Primitive::Fill { .. } => "fill",
1092                Primitive::Stroke { .. } => "stroke",
1093                Primitive::Image { .. } => "image",
1094                Primitive::PushLayer(_) => "push",
1095                Primitive::PopLayer => "pop",
1096            })
1097            .collect()
1098    }
1099
1100    #[test]
1101    fn layers_only_when_needed_and_balanced() {
1102        // Translucent leaf: opacity folds into the paint, no layer.
1103        let g = scene(|v| {
1104            v.add(Rect::new().opacity(0.5));
1105        });
1106        assert_eq!(kinds(&g), ["fill"]);
1107        // Translucent group with children: one layer around them.
1108        let g = scene(|v| {
1109            v.add(
1110                Group::new()
1111                    .opacity(0.5)
1112                    .child(Rect::new())
1113                    .child(Circle::new()),
1114            );
1115        });
1116        assert_eq!(kinds(&g), ["push", "fill", "fill", "pop"]);
1117        // Clip wraps only the children; shadow wraps node + children.
1118        let g = scene(|v| {
1119            v.add(
1120                Circle::new()
1121                    .clip(true)
1122                    .shadow(Color::BLACK, 4.0, (2.0, 2.0))
1123                    .child(Rect::new()),
1124            );
1125        });
1126        assert_eq!(kinds(&g), ["push", "fill", "push", "fill", "pop", "pop"]);
1127    }
1128
1129    #[test]
1130    fn z_index_orders_siblings_and_anchor_moves_box() {
1131        let g = scene(|v| {
1132            v.add(Rect::new().name("a").z_index(2.0));
1133            v.add(Rect::new().name("b"));
1134            v.add(Rect::new().name("c").size(20.0).anchor((-1.0, -1.0)).x(5.0));
1135        });
1136        let items = g.draw_list();
1137        let order: Vec<u32> = items.iter().map(|i| i.node.0).collect();
1138        assert_eq!(order, vec![2, 3, 1]);
1139        // Anchored at the top-left: the box center sits at (5+10, 0+10).
1140        assert_eq!(items[1].transform * Point::ZERO, Point::new(15.0, 10.0));
1141    }
1142
1143    fn boxes(g: &SceneGraph) -> Vec<(Point, f64)> {
1144        g.draw_list()
1145            .iter()
1146            .filter_map(|i| match &i.prim {
1147                Primitive::Fill { path, .. } => {
1148                    let bb = kurbo::Shape::bounding_box(path);
1149                    Some((i.transform * Point::ZERO, bb.width()))
1150                }
1151                _ => None,
1152            })
1153            .collect()
1154    }
1155
1156    #[test]
1157    fn flex_grow_stretch_wrap_and_grid() {
1158        use crate::{Justify, LayoutAlign};
1159        // grow: a 200-wide row; the second child takes the free space.
1160        let g = scene(|v| {
1161            v.add(
1162                Layout::new()
1163                    .width(200.0)
1164                    .justify(Justify::Start)
1165                    .child(Rect::new().size(40.0))
1166                    .child(Rect::new().size(40.0).grow(1.0)),
1167            );
1168        });
1169        let b = boxes(&g);
1170        assert_eq!(b[0], (Point::new(-80.0, 0.0), 40.0));
1171        assert_eq!(b[1], (Point::new(20.0, 0.0), 160.0));
1172
1173        // stretch: children fill the cross axis.
1174        let g = scene(|v| {
1175            v.add(
1176                Layout::new()
1177                    .column()
1178                    .width(120.0)
1179                    .align(LayoutAlign::Stretch)
1180                    .child(Rect::new().size(30.0)),
1181            );
1182        });
1183        assert_eq!(boxes(&g)[0].1, 120.0);
1184
1185        // wrap: three 60-wide boxes in 130 → two lines.
1186        let g = scene(|v| {
1187            v.add(
1188                Layout::new()
1189                    .width(130.0)
1190                    .wrap(true)
1191                    .child(Rect::new().size(60.0))
1192                    .child(Rect::new().size(60.0))
1193                    .child(Rect::new().size(60.0)),
1194            );
1195        });
1196        let ys: Vec<f64> = boxes(&g).iter().map(|b| b.0.y).collect();
1197        assert_eq!(ys[0], ys[1]);
1198        assert!(ys[2] > ys[0]);
1199
1200        // grid: 2 columns of equal width.
1201        let g = scene(|v| {
1202            v.add(
1203                Layout::new()
1204                    .grid(2)
1205                    .width(200.0)
1206                    .align(LayoutAlign::Stretch)
1207                    .child(Rect::new().size(10.0))
1208                    .child(Rect::new().size(10.0))
1209                    .child(Rect::new().size(10.0)),
1210            );
1211        });
1212        let b = boxes(&g);
1213        assert_eq!(
1214            b.iter().map(|b| b.1).collect::<Vec<_>>(),
1215            vec![100.0, 100.0, 100.0]
1216        );
1217        assert_eq!(b[2].0.x, b[0].0.x);
1218        assert!(b[2].0.y > b[0].0.y);
1219    }
1220
1221    #[test]
1222    fn describe_lists_signals() {
1223        let g = scene(|view| {
1224            view.add(Txt::new().text("abc"));
1225        });
1226        let d = g.describe(NodeId(1));
1227        assert!(d.iter().any(|(k, v)| k == "text" && v == "abc"));
1228    }
1229}