Skip to main content

wisp/scene/
callout.rs

1//! Callout primitives (M-VEC.9 / AUT-61).
2//!
3//! Reusable [`Vector`] constructors for tutorial-style annotations.
4//! Outputs are `Vector`s so they can be added to the stage,
5//! transformed, or used as masks. V1 ships the static shapes that
6//! don't need stroke-along-path commands:
7//!
8//! - **Label box** — rounded-rect callout for text annotations.
9//! - **Badge** — filled circle for numbered step markers.
10//! - **Caption pill** — wide rounded rect for a single-line caption.
11//!
12//! Arrow / pointer-line callouts need stroke-along-path support
13//! (M-VEC.10 / AUT-62). Once that lands, an `arrow_to(from, to)`
14//! constructor can join this module without breaking changes.
15
16use glam::Vec2;
17
18use crate::color::Color;
19use crate::math::Rect;
20use crate::scene::Fill;
21use crate::scene::vector::{Vector, VectorShape, VectorStroke};
22
23/// Callout preset constructors.
24pub struct Callout;
25
26impl Callout {
27    /// Filled rounded-rect label box. Optional stroke gives it an
28    /// outlined "card" feel.
29    #[must_use]
30    pub fn label_box(rect: Rect, fill: Color, stroke: Option<VectorStroke>, radius: f32) -> Vector {
31        let mut v =
32            Vector::new(VectorShape::rounded_rect(rect, radius)).with_fill(Fill::Solid(fill));
33        if let Some(s) = stroke {
34            v = v.with_stroke(s);
35        }
36        v
37    }
38
39    /// Filled circle for numbered step markers, badges, dots.
40    #[must_use]
41    pub fn badge(center: Vec2, radius: f32, fill: Color) -> Vector {
42        Vector::new(VectorShape::circle(center, radius)).with_fill(Fill::Solid(fill))
43    }
44
45    /// Wide rounded-rect caption pill. Convenience over
46    /// `label_box` for the common single-line caption use case (radius
47    /// is half the rect's height).
48    #[must_use]
49    pub fn caption_pill(rect: Rect, fill: Color) -> Vector {
50        let radius = (rect.size.y * 0.5).abs();
51        Vector::new(VectorShape::rounded_rect(rect, radius)).with_fill(Fill::Solid(fill))
52    }
53
54    /// Arrow from `from` to `to` (M-VEC.10 / AUT-62). Returns a
55    /// stroked [`Graphics`](crate::scene::Graphics) — arrows are
56    /// multi-segment and don't fit the single-`Vector` shape that
57    /// other callouts use. Caller adds it to the stage like any
58    /// other Graphics node.
59    ///
60    /// Arrowhead size auto-scales to ~18% of the stem length.
61    #[must_use]
62    pub fn arrow_to(
63        from: glam::Vec2,
64        to: glam::Vec2,
65        width: f32,
66        color: Color,
67    ) -> crate::scene::Graphics {
68        use crate::scene::path::PathBuilder;
69        let stem = to - from;
70        let stem_len = stem.length();
71        if stem_len < 1e-6 {
72            return crate::scene::Graphics::new();
73        }
74        let head = stem_len * 0.18;
75        let dir = stem / stem_len;
76        let perp = glam::Vec2::new(-dir.y, dir.x);
77        let back = to - dir * head;
78        let left = back + perp * head * 0.5;
79        let right = back - perp * head * 0.5;
80        PathBuilder::new()
81            .move_to(from)
82            .line_to(to)
83            .move_to(left)
84            .line_to(to)
85            .line_to(right)
86            .build()
87            .stroke_to_graphics(width, color, 0.005)
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn label_box_with_stroke_has_both() {
97        let v = Callout::label_box(
98            Rect::new(-0.5, -0.2, 1.0, 0.4),
99            Color::rgba_u8(220, 220, 130, 230),
100            Some(VectorStroke::new(0.012, Color::WHITE)),
101            0.04,
102        );
103        assert!(v.fill.is_some());
104        assert!(v.stroke.is_some());
105    }
106
107    #[test]
108    fn label_box_without_stroke_omits_it() {
109        let v = Callout::label_box(
110            Rect::new(-0.5, -0.2, 1.0, 0.4),
111            Color::rgba_u8(220, 220, 130, 230),
112            None,
113            0.04,
114        );
115        assert!(v.fill.is_some());
116        assert!(v.stroke.is_none());
117    }
118
119    #[test]
120    fn badge_is_circle_at_center() {
121        let v = Callout::badge(Vec2::new(0.4, 0.5), 0.06, Color::rgba(1.0, 0.4, 0.0, 1.0));
122        match &v.shape {
123            VectorShape::Circle { center, radius } => {
124                assert!((center.x - 0.4).abs() < f32::EPSILON);
125                assert!((radius - 0.06).abs() < f32::EPSILON);
126            }
127            _ => panic!("badge should be a circle"),
128        }
129    }
130
131    #[test]
132    fn caption_pill_radius_is_half_height() {
133        let v = Callout::caption_pill(
134            Rect::new(-0.6, -0.04, 1.2, 0.08),
135            Color::rgba_u8(255, 255, 255, 220),
136        );
137        match &v.shape {
138            VectorShape::RoundedRect { radius, .. } => {
139                assert!((radius - 0.04).abs() < f32::EPSILON);
140            }
141            _ => panic!("caption_pill should be a rounded rect"),
142        }
143    }
144}