1#[derive(Clone, Copy, Debug, Default, PartialEq)]
8pub struct Point {
9 pub x: f64,
10 pub y: f64,
11}
12
13impl Point {
14 pub const ZERO: Point = Point { x: 0.0, y: 0.0 };
15 #[inline]
16 pub const fn new(x: f64, y: f64) -> Self {
17 Point { x, y }
18 }
19 #[inline]
20 pub fn offset(self, dx: f64, dy: f64) -> Self {
21 Point::new(self.x + dx, self.y + dy)
22 }
23}
24
25#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub struct Size {
27 pub width: f64,
28 pub height: f64,
29}
30
31impl Size {
32 pub const ZERO: Size = Size {
33 width: 0.0,
34 height: 0.0,
35 };
36 #[inline]
37 pub const fn new(width: f64, height: f64) -> Self {
38 Size { width, height }
39 }
40 #[inline]
41 pub fn max(self, other: Size) -> Size {
42 Size::new(self.width.max(other.width), self.height.max(other.height))
43 }
44 #[inline]
46 pub fn approx_eq(self, other: Size, eps: f64) -> bool {
47 (self.width - other.width).abs() <= eps && (self.height - other.height).abs() <= eps
48 }
49}
50
51#[derive(Clone, Copy, Debug, Default, PartialEq)]
52pub struct Rect {
53 pub origin: Point,
54 pub size: Size,
55}
56
57impl Rect {
58 pub const ZERO: Rect = Rect {
59 origin: Point::ZERO,
60 size: Size::ZERO,
61 };
62 #[inline]
63 pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
64 Rect {
65 origin: Point::new(x, y),
66 size: Size::new(width, height),
67 }
68 }
69 #[inline]
70 pub fn from_size(size: Size) -> Self {
71 Rect {
72 origin: Point::ZERO,
73 size,
74 }
75 }
76 #[inline]
77 pub fn min_x(&self) -> f64 {
78 self.origin.x
79 }
80 #[inline]
81 pub fn min_y(&self) -> f64 {
82 self.origin.y
83 }
84 #[inline]
85 pub fn max_x(&self) -> f64 {
86 self.origin.x + self.size.width
87 }
88 #[inline]
89 pub fn max_y(&self) -> f64 {
90 self.origin.y + self.size.height
91 }
92 #[inline]
93 pub fn center(&self) -> Point {
94 Point::new(
95 self.origin.x + self.size.width / 2.0,
96 self.origin.y + self.size.height / 2.0,
97 )
98 }
99 #[inline]
100 pub fn inset(&self, d: f64) -> Rect {
101 self.inset_by(Insets::all(d))
102 }
103 pub fn inset_by(&self, i: Insets) -> Rect {
104 Rect::new(
105 self.origin.x + i.leading,
106 self.origin.y + i.top,
107 (self.size.width - i.leading - i.trailing).max(0.0),
108 (self.size.height - i.top - i.bottom).max(0.0),
109 )
110 }
111 pub fn intersects(&self, other: &Rect) -> bool {
112 self.min_x() < other.max_x()
113 && other.min_x() < self.max_x()
114 && self.min_y() < other.max_y()
115 && other.min_y() < self.max_y()
116 }
117 pub fn approx_eq(&self, other: &Rect, eps: f64) -> bool {
119 (self.origin.x - other.origin.x).abs() <= eps
120 && (self.origin.y - other.origin.y).abs() <= eps
121 && self.size.approx_eq(other.size, eps)
122 }
123}
124
125#[derive(Clone, Copy, Debug, PartialEq)]
129pub struct Affine {
130 pub a: f64,
131 pub b: f64,
132 pub c: f64,
133 pub d: f64,
134 pub tx: f64,
135 pub ty: f64,
136}
137
138impl Affine {
139 pub const IDENTITY: Affine = Affine {
140 a: 1.0,
141 b: 0.0,
142 c: 0.0,
143 d: 1.0,
144 tx: 0.0,
145 ty: 0.0,
146 };
147
148 #[inline]
149 pub const fn translate(x: f64, y: f64) -> Affine {
150 Affine {
151 a: 1.0,
152 b: 0.0,
153 c: 0.0,
154 d: 1.0,
155 tx: x,
156 ty: y,
157 }
158 }
159 #[inline]
160 pub const fn scale(sx: f64, sy: f64) -> Affine {
161 Affine {
162 a: sx,
163 b: 0.0,
164 c: 0.0,
165 d: sy,
166 tx: 0.0,
167 ty: 0.0,
168 }
169 }
170 #[inline]
172 pub fn rotate(radians: f64) -> Affine {
173 let (s, cos) = radians.sin_cos();
174 Affine {
175 a: cos,
176 b: s,
177 c: -s,
178 d: cos,
179 tx: 0.0,
180 ty: 0.0,
181 }
182 }
183 #[inline]
185 pub fn then(self, o: Affine) -> Affine {
186 Affine {
187 a: self.a * o.a + self.b * o.c,
188 b: self.a * o.b + self.b * o.d,
189 c: self.c * o.a + self.d * o.c,
190 d: self.c * o.b + self.d * o.d,
191 tx: self.tx * o.a + self.ty * o.c + o.tx,
192 ty: self.tx * o.b + self.ty * o.d + o.ty,
193 }
194 }
195 #[inline]
196 pub fn apply(&self, p: Point) -> Point {
197 Point::new(
198 self.a * p.x + self.c * p.y + self.tx,
199 self.b * p.x + self.d * p.y + self.ty,
200 )
201 }
202 pub fn invert_apply(&self, p: Point) -> Option<Point> {
204 let det = self.a * self.d - self.b * self.c;
205 if det.abs() < 1e-12 {
206 return None;
207 }
208 let inv = 1.0 / det;
209 let x = p.x - self.tx;
210 let y = p.y - self.ty;
211 Some(Point::new(
212 (x * self.d - y * self.c) * inv,
213 (y * self.a - x * self.b) * inv,
214 ))
215 }
216 #[inline]
217 pub fn is_identity(&self) -> bool {
218 *self == Affine::IDENTITY
219 }
220 #[inline]
221 pub fn as_array(&self) -> [f64; 6] {
222 [self.a, self.b, self.c, self.d, self.tx, self.ty]
223 }
224 #[inline]
225 pub fn from_array(m: [f64; 6]) -> Affine {
226 Affine {
227 a: m[0],
228 b: m[1],
229 c: m[2],
230 d: m[3],
231 tx: m[4],
232 ty: m[5],
233 }
234 }
235}
236
237#[derive(Clone, Copy, Debug, Default, PartialEq)]
239pub struct Insets {
240 pub top: f64,
241 pub leading: f64,
242 pub bottom: f64,
243 pub trailing: f64,
244}
245
246impl Insets {
247 pub const ZERO: Insets = Insets {
248 top: 0.0,
249 leading: 0.0,
250 bottom: 0.0,
251 trailing: 0.0,
252 };
253 #[inline]
255 pub const fn new(top: f64, leading: f64, bottom: f64, trailing: f64) -> Self {
256 Insets {
257 top,
258 leading,
259 bottom,
260 trailing,
261 }
262 }
263 #[inline]
264 pub const fn all(d: f64) -> Self {
265 Insets {
266 top: d,
267 leading: d,
268 bottom: d,
269 trailing: d,
270 }
271 }
272 #[inline]
273 pub const fn symmetric(horizontal: f64, vertical: f64) -> Self {
274 Insets {
275 top: vertical,
276 leading: horizontal,
277 bottom: vertical,
278 trailing: horizontal,
279 }
280 }
281 #[inline]
283 pub const fn horizontal(d: f64) -> Self {
284 Insets::symmetric(d, 0.0)
285 }
286 #[inline]
288 pub const fn vertical(d: f64) -> Self {
289 Insets::symmetric(0.0, d)
290 }
291 #[inline]
293 pub const fn top(d: f64) -> Self {
294 Insets {
295 top: d,
296 ..Insets::ZERO
297 }
298 }
299 #[inline]
301 pub const fn bottom(d: f64) -> Self {
302 Insets {
303 bottom: d,
304 ..Insets::ZERO
305 }
306 }
307 #[inline]
309 pub const fn leading(d: f64) -> Self {
310 Insets {
311 leading: d,
312 ..Insets::ZERO
313 }
314 }
315 #[inline]
317 pub const fn trailing(d: f64) -> Self {
318 Insets {
319 trailing: d,
320 ..Insets::ZERO
321 }
322 }
323 #[inline]
325 pub fn horizontal_total(&self) -> f64 {
326 self.leading + self.trailing
327 }
328 #[inline]
330 pub fn vertical_total(&self) -> f64 {
331 self.top + self.bottom
332 }
333}
334
335#[derive(Clone, Copy, Debug, PartialEq)]
337pub struct Color {
338 pub r: f64,
339 pub g: f64,
340 pub b: f64,
341 pub a: f64,
342}
343
344impl Color {
345 pub const fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
346 Color { r, g, b, a }
347 }
348 pub const fn rgb(r: f64, g: f64, b: f64) -> Self {
349 Color::rgba(r, g, b, 1.0)
350 }
351 pub const BLACK: Color = Color::rgb(0.0, 0.0, 0.0);
352 pub const WHITE: Color = Color::rgb(1.0, 1.0, 1.0);
353 pub const CLEAR: Color = Color::rgba(0.0, 0.0, 0.0, 0.0);
354 pub const fn hex(v: u32) -> Self {
356 Color::rgb(
357 ((v >> 16) & 0xff) as f64 / 255.0,
358 ((v >> 8) & 0xff) as f64 / 255.0,
359 (v & 0xff) as f64 / 255.0,
360 )
361 }
362
363 pub fn hsl(h: f64, s: f64, l: f64) -> Self {
366 Color::hsla(h, s, l, 1.0)
367 }
368 pub fn hsla(h: f64, s: f64, l: f64, a: f64) -> Self {
369 let (s, l) = (s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
370 if s == 0.0 {
371 return Color::rgba(l, l, l, a);
372 }
373 let hk = h.rem_euclid(360.0) / 360.0;
374 let q = if l < 0.5 {
375 l * (1.0 + s)
376 } else {
377 l + s - l * s
378 };
379 let p = 2.0 * l - q;
380 Color::rgba(
381 hue2rgb(p, q, hk + 1.0 / 3.0),
382 hue2rgb(p, q, hk),
383 hue2rgb(p, q, hk - 1.0 / 3.0),
384 a,
385 )
386 }
387
388 pub fn hsv(h: f64, s: f64, v: f64) -> Self {
390 Color::hsva(h, s, v, 1.0)
391 }
392 pub fn hsva(h: f64, s: f64, v: f64, a: f64) -> Self {
393 let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
394 let hh = h.rem_euclid(360.0) / 60.0;
395 let c = v * s;
396 let x = c * (1.0 - (hh % 2.0 - 1.0).abs());
397 let m = v - c;
398 let (r, g, b) = match hh as i32 {
399 0 => (c, x, 0.0),
400 1 => (x, c, 0.0),
401 2 => (0.0, c, x),
402 3 => (0.0, x, c),
403 4 => (x, 0.0, c),
404 _ => (c, 0.0, x),
405 };
406 Color::rgba(r + m, g + m, b + m, a)
407 }
408
409 pub fn to_hsl(&self) -> (f64, f64, f64) {
411 let (r, g, b) = (self.r, self.g, self.b);
412 let max = r.max(g).max(b);
413 let min = r.min(g).min(b);
414 let l = (max + min) / 2.0;
415 let d = max - min;
416 if d.abs() < 1e-9 {
417 return (0.0, 0.0, l);
418 }
419 let s = d / (1.0 - (2.0 * l - 1.0).abs());
420 let h = if max == r {
421 60.0 * (((g - b) / d).rem_euclid(6.0))
422 } else if max == g {
423 60.0 * ((b - r) / d + 2.0)
424 } else {
425 60.0 * ((r - g) / d + 4.0)
426 };
427 (h.rem_euclid(360.0), s.clamp(0.0, 1.0), l)
428 }
429
430 pub fn to_hsv(&self) -> (f64, f64, f64) {
434 let (r, g, b) = (self.r, self.g, self.b);
435 let max = r.max(g).max(b);
436 let min = r.min(g).min(b);
437 let d = max - min;
438 if d.abs() < 1e-9 {
439 return (0.0, 0.0, max);
440 }
441 let h = if max == r {
442 60.0 * (((g - b) / d).rem_euclid(6.0))
443 } else if max == g {
444 60.0 * ((b - r) / d + 2.0)
445 } else {
446 60.0 * ((r - g) / d + 4.0)
447 };
448 (h.rem_euclid(360.0), d / max, max)
449 }
450
451 pub fn with_alpha(self, a: f64) -> Color {
454 Color { a, ..self }
455 }
456
457 pub fn to_hex_string(&self) -> String {
460 let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
461 if self.a >= 1.0 {
462 format!("#{:02x}{:02x}{:02x}", q(self.r), q(self.g), q(self.b))
463 } else {
464 format!(
465 "#{:02x}{:02x}{:02x}{:02x}",
466 q(self.r),
467 q(self.g),
468 q(self.b),
469 q(self.a)
470 )
471 }
472 }
473
474 pub fn parse(s: &str) -> Option<Color> {
486 let s = s.trim();
487 let hex = s.strip_prefix('#').unwrap_or(s);
488 if !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
489 let nib = |i: usize| u8::from_str_radix(&hex[i..i + 1], 16).ok().map(f64::from);
490 let byte = |i: usize| {
491 u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
492 .ok()
493 .map(f64::from)
494 };
495 return match hex.len() {
496 3 | 4 => Some(Color::rgba(
497 nib(0)? / 15.0,
498 nib(1)? / 15.0,
499 nib(2)? / 15.0,
500 if hex.len() == 4 { nib(3)? / 15.0 } else { 1.0 },
501 )),
502 6 | 8 => Some(Color::rgba(
503 byte(0)? / 255.0,
504 byte(1)? / 255.0,
505 byte(2)? / 255.0,
506 if hex.len() == 8 {
507 byte(3)? / 255.0
508 } else {
509 1.0
510 },
511 )),
512 _ => None,
513 };
514 }
515 let mut it = s.split_whitespace();
516 let (r, g, b) = (
517 it.next()?.parse::<f64>().ok()?,
518 it.next()?.parse::<f64>().ok()?,
519 it.next()?.parse::<f64>().ok()?,
520 );
521 let a = match it.next() {
522 Some(a) => a.parse::<f64>().ok()?,
523 None => 1.0,
524 };
525 if it.next().is_some() {
526 return None;
527 }
528 Some(Color::rgba(
529 r.clamp(0.0, 1.0),
530 g.clamp(0.0, 1.0),
531 b.clamp(0.0, 1.0),
532 a.clamp(0.0, 1.0),
533 ))
534 }
535
536 pub fn lerp_hsl(self, to: Color, t: f64) -> Color {
541 let (h0, s0, l0) = self.to_hsl();
542 let (h1, s1, l1) = to.to_hsl();
543 let mut dh = (h1 - h0).rem_euclid(360.0);
544 if dh > 180.0 {
545 dh -= 360.0;
546 }
547 let lerp = |a: f64, b: f64| a + (b - a) * t;
548 Color::hsla(h0 + dh * t, lerp(s0, s1), lerp(l0, l1), lerp(self.a, to.a))
549 }
550}
551
552impl std::fmt::Display for Color {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 write!(f, "{} {} {} {}", self.r, self.g, self.b, self.a)
560 }
561}
562
563fn hue2rgb(p: f64, q: f64, t: f64) -> f64 {
565 let t = t.rem_euclid(1.0);
566 if t < 1.0 / 6.0 {
567 p + (q - p) * 6.0 * t
568 } else if t < 1.0 / 2.0 {
569 q
570 } else if t < 2.0 / 3.0 {
571 p + (q - p) * (2.0 / 3.0 - t) * 6.0
572 } else {
573 p
574 }
575}
576
577#[derive(Clone, Copy, Debug, Default, PartialEq)]
579pub struct Proposal {
580 pub width: Option<f64>,
581 pub height: Option<f64>,
582}
583
584impl Proposal {
585 pub const UNCONSTRAINED: Proposal = Proposal {
586 width: None,
587 height: None,
588 };
589 #[inline]
590 pub const fn new(width: Option<f64>, height: Option<f64>) -> Self {
591 Proposal { width, height }
592 }
593 #[inline]
594 pub const fn exact(size: Size) -> Self {
595 Proposal {
596 width: Some(size.width),
597 height: Some(size.height),
598 }
599 }
600 pub fn cache_key(&self) -> (u64, u64) {
602 #[inline]
603 fn q(v: Option<f64>) -> u64 {
604 match v {
605 None => u64::MAX,
606 Some(f) => (f * 10.0).round().max(0.0) as u64,
607 }
608 }
609 (q(self.width), q(self.height))
610 }
611}
612
613#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
614pub enum LayoutDirection {
615 #[default]
616 Ltr,
617 Rtl,
618}
619
620#[derive(Clone, Copy, Debug, PartialEq)]
626pub struct Transform {
627 pub tx: f64,
628 pub ty: f64,
629 pub sx: f64,
630 pub sy: f64,
631 pub rotate_deg: f64,
632 pub anchor_x: f64,
634 pub anchor_y: f64,
635}
636
637impl Default for Transform {
638 fn default() -> Self {
639 Transform::IDENTITY
640 }
641}
642
643impl Transform {
644 pub const IDENTITY: Transform = Transform {
645 tx: 0.0,
646 ty: 0.0,
647 sx: 1.0,
648 sy: 1.0,
649 rotate_deg: 0.0,
650 anchor_x: 0.5,
651 anchor_y: 0.5,
652 };
653
654 #[inline]
655 pub const fn translate(tx: f64, ty: f64) -> Transform {
656 Transform {
657 tx,
658 ty,
659 ..Transform::IDENTITY
660 }
661 }
662 #[inline]
663 pub const fn scale(sx: f64, sy: f64) -> Transform {
664 Transform {
665 sx,
666 sy,
667 ..Transform::IDENTITY
668 }
669 }
670 #[inline]
671 pub const fn rotate(deg: f64) -> Transform {
672 Transform {
673 rotate_deg: deg,
674 ..Transform::IDENTITY
675 }
676 }
677 #[inline]
679 pub fn is_identity(&self) -> bool {
680 *self == Transform::IDENTITY
681 }
682}
683
684pub trait Animatable: Copy {
688 fn lerp(self, to: Self, t: f64) -> Self;
689}
690
691#[inline]
692fn flerp(a: f64, b: f64, t: f64) -> f64 {
693 a + (b - a) * t
694}
695
696impl Animatable for f64 {
697 #[inline]
698 fn lerp(self, to: f64, t: f64) -> f64 {
699 flerp(self, to, t)
700 }
701}
702impl Animatable for Point {
703 #[inline]
704 fn lerp(self, to: Point, t: f64) -> Point {
705 Point::new(flerp(self.x, to.x, t), flerp(self.y, to.y, t))
706 }
707}
708impl Animatable for Size {
709 #[inline]
710 fn lerp(self, to: Size, t: f64) -> Size {
711 Size::new(
712 flerp(self.width, to.width, t),
713 flerp(self.height, to.height, t),
714 )
715 }
716}
717impl Animatable for Rect {
718 #[inline]
719 fn lerp(self, to: Rect, t: f64) -> Rect {
720 Rect {
721 origin: self.origin.lerp(to.origin, t),
722 size: self.size.lerp(to.size, t),
723 }
724 }
725}
726impl Animatable for Color {
727 #[inline]
728 fn lerp(self, to: Color, t: f64) -> Color {
729 Color::rgba(
730 flerp(self.r, to.r, t),
731 flerp(self.g, to.g, t),
732 flerp(self.b, to.b, t),
733 flerp(self.a, to.a, t),
734 )
735 }
736}
737impl Animatable for Transform {
738 #[inline]
739 fn lerp(self, to: Transform, t: f64) -> Transform {
740 Transform {
742 tx: flerp(self.tx, to.tx, t),
743 ty: flerp(self.ty, to.ty, t),
744 sx: flerp(self.sx, to.sx, t),
745 sy: flerp(self.sy, to.sy, t),
746 rotate_deg: flerp(self.rotate_deg, to.rotate_deg, t),
747 anchor_x: to.anchor_x,
748 anchor_y: to.anchor_y,
749 }
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
758 fn rect_insets() {
759 let r = Rect::new(10.0, 10.0, 100.0, 50.0).inset_by(Insets::symmetric(4.0, 2.0));
760 assert_eq!(r, Rect::new(14.0, 12.0, 92.0, 46.0));
761 }
762
763 #[test]
764 fn hsl_primaries_and_roundtrip() {
765 let approx = |a: Color, b: Color| {
766 (a.r - b.r).abs() < 1e-6 && (a.g - b.g).abs() < 1e-6 && (a.b - b.b).abs() < 1e-6
767 };
768 assert!(approx(Color::hsl(0.0, 1.0, 0.5), Color::rgb(1.0, 0.0, 0.0))); assert!(approx(
770 Color::hsl(120.0, 1.0, 0.5),
771 Color::rgb(0.0, 1.0, 0.0)
772 )); assert!(approx(
774 Color::hsl(240.0, 1.0, 0.5),
775 Color::rgb(0.0, 0.0, 1.0)
776 )); assert!(approx(Color::hsl(0.0, 0.0, 0.5), Color::rgb(0.5, 0.5, 0.5))); assert!(approx(Color::hsv(0.0, 1.0, 1.0), Color::rgb(1.0, 0.0, 0.0))); let (h, s, l) = Color::hsl(370.0, 0.6, 0.4).to_hsl();
781 assert!((h - 10.0).abs() < 1e-3 && (s - 0.6).abs() < 1e-3 && (l - 0.4).abs() < 1e-3);
782 let mid = Color::hsl(0.0, 1.0, 0.5).lerp_hsl(Color::hsl(300.0, 1.0, 0.5), 0.5);
785 assert!((mid.to_hsl().0 - 330.0).abs() < 1.0);
786 }
787
788 #[test]
789 fn hsv_roundtrip_and_gray_hue() {
790 let c = Color::rgb(0.2, 0.7, 0.45);
791 let (h, s, v) = c.to_hsv();
792 let back = Color::hsv(h, s, v);
793 assert!((back.r - c.r).abs() < 1e-6 && (back.g - c.g).abs() < 1e-6);
794 assert!((back.b - c.b).abs() < 1e-6);
795 assert_eq!(Color::rgb(0.4, 0.4, 0.4).to_hsv(), (0.0, 0.0, 0.4));
797 }
798
799 #[test]
800 fn color_hex_and_component_parsing() {
801 let coral = Color::hex(0xE86A3C);
803 for s in ["#e86a3c", "E86A3C", "#e86a3cff"] {
804 let p = Color::parse(s).unwrap();
805 assert!((p.r - coral.r).abs() < 1e-9, "{s}");
806 assert!(
807 (p.g - coral.g).abs() < 1e-9 && (p.b - coral.b).abs() < 1e-9,
808 "{s}"
809 );
810 assert_eq!(p.a, 1.0, "{s}");
811 }
812 assert_eq!(Color::parse("#f00").unwrap(), Color::rgb(1.0, 0.0, 0.0));
813 assert_eq!(
814 Color::parse("#0000").unwrap(),
815 Color::rgba(0.0, 0.0, 0.0, 0.0)
816 );
817 assert_eq!(Color::parse("#00000080").unwrap().a, 128.0 / 255.0);
818 assert_eq!(Color::parse("1 0 0").unwrap(), Color::rgb(1.0, 0.0, 0.0));
820 let c = Color::rgba(0.937_254_901, 0.4, 0.298, 0.6);
821 assert_eq!(
822 Color::parse(&c.to_string()).unwrap(),
823 c,
824 "Display round-trips"
825 );
826 assert_eq!(Color::parse(&coral.to_hex_string()).unwrap(), coral);
828 assert_eq!(
829 Color::rgba(0.0, 0.0, 0.0, 0.5).to_hex_string(),
830 "#00000080",
831 "alpha appears only when it is not opaque"
832 );
833 for bad in ["", "#12345", "not-a-color", "1 0", "1 0 0 0 0", "#gg0000"] {
834 assert!(Color::parse(bad).is_none(), "{bad:?} rejected");
835 }
836 }
837
838 #[test]
839 fn proposal_cache_key_quantizes() {
840 assert_eq!(
841 Proposal::new(Some(100.02), None).cache_key(),
842 Proposal::new(Some(100.04), None).cache_key()
843 );
844 assert_ne!(
845 Proposal::new(Some(100.0), None).cache_key(),
846 Proposal::UNCONSTRAINED.cache_key()
847 );
848 }
849}