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