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