1use std::{
8 cell::{Cell, RefCell},
9 rc::Rc,
10 sync::Arc,
11 time::Duration,
12};
13
14use gpui::{
15 div, prelude::*, px, Animation, AnimationExt, App, Bounds, ElementId, Entity, Hsla,
16 InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
17 RenderOnce, SharedString, Styled, Window,
18};
19#[allow(unused_imports)] use herogpui_core::{element_id, FieldVariant, Placement, SizeXl};
21use herogpui_theme::ActiveTheme;
22
23use crate::{
24 a11y::{self, A11y as _},
25 input::Input,
26 util,
27};
28
29pub(super) const CHECKER_CELL: f32 = 8.0;
35pub(super) const CHECKER_LIGHT: u32 = 0xefefef;
36pub(super) const CHECKER_DARK: u32 = 0xf7f7f7;
37
38pub(super) fn transparency_checker_cells(
62 width: Pixels,
63 height: Pixels,
64 radius: Pixels,
65 opacity: f32,
66) -> gpui::Svg {
67 use std::fmt::Write as _;
68 const CELL: f32 = CHECKER_CELL;
69 let w = f32::from(width);
70 let h = f32::from(height);
71 let r = f32::from(radius);
72 let columns = (w / CELL).ceil().max(1.0) as usize;
73 let rows = (h / CELL).ceil().max(1.0) as usize;
74 let mut cells = String::new();
76 for row in 0..rows {
77 for column in 0..columns {
78 if (row + column) % 2 == 0 {
79 continue;
80 }
81 let _ = write!(
82 cells,
83 "<rect x=\"{:.2}\" y=\"{:.2}\" width=\"{CELL:.2}\" height=\"{CELL:.2}\"/>",
84 column as f32 * CELL,
85 row as f32 * CELL,
86 );
87 }
88 }
89 let doc = format!(
90 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.2}\" height=\"{h:.2}\" \
91 viewBox=\"0 0 {w:.2} {h:.2}\">\
92 <defs><clipPath id=\"c\"><rect width=\"{w:.2}\" height=\"{h:.2}\" rx=\"{r:.2}\"/></clipPath></defs>\
93 <g clip-path=\"url(#c)\" fill=\"#000000\">{cells}</g></svg>"
94 );
95 let bytes = doc.into_bytes();
96 gpui::svg()
102 .data(&bytes)
103 .absolute()
104 .top_0()
105 .left_0()
106 .w(width)
107 .h(height)
108 .text_color(gpui::rgb(CHECKER_DARK).alpha(opacity.clamp(0.0, 1.0)))
109}
110
111pub(super) fn color_inner_shadow() -> gpui::BoxShadow {
116 gpui::BoxShadow {
117 color: gpui::black().alpha(0.1),
118 offset: gpui::point(px(0.), px(0.)),
119 blur_radius: px(1.),
120 spread_radius: px(1.),
121 inset: true,
122 }
123}
124
125pub(super) fn color_thumb_shadows() -> Vec<gpui::BoxShadow> {
128 let edge = gpui::black().alpha(0.1);
129 vec![
130 gpui::BoxShadow {
131 color: edge,
132 offset: gpui::point(px(0.), px(0.)),
133 blur_radius: px(1.),
134 spread_radius: px(1.),
135 inset: false,
136 },
137 gpui::BoxShadow {
138 color: edge,
139 offset: gpui::point(px(0.), px(0.)),
140 blur_radius: px(1.),
141 spread_radius: px(1.),
142 inset: true,
143 },
144 ]
145}
146
147pub(super) fn color_track_shadows() -> Vec<gpui::BoxShadow> {
150 let edge = gpui::black().alpha(0.1);
151 let offsets = [
152 (px(1.), px(0.)),
153 (px(-1.), px(0.)),
154 (px(0.), px(1.)),
155 (px(0.), px(-1.)),
156 ];
157 offsets
158 .into_iter()
159 .map(|(x, y)| gpui::BoxShadow {
160 color: edge,
161 offset: gpui::point(x, y),
162 blur_radius: px(0.),
163 spread_radius: px(0.),
164 inset: true,
165 })
166 .collect()
167}
168
169pub(super) fn color_picker_entry_offset(placement: Placement) -> (f32, f32) {
174 if placement.is_above() {
175 (0.0, 4.0)
176 } else if placement.is_side() {
177 if placement.is_start_side() {
178 (4.0, 0.0)
179 } else {
180 (-4.0, 0.0)
181 }
182 } else {
183 (0.0, -4.0)
184 }
185}
186
187const COLOR_FOCUS_RING_TRANSITION_MS: u64 = 150;
192
193#[derive(Clone)]
194struct ColorFocusRingMotion {
195 focused: bool,
196 generation: usize,
197 from: f32,
198 opacity: Rc<Cell<f32>>,
199}
200
201pub(super) struct ColorFocusRingMotionFrame {
202 base: ElementId,
203 generation: usize,
204 from: f32,
205 to: f32,
206 opacity: Rc<Cell<f32>>,
207 animate: bool,
208}
209
210fn color_focus_ring_shadows(
211 base: &[gpui::BoxShadow],
212 ring: &[gpui::BoxShadow],
213 opacity: f32,
214) -> Vec<gpui::BoxShadow> {
215 if opacity <= f32::EPSILON {
216 return base.to_vec();
217 }
218 let mut shadows = base.to_vec();
219 shadows.extend(ring.iter().cloned().map(|mut shadow| {
220 shadow.color = shadow.color.alpha(opacity);
221 shadow
222 }));
223 shadows
224}
225
226impl ColorFocusRingMotionFrame {
227 fn render<T>(
228 self,
229 element: T,
230 base_shadows: Vec<gpui::BoxShadow>,
231 offset: bool,
232 cx: &App,
233 ) -> gpui::AnyElement
234 where
235 T: Styled + IntoElement + 'static,
236 {
237 let ring_shadows = util::focus_ring_shadows(offset, cx);
238 let paint = move |element: T, opacity: f32| {
239 element.shadow(color_focus_ring_shadows(
240 &base_shadows,
241 &ring_shadows,
242 opacity,
243 ))
244 };
245 if !self.animate {
246 self.opacity.set(self.to);
247 return paint(element, self.to).into_any_element();
248 }
249
250 let opacity = self.opacity;
251 let from = self.from;
252 let to = self.to;
253 element
254 .with_animation(
255 element_id::indexed(&self.base, "focus-ring", self.generation),
256 Animation::new(Duration::from_millis(COLOR_FOCUS_RING_TRANSITION_MS))
257 .with_easing(crate::anim::ease_out()),
258 move |element, delta| {
259 let next = from + (to - from) * delta;
260 opacity.set(next);
261 paint(element, next)
262 },
263 )
264 .into_any_element()
265 }
266}
267
268pub(super) fn color_focus_ring_motion(
269 id: &ElementId,
270 focused: bool,
271 window: &mut Window,
272 cx: &mut App,
273) -> ColorFocusRingMotionFrame {
274 let state = window.use_keyed_state(element_id::scoped(id, "focus-ring-motion"), cx, |_, _| {
275 ColorFocusRingMotion {
276 focused,
277 generation: 0,
278 from: if focused { 1.0 } else { 0.0 },
279 opacity: Rc::new(Cell::new(if focused { 1.0 } else { 0.0 })),
280 }
281 });
282 let mut current = state.read(cx).clone();
283 let to = if focused { 1.0 } else { 0.0 };
284 if current.focused != focused {
285 current.focused = focused;
286 current.generation = current.generation.wrapping_add(1);
287 current.from = current.opacity.get();
288 state.update(cx, |stored, _| *stored = current.clone());
289 }
290 let reduced_motion = ActiveTheme::reduce_motion(cx);
291 if reduced_motion && (current.opacity.get() - to).abs() > f32::EPSILON {
292 current.from = to;
293 current.opacity.set(to);
294 state.update(cx, |stored, _| *stored = current.clone());
295 }
296 ColorFocusRingMotionFrame {
297 base: id.clone(),
298 generation: current.generation,
299 from: current.from,
300 to,
301 opacity: current.opacity,
302 animate: current.generation != 0
303 && !reduced_motion
304 && (current.from - to).abs() > f32::EPSILON,
305 }
306}
307
308#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
314pub enum ColorSpace {
315 #[default]
317 Hsb,
318 Hsl,
320 Rgb,
322}
323
324impl ColorSpace {
325 pub const ALL: [ColorSpace; 3] = [ColorSpace::Hsb, ColorSpace::Hsl, ColorSpace::Rgb];
327
328 pub fn label(self) -> &'static str {
330 match self {
331 ColorSpace::Hsb => "HSB",
332 ColorSpace::Hsl => "HSL",
333 ColorSpace::Rgb => "RGB",
334 }
335 }
336
337 pub fn area_channels(self) -> (ColorChannel, ColorChannel) {
339 match self {
340 ColorSpace::Hsb => (ColorChannel::Saturation, ColorChannel::Brightness),
341 ColorSpace::Hsl => (ColorChannel::Saturation, ColorChannel::Lightness),
342 ColorSpace::Rgb => (ColorChannel::Red, ColorChannel::Green),
343 }
344 }
345}
346
347#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum ColorChannel {
350 Hue,
352 Saturation,
354 Brightness,
356 Lightness,
358 Alpha,
360 Red,
362 Green,
364 Blue,
366}
367
368impl ColorChannel {
369 pub fn label(self) -> &'static str {
371 match self {
372 ColorChannel::Hue => "Hue",
373 ColorChannel::Saturation => "Saturation",
374 ColorChannel::Brightness => "Brightness",
375 ColorChannel::Lightness => "Lightness",
376 ColorChannel::Alpha => "Alpha",
377 ColorChannel::Red => "Red",
378 ColorChannel::Green => "Green",
379 ColorChannel::Blue => "Blue",
380 }
381 }
382
383 pub fn localized_label(self, cx: &App) -> SharedString {
386 use crate::i18n::{ui_string, UiString};
387 ui_string(
388 match self {
389 ColorChannel::Hue => UiString::Hue,
390 ColorChannel::Saturation => UiString::Saturation,
391 ColorChannel::Brightness => UiString::Brightness,
392 ColorChannel::Lightness => UiString::Lightness,
393 ColorChannel::Alpha => UiString::Alpha,
394 ColorChannel::Red => UiString::Red,
395 ColorChannel::Green => UiString::Green,
396 ColorChannel::Blue => UiString::Blue,
397 },
398 cx,
399 )
400 }
401
402 pub fn range(self) -> (f32, f32) {
404 match self {
405 ColorChannel::Hue => (0.0, 360.0),
406 ColorChannel::Saturation
407 | ColorChannel::Brightness
408 | ColorChannel::Lightness
409 | ColorChannel::Alpha => (0.0, 1.0),
410 ColorChannel::Red | ColorChannel::Green | ColorChannel::Blue => (0.0, 255.0),
411 }
412 }
413}
414
415#[derive(Clone, Copy, Debug)]
422pub struct PickerColor {
423 coordinates: HsbCoordinates,
424 model: ColorModel,
425}
426
427#[derive(Clone, Copy, Debug, PartialEq)]
429pub struct HsbCoordinates {
430 pub hue: f32,
432 pub saturation: f32,
434 pub brightness: f32,
436 pub alpha: f32,
438}
439
440#[derive(Clone, Copy, Debug, PartialEq)]
441enum ColorModel {
442 Hsb,
443 Hsl { saturation: f32, lightness: f32 },
444}
445
446impl std::ops::Deref for PickerColor {
447 type Target = HsbCoordinates;
448
449 fn deref(&self) -> &Self::Target {
450 &self.coordinates
451 }
452}
453
454impl PartialEq for PickerColor {
455 fn eq(&self, other: &Self) -> bool {
456 self.coordinates == other.coordinates
457 }
458}
459
460impl Default for PickerColor {
461 fn default() -> Self {
462 Self::hsb(210.0, 1.0, 1.0)
464 }
465}
466
467impl PickerColor {
468 pub fn hsb(hue: f32, saturation: f32, brightness: f32) -> Self {
470 Self {
471 coordinates: HsbCoordinates {
472 hue: normalize_hue(hue),
473 saturation: saturation.clamp(0.0, 1.0),
474 brightness: brightness.clamp(0.0, 1.0),
475 alpha: 1.0,
476 },
477 model: ColorModel::Hsb,
478 }
479 }
480
481 pub fn with_alpha(mut self, alpha: f32) -> Self {
483 self.coordinates.alpha = alpha.clamp(0.0, 1.0);
484 self
485 }
486
487 pub fn from_hex(text: &str) -> Option<Self> {
489 let hex = text.trim().trim_start_matches('#');
490 let expand = |c: char| {
491 let d = c.to_digit(16)? as f32;
492 Some(d * 17.0 / 255.0)
493 };
494 let (r, g, b, a) = match hex.len() {
495 3 => {
496 let mut it = hex.chars();
497 (
498 expand(it.next()?)?,
499 expand(it.next()?)?,
500 expand(it.next()?)?,
501 1.0,
502 )
503 }
504 6 | 8 => {
505 let byte = |i: usize| {
506 u8::from_str_radix(hex.get(i..i + 2)?, 16)
507 .ok()
508 .map(|v| v as f32 / 255.0)
509 };
510 (
511 byte(0)?,
512 byte(2)?,
513 byte(4)?,
514 if hex.len() == 8 { byte(6)? } else { 1.0 },
515 )
516 }
517 _ => return None,
518 };
519 Some(Self::from_rgb(r, g, b).with_alpha(a))
520 }
521
522 #[allow(clippy::float_cmp)]
527 pub fn from_rgb(r: f32, g: f32, b: f32) -> Self {
528 let max = r.max(g).max(b);
529 let min = r.min(g).min(b);
530 let delta = max - min;
531 #[allow(clippy::float_cmp)]
535 let hue = if delta <= f32::EPSILON {
536 0.0
537 } else if max == r {
538 60.0 * (((g - b) / delta) % 6.0)
539 } else if max == g {
540 60.0 * ((b - r) / delta + 2.0)
541 } else {
542 60.0 * ((r - g) / delta + 4.0)
543 };
544 Self {
545 coordinates: HsbCoordinates {
546 hue: normalize_hue(hue),
547 saturation: if max <= f32::EPSILON {
548 0.0
549 } else {
550 delta / max
551 },
552 brightness: max,
553 alpha: 1.0,
554 },
555 model: ColorModel::Hsb,
556 }
557 }
558
559 pub fn to_rgb(self) -> (f32, f32, f32) {
561 let c = self.brightness * self.saturation;
562 let h = self.hue / 60.0;
563 let x = c * (1.0 - ((h % 2.0) - 1.0).abs());
564 let (r, g, b) = match h as u32 % 6 {
565 0 => (c, x, 0.0),
566 1 => (x, c, 0.0),
567 2 => (0.0, c, x),
568 3 => (0.0, x, c),
569 4 => (x, 0.0, c),
570 _ => (c, 0.0, x),
571 };
572 let m = self.brightness - c;
573 (r + m, g + m, b + m)
574 }
575
576 pub fn to_hsla(self) -> Hsla {
578 let (r, g, b) = self.to_rgb();
579 Hsla::from(gpui::Rgba {
580 r,
581 g,
582 b,
583 a: self.alpha,
584 })
585 }
586
587 pub fn to_hex(self) -> String {
589 let (r, g, b) = self.to_rgb();
590 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
591 if self.alpha >= 1.0 {
592 format!("#{:02X}{:02X}{:02X}", q(r), q(g), q(b))
593 } else {
594 format!("#{:02X}{:02X}{:02X}{:02X}", q(r), q(g), q(b), q(self.alpha))
595 }
596 }
597
598 pub fn hsl_saturation(self) -> f32 {
602 if let ColorModel::Hsl { saturation, .. } = self.model {
603 return saturation;
604 }
605 let l = self.brightness * (1.0 - self.saturation / 2.0);
606 let denom = l.min(1.0 - l);
607 if denom <= f32::EPSILON {
608 0.0
609 } else {
610 ((self.brightness - l) / denom).clamp(0.0, 1.0)
611 }
612 }
613
614 pub fn with_hsl_saturation(self, s: f32) -> Self {
616 let s = s.clamp(0.0, 1.0);
617 let l = self.hsl_lightness();
618 self.with_hsl_channels(s, l)
619 }
620
621 pub(super) fn with_hsl_lightness(self, l: f32) -> Self {
622 self.with_hsl_channels(self.hsl_saturation(), l.clamp(0.0, 1.0))
623 }
624
625 pub(super) fn with_hsl_channels(self, s: f32, l: f32) -> Self {
626 let v = l + s * l.min(1.0 - l);
627 let sv = if v <= f32::EPSILON {
628 0.0
629 } else {
630 (2.0 * (1.0 - l / v)).clamp(0.0, 1.0)
631 };
632 Self {
633 coordinates: HsbCoordinates {
634 saturation: sv,
635 brightness: v,
636 ..self.coordinates
637 },
638 model: ColorModel::Hsl {
639 saturation: s,
640 lightness: l,
641 },
642 }
643 }
644
645 pub(super) fn hsl_lightness(self) -> f32 {
646 match self.model {
647 ColorModel::Hsl { lightness, .. } => lightness,
648 ColorModel::Hsb => self.brightness * (1.0 - self.saturation / 2.0),
649 }
650 }
651
652 pub fn channel_in(self, channel: ColorChannel, space: ColorSpace) -> f32 {
654 match (channel, space) {
655 (ColorChannel::Saturation, ColorSpace::Hsl) => self.hsl_saturation(),
656 (ColorChannel::Lightness, ColorSpace::Hsl) => self.hsl_lightness(),
657 _ => self.channel(channel),
658 }
659 }
660
661 pub fn with_channel_in(self, channel: ColorChannel, space: ColorSpace, value: f32) -> Self {
663 match (channel, space) {
664 (ColorChannel::Saturation, ColorSpace::Hsl) => self.with_hsl_saturation(value),
665 (ColorChannel::Lightness, ColorSpace::Hsl) => self.with_hsl_lightness(value),
666 (ColorChannel::Hue, ColorSpace::Hsl) => Self {
667 coordinates: HsbCoordinates {
668 hue: normalize_hue(value),
669 ..self.coordinates
670 },
671 ..self
672 },
673 (ColorChannel::Alpha, ColorSpace::Hsl) => self.with_alpha(value),
674 _ => self.with_channel(channel, value),
675 }
676 }
677
678 pub fn channel(self, channel: ColorChannel) -> f32 {
680 let (r, g, b) = self.to_rgb();
681 match channel {
682 ColorChannel::Hue => self.hue,
683 ColorChannel::Saturation => self.saturation,
684 ColorChannel::Brightness => self.brightness,
685 ColorChannel::Lightness => self.hsl_lightness(),
687 ColorChannel::Alpha => self.alpha,
688 ColorChannel::Red => r * 255.0,
689 ColorChannel::Green => g * 255.0,
690 ColorChannel::Blue => b * 255.0,
691 }
692 }
693
694 pub fn with_channel(self, channel: ColorChannel, value: f32) -> Self {
696 let (min, max) = channel.range();
697 let value = value.clamp(min, max);
698 let (r, g, b) = self.to_rgb();
699 match channel {
700 ColorChannel::Hue => Self {
701 coordinates: HsbCoordinates {
702 hue: normalize_hue(value),
703 ..self.coordinates
704 },
705 model: ColorModel::Hsb,
706 },
707 ColorChannel::Saturation => Self {
708 coordinates: HsbCoordinates {
709 saturation: value,
710 ..self.coordinates
711 },
712 model: ColorModel::Hsb,
713 },
714 ColorChannel::Brightness => Self {
715 coordinates: HsbCoordinates {
716 brightness: value,
717 ..self.coordinates
718 },
719 model: ColorModel::Hsb,
720 },
721 ColorChannel::Lightness => self.with_hsl_lightness(value),
722 ColorChannel::Alpha => Self {
723 coordinates: HsbCoordinates {
724 alpha: value,
725 ..self.coordinates
726 },
727 ..self
728 },
729 ColorChannel::Red => Self::from_rgb(value / 255.0, g, b).with_alpha(self.alpha),
730 ColorChannel::Green => Self::from_rgb(r, value / 255.0, b).with_alpha(self.alpha),
731 ColorChannel::Blue => Self::from_rgb(r, g, value / 255.0).with_alpha(self.alpha),
732 }
733 }
734}
735
736#[allow(clippy::float_cmp)]
739pub(super) fn normalize_hue(hue: f32) -> f32 {
740 if hue == 360.0 {
741 hue
742 } else {
743 hue.rem_euclid(360.0)
744 }
745}
746
747pub(super) type OnColorChange = Arc<dyn Fn(&PickerColor, &mut Window, &mut App) + 'static>;
748
749pub(super) fn color_swatch_indicator_color(swatch: PickerColor) -> Hsla {
750 let (r, g, b) = swatch.to_rgb();
751 let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
752 if luminance > 0.5 {
753 gpui::black()
754 } else {
755 gpui::white()
756 }
757}
758
759pub(super) type OnColorFieldChange =
762 Arc<dyn Fn(&Option<PickerColor>, &mut Window, &mut App) + 'static>;
763
764#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
766pub enum SwatchShape {
767 #[default]
769 Circle,
770 Square,
772}
773
774impl SwatchShape {
775 pub const ALL: [SwatchShape; 2] = [SwatchShape::Circle, SwatchShape::Square];
777
778 pub fn label(self) -> &'static str {
780 match self {
781 SwatchShape::Circle => "Circle",
782 SwatchShape::Square => "Square",
783 }
784 }
785}
786
787mod area;
788mod field;
789mod picker;
790mod slider;
791mod swatch;
792mod swatch_picker;
793
794pub use area::*;
795#[allow(unused_imports)]
796pub(super) use area::*;
797pub use field::*;
798#[allow(unused_imports)]
799pub(super) use field::*;
800pub use picker::*;
801#[allow(unused_imports)]
802pub(super) use picker::*;
803pub use slider::*;
804#[allow(unused_imports)]
805pub(super) use slider::*;
806pub use swatch::*;
807#[allow(unused_imports)]
808pub(super) use swatch::*;
809pub use swatch_picker::*;
810#[allow(unused_imports)]
811pub(super) use swatch_picker::*;
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 #[test]
818 fn hsl_and_hsb_saturation_differ_and_round_trip() {
819 let c = PickerColor::hsb(210.0, 0.5, 0.6);
822 let hsl_s = c.hsl_saturation();
823 assert!(
824 (hsl_s - c.saturation).abs() > 0.05,
825 "expected the two saturations to differ, got {hsl_s} vs {}",
826 c.saturation
827 );
828
829 for target in [0.0, 0.25, 0.5, 0.75, 1.0] {
831 let out = c.with_hsl_saturation(target);
832 assert!(
833 (out.hsl_saturation() - target).abs() < 1e-3,
834 "hsl saturation {target} round-tripped to {}",
835 out.hsl_saturation()
836 );
837 assert!((out.hue - c.hue).abs() < 1e-3);
839 }
840 }
841
842 #[test]
843 fn equality_uses_public_hsb_coordinates_not_color_model_history() {
844 let hsl_black = PickerColor::hsb(40.0, 0.5, 0.5)
845 .with_channel_in(ColorChannel::Saturation, ColorSpace::Hsl, 0.75)
846 .with_channel_in(ColorChannel::Lightness, ColorSpace::Hsl, 0.0);
847 let hsb_black = PickerColor::hsb(hsl_black.hue, 0.0, 0.0);
848 assert_eq!(hsl_black, hsb_black);
849 assert_eq!(hsl_black.to_hex(), hsb_black.to_hex());
850 }
851
852 #[test]
853 fn hue_gradients_have_seven_stops_and_reverse_on_the_vertical_axis() {
854 let stops = hue_stop_colors(PickerColor::hsb(0.0, 1.0, 1.0), ColorSpace::Hsb);
855 assert_eq!(stops[0], stops[6]);
856 for pair in stops.windows(2).take(5) {
857 assert_ne!(pair[0], pair[1]);
858 }
859 assert!((hue_band_offset(0, false, 0.0) - 0.0).abs() < f32::EPSILON);
860 assert!((hue_band_offset(5, false, 0.0) - 5.0 / 6.0).abs() < f32::EPSILON);
861 assert!((hue_band_offset(0, true, 0.0) - 5.0 / 6.0).abs() < f32::EPSILON);
862 assert!((hue_band_offset(5, true, 0.0) - 0.0).abs() < f32::EPSILON);
863 for index in 0..6 {
864 assert!((hue_band_extent(index, 0.0) - 1.0 / 6.0).abs() < 1e-6);
865 }
866 }
867
868 #[test]
869 fn inset_hue_bands_stretch_only_the_two_end_bands() {
870 let inset = 10.0 / 240.0;
872 let width = (1.0 - inset * 2.0) / 6.0;
873 assert!((hue_band_offset(0, false, inset) - 0.0).abs() < 1e-6);
876 assert!((hue_band_extent(0, inset) - (width + inset)).abs() < 1e-6);
877 assert!((hue_band_extent(5, inset) - (width + inset)).abs() < 1e-6);
878 for index in 1..5 {
879 assert!((hue_band_extent(index, inset) - width).abs() < 1e-6);
880 assert!(
881 (hue_band_offset(index, false, inset) - (inset + index as f32 * width)).abs()
882 < 1e-6
883 );
884 }
885 assert!((hue_band_offset(5, true, inset) - 0.0).abs() < 1e-6);
887 assert!(
888 (hue_band_offset(0, true, inset) - (1.0 - width - inset)).abs() < 1e-6,
889 "band 0 must end at the bottom edge"
890 );
891 let total: f32 = (0..6).map(|i| hue_band_extent(i, inset)).sum();
892 assert!((total - 1.0).abs() < 1e-5, "the bands must tile the box");
893 }
894
895 #[test]
896 fn corner_arc_inset_is_zero_outside_the_corner_and_full_at_the_edge() {
897 let r = 16.0;
898 assert!((corner_arc_inset(0.0, r) - r).abs() < 1e-6);
899 assert!((corner_arc_inset(r, r) - 0.0).abs() < f32::EPSILON);
900 assert!((corner_arc_inset(r + 5.0, r) - 0.0).abs() < f32::EPSILON);
901 assert!((corner_arc_inset(1.0, 0.0) - 0.0).abs() < f32::EPSILON);
902 let mut previous = f32::INFINITY;
903 for step in 0..=32 {
904 let d = r * step as f32 / 32.0;
905 let inset = corner_arc_inset(d, r);
906 assert!(inset <= previous + 1e-6, "must decrease with distance");
907 assert!((0.0..=r).contains(&inset));
908 let dx = r - d;
911 let dy = r - inset;
912 assert!((dx * dx + dy * dy).sqrt() <= r + 1e-3);
913 previous = inset;
914 }
915 assert!((previous - 0.0).abs() < 1e-5);
916 }
917
918 #[test]
919 fn the_slider_ramp_is_one_full_length_element_over_nothing_but_the_checkerboard() {
920 let source = include_str!("slider.rs");
921 for gone in [
925 "END_BASE_PX",
926 "wide_ends",
927 "wide_opaque_ends",
928 "start_cap",
929 "end_cap",
930 ] {
931 assert!(
932 !source.contains(gone),
933 "`{gone}` must not come back: it paints under or over the ramp"
934 );
935 }
936 let painted: Vec<&str> = source
939 .lines()
940 .map(str::trim)
941 .filter(|line| line.starts_with("layers = layers"))
942 .collect();
943 assert_eq!(
944 painted,
945 vec!["layers = layers", "layers = layers.child(ramp);"],
946 "only the checkerboard and the ramp may be painted into the clip"
947 );
948 assert!(source.contains("let ramp = div().absolute().inset_0().rounded(track_r);"));
950 assert!(source.contains("gpui::linear_color_stop(start_color, inset)"));
951 assert!(source.contains("gpui::linear_color_stop(end_color, 1.0 - inset)"));
952 }
953
954 #[test]
955 fn lightness_midpoint_preserves_the_current_hue() {
956 let value = PickerColor::hsb(0.0, 1.0, 1.0);
957 let (start, middle, end) = lightness_gradient_colors(value, ColorSpace::Hsl, 0.0, 1.0);
958 assert_eq!(start, gpui::black());
959 assert_eq!(middle, value.to_hsla());
960 assert_eq!(end, gpui::white());
961 }
962
963 #[test]
964 #[allow(clippy::float_cmp)] fn channel_in_only_diverges_for_saturation() {
966 let c = PickerColor::hsb(30.0, 0.4, 0.8);
967 for ch in [
968 ColorChannel::Hue,
969 ColorChannel::Brightness,
970 ColorChannel::Lightness,
971 ColorChannel::Alpha,
972 ColorChannel::Red,
973 ColorChannel::Green,
974 ColorChannel::Blue,
975 ] {
976 let (hsl, hsb) = (
977 c.channel_in(ch, ColorSpace::Hsl),
978 c.channel_in(ch, ColorSpace::Hsb),
979 );
980 assert!(
981 (hsl - hsb).abs() < 1e-6,
982 "{ch:?} should not depend on the colour space"
983 );
984 }
985 assert_ne!(
986 c.channel_in(ColorChannel::Saturation, ColorSpace::Hsl),
987 c.channel_in(ColorChannel::Saturation, ColorSpace::Hsb)
988 );
989 }
990
991 #[test]
992 fn area_channels_match_the_space() {
993 assert_eq!(
994 ColorSpace::Hsl.area_channels(),
995 (ColorChannel::Saturation, ColorChannel::Lightness)
996 );
997 assert_eq!(
998 ColorSpace::Hsb.area_channels(),
999 (ColorChannel::Saturation, ColorChannel::Brightness)
1000 );
1001 assert_eq!(
1002 ColorSpace::Rgb.area_channels(),
1003 (ColorChannel::Red, ColorChannel::Green)
1004 );
1005 }
1006
1007 #[test]
1008 fn hex_round_trips() {
1009 for hex in [
1010 "#FF0000", "#00FF00", "#0000FF", "#123456", "#FFFFFF", "#000000",
1011 ] {
1012 let c = PickerColor::from_hex(hex).expect(hex);
1013 assert_eq!(c.to_hex(), hex, "{hex}");
1014 }
1015 }
1016
1017 #[test]
1018 fn short_hex_expands() {
1019 assert_eq!(PickerColor::from_hex("#f00").unwrap().to_hex(), "#FF0000");
1020 }
1021
1022 #[test]
1023 fn hex_with_alpha_round_trips() {
1024 let c = PickerColor::from_hex("#11223344").unwrap();
1025 assert_eq!(c.to_hex(), "#11223344");
1026 }
1027
1028 #[test]
1029 fn rejects_bad_hex() {
1030 assert!(PickerColor::from_hex("#12345").is_none());
1031 assert!(PickerColor::from_hex("nope").is_none());
1032 }
1033
1034 #[test]
1035 fn color_surfaces_keep_the_pinned_depth_layers() {
1036 let inner = color_inner_shadow();
1037 assert!(inner.inset);
1038 assert_eq!(inner.offset, gpui::point(px(0.), px(0.)));
1039 assert_eq!(inner.spread_radius, px(1.));
1040 assert!((inner.color.a - 0.1).abs() < f32::EPSILON);
1041
1042 let thumb = color_thumb_shadows();
1043 assert_eq!(thumb.len(), 2);
1044 assert!(!thumb[0].inset);
1045 assert!(thumb[1].inset);
1046 assert!(thumb.iter().all(|shadow| {
1047 shadow.offset == gpui::point(px(0.), px(0.))
1048 && shadow.spread_radius == px(1.)
1049 && (shadow.color.a - 0.1).abs() < f32::EPSILON
1050 }));
1051
1052 let track = color_track_shadows();
1053 assert_eq!(track.len(), 4);
1054 assert!(track.iter().all(|shadow| shadow.inset
1055 && shadow.blur_radius == px(0.)
1056 && shadow.spread_radius == px(0.)));
1057
1058 assert_eq!(
1059 color_slider_thumb_transition_offset(0.25, 0.75, px(200.), false,),
1060 px(-100.)
1061 );
1062 assert_eq!(
1063 color_slider_thumb_transition_offset(0.25, 0.75, px(200.), true,),
1064 px(100.)
1065 );
1066 }
1067
1068 #[test]
1069 fn color_focus_ring_transition_preserves_depth_and_fades_ring_layers() {
1070 let base = color_thumb_shadows();
1071 let ring = vec![gpui::BoxShadow {
1072 color: gpui::white(),
1073 offset: gpui::point(px(0.), px(0.)),
1074 blur_radius: px(1.),
1075 spread_radius: px(2.),
1076 inset: false,
1077 }];
1078
1079 let resting = color_focus_ring_shadows(&base, &ring, 0.0);
1080 assert_eq!(resting, base);
1081
1082 let halfway = color_focus_ring_shadows(&base, &ring, 0.5);
1083 assert_eq!(halfway.len(), base.len() + 1);
1084 assert!((halfway.last().expect("ring layer").color.a - 0.5).abs() < 1e-6);
1085
1086 let focused = color_focus_ring_shadows(&base, &ring, 1.0);
1087 assert_eq!(focused.len(), base.len() + 1);
1088 assert_eq!(focused.last().expect("ring layer").color, gpui::white());
1089 }
1090
1091 #[test]
1092 fn color_picker_entry_offsets_follow_the_requested_side() {
1093 assert_eq!(
1094 color_picker_entry_offset(Placement::BottomStart),
1095 (0.0, -4.0)
1096 );
1097 assert_eq!(color_picker_entry_offset(Placement::TopEnd), (0.0, 4.0));
1098 assert_eq!(color_picker_entry_offset(Placement::Left), (4.0, 0.0));
1099 assert_eq!(color_picker_entry_offset(Placement::Right), (-4.0, 0.0));
1100 }
1101
1102 #[test]
1103 fn channel_edits_are_isolated() {
1104 let c = PickerColor::hsb(120.0, 0.5, 0.5);
1105 let hue = c.with_channel(ColorChannel::Hue, 240.0);
1106 assert!((hue.hue - 240.0).abs() < 1e-3);
1107 assert!((hue.saturation - c.saturation).abs() < 1e-3);
1108 assert!((hue.brightness - c.brightness).abs() < 1e-3);
1109 }
1110
1111 #[test]
1112 #[allow(clippy::float_cmp)] fn channel_values_stay_in_range() {
1114 let c = PickerColor::default();
1115 assert!((c.with_channel(ColorChannel::Alpha, 5.0).alpha - 1.0).abs() < 1e-6);
1116 assert_eq!(c.with_channel(ColorChannel::Alpha, -1.0).alpha, 0.0);
1117 assert!(
1118 c.with_channel(ColorChannel::Red, 999.0)
1119 .channel(ColorChannel::Red)
1120 <= 255.0
1121 );
1122 }
1123
1124 #[test]
1125 fn rgb_channel_edit_matches_readback() {
1126 let c = PickerColor::from_hex("#204060").unwrap();
1127 let next = c.with_channel(ColorChannel::Green, 128.0);
1128 assert!((next.channel(ColorChannel::Green) - 128.0).abs() < 1.0);
1129 }
1130
1131 #[test]
1132 fn dark_saturated_blue_uses_a_white_selected_indicator() {
1133 let blue = PickerColor::from_hex("#0000FF").unwrap();
1134 assert_eq!(color_swatch_indicator_color(blue), gpui::white());
1135 }
1136
1137 #[test]
1138 fn mid_gray_uses_a_black_selected_indicator() {
1139 let gray = PickerColor::from_hex("#808080").unwrap();
1140 assert_eq!(color_swatch_indicator_color(gray), gpui::black());
1141 }
1142}