Skip to main content

blockworx/tutorial/
cues.rs

1//! Draws a sampled `CueFrame` over a playing script: the pulsing
2//! spotlight, the demo cursor with its mouse badge, the click flash, and the
3//! typing box. Doc-anchored targets resolve to world positions and map to the
4//! painter's space through [`CueScene::to_screen`] — identity in the tutorial
5//! player (its `egui::Scene` painter already works in world coordinates), the
6//! canvas view transform when replaying on the main UI. Toolbar targets
7//! resolve through `tool_rects` — the toolbar's button rects, already in the
8//! painter's space — so the demo cursor can visit the chrome too.
9
10use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, emath::TSTransform, vec2};
11
12use crate::grid::GRID_SIZE;
13use crate::tools::names::ToolName;
14
15use crate::script::step::{
16    Anchoring, ButtonState, CueFrame, CueScope, CueTarget, CursorPos, HeldKey,
17};
18
19/// Period of the spotlight's pulse.
20const PULSE: std::time::Duration = std::time::Duration::from_millis(900);
21
22/// What cue targets resolve against: the video session's document
23/// (doc-anchored targets follow the objects wherever the demo put them) and
24/// the embedded toolbar's button rects (empty when the video shows no
25/// toolbar).
26pub struct CueScene<'a> {
27    pub doc: CueScope<'a>,
28    pub tool_rects: &'a [(ToolName, Rect)],
29    /// World → painter-space map for doc-anchored targets (toolbar rects skip
30    /// it — they are captured in the painter's space already).
31    pub to_screen: TSTransform,
32}
33
34/// `time` drives the spotlight pulse.
35pub fn draw(
36    frame: &CueFrame,
37    painter: &egui::Painter,
38    scene: &CueScene<'_>,
39    visuals: &egui::Visuals,
40    time: f64,
41) {
42    if let Some(target) = &frame.highlight
43        && let Some(rect) = scene.target_rect(target)
44    {
45        let phase = (time / PULSE.as_secs_f64()).rem_euclid(1.0) as f32;
46        let pulse = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5;
47        painter.rect_stroke(
48            rect.expand(3.0 + 2.0 * pulse),
49            4.0,
50            Stroke::new(2.0 + pulse, visuals.selection.stroke.color),
51            StrokeKind::Outside,
52        );
53    }
54
55    if let Some(typing) = &frame.typing
56        && let Some(pos) = scene.resolve(&typing.target)
57    {
58        draw_typed_text(painter, pos, typing.typed, visuals);
59    }
60
61    if let Some(cursor) = &frame.cursor
62        && let Some(pos) = scene.cursor_pos(cursor)
63    {
64        if let ButtonState::Flash { t } = frame.button {
65            let color = visuals
66                .selection
67                .stroke
68                .color
69                .gamma_multiply(t.complement().get());
70            painter.circle_stroke(pos, 4.0 + 12.0 * t.get(), Stroke::new(2.0, color));
71        }
72        draw_cursor(painter, pos, visuals);
73        // The badge exists to show button state: only while a button is held or
74        // flashing — a plain glide or hover shows just the cursor. A held key
75        // brings it back regardless, since "+ ctrl" needs the mouse to modify.
76        if !matches!(frame.button, ButtonState::Up) || frame.held.is_some() {
77            let center = pos + vec2(20.0, 28.0);
78            let badge = draw_mouse_badge(painter, center, frame.button, visuals);
79            if let Some(held) = frame.held {
80                draw_held_key(painter, badge, held, visuals);
81            }
82        }
83    }
84}
85
86impl CueScene<'_> {
87    /// The point a cue target names: a toolbar button's center, or the
88    /// target's world position.
89    fn resolve(&self, target: &CueTarget) -> Option<Pos2> {
90        match target.anchoring() {
91            Anchoring::Toolbar(name) => self.tool_rect(name).map(|r| r.center()),
92            Anchoring::Document | Anchoring::FromDragBase(_) => {
93                target.world(&self.doc).map(|p| self.to_screen * p)
94            }
95        }
96    }
97
98    /// The rect a spotlight outlines: the toolbar button itself, or one grid
99    /// cell around the target's world position.
100    fn target_rect(&self, target: &CueTarget) -> Option<Rect> {
101        match target.anchoring() {
102            Anchoring::Toolbar(name) => self.tool_rect(name),
103            Anchoring::Document | Anchoring::FromDragBase(_) => target.world(&self.doc).map(|p| {
104                Rect::from_center_size(
105                    self.to_screen * p,
106                    egui::Vec2::splat(GRID_SIZE * self.to_screen.scaling),
107                )
108            }),
109        }
110    }
111
112    fn tool_rect(&self, name: ToolName) -> Option<Rect> {
113        self.tool_rects
114            .iter()
115            .find(|(tool, _)| *tool == name)
116            .map(|(_, rect)| *rect)
117    }
118
119    /// A glide between two targets lerps between their resolved positions; an
120    /// endpoint that resolves nowhere hides the cursor for the glide.
121    fn cursor_pos(&self, cursor: &CursorPos) -> Option<Pos2> {
122        match cursor {
123            CursorPos::At(target) => self.resolve(target),
124            CursorPos::Between { from, to, t } => {
125                Some(self.resolve(from)?.lerp(self.resolve(to)?, t.get()))
126            }
127        }
128    }
129}
130
131/// The demo pointer: a filled triangle with a contrasting outline, tip at `pos`.
132/// Half again the size of a real cursor — it has to read as a demonstration
133/// across the room, not blend in with the user's own pointer.
134fn draw_cursor(painter: &egui::Painter, pos: Pos2, visuals: &egui::Visuals) {
135    let points = vec![pos, pos + vec2(0.0, 24.0), pos + vec2(17.0, 17.0)];
136    painter.add(egui::Shape::convex_polygon(
137        points,
138        ink(visuals),
139        Stroke::new(2.0, paper(visuals)),
140    ));
141}
142
143/// The text-entry cue: a field centered on `pos` holding the characters typed
144/// so far, with a caret at the end. It depicts the editor the demo session
145/// has open (the video paints tools, not egui text widgets).
146fn draw_typed_text(painter: &egui::Painter, pos: Pos2, typed: &str, visuals: &egui::Visuals) {
147    const CARET_WIDTH: f32 = 2.0;
148    const PADDING: egui::Vec2 = egui::vec2(14.0, 8.0);
149    let galley = painter.layout_no_wrap(
150        typed.to_owned(),
151        egui::FontId::proportional(20.0),
152        ink(visuals),
153    );
154    let content = vec2(galley.size().x + CARET_WIDTH, galley.size().y).max(vec2(36.0, 24.0));
155    painter.rect(
156        Rect::from_center_size(pos, content + PADDING * 2.0),
157        4.0,
158        paper(visuals),
159        Stroke::new(2.0, visuals.selection.stroke.color),
160        StrokeKind::Outside,
161    );
162    let text_min = pos - galley.size() / 2.0 - vec2(CARET_WIDTH / 2.0, 0.0);
163    let caret = Rect::from_min_size(
164        egui::pos2(text_min.x + galley.size().x, text_min.y),
165        vec2(CARET_WIDTH, galley.size().y),
166    );
167    painter.galley(text_min, galley, ink(visuals));
168    painter.rect_filled(caret, 0.0, visuals.selection.stroke.color);
169}
170
171/// The cues' foreground. Deliberately *not* `Visuals::strong_text_color` —
172/// that reads `widgets.active.fg_stroke`, which this app's palette sets to its
173/// darkest background color, so cues drawn with it vanish into the canvas.
174fn ink(visuals: &egui::Visuals) -> Color32 {
175    visuals.text_color()
176}
177
178/// The surface cue chrome sits on, for outlines and field backgrounds.
179fn paper(visuals: &egui::Visuals) -> Color32 {
180    visuals.window_fill
181}
182
183/// The "+ ctrl" chip beside the mouse badge, naming the key the demo holds.
184/// Sized to its text so a longer key name (space) still reads.
185fn draw_held_key(painter: &egui::Painter, badge: Rect, held: HeldKey, visuals: &egui::Visuals) {
186    const GAP: f32 = 6.0;
187    const PADDING: egui::Vec2 = egui::vec2(7.0, 3.0);
188    let plus = painter.layout_no_wrap(
189        "+".to_owned(),
190        egui::FontId::proportional(18.0),
191        ink(visuals),
192    );
193    let key = painter.layout_no_wrap(
194        held.label().to_owned(),
195        egui::FontId::proportional(15.0),
196        ink(visuals),
197    );
198    let plus_at = egui::pos2(badge.right() + GAP, badge.center().y - plus.size().y / 2.0);
199    let chip_size = key.size() + PADDING * 2.0;
200    let chip = Rect::from_min_size(
201        egui::pos2(
202            plus_at.x + plus.size().x + GAP,
203            badge.center().y - chip_size.y / 2.0,
204        ),
205        chip_size,
206    );
207    painter.galley(plus_at, plus, ink(visuals));
208    painter.rect(
209        chip,
210        4.0,
211        paper(visuals),
212        Stroke::new(2.0, ink(visuals)),
213        StrokeKind::Outside,
214    );
215    painter.galley(chip.min + PADDING, key, ink(visuals));
216}
217
218/// A small two-button mouse: body outline plus the left button cell, filled
219/// while the demo holds the button down (drags and click flashes). Returns the
220/// badge's rect, so anything drawn beside it can sit against its edge.
221fn draw_mouse_badge(
222    painter: &egui::Painter,
223    center: Pos2,
224    button: ButtonState,
225    visuals: &egui::Visuals,
226) -> Rect {
227    let pressed = !matches!(button, ButtonState::Up);
228    let body = Rect::from_center_size(center, vec2(20.0, 28.0));
229    painter.rect(
230        body,
231        6.0,
232        paper(visuals),
233        Stroke::new(2.0, ink(visuals)),
234        StrokeKind::Outside,
235    );
236    // The left button cell: the upper-left quadrant of the body.
237    let cell = Rect::from_min_size(body.min, vec2(body.width() / 2.0, body.height() * 0.45));
238    let fill = if pressed {
239        visuals.selection.stroke.color
240    } else {
241        Color32::TRANSPARENT
242    };
243    painter.rect(
244        cell,
245        3.0,
246        fill,
247        Stroke::new(1.5, ink(visuals)),
248        StrokeKind::Inside,
249    );
250    body
251}
252
253#[cfg(all(test, feature = "kittest"))]
254mod kittest_visual {
255    use super::*;
256    use crate::canvas::palette::Luminance;
257    use crate::font::build_fonts;
258    use crate::preferences::{FontChoice, Theme};
259    use crate::script::step::{ButtonState, CursorPos, HeldKey};
260    use egui::{pos2, vec2};
261    use egui_kittest::Harness;
262
263    /// The demo cursor mid-drag with a key held: mouse badge plus its "+ space"
264    /// chip, the cue a pan tutorial leans on.
265    #[test]
266    fn cursor_badge_with_a_held_key() {
267        let mut harness = Harness::builder()
268            .with_size(vec2(260.0, 140.0))
269            .build_ui(move |ui| {
270                let ctx = ui.ctx().clone();
271                ctx.set_fonts(build_fonts(FontChoice::Basic));
272                ctx.set_visuals(Theme::Catppuccin.palette(Luminance::Dark).egui_visuals());
273                let mut fixture = crate::script::step::CueFixture::empty();
274                let scene = CueScene {
275                    doc: fixture.scope(),
276                    tool_rects: &[],
277                    to_screen: TSTransform::IDENTITY,
278                };
279                let frame = CueFrame {
280                    cursor: Some(CursorPos::At(CueTarget::World(pos2(60.0, 40.0)))),
281                    button: ButtonState::Down,
282                    held: Some(HeldKey::Space),
283                    highlight: None,
284                    typing: None,
285                };
286                let visuals = ui.visuals().clone();
287                draw(&frame, ui.painter(), &scene, &visuals, 0.0);
288            });
289        harness.run();
290        harness.snapshot("cue_held_key");
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use egui::pos2;
298
299    fn scene<'a>(
300        fixture: &'a mut crate::script::step::CueFixture,
301        tool_rects: &'a [(ToolName, Rect)],
302    ) -> CueScene<'a> {
303        CueScene {
304            doc: fixture.scope(),
305            tool_rects,
306            to_screen: TSTransform::new(egui::vec2(100.0, 50.0), 2.0),
307        }
308    }
309
310    #[test]
311    fn world_targets_map_through_the_transform() {
312        let mut fixture = crate::script::step::CueFixture::empty();
313        let scene = scene(&mut fixture, &[]);
314        let target = CueTarget::World(pos2(10.0, 20.0));
315        assert_eq!(scene.resolve(&target), Some(pos2(120.0, 90.0)));
316        let rect = scene.target_rect(&target).unwrap();
317        assert_eq!(rect.center(), pos2(120.0, 90.0));
318        assert_eq!(rect.size(), egui::Vec2::splat(GRID_SIZE * 2.0));
319    }
320
321    #[test]
322    fn toolbar_targets_pass_through_untransformed() {
323        let mut fixture = crate::script::step::CueFixture::empty();
324        let button = Rect::from_min_size(pos2(5.0, 5.0), egui::vec2(30.0, 30.0));
325        let rects = [(ToolName::Route, button)];
326        let scene = scene(&mut fixture, &rects);
327        let target = CueTarget::ToolButton(ToolName::Route);
328        assert_eq!(scene.resolve(&target), Some(button.center()));
329        assert_eq!(scene.target_rect(&target), Some(button));
330    }
331}