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