Skip to main content

oxframe_2d/
graph.rs

1use std::cell::{Cell, Ref, RefCell};
2use std::rc::Rc;
3
4use kurbo::{Affine, BezPath, Point, Shape, Vec2};
5use oxframe_core::{BlendMode, Color, Gradient, Signal};
6use oxframe_text::{Lang, TextStyle};
7
8use crate::text::{CodeTheme, DiffFrame, RichSpan, Styled};
9
10use crate::draw::{DrawItem, Layer, LineCap, LineJoin, Primitive, Shadow};
11use crate::flex::{FlexBox, FlexMap};
12use crate::image::ImageSource;
13
14/// Index of a node in its [`SceneGraph`]. Assigned in creation order, so it
15/// is deterministic too.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct NodeId(
18    /// Position in creation order, starting at 0 for the root view.
19    pub u32,
20);
21
22/// Main axis of a [`Layout`](crate::Layout).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Direction {
25    /// Left to right.
26    #[default]
27    Row,
28    /// Top to bottom.
29    Column,
30}
31
32/// Cross-axis alignment of children in a [`Layout`](crate::Layout).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum LayoutAlign {
35    /// Top (in a row) or left (in a column).
36    Start,
37    /// Centered.
38    #[default]
39    Center,
40    /// Bottom (in a row) or right (in a column).
41    End,
42    /// Fill the cross axis (resizes rects, images and nested layouts).
43    Stretch,
44}
45
46/// Main-axis distribution of children; only matters with a fixed size.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum Justify {
49    /// Packed at the start.
50    Start,
51    /// Packed in the middle.
52    #[default]
53    Center,
54    /// Packed at the end.
55    End,
56    /// First and last at the edges, equal space between.
57    SpaceBetween,
58    /// Equal space around each child (half-size at the edges).
59    SpaceAround,
60    /// Equal space between children and at the edges.
61    SpaceEvenly,
62}
63
64/// Node type and its type-specific properties (internal representation).
65#[doc(hidden)]
66pub enum NodeKind {
67    Group,
68    /// Looks at `x`/`y` with `zoom` and `rotation`; its children are drawn
69    /// through it (see [`NodeData::local_transform`]).
70    Camera {
71        zoom: Signal<f64>,
72    },
73    Rect {
74        width: Signal<f64>,
75        height: Signal<f64>,
76        radius: Signal<f64>,
77    },
78    Circle {
79        size: Signal<f64>,
80        /// Degrees clockwise from 12 o'clock; a full turn draws a circle.
81        start_angle: Signal<f64>,
82        end_angle: Signal<f64>,
83        /// Close an arc through the center (pie slice).
84        pie: bool,
85    },
86    Polygon {
87        sides: Signal<f64>,
88        size: Signal<f64>,
89    },
90    Star {
91        points: Signal<f64>,
92        size: Signal<f64>,
93        /// Inner radius as a fraction of the outer one.
94        inner: Signal<f64>,
95    },
96    Path {
97        data: Signal<BezPath>,
98    },
99    Line {
100        points: Signal<Vec<Vec2>>,
101        closed: bool,
102        /// Smooth curve through the points (Catmull-Rom).
103        smooth: bool,
104        start_arrow: bool,
105        end_arrow: bool,
106        arrow_size: Signal<f64>,
107    },
108    Txt {
109        text: Signal<String>,
110        /// When non-empty, replaces `text` with styled runs.
111        spans: Signal<Vec<RichSpan>>,
112        font_size: Signal<f64>,
113        weight: Signal<f64>,
114        style: TextStyle,
115        /// Active glyph morph: (from, to) and progress 0..1.
116        morph: Signal<Option<(String, String)>>,
117        morph_t: Signal<f64>,
118    },
119    Code {
120        code: Signal<String>,
121        /// Source a running diff started from. Ignored unless `editing` is set.
122        edit_from: Signal<String>,
123        /// Source a running diff is going to.
124        edit_to: Signal<String>,
125        /// A diff tween is in progress.
126        editing: Signal<bool>,
127        /// Progress of the diff, 0 = `edit_from`, 1 = `edit_to`.
128        edit_t: Signal<f64>,
129        /// Flash a background behind text the diff inserts.
130        edit_flash: Signal<bool>,
131        lang: Lang,
132        theme: CodeTheme,
133        font_size: Signal<f64>,
134        style: TextStyle,
135        /// Focused lines (1-based, inclusive); (0, 0) focuses all.
136        focus: Signal<Vec2>,
137        /// Alpha of unfocused lines.
138        dim: Signal<f64>,
139    },
140    Img {
141        source: ImageSource,
142        width: Signal<f64>,
143        height: Signal<f64>,
144    },
145    Layout {
146        direction: Direction,
147        align: LayoutAlign,
148        justify: Justify,
149        /// Wrap children onto several lines.
150        wrap: bool,
151        /// CSS grid with this many equal columns instead of flexbox.
152        columns: Option<u16>,
153        gap: Signal<f64>,
154        padding: Signal<f64>,
155        width: Signal<Option<f64>>,
156        height: Signal<Option<f64>>,
157        radius: Signal<f64>,
158    },
159    Grid {
160        width: Signal<f64>,
161        height: Signal<f64>,
162        spacing: Signal<f64>,
163    },
164    /// An imported SVG. Draws nothing itself; its children are the file's
165    /// groups, paths and images. `width` and `height` are the fitted size.
166    Svg {
167        width: Signal<f64>,
168        height: Signal<f64>,
169    },
170}
171
172impl NodeKind {
173    pub fn name(&self) -> &'static str {
174        match self {
175            Self::Group => "Group",
176            Self::Camera { .. } => "Camera",
177            Self::Rect { .. } => "Rect",
178            Self::Circle { .. } => "Circle",
179            Self::Polygon { .. } => "Polygon",
180            Self::Star { .. } => "Star",
181            Self::Path { .. } => "Path",
182            Self::Line { .. } => "Line",
183            Self::Txt { .. } => "Txt",
184            Self::Code { .. } => "Code",
185            Self::Img { .. } => "Img",
186            Self::Layout { .. } => "Layout",
187            Self::Grid { .. } => "Grid",
188            Self::Svg { .. } => "Svg",
189        }
190    }
191}
192
193/// Stored state of one node (internal representation).
194#[doc(hidden)]
195pub struct NodeData {
196    pub id: NodeId,
197    pub name: Option<String>,
198    /// `file:line` of the `add` call that created the node, when known.
199    pub source: Option<String>,
200    pub parent: Option<NodeId>,
201    pub children: Vec<NodeId>,
202    pub kind: NodeKind,
203    pub x: Signal<f64>,
204    pub y: Signal<f64>,
205    /// Degrees, clockwise (y points down).
206    pub rotation: Signal<f64>,
207    pub scale: Signal<Vec2>,
208    pub opacity: Signal<f64>,
209    pub fill: Signal<Color>,
210    pub stroke: Signal<Color>,
211    pub line_width: Signal<f64>,
212    /// Drawn part of the outline, as fractions of its length.
213    pub start: Signal<f64>,
214    pub end: Signal<f64>,
215    /// Overrides `fill` when set (local coordinates).
216    pub fill_gradient: Signal<Option<Gradient>>,
217    pub stroke_gradient: Signal<Option<Gradient>>,
218    /// Dash pattern (on, off, on, …) in local units; empty = solid.
219    pub dash: Signal<Vec<f64>>,
220    pub dash_offset: Signal<f64>,
221    pub line_cap: Signal<LineCap>,
222    pub line_join: Signal<LineJoin>,
223    /// Gaussian blur of the node and its children (local units).
224    pub blur: Signal<f64>,
225    pub shadow_color: Signal<Color>,
226    pub shadow_blur: Signal<f64>,
227    pub shadow_offset: Signal<Vec2>,
228    /// Children are clipped to this node's outline.
229    pub clip: Signal<bool>,
230    pub blend: Signal<BlendMode>,
231    /// Paint order among siblings (higher on top; ties keep insertion order).
232    pub z_index: Signal<f64>,
233    /// Which point of the node's box sits at (x, y) and is the pivot of
234    /// rotation and scale: (-1, -1) top-left, (0, 0) center, (1, 1)
235    /// bottom-right.
236    pub anchor: Signal<Vec2>,
237    // Flex item properties (used when the parent is a `Layout`).
238    pub grow: Signal<f64>,
239    pub shrink: Signal<f64>,
240    pub basis: Signal<Option<f64>>,
241    pub margin: Signal<f64>,
242    pub align_self: Signal<Option<LayoutAlign>>,
243    /// Recomputed every frame. `None` means `x` and `y` are used as they are.
244    pub place: Option<crate::place::Place>,
245    /// Position after [`place`](Self::place) is resolved. Draw uses this.
246    pub placed: Cell<(f64, f64)>,
247}
248
249impl NodeData {
250    /// Transform relative to the parent, plus a layout-assigned offset.
251    /// `size` is the node's box, needed for the anchor.
252    pub fn local_transform(&self, offset: Vec2, size: (f64, f64)) -> Affine {
253        if let NodeKind::Camera { zoom } = &self.kind {
254            // The view through the camera: the point it looks at comes to
255            // the camera's origin, scaled by zoom and turned against its
256            // rotation.
257            let (x, y) = self.placed.get();
258            return Affine::translate(offset)
259                * Affine::rotate(-self.rotation.get().to_radians())
260                * Affine::scale(zoom.get().max(1e-6))
261                * Affine::translate((-x, -y));
262        }
263        let s = self.scale.get();
264        let a = self.anchor.get();
265        let (x, y) = self.placed.get();
266        Affine::translate(offset + Vec2::new(x, y))
267            * Affine::rotate(self.rotation.get().to_radians())
268            * Affine::scale_non_uniform(s.x, s.y)
269            * Affine::translate((-a.x * size.0 / 2.0, -a.y * size.1 / 2.0))
270    }
271
272    /// Text to draw for `Txt` and `Code` nodes.
273    pub(crate) fn text_draw(&self) -> Option<TextDraw> {
274        match &self.kind {
275            NodeKind::Txt {
276                text,
277                spans,
278                font_size,
279                weight,
280                style,
281                morph,
282                morph_t,
283            } => {
284                let style = TextStyle {
285                    size: font_size.get().max(0.1),
286                    weight: weight.get().clamp(1.0, 1000.0),
287                    ..style.clone()
288                };
289                if let Some((from, to)) = morph.get() {
290                    let a = oxframe_text::layout(&from, &style);
291                    let b = oxframe_text::layout(&to, &style);
292                    let (glyphs, size) = crate::text::morph(&a, &b, morph_t.get().clamp(0.0, 1.0));
293                    return Some(TextDraw::Morph { glyphs, size });
294                }
295                let spans = spans.get();
296                Some(TextDraw::Styled(if spans.is_empty() {
297                    crate::text::rich(&[RichSpan::new(text.get())], &style)
298                } else {
299                    crate::text::rich(&spans, &style)
300                }))
301            }
302            NodeKind::Code {
303                code,
304                edit_from,
305                edit_to,
306                editing,
307                edit_t,
308                edit_flash,
309                lang,
310                theme,
311                font_size,
312                style,
313                focus,
314                dim,
315            } => {
316                let style = TextStyle {
317                    size: font_size.get().max(0.1),
318                    ..style.clone()
319                };
320                if editing.get() {
321                    let (frame, _) = crate::text::layout_diff(
322                        &edit_from.get(),
323                        &edit_to.get(),
324                        *lang,
325                        crate::text::DiffView {
326                            theme,
327                            style: &style,
328                            focus: focus.get(),
329                            dim: dim.get(),
330                            t: edit_t.get(),
331                            flash: edit_flash.get(),
332                        },
333                    );
334                    return Some(TextDraw::Diff(frame));
335                }
336                Some(TextDraw::Styled(crate::text::code(
337                    &code.get(),
338                    *lang,
339                    theme,
340                    &style,
341                    focus.get(),
342                    dim.get(),
343                )))
344            }
345            _ => None,
346        }
347    }
348
349    /// Untrimmed outline in local coordinates, centered on the origin.
350    pub fn outline(&self, size: (f64, f64)) -> Option<BezPath> {
351        const TOLERANCE: f64 = 0.1;
352        match &self.kind {
353            NodeKind::Group
354            | NodeKind::Svg { .. }
355            | NodeKind::Camera { .. }
356            | NodeKind::Img { .. }
357            | NodeKind::Txt { .. }
358            | NodeKind::Code { .. }
359            | NodeKind::Grid { .. } => None,
360            NodeKind::Rect { radius, .. } | NodeKind::Layout { radius, .. } => {
361                let (w, h) = (size.0.max(0.0), size.1.max(0.0));
362                let r = radius.get().clamp(0.0, w.min(h) / 2.0);
363                let rect = kurbo::Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);
364                Some(if r > 0.0 {
365                    rect.to_rounded_rect(r).to_path(TOLERANCE)
366                } else {
367                    rect.to_path(TOLERANCE)
368                })
369            }
370            NodeKind::Circle {
371                start_angle,
372                end_angle,
373                pie,
374                ..
375            } => {
376                // Start at the top, clockwise — like a clock hand, which is
377                // what `end` animations expect.
378                let r = size.0.max(0.0) / 2.0;
379                let (a0, a1) = (start_angle.get(), end_angle.get());
380                let sweep = (a1 - a0).clamp(-360.0, 360.0);
381                if sweep.abs() >= 360.0 - 1e-9 {
382                    let circle = kurbo::Circle::new((0.0, 0.0), r).to_path(TOLERANCE);
383                    return Some(Affine::rotate((a0 - 90.0).to_radians()) * circle);
384                }
385                let arc = kurbo::Arc::new(
386                    (0.0, 0.0),
387                    (r, r),
388                    (a0 - 90.0).to_radians(),
389                    sweep.to_radians(),
390                    0.0,
391                );
392                let mut p = BezPath::new();
393                if *pie {
394                    p.move_to((0.0, 0.0));
395                    p.line_to(arc.center + kurbo::Vec2::from_angle(arc.start_angle) * r);
396                    arc.to_cubic_beziers(TOLERANCE, |a, b, c| p.curve_to(a, b, c));
397                    p.close_path();
398                } else {
399                    p.move_to(arc.center + kurbo::Vec2::from_angle(arc.start_angle) * r);
400                    arc.to_cubic_beziers(TOLERANCE, |a, b, c| p.curve_to(a, b, c));
401                }
402                Some(p)
403            }
404            NodeKind::Polygon { sides, .. } => {
405                let n = sides.get().round().max(3.0) as usize;
406                Some(regular(n, size.0.max(0.0) / 2.0, None))
407            }
408            NodeKind::Star { points, inner, .. } => {
409                let n = points.get().round().max(2.0) as usize;
410                let r = size.0.max(0.0) / 2.0;
411                Some(regular(n, r, Some(r * inner.get().clamp(0.0, 1.0))))
412            }
413            NodeKind::Path { data } => Some(data.get()),
414            NodeKind::Line {
415                points,
416                closed,
417                smooth,
418                ..
419            } => {
420                let pts = points.get();
421                if *smooth && pts.len() > 2 {
422                    return Some(catmull_rom(&pts, *closed));
423                }
424                let mut p = BezPath::new();
425                for (i, v) in pts.iter().enumerate() {
426                    if i == 0 {
427                        p.move_to(v.to_point());
428                    } else {
429                        p.line_to(v.to_point());
430                    }
431                }
432                if *closed && pts.len() > 2 {
433                    p.close_path();
434                }
435                Some(p)
436            }
437        }
438    }
439}
440
441/// Regular polygon (or star with an inner radius), first vertex at the top.
442fn regular(n: usize, r: f64, inner: Option<f64>) -> BezPath {
443    let count = if inner.is_some() { 2 * n } else { n };
444    let mut p = BezPath::new();
445    for i in 0..count {
446        let radius = match inner {
447            Some(ri) if i % 2 == 1 => ri,
448            _ => r,
449        };
450        let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / count as f64;
451        let pt = Point::new(a.cos() * radius, a.sin() * radius);
452        if i == 0 {
453            p.move_to(pt);
454        } else {
455            p.line_to(pt);
456        }
457    }
458    p.close_path();
459    p
460}
461
462/// Smooth curve through the points (uniform Catmull-Rom as cubic Béziers).
463fn catmull_rom(pts: &[Vec2], closed: bool) -> BezPath {
464    let n = pts.len();
465    let at = |i: isize| -> Vec2 {
466        if closed {
467            pts[i.rem_euclid(n as isize) as usize]
468        } else {
469            pts[i.clamp(0, n as isize - 1) as usize]
470        }
471    };
472    let mut p = BezPath::new();
473    p.move_to(pts[0].to_point());
474    let segs = if closed { n } else { n - 1 };
475    for i in 0..segs as isize {
476        let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
477        let c1 = p1 + (p2 - p0) / 6.0;
478        let c2 = p2 - (p3 - p1) / 6.0;
479        p.curve_to(c1.to_point(), c2.to_point(), p2.to_point());
480    }
481    if closed {
482        p.close_path();
483    }
484    p
485}
486
487pub(crate) enum TextDraw {
488    Styled(Styled),
489    /// Glyphs with their alpha, already centered.
490    Morph {
491        glyphs: Vec<(BezPath, f64)>,
492        size: (f64, f64),
493    },
494    /// A code diff, glyphs in a top-left layout box.
495    Diff(DiffFrame),
496}
497
498/// Arena of nodes for one scene run. Node 0 is the view (root).
499pub struct SceneGraph {
500    nodes: RefCell<Vec<NodeData>>,
501    size: (u32, u32),
502}
503
504impl SceneGraph {
505    /// The root node (the scene's view).
506    pub const ROOT: NodeId = NodeId(0);
507
508    /// An empty scene of the given size in pixels, with only the root.
509    pub fn new(width: u32, height: u32) -> Rc<Self> {
510        let graph = Rc::new(Self {
511            nodes: RefCell::new(Vec::new()),
512            size: (width, height),
513        });
514        graph.insert(
515            None,
516            NodeKind::Group,
517            crate::BaseProps {
518                name: Some("view".into()),
519                ..Default::default()
520            },
521        );
522        graph
523    }
524
525    /// Scene size in pixels.
526    pub fn size(&self) -> (u32, u32) {
527        self.size
528    }
529
530    /// Number of nodes, including the root.
531    pub fn len(&self) -> usize {
532        self.nodes.borrow().len()
533    }
534
535    /// Never true: the root always exists.
536    pub fn is_empty(&self) -> bool {
537        self.len() == 0
538    }
539
540    /// Borrows a node's stored state.
541    ///
542    /// # Panics
543    ///
544    /// If `id` is not a node of this graph.
545    pub fn node(&self, id: NodeId) -> Ref<'_, NodeData> {
546        Ref::map(self.nodes.borrow(), |n| &n[id.0 as usize])
547    }
548
549    pub(crate) fn next_id(&self) -> u32 {
550        self.len() as u32
551    }
552
553    pub(crate) fn insert(
554        &self,
555        parent: Option<NodeId>,
556        kind: NodeKind,
557        base: crate::BaseProps,
558    ) -> NodeId {
559        let mut nodes = self.nodes.borrow_mut();
560        let id = NodeId(u32::try_from(nodes.len()).expect("too many nodes"));
561        let label = format!("{}#{}", kind.name(), id.0);
562        let sig = |field: &str| format!("{label}.{field}");
563        nodes.push(NodeData {
564            id,
565            name: base.name,
566            source: current_add_at().map(|(file, line)| source_label(file, line)),
567            parent,
568            children: Vec::new(),
569            x: Signal::new(base.x).with_name(sig("x")),
570            y: Signal::new(base.y).with_name(sig("y")),
571            rotation: Signal::new(base.rotation).with_name(sig("rotation")),
572            scale: Signal::new(base.scale).with_name(sig("scale")),
573            opacity: Signal::new(base.opacity).with_name(sig("opacity")),
574            fill: Signal::new(base.fill).with_name(sig("fill")),
575            stroke: Signal::new(base.stroke).with_name(sig("stroke")),
576            line_width: Signal::new(base.line_width).with_name(sig("line_width")),
577            start: Signal::new(base.start).with_name(sig("start")),
578            end: Signal::new(base.end).with_name(sig("end")),
579            fill_gradient: Signal::new(base.fill_gradient).with_name(sig("fill_gradient")),
580            stroke_gradient: Signal::new(base.stroke_gradient).with_name(sig("stroke_gradient")),
581            dash: Signal::new(base.dash).with_name(sig("dash")),
582            dash_offset: Signal::new(base.dash_offset).with_name(sig("dash_offset")),
583            line_cap: Signal::new(base.line_cap).with_name(sig("line_cap")),
584            line_join: Signal::new(base.line_join).with_name(sig("line_join")),
585            blur: Signal::new(base.blur).with_name(sig("blur")),
586            shadow_color: Signal::new(base.shadow_color).with_name(sig("shadow_color")),
587            shadow_blur: Signal::new(base.shadow_blur).with_name(sig("shadow_blur")),
588            shadow_offset: Signal::new(base.shadow_offset).with_name(sig("shadow_offset")),
589            clip: Signal::new(base.clip).with_name(sig("clip")),
590            blend: Signal::new(base.blend).with_name(sig("blend")),
591            z_index: Signal::new(base.z_index).with_name(sig("z_index")),
592            anchor: Signal::new(base.anchor).with_name(sig("anchor")),
593            grow: Signal::new(base.grow).with_name(sig("grow")),
594            shrink: Signal::new(base.shrink).with_name(sig("shrink")),
595            basis: Signal::new(base.basis).with_name(sig("basis")),
596            margin: Signal::new(base.margin).with_name(sig("margin")),
597            align_self: Signal::new(base.align_self).with_name(sig("align_self")),
598            place: base.place,
599            placed: Cell::new((base.x, base.y)),
600            kind,
601        });
602        if let Some(p) = parent {
603            nodes[p.0 as usize].children.push(id);
604        }
605        id
606    }
607
608    pub(crate) fn detach(&self, id: NodeId) {
609        let mut nodes = self.nodes.borrow_mut();
610        if let Some(p) = nodes[id.0 as usize].parent.take() {
611            nodes[p.0 as usize].children.retain(|c| *c != id);
612        }
613    }
614
615    pub(crate) fn reparent(&self, id: NodeId, parent: NodeId) {
616        self.detach(id);
617        let mut nodes = self.nodes.borrow_mut();
618        nodes[id.0 as usize].parent = Some(parent);
619        nodes[parent.0 as usize].children.push(id);
620    }
621
622    /// Size of a node's box in its own (unscaled) coordinates.
623    pub fn measure(&self, id: NodeId) -> (f64, f64) {
624        self.resolve_places();
625        let nodes = self.nodes.borrow();
626        measure(&nodes, id)
627    }
628
629    pub(crate) fn set_place(&self, id: NodeId, place: crate::place::Place) {
630        self.nodes.borrow_mut()[id.0 as usize].place = Some(place);
631    }
632
633    fn resolve_places(&self) {
634        let nodes = self.nodes.borrow();
635        crate::place::resolve(&nodes, (f64::from(self.size.0), f64::from(self.size.1)));
636    }
637
638    /// Flattened, paint-ordered list of everything to draw.
639    pub fn draw_list(&self) -> Vec<DrawItem> {
640        self.resolve_places();
641        let nodes = self.nodes.borrow();
642        let mut out = Vec::new();
643        visit(&nodes, Self::ROOT, Affine::IDENTITY, None, None, &mut out);
644        out
645    }
646
647    /// Topmost node under `p` (scene coordinates), excluding the view.
648    pub fn pick(&self, p: Point) -> Option<NodeId> {
649        self.draw_list()
650            .iter()
651            .rev()
652            .find(|item| item.node != Self::ROOT && item.hit(p))
653            .map(|item| item.node)
654    }
655
656    /// World transform of a node (including layout offsets).
657    pub fn world_transform(&self, id: NodeId) -> Option<Affine> {
658        // The draw list already composes transforms; reuse it for nodes
659        // that draw, and fall back to walking parents for those that don't.
660        if let Some(item) = self.draw_list().into_iter().find(|i| i.node == id) {
661            return Some(item.transform);
662        }
663        let nodes = self.nodes.borrow();
664        let mut tf = Affine::IDENTITY;
665        let mut cur = Some(id);
666        while let Some(c) = cur {
667            let n = nodes.get(c.0 as usize)?;
668            tf = n.local_transform(Vec2::ZERO, measure(&nodes, c)) * tf;
669            cur = n.parent;
670        }
671        Some(tf)
672    }
673
674    /// The box of `id` (its size around its origin) in the coordinate frame
675    /// of `frame`'s children: for a camera, where the node sits in the
676    /// world the camera looks at. Axis-aligned.
677    pub fn box_in(&self, id: NodeId, frame: NodeId) -> Option<kurbo::Rect> {
678        let node_tf = self.world_transform(id)?;
679        let frame_tf = self.world_transform(frame)?;
680        let (w, h) = measure(&self.nodes.borrow(), id);
681        let [x0, y0, x1, y1] = aabb(frame_tf.inverse() * node_tf, w, h);
682        Some(kurbo::Rect::new(x0, y0, x1, y1))
683    }
684
685    /// Frame size in pixels.
686    pub fn frame_size(&self) -> (f64, f64) {
687        (f64::from(self.size.0), f64::from(self.size.1))
688    }
689
690    /// First descendant of `root` (including `root`) whose name is `name`.
691    pub(crate) fn descendant_named(&self, root: NodeId, name: &str) -> Option<NodeId> {
692        fn walk(nodes: &[NodeData], id: NodeId, name: &str) -> Option<NodeId> {
693            let n = nodes.get(id.0 as usize)?;
694            if n.name.as_deref() == Some(name) {
695                return Some(id);
696            }
697            for &c in &n.children {
698                if let Some(hit) = walk(nodes, c, name) {
699                    return Some(hit);
700                }
701            }
702            None
703        }
704        walk(&self.nodes.borrow(), root, name)
705    }
706
707    /// Path nodes under `root`, in paint order.
708    pub(crate) fn descendant_paths(&self, root: NodeId) -> Vec<NodeId> {
709        fn walk(nodes: &[NodeData], id: NodeId, out: &mut Vec<NodeId>) {
710            let Some(n) = nodes.get(id.0 as usize) else {
711                return;
712            };
713            if matches!(n.kind, NodeKind::Path { .. }) {
714                out.push(id);
715            }
716            for &c in &n.children {
717                walk(nodes, c, out);
718            }
719        }
720        let mut out = Vec::new();
721        walk(&self.nodes.borrow(), root, &mut out);
722        out
723    }
724
725    /// `(id, parent, kind, name)` of every attached node, depth-first.
726    pub fn tree(&self) -> Vec<(NodeId, Option<NodeId>, &'static str, Option<String>)> {
727        let nodes = self.nodes.borrow();
728        let mut out = Vec::new();
729        let mut stack = vec![Self::ROOT];
730        while let Some(id) = stack.pop() {
731            let n = &nodes[id.0 as usize];
732            out.push((id, n.parent, n.kind.name(), n.name.clone()));
733            stack.extend(n.children.iter().rev());
734        }
735        out
736    }
737
738    /// Current signal values of a node, for the inspector.
739    pub fn describe(&self, id: NodeId) -> Vec<(String, String)> {
740        let nodes = self.nodes.borrow();
741        let Some(n) = nodes.get(id.0 as usize) else {
742            return Vec::new();
743        };
744        let f = |v: f64| format!("{v:.3}");
745        let s = n.scale.get();
746        let mut out = vec![
747            ("x".into(), f(n.x.get())),
748            ("y".into(), f(n.y.get())),
749            ("rotation".into(), f(n.rotation.get())),
750            ("scale".into(), format!("{:.3}, {:.3}", s.x, s.y)),
751            ("opacity".into(), f(n.opacity.get())),
752            ("fill".into(), n.fill.get().to_string()),
753            ("stroke".into(), n.stroke.get().to_string()),
754            ("line_width".into(), f(n.line_width.get())),
755            ("start".into(), f(n.start.get())),
756            ("end".into(), f(n.end.get())),
757        ];
758        let (w, h) = measure(&nodes, id);
759        out.push(("size".into(), format!("{w:.1} × {h:.1}")));
760        match &n.kind {
761            NodeKind::Rect { radius, .. } => out.push(("radius".into(), f(radius.get()))),
762            NodeKind::Code { code, .. } => out.push(("code".into(), code.get())),
763            NodeKind::Txt {
764                text,
765                font_size,
766                weight,
767                ..
768            } => {
769                out.push(("text".into(), text.get()));
770                out.push(("font_size".into(), f(font_size.get())));
771                out.push(("weight".into(), f(weight.get())));
772            }
773            NodeKind::Line { points, .. } => {
774                out.push(("points".into(), points.get().len().to_string()))
775            }
776            NodeKind::Layout { gap, padding, .. } => {
777                out.push(("gap".into(), f(gap.get())));
778                out.push(("padding".into(), f(padding.get())));
779            }
780            NodeKind::Grid { spacing, .. } => out.push(("spacing".into(), f(spacing.get()))),
781            _ => {}
782        }
783        out
784    }
785
786    /// One record per attached node, in tree order, with scene-space bounds.
787    ///
788    /// `order` is paint order (0 is the back). Nodes that are not drawn
789    /// (fully transparent) get `u32::MAX`.
790    pub fn inspect_nodes(&self) -> Vec<InspectNode> {
791        let list = self.draw_list();
792        let mut drawn: Vec<Option<(Affine, u32)>> = vec![None; self.len()];
793        let mut order = 0u32;
794        for item in &list {
795            let slot = &mut drawn[item.node.0 as usize];
796            if slot.is_none() {
797                *slot = Some((item.transform, order));
798                order += 1;
799            }
800        }
801        let nodes = self.nodes.borrow();
802        let mut out = Vec::new();
803        let mut stack = vec![Self::ROOT];
804        while let Some(id) = stack.pop() {
805            let n = &nodes[id.0 as usize];
806            stack.extend(n.children.iter().rev());
807            let (w, h) = measure(&nodes, id);
808            let (tf, order) = drawn[id.0 as usize]
809                .unwrap_or_else(|| (transform_without_layout(&nodes, id), u32::MAX));
810            let (text, text_overflow) = text_info(n);
811            out.push(InspectNode {
812                id: id.0,
813                parent: n.parent.map(|p| p.0),
814                kind: n.kind.name(),
815                name: n.name.clone().unwrap_or_else(|| {
816                    n.source
817                        .clone()
818                        .unwrap_or_else(|| format!("{}#{}", n.kind.name(), id.0))
819                }),
820                source: n.source.clone(),
821                bounds: aabb(tf, w, h),
822                opacity: n.opacity.get().clamp(0.0, 1.0),
823                order,
824                text,
825                fill: n.fill.get(),
826                text_overflow,
827                parent_kind: n.parent.map(|p| nodes[p.0 as usize].kind.name()),
828                in_camera: {
829                    let mut up = n.parent;
830                    let mut found = false;
831                    while let Some(p) = up {
832                        let pn = &nodes[p.0 as usize];
833                        if matches!(pn.kind, NodeKind::Camera { .. }) {
834                            found = true;
835                            break;
836                        }
837                        up = pn.parent;
838                    }
839                    found
840                },
841            });
842        }
843        out
844    }
845}
846
847/// A node as `oxframe inspect` and the visual linter see it.
848#[derive(Debug, Clone, PartialEq)]
849pub struct InspectNode {
850    /// Node index.
851    pub id: u32,
852    /// Parent index; `None` for the root.
853    pub parent: Option<u32>,
854    /// Node type, e.g. `"Rect"`.
855    pub kind: &'static str,
856    /// `.name(..)`, or the `add` call site, or `Kind#id`.
857    pub name: String,
858    /// `file:line` of the `add` call, when it was recorded.
859    pub source: Option<String>,
860    /// Axis-aligned bounds in scene coordinates: min x, min y, max x, max y.
861    /// The origin is the center of the frame, y points down.
862    pub bounds: [f64; 4],
863    /// 0 (invisible) to 1.
864    pub opacity: f64,
865    /// Paint order among drawn nodes; `u32::MAX` if the node is not drawn.
866    pub order: u32,
867    /// Text of a `Txt` or `Code` node.
868    pub text: Option<String>,
869    /// Fill color.
870    pub fill: Color,
871    /// How many pixels the text is wider than its `max_width` (0 if it fits
872    /// or `max_width` is unset).
873    pub text_overflow: f64,
874    /// Type of the parent, if any.
875    pub parent_kind: Option<&'static str>,
876    /// Seen through a [`Camera`](crate::Camera): leaving the frame is then
877    /// usually the camera's framing, not a layout mistake.
878    pub in_camera: bool,
879}
880
881thread_local! {
882    static ADD_AT: Cell<Option<(&'static str, u32)>> = const { Cell::new(None) };
883}
884
885pub(crate) fn push_add_at(file: &'static str, line: u32) -> Option<(&'static str, u32)> {
886    ADD_AT.with(|c| c.replace(Some((file, line))))
887}
888
889pub(crate) fn pop_add_at(prev: Option<(&'static str, u32)>) {
890    ADD_AT.with(|c| c.set(prev));
891}
892
893fn current_add_at() -> Option<(&'static str, u32)> {
894    ADD_AT.with(|c| c.get())
895}
896
897/// `src/main.rs:42` rather than an absolute build path.
898fn source_label(file: &str, line: u32) -> String {
899    let file = file.replace('\\', "/");
900    let rel = file
901        .rfind("/src/")
902        .map_or(file.as_str(), |i| &file[i + 1..]);
903    format!("{rel}:{line}")
904}
905
906fn aabb(tf: Affine, w: f64, h: f64) -> [f64; 4] {
907    let pts = [
908        (-w / 2.0, -h / 2.0),
909        (w / 2.0, -h / 2.0),
910        (w / 2.0, h / 2.0),
911        (-w / 2.0, h / 2.0),
912    ];
913    let mut b = [
914        f64::INFINITY,
915        f64::INFINITY,
916        f64::NEG_INFINITY,
917        f64::NEG_INFINITY,
918    ];
919    for (x, y) in pts {
920        let p = tf * Point::new(x, y);
921        b[0] = b[0].min(p.x);
922        b[1] = b[1].min(p.y);
923        b[2] = b[2].max(p.x);
924        b[3] = b[3].max(p.y);
925    }
926    b
927}
928
929fn transform_without_layout(nodes: &[NodeData], id: NodeId) -> Affine {
930    let mut chain = Vec::new();
931    let mut cur = Some(id);
932    while let Some(c) = cur {
933        chain.push(c);
934        cur = nodes.get(c.0 as usize).and_then(|n| n.parent);
935    }
936    let mut tf = Affine::IDENTITY;
937    for c in chain.into_iter().rev() {
938        let Some(n) = nodes.get(c.0 as usize) else {
939            break;
940        };
941        tf *= n.local_transform(Vec2::ZERO, measure(nodes, c));
942    }
943    tf
944}
945
946fn text_info(n: &NodeData) -> (Option<String>, f64) {
947    let (body, max_width) = match &n.kind {
948        NodeKind::Txt {
949            text, spans, style, ..
950        } => {
951            let spans = spans.get();
952            let body = if spans.is_empty() {
953                text.get()
954            } else {
955                spans.into_iter().map(|s| s.text).collect()
956            };
957            (body, style.max_width)
958        }
959        NodeKind::Code { code, style, .. } => (code.get(), style.max_width),
960        _ => return (None, 0.0),
961    };
962    let overflow = match (max_width, n.text_draw()) {
963        (Some(max), Some(TextDraw::Styled(s))) => (s.layout.width - max).max(0.0),
964        (Some(max), Some(TextDraw::Morph { size, .. })) => (size.0 - max).max(0.0),
965        (Some(max), Some(TextDraw::Diff(frame))) => (frame.size.0 - max).max(0.0),
966        _ => 0.0,
967    };
968    (Some(body), overflow)
969}
970
971pub(crate) fn measure(nodes: &[NodeData], id: NodeId) -> (f64, f64) {
972    let n = &nodes[id.0 as usize];
973    match &n.kind {
974        NodeKind::Rect { width, height, .. } => (width.get(), height.get()),
975        NodeKind::Circle { size, .. }
976        | NodeKind::Polygon { size, .. }
977        | NodeKind::Star { size, .. } => (size.get(), size.get()),
978        NodeKind::Img { width, height, .. }
979        | NodeKind::Grid { width, height, .. }
980        | NodeKind::Svg { width, height } => (width.get(), height.get()),
981        NodeKind::Txt { .. } | NodeKind::Code { .. } => match n.text_draw() {
982            Some(TextDraw::Styled(s)) => (s.layout.width, s.layout.height),
983            Some(TextDraw::Morph { size, .. }) => size,
984            Some(TextDraw::Diff(frame)) => frame.size,
985            None => (0.0, 0.0),
986        },
987        NodeKind::Path { .. } | NodeKind::Line { .. } => {
988            let bb = n
989                .outline((0.0, 0.0))
990                .map_or(kurbo::Rect::ZERO, |p| p.bounding_box());
991            (bb.width(), bb.height())
992        }
993        NodeKind::Layout { .. } => crate::flex::compute(nodes, id, &|c| measure(nodes, c)).0,
994        // A window onto its children, not a shape: no box of its own.
995        NodeKind::Camera { .. } => (0.0, 0.0),
996        NodeKind::Group => {
997            let mut bb: Option<kurbo::Rect> = None;
998            for &c in &n.children {
999                let child = &nodes[c.0 as usize];
1000                let (w, h) = measure(nodes, c);
1001                let r = child
1002                    .local_transform(Vec2::ZERO, (w, h))
1003                    .transform_rect_bbox(kurbo::Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0));
1004                bb = Some(bb.map_or(r, |b| b.union(r)));
1005            }
1006            bb.map_or((0.0, 0.0), |b| (b.width(), b.height()))
1007        }
1008    }
1009}
1010
1011/// Kinds whose drawn size follows the box a layout gives them.
1012fn resizable(kind: &NodeKind) -> bool {
1013    matches!(
1014        kind,
1015        NodeKind::Rect { .. }
1016            | NodeKind::Layout { .. }
1017            | NodeKind::Img { .. }
1018            | NodeKind::Grid { .. }
1019            | NodeKind::Svg { .. }
1020    )
1021}
1022
1023fn visit(
1024    nodes: &[NodeData],
1025    id: NodeId,
1026    parent_tf: Affine,
1027    placement: Option<FlexBox>,
1028    flex: Option<&FlexMap>,
1029    out: &mut Vec<DrawItem>,
1030) {
1031    let n = &nodes[id.0 as usize];
1032    let opacity = n.opacity.get().clamp(0.0, 1.0);
1033    if opacity <= 0.0 {
1034        return;
1035    }
1036    let size = match placement {
1037        Some(b) if resizable(&n.kind) => {
1038            let s = n.scale.get();
1039            (
1040                b.size.0 / s.x.abs().max(1e-9),
1041                b.size.1 / s.y.abs().max(1e-9),
1042            )
1043        }
1044        _ => measure(nodes, id),
1045    };
1046    let offset = placement.map_or(Vec2::ZERO, |b| b.center);
1047    let tf = parent_tf * n.local_transform(offset, size);
1048
1049    // A layer is needed when the node's effects apply to it and its children
1050    // as a whole; plain leaves just fold opacity into their paints.
1051    let blend = n.blend.get();
1052    let blur = n.blur.get().max(0.0);
1053    let shadow_color = n.shadow_color.get();
1054    let shadow = (shadow_color.a > 0.0).then(|| Shadow {
1055        color: shadow_color,
1056        blur: n.shadow_blur.get().max(0.0),
1057        offset: n.shadow_offset.get(),
1058    });
1059    let layered = blend != BlendMode::Normal
1060        || blur > 0.0
1061        || shadow.is_some()
1062        || (opacity < 1.0 && !n.children.is_empty());
1063    if layered {
1064        out.push(DrawItem {
1065            node: id,
1066            transform: tf,
1067            prim: Primitive::PushLayer(Layer {
1068                opacity,
1069                blend,
1070                clip: None,
1071                blur,
1072                shadow,
1073            }),
1074        });
1075    }
1076    let paint_opacity = if layered { 1.0 } else { opacity };
1077    for prim in crate::draw::primitives(n, size, paint_opacity) {
1078        out.push(DrawItem {
1079            node: id,
1080            transform: tf,
1081            prim,
1082        });
1083    }
1084
1085    if !n.children.is_empty() {
1086        // Nodes without an outline (groups, text, images) clip to their box.
1087        let clip = n.clip.get().then(|| {
1088            n.outline(size).unwrap_or_else(|| {
1089                kurbo::Rect::new(-size.0 / 2.0, -size.1 / 2.0, size.0 / 2.0, size.1 / 2.0)
1090                    .to_path(0.1)
1091            })
1092        });
1093        let clipped = clip.is_some();
1094        if let Some(path) = clip {
1095            out.push(DrawItem {
1096                node: id,
1097                transform: tf,
1098                prim: Primitive::PushLayer(Layer::clip(path)),
1099            });
1100        }
1101        // Boxes of the children: from the enclosing layout tree, or a new
1102        // tree when this is a top-level layout.
1103        let own;
1104        let map: Option<&FlexMap> = match (&n.kind, flex) {
1105            (NodeKind::Layout { .. }, Some(m)) => Some(m),
1106            (NodeKind::Layout { .. }, None) => {
1107                own = crate::flex::compute(nodes, id, &|c| measure(nodes, c)).1;
1108                Some(&own)
1109            }
1110            _ => None,
1111        };
1112        let mut order: Vec<(usize, NodeId)> = n.children.iter().copied().enumerate().collect();
1113        // Stable: equal z keeps insertion order.
1114        order.sort_by(|a, b| {
1115            let za = nodes[a.1.0 as usize].z_index.get();
1116            let zb = nodes[b.1.0 as usize].z_index.get();
1117            za.total_cmp(&zb)
1118        });
1119        for (_, c) in order {
1120            let placement = map.and_then(|m| m.get(&c).copied());
1121            visit(nodes, c, tf, placement, map, out);
1122        }
1123        if clipped {
1124            out.push(DrawItem {
1125                node: id,
1126                transform: tf,
1127                prim: Primitive::PopLayer,
1128            });
1129        }
1130    }
1131    if layered {
1132        out.push(DrawItem {
1133            node: id,
1134            transform: tf,
1135            prim: Primitive::PopLayer,
1136        });
1137    }
1138}
1139
1140impl DrawItem {
1141    fn hit(&self, p: Point) -> bool {
1142        let local = self.transform.inverse() * p;
1143        match &self.prim {
1144            Primitive::Fill { path, .. } => {
1145                (is_text_like(path) && path.bounding_box().contains(local))
1146                    || closed(path).contains(local)
1147            }
1148            Primitive::Stroke { path, style, .. } => {
1149                let tol = (style.width / 2.0)
1150                    .max(4.0 / self.transform.determinant().abs().sqrt().max(1e-9));
1151                path.segments().any(|s| {
1152                    kurbo::ParamCurveNearest::nearest(&s, local, 0.1).distance_sq <= tol * tol
1153                })
1154            }
1155            Primitive::Image { rect, .. } => rect.contains(local),
1156            Primitive::PushLayer(_) | Primitive::PopLayer => false,
1157        }
1158    }
1159}
1160
1161/// Fills implicitly close open subpaths; kurbo's winding does not.
1162fn closed(path: &BezPath) -> BezPath {
1163    let mut out = BezPath::new();
1164    let mut open = false;
1165    for el in path.elements() {
1166        match el {
1167            kurbo::PathEl::MoveTo(_) => {
1168                if open {
1169                    out.close_path();
1170                }
1171                open = true;
1172            }
1173            kurbo::PathEl::ClosePath => open = false,
1174            _ => {}
1175        }
1176        out.push(*el);
1177    }
1178    if open {
1179        out.close_path();
1180    }
1181    out
1182}
1183
1184/// Glyph runs are hard to hit exactly; treat many-subpath fills (text) by
1185/// their bounding box.
1186fn is_text_like(path: &BezPath) -> bool {
1187    path.elements()
1188        .iter()
1189        .filter(|e| matches!(e, kurbo::PathEl::MoveTo(_)))
1190        .count()
1191        > 3
1192}
1193
1194thread_local! {
1195    static CURRENT: RefCell<Option<Rc<SceneGraph>>> = const { RefCell::new(None) };
1196}
1197
1198impl SceneGraph {
1199    /// Makes this graph the target of node handles for the duration of `f`.
1200    pub fn enter<R>(self: &Rc<Self>, f: impl FnOnce() -> R) -> R {
1201        let prev = CURRENT.with(|c| c.borrow_mut().replace(self.clone()));
1202        struct Restore(Option<Rc<SceneGraph>>);
1203        impl Drop for Restore {
1204            fn drop(&mut self) {
1205                let prev = self.0.take();
1206                CURRENT.with(|c| *c.borrow_mut() = prev);
1207            }
1208        }
1209        let _restore = Restore(prev);
1210        f()
1211    }
1212}
1213
1214pub(crate) fn with_graph<R>(f: impl FnOnce(&SceneGraph) -> R) -> R {
1215    CURRENT.with(|c| {
1216        let c = c.borrow();
1217        f(c.as_ref()
1218            .expect("oxframe: node handles can only be used while a scene is running"))
1219    })
1220}