Skip to main content

gpui_base/
color_picker.rs

1use crate::input::InputState;
2use std::rc::Rc;
3
4use gpui::{
5    AnyElement, App, AppContext as _, ClickEvent, Context, Div, ElementId, Entity, EventEmitter,
6    FocusHandle, Focusable, Hsla, InteractiveElement, Interactivity, IntoElement, KeyBinding,
7    ParentElement, Render, RenderOnce, Rgba, Role, SharedString, Stateful,
8    StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Toggled, Window, div, hsla,
9    prelude::FluentBuilder as _,
10};
11use smallvec::SmallVec;
12
13use crate::{
14    RoleOverride, StyledExt as _,
15    actions::{Cancel, Confirm},
16    input::InputEvent,
17    slider::{SliderEvent, SliderState},
18};
19
20const CONTEXT: &str = "ColorPicker";
21
22/// The hex values this picker's text field accepts while being typed.
23const HEX_PATTERN: &str = r"^#[0-9a-fA-F]{0,8}$";
24
25pub(crate) fn init(cx: &mut App) {
26    cx.bind_keys([
27        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
28        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
29    ]);
30}
31
32/// Parses `#rgb`, `#rgba`, `#rrggbb`, and `#rrggbbaa`, with or without the `#`.
33fn parse_hex(value: &str) -> Option<Hsla> {
34    let value = value.strip_prefix('#').unwrap_or(value);
35    // `from_str_radix` accepts a leading sign, so reject anything that is not
36    // purely hexadecimal before slicing components out of it.
37    if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
38        return None;
39    }
40
41    let (width, has_alpha) = match value.len() {
42        3 => (1, false),
43        4 => (1, true),
44        6 => (2, false),
45        8 => (2, true),
46        _ => return None,
47    };
48
49    let component = |index: usize| {
50        let start = index * width;
51        let raw = u8::from_str_radix(&value[start..start + width], 16).ok()?;
52        // A single digit repeats itself rather than scaling, so `#fff` is white.
53        let raw = if width == 1 { raw * 0x11 } else { raw };
54        Some(raw as f32 / 255.0)
55    };
56
57    Some(
58        Rgba {
59            r: component(0)?,
60            g: component(1)?,
61            b: component(2)?,
62            a: if has_alpha { component(3)? } else { 1.0 },
63        }
64        .into(),
65    )
66}
67
68/// Formats a color as `#RRGGBB`, or `#RRGGBBAA` when it is translucent.
69fn hex_string(color: Hsla) -> String {
70    let rgba = Rgba::from(color);
71    let channel = |value: f32| (value * 255.) as u32;
72    if rgba.a < 1. {
73        format!(
74            "#{:02X}{:02X}{:02X}{:02X}",
75            channel(rgba.r),
76            channel(rgba.g),
77            channel(rgba.b),
78            channel(rgba.a)
79        )
80    } else {
81        format!(
82            "#{:02X}{:02X}{:02X}",
83            channel(rgba.r),
84            channel(rgba.g),
85            channel(rgba.b)
86        )
87    }
88}
89
90/// Events emitted by a [`ColorPickerState`].
91#[derive(Clone)]
92pub enum ColorPickerEvent {
93    /// The committed color changed.
94    Change(Option<Hsla>),
95}
96
97/// The four component sliders owned by a [`ColorPickerState`].
98///
99/// Applications render these with their own slider presentation; the picker
100/// keeps them in sync with the committed color.
101#[derive(Clone)]
102pub struct HslaSliders {
103    hue: Entity<SliderState>,
104    saturation: Entity<SliderState>,
105    lightness: Entity<SliderState>,
106    alpha: Entity<SliderState>,
107}
108
109impl HslaSliders {
110    fn new(cx: &mut App) -> Self {
111        let component = |cx: &mut App| {
112            cx.new(|_| {
113                SliderState::new()
114                    .min(0.)
115                    .max(1.)
116                    .step(0.01)
117                    .default_value(0.)
118            })
119        };
120
121        Self {
122            hue: component(cx),
123            saturation: component(cx),
124            lightness: component(cx),
125            alpha: component(cx),
126        }
127    }
128
129    /// The hue slider, in `0..=1`.
130    pub fn hue(&self) -> &Entity<SliderState> {
131        &self.hue
132    }
133
134    /// The saturation slider, in `0..=1`.
135    pub fn saturation(&self) -> &Entity<SliderState> {
136        &self.saturation
137    }
138
139    /// The lightness slider, in `0..=1`.
140    pub fn lightness(&self) -> &Entity<SliderState> {
141        &self.lightness
142    }
143
144    /// The alpha slider, in `0..=1`.
145    pub fn alpha(&self) -> &Entity<SliderState> {
146        &self.alpha
147    }
148
149    fn read(&self, cx: &App) -> Hsla {
150        hsla(
151            self.hue.read(cx).value().start(),
152            self.saturation.read(cx).value().start(),
153            self.lightness.read(cx).value().start(),
154            self.alpha.read(cx).value().start(),
155        )
156    }
157
158    fn write(&self, color: Hsla, window: &mut Window, cx: &mut App) {
159        let components = [
160            (&self.hue, color.h),
161            (&self.saturation, color.s),
162            (&self.lightness, color.l),
163            (&self.alpha, color.a),
164        ];
165        for (slider, value) in components {
166            slider.update(cx, |slider, cx| slider.set_value(value, window, cx));
167        }
168    }
169}
170
171/// State and interaction model for a color picker.
172///
173/// This owns the committed color, the transient preview shown while the user
174/// hovers or edits, the controlled open state, the active panel, and the hex
175/// field and component sliders that stay in sync with all of them. The
176/// application owns the palette, popup, layout, and every visual decision.
177pub struct ColorPickerState {
178    focus_handle: FocusHandle,
179    value: Option<Hsla>,
180    preview: Option<Hsla>,
181    open: bool,
182    active_tab: usize,
183    hex_input: Entity<InputState>,
184    sliders: HslaSliders,
185    /// A builder-supplied value cannot reach the sliders without a `Window`,
186    /// so the first render flushes it through [`Self::sync_pending_value`].
187    needs_slider_sync: bool,
188    suppress_input_change: bool,
189    _subscriptions: Vec<Subscription>,
190}
191
192impl ColorPickerState {
193    /// Creates an empty, closed picker.
194    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
195        let hex_input = cx
196            .new(|cx| InputState::new(window, cx).pattern(regex::Regex::new(HEX_PATTERN).unwrap()));
197        let sliders = HslaSliders::new(cx);
198
199        let mut subscriptions = vec![cx.subscribe_in(
200            &hex_input,
201            window,
202            |this, input, event: &InputEvent, window, cx| match event {
203                InputEvent::Change => {
204                    if this.suppress_input_change {
205                        this.suppress_input_change = false;
206                        return;
207                    }
208                    let value = input.read(cx).value();
209                    if this.preview_hex(value.as_str(), window, cx) {
210                        let color = this.preview.expect("valid hex leaves a preview");
211                        this.sliders.write(color, window, cx);
212                    }
213                }
214                InputEvent::PressEnter { .. } => {
215                    let value = input.read(cx).value();
216                    this.commit_hex(value.as_str(), window, cx);
217                }
218                _ => {}
219            },
220        )];
221
222        subscriptions.extend(
223            [
224                sliders.hue.clone(),
225                sliders.saturation.clone(),
226                sliders.lightness.clone(),
227                sliders.alpha.clone(),
228            ]
229            .iter()
230            .map(|slider| {
231                cx.subscribe_in(slider, window, |this, _, _: &SliderEvent, window, cx| {
232                    let color = this.sliders.read(cx);
233                    this.update_value_from_slider(color, window, cx);
234                })
235            }),
236        );
237
238        Self {
239            focus_handle: cx.focus_handle(),
240            value: None,
241            preview: None,
242            open: false,
243            active_tab: 0,
244            hex_input,
245            sliders,
246            needs_slider_sync: false,
247            suppress_input_change: false,
248            _subscriptions: subscriptions,
249        }
250    }
251
252    /// Sets the initial committed and previewed color.
253    pub fn default_value(mut self, value: impl Into<Hsla>) -> Self {
254        let value = value.into();
255        self.value = Some(value);
256        self.preview = Some(value);
257        self.needs_slider_sync = true;
258        self
259    }
260
261    /// Returns the committed color.
262    pub fn value(&self) -> Option<Hsla> {
263        self.value
264    }
265
266    /// Returns the color currently being previewed.
267    pub fn preview(&self) -> Option<Hsla> {
268        self.preview
269    }
270
271    /// Returns the previewed color, falling back to the committed color.
272    pub fn displayed_color(&self) -> Option<Hsla> {
273        self.preview.or(self.value)
274    }
275
276    /// The hex text field. Render it with an application-owned input element.
277    pub fn hex_input(&self) -> &Entity<InputState> {
278        &self.hex_input
279    }
280
281    /// The HSLA component sliders.
282    pub fn sliders(&self) -> &HslaSliders {
283        &self.sliders
284    }
285
286    /// Replaces the committed color without emitting a change.
287    pub fn set_value(
288        &mut self,
289        value: impl Into<Hsla>,
290        window: &mut Window,
291        cx: &mut Context<Self>,
292    ) {
293        self.update_value(Some(value.into()), false, window, cx);
294    }
295
296    /// Clears the committed color without emitting a change.
297    pub fn clear_value(&mut self, window: &mut Window, cx: &mut Context<Self>) {
298        self.update_value(None, false, window, cx);
299    }
300
301    /// Applies a value supplied to [`Self::default_value`] to the hex field and
302    /// sliders. Call this from render; it is a no-op once nothing is pending.
303    pub fn sync_pending_value(&mut self, window: &mut Window, cx: &mut Context<Self>) {
304        if self.needs_slider_sync {
305            self.update_value(self.value, false, window, cx);
306        }
307    }
308
309    /// Updates the transient preview color and the hex field that shows it.
310    pub fn preview_color(&mut self, value: Hsla, window: &mut Window, cx: &mut Context<Self>) {
311        self.preview = Some(value);
312        self.write_hex_input(Some(value), window, cx);
313        cx.notify();
314    }
315
316    /// Drops the transient preview, restoring the committed color.
317    pub fn clear_preview(&mut self, window: &mut Window, cx: &mut Context<Self>) {
318        if self.preview == self.value {
319            return;
320        }
321        self.preview = self.value;
322        self.write_hex_input(self.value, window, cx);
323        cx.notify();
324    }
325
326    /// Parses a hex color into the transient preview.
327    ///
328    /// Invalid or incomplete input leaves the current preview unchanged. This
329    /// does not write the hex field, so it is safe to call while typing in it.
330    pub fn preview_hex(&mut self, value: &str, _: &mut Window, cx: &mut Context<Self>) -> bool {
331        let Some(value) = parse_hex(value) else {
332            return false;
333        };
334        self.preview = Some(value);
335        cx.notify();
336        true
337    }
338
339    /// Parses and commits a hex color, closing the picker on success.
340    pub fn commit_hex(
341        &mut self,
342        value: &str,
343        window: &mut Window,
344        cx: &mut Context<Self>,
345    ) -> Option<Hsla> {
346        let value = parse_hex(value)?;
347        self.select_color(value, window, cx);
348        Some(value)
349    }
350
351    /// Commits a color and closes the picker, as a palette selection does.
352    pub fn select_color(&mut self, value: Hsla, window: &mut Window, cx: &mut Context<Self>) {
353        self.open = false;
354        self.update_value(Some(value), true, window, cx);
355    }
356
357    /// Commits a color without changing the open state, as a slider drag does.
358    pub fn update_color(&mut self, value: Hsla, window: &mut Window, cx: &mut Context<Self>) {
359        self.update_value(Some(value), true, window, cx);
360    }
361
362    /// Sets whether the picker popup is open.
363    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
364        if self.open == open {
365            return;
366        }
367        self.open = open;
368        cx.notify();
369    }
370
371    /// Toggles the picker popup.
372    pub fn toggle_open(&mut self, cx: &mut Context<Self>) {
373        self.open = !self.open;
374        cx.notify();
375    }
376
377    /// Returns whether the picker popup is open.
378    pub fn is_open(&self) -> bool {
379        self.open
380    }
381
382    /// Selects the application-defined picker panel.
383    pub fn set_active_tab(&mut self, tab: usize, cx: &mut Context<Self>) {
384        if self.active_tab == tab {
385            return;
386        }
387        self.active_tab = tab;
388        cx.notify();
389    }
390
391    /// Returns the selected application-defined picker panel.
392    pub fn active_tab(&self) -> usize {
393        self.active_tab
394    }
395
396    fn update_value(
397        &mut self,
398        value: Option<Hsla>,
399        emit: bool,
400        window: &mut Window,
401        cx: &mut Context<Self>,
402    ) {
403        self.needs_slider_sync = false;
404        self.value = value;
405        self.preview = value;
406        self.write_hex_input(value, window, cx);
407        // Drive the sliders from the full-precision color rather than letting
408        // the hex round-trip do it.
409        if let Some(value) = value {
410            self.sliders.write(value, window, cx);
411        }
412        if emit {
413            cx.emit(ColorPickerEvent::Change(value));
414        }
415        cx.notify();
416    }
417
418    fn update_value_from_slider(
419        &mut self,
420        value: Hsla,
421        window: &mut Window,
422        cx: &mut Context<Self>,
423    ) {
424        self.needs_slider_sync = false;
425        self.value = Some(value);
426        self.preview = Some(value);
427        // Write the hex field, but leave the sliders alone: they are the source
428        // of this change and rewriting them would fight the drag in progress.
429        self.write_hex_input(Some(value), window, cx);
430        cx.emit(ColorPickerEvent::Change(Some(value)));
431        cx.notify();
432    }
433
434    /// Writes the hex field, suppressing the change it would otherwise feed
435    /// back through `Hsla` → hex → `Hsla` and lose precision to.
436    fn write_hex_input(&mut self, value: Option<Hsla>, window: &mut Window, cx: &mut App) {
437        self.suppress_input_change = true;
438        let text = value.map(hex_string).unwrap_or_default();
439        self.hex_input
440            .update(cx, |input, cx| input.set_value(text, window, cx));
441    }
442}
443
444impl EventEmitter<ColorPickerEvent> for ColorPickerState {}
445
446impl Focusable for ColorPickerState {
447    fn focus_handle(&self, _: &App) -> FocusHandle {
448        self.focus_handle.clone()
449    }
450}
451
452impl Render for ColorPickerState {
453    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
454        self.hex_input.clone()
455    }
456}
457
458type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
459
460/// An unstyled controlled color-picker root.
461///
462/// Applications own the trigger, palette, popup, and every visual decision.
463/// This root owns focus, accessibility semantics, and the keyboard behavior
464/// that opens and dismisses the picker.
465#[derive(IntoElement)]
466pub struct ColorPicker {
467    base: Stateful<Div>,
468    open: bool,
469    disabled: bool,
470    focus_handle: Option<FocusHandle>,
471    accessibility_label: Option<SharedString>,
472    style: StyleRefinement,
473    children: SmallVec<[AnyElement; 2]>,
474    on_open_change: Option<OpenChangeHandler>,
475    key_context: &'static str,
476    role: RoleOverride,
477}
478
479impl ColorPicker {
480    pub fn new(id: impl Into<ElementId>) -> Self {
481        Self {
482            base: div().id(id),
483            open: false,
484            disabled: false,
485            focus_handle: None,
486            accessibility_label: None,
487            style: StyleRefinement::default(),
488            children: SmallVec::new(),
489            on_open_change: None,
490            key_context: CONTEXT,
491            role: RoleOverride::Implicit,
492        }
493    }
494
495    /// Sets the application-controlled open state.
496    pub fn open(mut self, open: bool) -> Self {
497        self.open = open;
498        self
499    }
500
501    /// Prevents keyboard interaction and removes the trigger from tab traversal.
502    pub fn disabled(mut self, disabled: bool) -> Self {
503        self.disabled = disabled;
504        self
505    }
506
507    /// Supplies the focus handle for the picker trigger.
508    pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
509        self.focus_handle = Some(focus_handle.clone());
510        self
511    }
512
513    /// Sets the accessible name exposed by the controlled root.
514    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
515        self.accessibility_label = Some(label.into());
516        self
517    }
518
519    /// Overrides the accessibility role. The default is [`Role::Button`].
520    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
521        self.role = role.into();
522        self
523    }
524
525    /// Handles requests to update the controlled open state.
526    ///
527    /// Confirm toggles the picker and Cancel dismisses an open one.
528    pub fn on_open_change(
529        mut self,
530        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
531    ) -> Self {
532        self.on_open_change = Some(Rc::new(handler));
533        self
534    }
535
536    #[doc(hidden)]
537    pub fn key_context(mut self, key_context: &'static str) -> Self {
538        self.key_context = key_context;
539        self
540    }
541}
542
543impl Styled for ColorPicker {
544    fn style(&mut self) -> &mut StyleRefinement {
545        &mut self.style
546    }
547}
548
549impl ParentElement for ColorPicker {
550    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
551        self.children.extend(elements);
552    }
553}
554
555impl InteractiveElement for ColorPicker {
556    fn interactivity(&mut self) -> &mut Interactivity {
557        self.base.interactivity()
558    }
559}
560
561impl StatefulInteractiveElement for ColorPicker {}
562
563impl RenderOnce for ColorPicker {
564    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
565        let open = self.open;
566        let disabled = self.disabled;
567        let handler = self.on_open_change;
568
569        self.base
570            .when_some(self.role.resolve(|| Role::Button), |this, role| {
571                this.role(role)
572            })
573            .aria_expanded(open)
574            .when_some(self.accessibility_label, |this, label| {
575                this.aria_label(label)
576            })
577            .key_context(self.key_context)
578            .when_some(
579                self.focus_handle.filter(|_| !disabled),
580                |this, focus_handle| this.track_focus(&focus_handle.tab_stop(true)),
581            )
582            .on_action({
583                let handler = handler.clone();
584                move |_: &Confirm, window, cx| {
585                    if disabled {
586                        cx.propagate();
587                        return;
588                    }
589                    if let Some(handler) = handler.as_ref() {
590                        handler(!open, window, cx);
591                    }
592                }
593            })
594            .on_action(move |_: &Cancel, window, cx| {
595                if !open {
596                    cx.propagate();
597                    return;
598                }
599                cx.stop_propagation();
600                if let Some(handler) = handler.as_ref() {
601                    handler(false, window, cx);
602                }
603            })
604            .children(self.children)
605            .refine_style(&self.style)
606    }
607}
608
609type SwatchClickHandler = Rc<dyn Fn(Hsla, &ClickEvent, &mut Window, &mut App)>;
610type SwatchHoverHandler = Rc<dyn Fn(Hsla, bool, &mut Window, &mut App)>;
611
612/// An unstyled selectable color in a picker's palette.
613///
614/// The application paints the color; this part carries the radio semantics,
615/// the accessible hex name, and the hover and activation callbacks a picker
616/// uses to preview and commit a color.
617#[derive(IntoElement)]
618pub struct ColorSwatch {
619    id: ElementId,
620    base: Stateful<Div>,
621    color: Hsla,
622    selected: bool,
623    disabled: bool,
624    style: StyleRefinement,
625    children: SmallVec<[AnyElement; 1]>,
626    accessibility_label: Option<SharedString>,
627    on_click: Option<SwatchClickHandler>,
628    on_hover: Option<SwatchHoverHandler>,
629    tab_index: isize,
630    tab_stop: bool,
631    role: RoleOverride,
632}
633
634impl ColorSwatch {
635    pub fn new(id: impl Into<ElementId>, color: Hsla) -> Self {
636        let id = id.into();
637        Self {
638            base: div().id(id.clone()),
639            id,
640            color,
641            selected: false,
642            disabled: false,
643            style: StyleRefinement::default(),
644            children: SmallVec::new(),
645            accessibility_label: None,
646            on_click: None,
647            on_hover: None,
648            tab_index: 0,
649            tab_stop: true,
650            role: RoleOverride::Implicit,
651        }
652    }
653
654    /// The color this swatch represents.
655    pub fn color(&self) -> Hsla {
656        self.color
657    }
658
659    /// Marks this swatch as the picker's current color.
660    pub fn selected(mut self, selected: bool) -> Self {
661        self.selected = selected;
662        self
663    }
664
665    /// Sets whether pointer and keyboard activation are ignored.
666    pub fn disabled(mut self, disabled: bool) -> Self {
667        self.disabled = disabled;
668        self
669    }
670
671    /// Overrides the accessible name. The default is the color's hex value.
672    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
673        self.accessibility_label = Some(label.into());
674        self
675    }
676
677    /// Overrides the accessibility role. The default is [`Role::RadioButton`].
678    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
679        self.role = role.into();
680        self
681    }
682
683    /// Handles activation with the swatch's color.
684    pub fn on_click(
685        mut self,
686        handler: impl Fn(Hsla, &ClickEvent, &mut Window, &mut App) + 'static,
687    ) -> Self {
688        self.on_click = Some(Rc::new(handler));
689        self
690    }
691
692    /// Handles hover enter and exit with the swatch's color.
693    pub fn on_hover(
694        mut self,
695        handler: impl Fn(Hsla, bool, &mut Window, &mut App) + 'static,
696    ) -> Self {
697        self.on_hover = Some(Rc::new(handler));
698        self
699    }
700
701    /// Sets the focus traversal index. The default is `0`.
702    pub fn tab_index(mut self, tab_index: isize) -> Self {
703        self.tab_index = tab_index;
704        self
705    }
706
707    /// Sets whether this swatch participates in keyboard focus traversal.
708    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
709        self.tab_stop = tab_stop;
710        self
711    }
712}
713
714impl Styled for ColorSwatch {
715    fn style(&mut self) -> &mut StyleRefinement {
716        &mut self.style
717    }
718}
719
720impl ParentElement for ColorSwatch {
721    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
722        self.children.extend(elements);
723    }
724}
725
726impl InteractiveElement for ColorSwatch {
727    fn interactivity(&mut self) -> &mut Interactivity {
728        self.base.interactivity()
729    }
730}
731
732impl StatefulInteractiveElement for ColorSwatch {}
733
734impl RenderOnce for ColorSwatch {
735    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
736        let color = self.color;
737        let disabled = self.disabled;
738        let selected = self.selected;
739        let label = self
740            .accessibility_label
741            .unwrap_or_else(|| hex_string(color).into());
742        let focus_handle = window
743            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
744            .read(cx)
745            .clone();
746
747        self.base
748            .when_some(self.role.resolve(|| Role::RadioButton), |this, role| {
749                this.role(role)
750            })
751            .aria_label(label)
752            // A palette color is both "toggled" and "selected"; assistive
753            // technology reads one or the other, so state both.
754            .aria_toggled(if selected {
755                Toggled::True
756            } else {
757                Toggled::False
758            })
759            .aria_selected(selected)
760            .when(!disabled, |this| {
761                this.track_focus(
762                    &focus_handle
763                        .tab_index(self.tab_index)
764                        .tab_stop(self.tab_stop),
765                )
766            })
767            .when_some(
768                (!disabled).then_some(self.on_hover).flatten(),
769                |this, on_hover| {
770                    this.on_hover(move |entered, window, cx| on_hover(color, *entered, window, cx))
771                },
772            )
773            .when_some(
774                (!disabled).then_some(self.on_click).flatten(),
775                |this, on_click| {
776                    this.on_click(move |event, window, cx| on_click(color, event, window, cx))
777                },
778            )
779            .children(self.children)
780            .refine_style(&self.style)
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use gpui::{TestAppContext, hsla, px};
788    use std::{cell::RefCell, rc::Rc};
789
790    #[test]
791    fn parses_every_supported_hex_width() {
792        let white = parse_hex("#fff").unwrap();
793        assert_eq!(hex_string(white), "#FFFFFF");
794        assert_eq!(parse_hex("ffffff"), Some(white));
795
796        let half = parse_hex("#ff000080").unwrap();
797        assert!((half.a - 0.5).abs() < 0.01);
798        assert_eq!(parse_hex("#f008").map(|c| c.h), Some(half.h));
799    }
800
801    #[test]
802    fn rejects_malformed_hex() {
803        for value in ["#nope", "#12", "#1234567", "", "#+f0000", "#-fffff"] {
804            assert_eq!(parse_hex(value), None, "{value} should not parse");
805        }
806    }
807
808    #[test]
809    fn formats_alpha_only_when_translucent() {
810        assert_eq!(hex_string(hsla(0., 1., 0.5, 1.)), "#FF0000");
811        assert_eq!(hex_string(hsla(0., 1., 0.5, 0.5)), "#FF00007F");
812    }
813
814    fn state(cx: &mut TestAppContext) -> (Entity<ColorPickerState>, &mut gpui::VisualTestContext) {
815        let (state, cx) = cx.add_window_view(ColorPickerState::new);
816        cx.update(|window, cx| window.draw(cx).clear(cx));
817        (state, cx)
818    }
819
820    #[gpui::test]
821    fn default_value_reaches_the_hex_field_and_sliders(cx: &mut TestAppContext) {
822        let (state, cx) = cx.add_window_view(|window, cx| {
823            ColorPickerState::new(window, cx).default_value(hsla(0., 1., 0.5, 1.))
824        });
825        cx.update(|window, cx| {
826            state.update(cx, |state, cx| state.sync_pending_value(window, cx));
827        });
828
829        state.read_with(cx, |state, cx| {
830            assert_eq!(state.hex_input().read(cx).value().as_str(), "#FF0000");
831            assert_eq!(state.sliders().lightness().read(cx).value().start(), 0.5);
832        });
833    }
834
835    #[gpui::test]
836    fn preview_does_not_change_the_committed_value(cx: &mut TestAppContext) {
837        let (state, cx) = state(cx);
838        let committed = hsla(0.1, 0.2, 0.3, 1.);
839        let previewed = hsla(0.6, 0.7, 0.8, 1.);
840
841        cx.update(|window, cx| {
842            state.update(cx, |state, cx| {
843                state.set_value(committed, window, cx);
844                state.preview_color(previewed, window, cx);
845            });
846        });
847
848        state.read_with(cx, |state, _| {
849            assert_eq!(state.value(), Some(committed));
850            assert_eq!(state.displayed_color(), Some(previewed));
851        });
852
853        cx.update(|window, cx| {
854            state.update(cx, |state, cx| state.clear_preview(window, cx));
855        });
856        state.read_with(cx, |state, _| {
857            assert_eq!(state.displayed_color(), Some(committed));
858        });
859    }
860
861    #[gpui::test]
862    fn invalid_hex_leaves_preview_and_value_alone(cx: &mut TestAppContext) {
863        let (state, cx) = state(cx);
864        let color = hsla(0.1, 0.2, 0.3, 1.);
865
866        cx.update(|window, cx| {
867            state.update(cx, |state, cx| {
868                state.set_value(color, window, cx);
869                assert!(!state.preview_hex("#nope", window, cx));
870                assert_eq!(state.commit_hex("#12", window, cx), None);
871            });
872        });
873
874        state.read_with(cx, |state, _| {
875            assert_eq!(state.preview(), Some(color));
876            assert_eq!(state.value(), Some(color));
877        });
878    }
879
880    #[gpui::test]
881    fn palette_selection_closes_but_a_slider_update_stays_open(cx: &mut TestAppContext) {
882        let (state, cx) = state(cx);
883        let changes = Rc::new(RefCell::new(Vec::new()));
884        let _subscription = cx.update(|_, cx| {
885            let changes = changes.clone();
886            cx.subscribe(&state, move |_, event: &ColorPickerEvent, _| {
887                let ColorPickerEvent::Change(color) = event;
888                changes.borrow_mut().push(*color);
889            })
890        });
891
892        cx.update(|window, cx| {
893            state.update(cx, |state, cx| {
894                state.set_open(true, cx);
895                state.update_color(hsla(0.2, 0.3, 0.4, 1.), window, cx);
896            });
897        });
898        assert!(state.read_with(cx, |state, _| state.is_open()));
899
900        cx.update(|window, cx| {
901            state.update(cx, |state, cx| {
902                state.select_color(hsla(0.5, 0.6, 0.7, 1.), window, cx);
903            });
904        });
905        assert!(!state.read_with(cx, |state, _| state.is_open()));
906        assert_eq!(changes.borrow().len(), 2);
907    }
908
909    #[gpui::test]
910    fn committing_hex_updates_the_value_and_closes(cx: &mut TestAppContext) {
911        let (state, cx) = state(cx);
912
913        let committed = cx.update(|window, cx| {
914            state.update(cx, |state, cx| {
915                state.set_open(true, cx);
916                state.commit_hex("#ff0000", window, cx)
917            })
918        });
919
920        state.read_with(cx, |state, _| {
921            assert_eq!(state.value(), committed);
922            assert_eq!(state.preview(), committed);
923            assert!(!state.is_open());
924        });
925    }
926
927    #[gpui::test]
928    fn open_and_active_panel_are_controlled(cx: &mut TestAppContext) {
929        let (state, cx) = state(cx);
930
931        cx.update(|_, cx| {
932            state.update(cx, |state, cx| {
933                state.toggle_open(cx);
934                state.set_active_tab(1, cx);
935            });
936        });
937
938        state.read_with(cx, |state, _| {
939            assert!(state.is_open());
940            assert_eq!(state.active_tab(), 1);
941        });
942    }
943
944    struct PickerHarness {
945        open: bool,
946        focus_handle: FocusHandle,
947        changes: Rc<RefCell<Vec<bool>>>,
948    }
949
950    impl Render for PickerHarness {
951        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
952            let entity = cx.entity();
953            let changes = self.changes.clone();
954            ColorPicker::new("picker")
955                .open(self.open)
956                .track_focus(&self.focus_handle)
957                .size(px(20.))
958                .on_open_change(move |open, _, cx| {
959                    changes.borrow_mut().push(open);
960                    entity.update(cx, |this, cx| {
961                        this.open = open;
962                        cx.notify();
963                    });
964                })
965        }
966    }
967
968    #[gpui::test]
969    fn confirm_toggles_and_cancel_dismisses(cx: &mut TestAppContext) {
970        cx.update(crate::init);
971        let changes = Rc::new(RefCell::new(Vec::new()));
972        let (state, cx) = cx.add_window_view({
973            let changes = changes.clone();
974            move |_, cx| PickerHarness {
975                open: false,
976                focus_handle: cx.focus_handle(),
977                changes,
978            }
979        });
980        cx.update(|window, cx| {
981            state.read(cx).focus_handle.clone().focus(window, cx);
982            window.draw(cx).clear(cx);
983        });
984
985        cx.simulate_keystrokes("enter");
986        assert!(state.read_with(cx, |state, _| state.open));
987
988        cx.simulate_keystrokes("enter");
989        assert!(!state.read_with(cx, |state, _| state.open));
990
991        cx.simulate_keystrokes("enter escape");
992        assert!(!state.read_with(cx, |state, _| state.open));
993        assert_eq!(&*changes.borrow(), &[true, false, true, false]);
994    }
995}