1use core::fmt;
12
13use super::color::{EngineColor, Rgb};
14use super::glyph_buffer::{PxRect, Rect};
15use super::shape::{circle_outline_runs, clip_run, line_runs};
16
17#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
21#[non_exhaustive]
22pub enum GizmoRole {
23 Bounds,
25 Solid,
27 Trigger,
29 Path,
31 Bone,
33 Note,
35}
36
37impl GizmoRole {
38 pub const ALL: &[GizmoRole] = &[
40 GizmoRole::Bounds,
41 GizmoRole::Solid,
42 GizmoRole::Trigger,
43 GizmoRole::Path,
44 GizmoRole::Bone,
45 GizmoRole::Note,
46 ];
47
48 pub const fn name(self) -> &'static str {
50 match self {
51 GizmoRole::Bounds => "bounds",
52 GizmoRole::Solid => "solid",
53 GizmoRole::Trigger => "trigger",
54 GizmoRole::Path => "path",
55 GizmoRole::Bone => "bone",
56 GizmoRole::Note => "note",
57 }
58 }
59
60 pub const fn color(self) -> EngineColor {
62 match self {
63 GizmoRole::Bounds => EngineColor::TextDim,
64 GizmoRole::Solid => EngineColor::Warning,
65 GizmoRole::Trigger => EngineColor::Accent,
66 GizmoRole::Path => EngineColor::Focus,
67 GizmoRole::Bone => EngineColor::Bright,
68 GizmoRole::Note => EngineColor::Highlight,
69 }
70 }
71}
72
73pub const LABEL_MAX: usize = 24;
75
76#[derive(Copy, Clone, PartialEq, Eq, Hash)]
80pub struct LabelText {
81 bytes: [u8; LABEL_MAX],
82 len: u8,
83}
84
85impl LabelText {
86 pub fn new(text: &str) -> Self {
88 let mut bytes = [0; LABEL_MAX];
89 let mut len = 0;
90 for (slot, c) in bytes.iter_mut().zip(text.chars()) {
91 *slot = if c.is_ascii_graphic() || c == ' ' {
92 u8::try_from(c).unwrap_or(b'?')
94 } else {
95 b'?'
96 };
97 len += 1;
98 }
99 Self { bytes, len }
100 }
101
102 pub fn as_str(&self) -> &str {
104 let len = usize::from(self.len).min(LABEL_MAX);
105 core::str::from_utf8(&self.bytes[..len]).unwrap_or_default()
107 }
108}
109
110impl fmt::Debug for LabelText {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 fmt::Debug::fmt(self.as_str(), f)
113 }
114}
115
116#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
118#[non_exhaustive]
119pub enum GizmoShape {
120 Rect {
122 rect: PxRect,
124 },
125 Line {
127 from: (i32, i32),
129 to: (i32, i32),
131 },
132 Cross {
134 at: (i32, i32),
136 size: u8,
138 },
139 Circle {
141 center: (i32, i32),
143 radius: u16,
145 },
146 Label {
149 at: (i32, i32),
151 text: LabelText,
153 },
154}
155
156const REACH: i64 = 1 << 30;
160
161impl GizmoShape {
162 pub fn bounds(&self) -> PxRect {
165 let wide = |(x, y): (i32, i32)| (i64::from(x), i64::from(y));
166 let (left, top, right, bottom) = match *self {
167 GizmoShape::Rect { rect } => {
168 let (x, y) = wide((rect.x, rect.y));
169 let (w, h) = (i64::from(rect.w.max(0)), i64::from(rect.h.max(0)));
170 (x, y, x + w, y + h)
171 }
172 GizmoShape::Line { from, to } => {
173 let ((x0, y0), (x1, y1)) = (wide(from), wide(to));
174 (x0.min(x1), y0.min(y1), x0.max(x1) + 1, y0.max(y1) + 1)
175 }
176 GizmoShape::Cross { at, size } => {
177 let ((x, y), s) = (wide(at), i64::from(size));
178 (x - s, y - s, x + s + 1, y + s + 1)
179 }
180 GizmoShape::Circle { center, radius } => {
181 let ((x, y), r) = (wide(center), i64::from(radius));
182 (x - r, y - r, x + r + 1, y + r + 1)
183 }
184 GizmoShape::Label { at, .. } => {
185 let (x, y) = wide(at);
186 (x, y, x + 1, y + 1)
187 }
188 };
189 let clamp = |v: i64| v.clamp(-REACH, REACH - 1);
190 let (left, top, right, bottom) = (clamp(left), clamp(top), clamp(right), clamp(bottom));
191 let fit = |v: i64| i32::try_from(v).unwrap_or(0);
192 Rect::new(fit(left), fit(top), fit(right - left), fit(bottom - top))
193 }
194
195 pub(crate) fn moved(self, dx: i64, dy: i64) -> Option<Self> {
198 let point = |(x, y): (i32, i32)| {
199 Some((
200 i32::try_from(i64::from(x) + dx).ok()?,
201 i32::try_from(i64::from(y) + dy).ok()?,
202 ))
203 };
204 Some(match self {
205 GizmoShape::Rect { rect } => {
206 let (x, y) = point((rect.x, rect.y))?;
207 GizmoShape::Rect {
208 rect: Rect::new(x, y, rect.w, rect.h),
209 }
210 }
211 GizmoShape::Line { from, to } => GizmoShape::Line {
212 from: point(from)?,
213 to: point(to)?,
214 },
215 GizmoShape::Cross { at, size } => GizmoShape::Cross {
216 at: point(at)?,
217 size,
218 },
219 GizmoShape::Circle { center, radius } => GizmoShape::Circle {
220 center: point(center)?,
221 radius,
222 },
223 GizmoShape::Label { at, text } => GizmoShape::Label {
224 at: point(at)?,
225 text,
226 },
227 })
228 }
229}
230
231#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
235pub struct GizmoItem {
236 pub channel: &'static str,
238 pub role: GizmoRole,
240 pub shape: GizmoShape,
242 pub color: Rgb,
244 pub clip: PxRect,
248}
249
250impl GizmoItem {
251 pub fn new(channel: &'static str, role: GizmoRole, shape: GizmoShape, color: Rgb) -> Self {
254 Self {
255 channel,
256 role,
257 shape,
258 color,
259 clip: shape.bounds(),
260 }
261 }
262
263 pub fn for_each_run(&self, mut f: impl FnMut(i32, i32, i32)) {
268 let clip = self.clip;
269 match self.shape {
270 GizmoShape::Rect { rect } => rect_runs(rect, clip, &mut f),
271 GizmoShape::Line { from, to } => line_runs(from, to, clip, f),
272 GizmoShape::Cross { at, size } => {
273 let (x, y, s) = (i64::from(at.0), i64::from(at.1), i64::from(size));
274 clip_run(clip, y, x - s, x + s + 1, &mut f);
275 let rows = (y - s).max(i64::from(clip.y))..(y + s + 1).min(clip_bottom(clip));
276 for row in rows.filter(|&row| row != y) {
277 clip_run(clip, row, x, x + 1, &mut f);
278 }
279 }
280 GizmoShape::Circle { center, radius } => circle_outline_runs(center, radius, clip, f),
281 GizmoShape::Label { .. } => {}
282 }
283 }
284}
285
286fn clip_bottom(clip: PxRect) -> i64 {
288 i64::from(clip.y) + i64::from(clip.h.max(0))
289}
290
291fn rect_runs(rect: PxRect, clip: PxRect, f: &mut impl FnMut(i32, i32, i32)) {
295 if rect.is_empty() {
296 return;
297 }
298 let (x, y) = (i64::from(rect.x), i64::from(rect.y));
299 let (right, bottom) = (x + i64::from(rect.w), y + i64::from(rect.h));
300 clip_run(clip, y, x, right, f);
301 if bottom - 1 > y {
302 clip_run(clip, bottom - 1, x, right, f);
303 }
304 for row in (y + 1).max(i64::from(clip.y))..(bottom - 1).min(clip_bottom(clip)) {
305 clip_run(clip, row, x, x + 1, f);
306 if right - 1 > x {
307 clip_run(clip, row, right - 1, right, f);
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use alloc::collections::BTreeSet;
315
316 use super::*;
317 use crate::prelude::*;
318
319 const INK: Rgb = Rgb::new(1, 2, 3);
320
321 fn pixels(item: &GizmoItem) -> BTreeSet<(i32, i32)> {
322 let mut out = BTreeSet::new();
323 item.for_each_run(|y, x0, x1| out.extend((x0..x1).map(|x| (x, y))));
324 out
325 }
326
327 fn item(shape: GizmoShape) -> GizmoItem {
328 GizmoItem::new("test", GizmoRole::Note, shape, INK)
329 }
330
331 #[test]
332 fn a_rect_is_its_outline_one_pixel_wide() {
333 let rect = item(GizmoShape::Rect {
334 rect: Rect::new(2, 3, 4, 3),
335 });
336 let want: BTreeSet<_> = [
337 (2, 3),
338 (3, 3),
339 (4, 3),
340 (5, 3),
341 (2, 4),
342 (5, 4),
343 (2, 5),
344 (3, 5),
345 (4, 5),
346 (5, 5),
347 ]
348 .into();
349 assert_eq!(pixels(&rect), want);
350 assert_eq!(rect.clip, Rect::new(2, 3, 4, 3));
351 let flat = item(GizmoShape::Rect {
353 rect: Rect::new(0, 0, 3, 1),
354 });
355 assert_eq!(pixels(&flat).len(), 3);
356 let thin = item(GizmoShape::Rect {
357 rect: Rect::new(0, 0, 1, 3),
358 });
359 assert_eq!(pixels(&thin).len(), 3);
360 let none = item(GizmoShape::Rect {
361 rect: Rect::new(0, 0, 0, 3),
362 });
363 assert!(pixels(&none).is_empty());
364 }
365
366 #[test]
367 fn a_cross_reaches_its_size_each_way_and_a_label_draws_nothing() {
368 let cross = item(GizmoShape::Cross {
369 at: (5, 5),
370 size: 2,
371 });
372 assert_eq!(cross.clip, Rect::new(3, 3, 5, 5));
373 let got = pixels(&cross);
374 assert_eq!(got.len(), 9);
375 assert!(got.contains(&(3, 5)) && got.contains(&(5, 7)) && !got.contains(&(4, 4)));
376 let label = item(GizmoShape::Label {
377 at: (8, 16),
378 text: LabelText::new("0"),
379 });
380 assert!(pixels(&label).is_empty());
381 assert_eq!(label.clip, Rect::new(8, 16, 1, 1));
382 }
383
384 fn runs(shape: GizmoShape) -> Vec<(i32, i32, i32)> {
386 let wide = GizmoItem {
387 clip: Rect::new(-100, -100, 200, 200),
388 ..item(shape)
389 };
390 let mut out = Vec::new();
391 wide.for_each_run(|y, x0, x1| out.push((y, x0, x1)));
392 out
393 }
394
395 #[test]
396 fn each_shape_gives_its_own_runs_and_no_more() {
397 let rect = |x, y, w, h| GizmoShape::Rect {
398 rect: Rect::new(x, y, w, h),
399 };
400 assert_eq!(
402 runs(rect(2, 3, 4, 4)),
403 [
404 (3, 2, 6),
405 (6, 2, 6),
406 (4, 2, 3),
407 (4, 5, 6),
408 (5, 2, 3),
409 (5, 5, 6)
410 ]
411 );
412 assert_eq!(runs(rect(-2, 5, 3, 1)), [(5, -2, 1)]);
414 assert_eq!(runs(rect(4, -1, 1, 3)), [(-1, 4, 5), (1, 4, 5), (0, 4, 5)]);
415 assert_eq!(
417 runs(GizmoShape::Cross {
418 at: (5, 5),
419 size: 2
420 }),
421 [(5, 3, 8), (3, 5, 6), (4, 5, 6), (6, 5, 6), (7, 5, 6)]
422 );
423 assert_eq!(
424 runs(GizmoShape::Cross {
425 at: (-3, 4),
426 size: 1
427 }),
428 [(4, -4, -1), (3, -3, -2), (5, -3, -2)]
429 );
430 }
431
432 #[test]
433 fn a_clip_cuts_every_shape() {
434 let clip = Rect::new(0, 0, 4, 4);
435 for shape in [
436 GizmoShape::Rect {
437 rect: Rect::new(-3, -3, 6, 6),
438 },
439 GizmoShape::Line {
440 from: (-10, 2),
441 to: (10, 2),
442 },
443 GizmoShape::Cross {
444 at: (2, 2),
445 size: 9,
446 },
447 GizmoShape::Circle {
448 center: (2, 2),
449 radius: 2,
450 },
451 ] {
452 let cut = GizmoItem {
453 clip,
454 ..item(shape)
455 };
456 let got = pixels(&cut);
457 assert!(!got.is_empty(), "{shape:?}");
458 assert!(got.iter().all(|&(x, y)| clip.contains(x, y)), "{shape:?}");
459 }
460 }
461
462 #[test]
463 fn bounds_hold_every_pixel_and_stay_in_reach() {
464 let circle = GizmoShape::Circle {
465 center: (10, 10),
466 radius: 3,
467 };
468 assert_eq!(circle.bounds(), Rect::new(7, 7, 7, 7));
469 let line = GizmoShape::Line {
470 from: (5, 1),
471 to: (-2, 4),
472 };
473 assert_eq!(line.bounds(), Rect::new(-2, 1, 8, 4));
474 let far = GizmoShape::Line {
475 from: (i32::MIN, i32::MIN),
476 to: (i32::MAX, i32::MAX),
477 };
478 let b = far.bounds();
479 assert_eq!((b.x, b.y), (-(1 << 30), -(1 << 30)));
480 assert_eq!((b.w, b.h), (i32::MAX, i32::MAX));
481 }
482
483 #[test]
484 fn a_shape_moves_unless_it_leaves_i32() {
485 let cross = GizmoShape::Cross {
486 at: (1, 2),
487 size: 1,
488 };
489 assert_eq!(
490 cross.moved(10, -20),
491 Some(GizmoShape::Cross {
492 at: (11, -18),
493 size: 1
494 })
495 );
496 assert_eq!(cross.moved(i64::from(i32::MAX), 0), None);
497 }
498
499 #[test]
500 fn a_label_keeps_printable_ascii_and_is_cut() {
501 assert_eq!(LabelText::new("unit 3").as_str(), "unit 3");
502 assert_eq!(LabelText::new("é\n").as_str(), "??");
503 let long = "x".repeat(40);
504 assert_eq!(LabelText::new(&long).as_str().len(), LABEL_MAX);
505 assert_eq!(format!("{:?}", LabelText::new("a")), "\"a\"");
506 }
507
508 #[test]
509 fn each_gizmo_has_its_snapshot_line() {
510 use crate::frame::snapshot::gizmo_line;
511 let line = |role, shape| gizmo_line(&GizmoItem::new("grid", role, shape, INK));
512 let rect = GizmoShape::Rect {
513 rect: Rect::new(0, 16, 8, 8),
514 };
515 assert_eq!(
516 line(GizmoRole::Bounds, rect),
517 "gizmo grid bounds rect 0,16 8x8"
518 );
519 let to = GizmoShape::Line {
520 from: (1, 2),
521 to: (-3, 4),
522 };
523 assert_eq!(line(GizmoRole::Path, to), "gizmo grid path line 1,2 -3,4");
524 let cross = GizmoShape::Cross {
525 at: (5, 6),
526 size: 3,
527 };
528 assert_eq!(
529 line(GizmoRole::Trigger, cross),
530 "gizmo grid trigger cross 5,6 3"
531 );
532 let circle = GizmoShape::Circle {
533 center: (7, 8),
534 radius: 9,
535 };
536 assert_eq!(
537 line(GizmoRole::Solid, circle),
538 "gizmo grid solid circle 7,8 9"
539 );
540 let label = GizmoShape::Label {
541 at: (8, 16),
542 text: LabelText::new("unit \"2\""),
543 };
544 assert_eq!(
545 line(GizmoRole::Note, label),
546 r#"gizmo grid note label 8,16 "unit \"2\"""#
547 );
548 let bone = GizmoShape::Cross {
549 at: (0, 0),
550 size: 0,
551 };
552 assert_eq!(line(GizmoRole::Bone, bone), "gizmo grid bone cross 0,0 0");
553 let cut = GizmoItem {
555 clip: Rect::new(0, 16, 4, 8),
556 ..GizmoItem::new("grid", GizmoRole::Bounds, rect, INK)
557 };
558 assert_eq!(
559 gizmo_line(&cut),
560 "gizmo grid bounds rect 0,16 8x8 clip=0,16 4x8"
561 );
562 let placed = GizmoItem {
563 clip: Rect::new(8, 16, 8, 16),
564 ..GizmoItem::new("grid", GizmoRole::Note, label, INK)
565 };
566 assert!(!gizmo_line(&placed).contains("clip"));
567 }
568
569 #[test]
570 fn each_role_has_a_name_and_an_engine_colour() {
571 let names: BTreeSet<_> = GizmoRole::ALL.iter().map(|r| r.name()).collect();
572 assert_eq!(names.len(), GizmoRole::ALL.len());
573 assert_eq!(GizmoRole::Bounds.color(), EngineColor::TextDim);
574 assert_eq!(GizmoRole::Note.color(), EngineColor::Highlight);
575 }
576}