1use 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
19const PULSE: std::time::Duration = std::time::Duration::from_millis(900);
21
22pub struct CueScene<'a> {
27 pub doc: CueScope<'a>,
28 pub tool_rects: &'a [(ToolName, Rect)],
29 pub to_screen: TSTransform,
32}
33
34pub 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 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 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 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 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
131fn 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
143fn 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
171fn ink(visuals: &egui::Visuals) -> Color32 {
175 visuals.text_color()
176}
177
178fn paper(visuals: &egui::Visuals) -> Color32 {
180 visuals.window_fill
181}
182
183fn 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
218fn 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 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 #[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}