Skip to main content

oxframe_2d/
handles.rs

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