Skip to main content

gpui_component/
color_picker.rs

1use gpui::{
2    Anchor, App, ElementId, Entity, FocusHandle, Focusable, Hsla, InteractiveElement as _,
3    IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _,
4    StyleRefinement, Styled, TextAlign, Window, div, hsla, linear_color_stop, linear_gradient,
5    prelude::FluentBuilder as _,
6};
7use rust_i18n::t;
8
9use gpui_base::{ColorPicker as BaseColorPicker, ColorSwatch};
10pub use gpui_base::{ColorPickerEvent, ColorPickerState};
11
12use crate::{
13    ActiveTheme as _, Colorize as _, Icon, Selectable, Sizable, Size, StyleSized, h_flex,
14    input::Input,
15    popover::Popover,
16    separator::Separator,
17    slider::Slider,
18    tab::{Tab, TabBar},
19    tooltip::{ManagedTooltipExt as _, Tooltip},
20    v_flex,
21};
22
23fn color_palettes() -> Vec<Vec<Hsla>> {
24    use crate::theme::DEFAULT_COLORS;
25    use itertools::Itertools as _;
26
27    macro_rules! c {
28        ($color:tt) => {
29            DEFAULT_COLORS
30                .$color
31                .keys()
32                .sorted()
33                .map(|k| DEFAULT_COLORS.$color.get(k).map(|c| c.hsla).unwrap())
34                .collect::<Vec<_>>()
35        };
36    }
37
38    vec![
39        c!(stone),
40        c!(red),
41        c!(orange),
42        c!(yellow),
43        c!(green),
44        c!(cyan),
45        c!(blue),
46        c!(purple),
47        c!(pink),
48    ]
49}
50
51/// A color picker element.
52#[derive(IntoElement)]
53pub struct ColorPicker {
54    id: ElementId,
55    style: StyleRefinement,
56    state: Entity<ColorPickerState>,
57    featured_colors: Option<Vec<Hsla>>,
58    label: Option<SharedString>,
59    /// The announced name, when the visible label is not it.
60    accessibility_label: Option<SharedString>,
61    icon: Option<Icon>,
62    size: Size,
63    anchor: Anchor,
64}
65
66impl ColorPicker {
67    /// Create a new color picker element with the given [`ColorPickerState`].
68    pub fn new(state: &Entity<ColorPickerState>) -> Self {
69        Self {
70            id: ("color-picker", state.entity_id()).into(),
71            style: StyleRefinement::default(),
72            state: state.clone(),
73            featured_colors: None,
74            size: Size::Medium,
75            label: None,
76            accessibility_label: None,
77            icon: None,
78            anchor: Anchor::TopLeft,
79        }
80    }
81
82    /// Set the featured colors to be displayed in the color picker.
83    ///
84    /// This is used to display a set of colors that the user can quickly select from,
85    /// for example provided user's last used colors.
86    pub fn featured_colors(mut self, colors: Vec<Hsla>) -> Self {
87        self.featured_colors = Some(colors);
88        self
89    }
90
91    /// Set the icon to the color picker button.
92    ///
93    /// If this is set the color picker button will display the icon.
94    /// Else it will display the square color of the current value.
95    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
96        self.icon = Some(icon.into());
97        self
98    }
99
100    /// Set the label to be displayed above the color picker.
101    ///
102    /// Default is `None`.
103    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
104        self.label = Some(label.into());
105        self
106    }
107
108    /// Set the name a screen reader announces, when the visible label is not
109    /// it.
110    ///
111    /// A color picker's name comes from its [`label`](Self::label) by default.
112    /// Setting this replaces the announced name without changing the visible
113    /// label.
114    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
115        self.accessibility_label = Some(label.into());
116        self
117    }
118
119    /// Set the anchor corner of the color picker.
120    ///
121    /// Default is `Anchor::TopLeft`.
122    pub fn anchor(mut self, anchor: Anchor) -> Self {
123        self.anchor = anchor;
124        self
125    }
126
127    fn render_item(&self, id: impl Into<ElementId>, color: Hsla, cx: &mut App) -> ColorSwatch {
128        let selected = self.state.read(cx).value() == Some(color);
129        let hover_state = self.state.clone();
130        let click_state = self.state.clone();
131
132        ColorSwatch::new(id, color)
133            .selected(selected)
134            .h_5()
135            .w_5()
136            .bg(color)
137            .border_1()
138            .border_color(color.darken(0.1))
139            .hover(|this| this.border_color(color.darken(0.3)).bg(color.lighten(0.1)))
140            .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
141            .on_hover(move |color, entered, window, cx| {
142                if entered {
143                    hover_state.update(cx, |state, cx| state.preview_color(color, window, cx));
144                }
145            })
146            .on_click(move |color, _, window, cx| {
147                click_state.update(cx, |state, cx| state.select_color(color, window, cx));
148            })
149    }
150
151    fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
152        self.state
153            .update(cx, |state, cx| state.sync_pending_value(window, cx));
154
155        let active_tab = self.state.read(cx).active_tab();
156        let (slider_color, hovered_color) = {
157            let state = self.state.read(cx);
158            let slider_color = state
159                .displayed_color()
160                .unwrap_or_else(|| hsla(0., 0., 0., 1.));
161            (slider_color, state.preview())
162        };
163        let tab_state = self.state.clone();
164
165        v_flex()
166            .p_0p5()
167            .gap_3()
168            .child(
169                TabBar::new("mode")
170                    .segmented()
171                    .selected_index(active_tab)
172                    .on_click(move |ix: &usize, _, cx| {
173                        tab_state.update(cx, |state, cx| state.set_active_tab(*ix, cx));
174                    })
175                    .child(Tab::new().flex_1().label(t!("ColorPicker.Palette")))
176                    .child(Tab::new().flex_1().label(t!("ColorPicker.HSLA"))),
177            )
178            .child(match active_tab {
179                0 => self.render_palette_panel(cx).into_any_element(),
180                _ => self
181                    .render_slider_tab_panel(slider_color, cx)
182                    .into_any_element(),
183            })
184            .when_some(hovered_color, |this, hovered_color| {
185                this.child(Separator::horizontal()).child(
186                    h_flex()
187                        .gap_2()
188                        .items_center()
189                        .child(
190                            div()
191                                .bg(hovered_color)
192                                .flex_shrink_0()
193                                .border_1()
194                                .border_color(hovered_color.darken(0.2))
195                                .size_5()
196                                .rounded(cx.theme().radius),
197                        )
198                        .child(Input::new(self.state.read(cx).hex_input()).small().px_2p5()),
199                )
200            })
201    }
202
203    fn render_palette_panel(&self, cx: &mut App) -> impl IntoElement {
204        let featured_colors = self.featured_colors.clone().unwrap_or(vec![
205            cx.theme().red,
206            cx.theme().red_light,
207            cx.theme().blue,
208            cx.theme().blue_light,
209            cx.theme().green,
210            cx.theme().green_light,
211            cx.theme().yellow,
212            cx.theme().yellow_light,
213            cx.theme().cyan,
214            cx.theme().cyan_light,
215            cx.theme().magenta,
216            cx.theme().magenta_light,
217        ]);
218
219        v_flex()
220            .gap_3()
221            .child(
222                h_flex().gap_1().children(
223                    featured_colors
224                        .iter()
225                        // Featured slots may contain the same color more than once.
226                        .enumerate()
227                        .map(|(ix, color)| self.render_item(("featured-color", ix), *color, cx)),
228                ),
229            )
230            .child(Separator::horizontal())
231            .child(
232                v_flex()
233                    .gap_1()
234                    .children(color_palettes().iter().enumerate().map(|(ix, sub_colors)| {
235                        h_flex().id(("palette-row", ix)).gap_1().children(
236                            sub_colors.iter().rev().map(|color| {
237                                self.render_item(
238                                    SharedString::from(format!("color-{}", color.to_hex())),
239                                    *color,
240                                    cx,
241                                )
242                            }),
243                        )
244                    })),
245            )
246    }
247
248    fn render_slider_tab_panel(&self, slider_color: Hsla, cx: &mut App) -> impl IntoElement {
249        let sliders = self.state.read(cx).sliders().clone();
250        let steps = 96usize;
251        let hue_colors = (0..steps)
252            .map(|ix| {
253                let h = ix as f32 / (steps.saturating_sub(1)) as f32;
254                hsla(h, 1.0, 0.5, 1.0)
255            })
256            .collect::<Vec<_>>();
257        let saturation_start = hsla(slider_color.h, 0.0, slider_color.l, 1.0);
258        let saturation_end = hsla(slider_color.h, 1.0, slider_color.l, 1.0);
259        let lightness_colors = (0..steps)
260            .map(|ix| {
261                let l = ix as f32 / (steps.saturating_sub(1)) as f32;
262                hsla(slider_color.h, 1.0, l, 1.0)
263            })
264            .collect::<Vec<_>>();
265        let alpha_start = hsla(slider_color.h, slider_color.s, slider_color.l, 0.0);
266        let alpha_end = hsla(slider_color.h, slider_color.s, slider_color.l, 1.0);
267
268        let label_color = cx.theme().foreground.opacity(0.7);
269
270        v_flex()
271            .gap_2()
272            .child(
273                h_flex()
274                    .gap_2()
275                    .items_center()
276                    .child(
277                        div()
278                            .min_w_16()
279                            .text_xs()
280                            .text_color(label_color)
281                            .child(t!("ColorPicker.Hue")),
282                    )
283                    .child(
284                        div()
285                            .relative()
286                            .flex()
287                            .items_center()
288                            .flex_1()
289                            .h_8()
290                            .child(self.render_slider_track(hue_colors, cx))
291                            .child(
292                                Slider::new(sliders.hue())
293                                    .flex_1()
294                                    .bg(cx.theme().transparent),
295                            ),
296                    )
297                    .child(
298                        div()
299                            .w_10()
300                            .text_xs()
301                            .text_color(label_color)
302                            .text_align(TextAlign::Right)
303                            .child(format!("{:.0}", slider_color.h * 360.)),
304                    ),
305            )
306            .child(
307                h_flex()
308                    .gap_2()
309                    .items_center()
310                    .child(
311                        div()
312                            .min_w_16()
313                            .text_xs()
314                            .text_color(label_color)
315                            .child(t!("ColorPicker.Saturation")),
316                    )
317                    .child(
318                        div()
319                            .relative()
320                            .flex()
321                            .items_center()
322                            .flex_1()
323                            .h_8()
324                            .child(self.render_slider_track_gradient(
325                                saturation_start,
326                                saturation_end,
327                                cx,
328                            ))
329                            .child(
330                                Slider::new(sliders.saturation())
331                                    .flex_1()
332                                    .bg(cx.theme().transparent),
333                            ),
334                    )
335                    .child(
336                        div()
337                            .w_10()
338                            .text_xs()
339                            .text_color(label_color)
340                            .text_align(TextAlign::Right)
341                            .child(format!("{:.0}", slider_color.s * 100.)),
342                    ),
343            )
344            .child(
345                h_flex()
346                    .gap_2()
347                    .items_center()
348                    .child(
349                        div()
350                            .min_w_16()
351                            .text_xs()
352                            .text_color(label_color)
353                            .child(t!("ColorPicker.Lightness")),
354                    )
355                    .child(
356                        div()
357                            .relative()
358                            .flex()
359                            .items_center()
360                            .flex_1()
361                            .h_8()
362                            .child(self.render_slider_track(lightness_colors, cx))
363                            .child(
364                                Slider::new(sliders.lightness())
365                                    .flex_1()
366                                    .bg(cx.theme().transparent),
367                            ),
368                    )
369                    .child(
370                        div()
371                            .w_10()
372                            .text_xs()
373                            .text_color(label_color)
374                            .text_align(TextAlign::Right)
375                            .child(format!("{:.0}", slider_color.l * 100.)),
376                    ),
377            )
378            .child(
379                h_flex()
380                    .gap_2()
381                    .items_center()
382                    .child(
383                        div()
384                            .min_w_16()
385                            .text_xs()
386                            .text_color(label_color)
387                            .child(t!("ColorPicker.Alpha")),
388                    )
389                    .child(
390                        div()
391                            .relative()
392                            .flex()
393                            .items_center()
394                            .flex_1()
395                            .h_8()
396                            .child(self.render_slider_track_gradient(alpha_start, alpha_end, cx))
397                            .child(
398                                Slider::new(sliders.alpha())
399                                    .flex_1()
400                                    .bg(cx.theme().transparent),
401                            ),
402                    )
403                    .child(
404                        div()
405                            .w_10()
406                            .text_xs()
407                            .text_color(label_color)
408                            .text_align(TextAlign::Right)
409                            .child(format!("{:.0}", slider_color.a * 100.)),
410                    ),
411            )
412    }
413
414    fn render_slider_track(&self, colors: Vec<Hsla>, _: &App) -> impl IntoElement {
415        h_flex()
416            .absolute()
417            .left_0()
418            .right_0()
419            .h_2_5()
420            .overflow_hidden()
421            .children(
422                colors
423                    .into_iter()
424                    .map(|color| div().flex_1().h_full().bg(color)),
425            )
426    }
427
428    fn render_slider_track_gradient(&self, start: Hsla, end: Hsla, _: &App) -> impl IntoElement {
429        div()
430            .absolute()
431            .left_0()
432            .right_0()
433            .h_2_5()
434            .overflow_hidden()
435            .bg(linear_gradient(
436                90.,
437                linear_color_stop(start, 0.),
438                linear_color_stop(end, 1.),
439            ))
440    }
441}
442
443impl Sizable for ColorPicker {
444    fn with_size(mut self, size: impl Into<Size>) -> Self {
445        self.size = size.into();
446        self
447    }
448}
449
450impl Focusable for ColorPicker {
451    fn focus_handle(&self, cx: &App) -> FocusHandle {
452        self.state.focus_handle(cx)
453    }
454}
455
456impl Styled for ColorPicker {
457    fn style(&mut self) -> &mut StyleRefinement {
458        &mut self.style
459    }
460}
461
462impl RenderOnce for ColorPicker {
463    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
464        let state = self.state.read(cx);
465        let display_title: SharedString = if let Some(value) = state.value() {
466            value.to_hex()
467        } else {
468            "".to_string()
469        }
470        .into();
471
472        let open = state.is_open();
473        let value = state.value();
474        let focus_handle = self.state.focus_handle(cx);
475        let open_state = self.state.clone();
476        let popover_state = self.state.clone();
477
478        BaseColorPicker::new(self.id.clone())
479            .open(open)
480            .track_focus(&focus_handle)
481            .when_some(
482                self.accessibility_label
483                    .clone()
484                    .or_else(|| self.label.clone()),
485                |this, label| this.accessibility_label(label),
486            )
487            .on_open_change(move |open, _, cx| {
488                open_state.update(cx, |state, cx| state.set_open(open, cx));
489            })
490            .child(
491                Popover::new("popover")
492                    .open(open)
493                    .w_72()
494                    .on_open_change(move |open: &bool, _, cx| {
495                        popover_state.update(cx, |state, cx| state.set_open(*open, cx));
496                    })
497                    .trigger(ColorPickerButton {
498                        id: "trigger".into(),
499                        size: self.size,
500                        label: self.label.clone(),
501                        value,
502                        tooltip: if display_title.is_empty() {
503                            None
504                        } else {
505                            Some(display_title.clone())
506                        },
507                        icon: self.icon.clone(),
508                        selected: false,
509                    })
510                    .child(self.render_colors(window, cx)),
511            )
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use gpui::{AppContext as _, Context, Render, TestAppContext};
518
519    use super::*;
520
521    struct PaletteHarness {
522        state: Entity<ColorPickerState>,
523    }
524
525    impl Render for PaletteHarness {
526        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
527            let color = color_palettes()[0][0];
528            ColorPicker::new(&self.state)
529                .featured_colors(vec![color, color])
530                .render_palette_panel(cx)
531                .into_any_element()
532        }
533    }
534
535    #[gpui::test]
536    fn repeated_palette_colors_have_independent_focus_stops(cx: &mut TestAppContext) {
537        cx.update(crate::init);
538        let (_, cx) = cx.add_window_view(|window, cx| PaletteHarness {
539            state: cx.new(|cx| ColorPickerState::new(window, cx)),
540        });
541        cx.update(|window, cx| {
542            window.draw(cx).clear(cx);
543            let swatch_count = 2 + color_palettes().iter().map(Vec::len).sum::<usize>();
544            let mut focused = Vec::new();
545            for _ in 0..swatch_count {
546                window.focus_next(cx);
547                let handle = window.focused(cx).expect("each swatch is focusable");
548                assert!(
549                    !focused.contains(&handle),
550                    "equal colors must not share element identity or a focus stop"
551                );
552                focused.push(handle);
553            }
554            window.focus_next(cx);
555            assert_eq!(window.focused(cx), focused.first().cloned());
556        });
557    }
558
559    #[gpui::test]
560    fn an_explicit_accessibility_label_replaces_the_visible_one(cx: &mut TestAppContext) {
561        cx.update(crate::init);
562        let cx = cx.add_empty_window();
563        cx.update(|window, cx| {
564            let state = cx.new(|cx| ColorPickerState::new(window, cx));
565
566            let plain = ColorPicker::new(&state).label("Color");
567            assert_eq!(plain.accessibility_label, None);
568            assert_eq!(plain.label.as_deref(), Some("Color"));
569
570            let named = ColorPicker::new(&state)
571                .label("Color")
572                .accessibility_label("Text color");
573            assert_eq!(
574                named.accessibility_label.as_deref(),
575                Some("Text color"),
576                "an explicit name must win over the visible label"
577            );
578            assert_eq!(
579                named.label.as_deref(),
580                Some("Color"),
581                "and must not change what is drawn"
582            );
583        });
584    }
585}
586
587#[derive(IntoElement)]
588struct ColorPickerButton {
589    id: ElementId,
590    selected: bool,
591    icon: Option<Icon>,
592    value: Option<Hsla>,
593    size: Size,
594    label: Option<SharedString>,
595    tooltip: Option<SharedString>,
596}
597
598impl Selectable for ColorPickerButton {
599    fn selected(mut self, selected: bool) -> Self {
600        self.selected = selected;
601        self
602    }
603
604    fn is_selected(&self) -> bool {
605        self.selected
606    }
607}
608
609impl Sizable for ColorPickerButton {
610    fn with_size(mut self, size: impl Into<Size>) -> Self {
611        self.size = size.into();
612        self
613    }
614}
615
616impl RenderOnce for ColorPickerButton {
617    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
618        let has_icon = self.icon.is_some();
619        h_flex()
620            .id(self.id)
621            .gap_2()
622            .children(self.icon)
623            .when(!has_icon, |this| {
624                this.child(
625                    div()
626                        .id("square")
627                        .bg(cx.theme().tokens.background)
628                        .border_1()
629                        .border_color(cx.theme().input)
630                        .rounded(cx.theme().radius)
631                        .overflow_hidden()
632                        .size_with(self.size)
633                        .when_some(self.value, |this, value| {
634                            this.bg(value)
635                                .border_color(value.darken(0.3))
636                                .when(self.selected, |this| this.border_2())
637                        })
638                        .when_some(self.tooltip, |this, tooltip| {
639                            this.managed_tooltip(move |window, cx| {
640                                Tooltip::new(tooltip.clone()).build(window, cx)
641                            })
642                        }),
643                )
644            })
645            .when_some(self.label, |this, label| this.child(label))
646    }
647}