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, 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(
133            SharedString::from(format!("color-{}", color.to_hex())),
134            color,
135        )
136        .selected(selected)
137        .h_5()
138        .w_5()
139        .bg(color)
140        .border_1()
141        .border_color(color.darken(0.1))
142        .hover(|this| this.border_color(color.darken(0.3)).bg(color.lighten(0.1)))
143        .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
144        .on_hover(move |color, entered, window, cx| {
145            if entered {
146                hover_state.update(cx, |state, cx| state.preview_color(color, window, cx));
147            }
148        })
149        .on_click(move |color, _, window, cx| {
150            click_state.update(cx, |state, cx| state.select_color(color, window, cx));
151        })
152    }
153
154    fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
155        self.state
156            .update(cx, |state, cx| state.sync_pending_value(window, cx));
157
158        let active_tab = self.state.read(cx).active_tab();
159        let (slider_color, hovered_color) = {
160            let state = self.state.read(cx);
161            let slider_color = state
162                .displayed_color()
163                .unwrap_or_else(|| hsla(0., 0., 0., 1.));
164            (slider_color, state.preview())
165        };
166        let tab_state = self.state.clone();
167
168        v_flex()
169            .p_0p5()
170            .gap_3()
171            .child(
172                TabBar::new("mode")
173                    .segmented()
174                    .selected_index(active_tab)
175                    .on_click(move |ix: &usize, _, cx| {
176                        tab_state.update(cx, |state, cx| state.set_active_tab(*ix, cx));
177                    })
178                    .child(Tab::new().flex_1().label(t!("ColorPicker.Palette")))
179                    .child(Tab::new().flex_1().label(t!("ColorPicker.HSLA"))),
180            )
181            .child(match active_tab {
182                0 => self.render_palette_panel(cx).into_any_element(),
183                _ => self
184                    .render_slider_tab_panel(slider_color, cx)
185                    .into_any_element(),
186            })
187            .when_some(hovered_color, |this, hovered_color| {
188                this.child(Separator::horizontal()).child(
189                    h_flex()
190                        .gap_2()
191                        .items_center()
192                        .child(
193                            div()
194                                .bg(hovered_color)
195                                .flex_shrink_0()
196                                .border_1()
197                                .border_color(hovered_color.darken(0.2))
198                                .size_5()
199                                .rounded(cx.theme().radius),
200                        )
201                        .child(Input::new(self.state.read(cx).hex_input()).small().px_2p5()),
202                )
203            })
204    }
205
206    fn render_palette_panel(&self, cx: &mut App) -> impl IntoElement {
207        let featured_colors = self.featured_colors.clone().unwrap_or(vec![
208            cx.theme().red,
209            cx.theme().red_light,
210            cx.theme().blue,
211            cx.theme().blue_light,
212            cx.theme().green,
213            cx.theme().green_light,
214            cx.theme().yellow,
215            cx.theme().yellow_light,
216            cx.theme().cyan,
217            cx.theme().cyan_light,
218            cx.theme().magenta,
219            cx.theme().magenta_light,
220        ]);
221
222        v_flex()
223            .gap_3()
224            .child(
225                h_flex().gap_1().children(
226                    featured_colors
227                        .iter()
228                        .map(|color| self.render_item(*color, cx)),
229                ),
230            )
231            .child(Separator::horizontal())
232            .child(
233                v_flex()
234                    .gap_1()
235                    .children(color_palettes().iter().map(|sub_colors| {
236                        h_flex().gap_1().children(
237                            sub_colors
238                                .iter()
239                                .rev()
240                                .map(|color| self.render_item(*color, cx)),
241                        )
242                    })),
243            )
244    }
245
246    fn render_slider_tab_panel(&self, slider_color: Hsla, cx: &mut App) -> impl IntoElement {
247        let sliders = self.state.read(cx).sliders().clone();
248        let steps = 96usize;
249        let hue_colors = (0..steps)
250            .map(|ix| {
251                let h = ix as f32 / (steps.saturating_sub(1)) as f32;
252                hsla(h, 1.0, 0.5, 1.0)
253            })
254            .collect::<Vec<_>>();
255        let saturation_start = hsla(slider_color.h, 0.0, slider_color.l, 1.0);
256        let saturation_end = hsla(slider_color.h, 1.0, slider_color.l, 1.0);
257        let lightness_colors = (0..steps)
258            .map(|ix| {
259                let l = ix as f32 / (steps.saturating_sub(1)) as f32;
260                hsla(slider_color.h, 1.0, l, 1.0)
261            })
262            .collect::<Vec<_>>();
263        let alpha_start = hsla(slider_color.h, slider_color.s, slider_color.l, 0.0);
264        let alpha_end = hsla(slider_color.h, slider_color.s, slider_color.l, 1.0);
265
266        let label_color = cx.theme().foreground.opacity(0.7);
267
268        v_flex()
269            .gap_2()
270            .child(
271                h_flex()
272                    .gap_2()
273                    .items_center()
274                    .child(
275                        div()
276                            .min_w_16()
277                            .text_xs()
278                            .text_color(label_color)
279                            .child(t!("ColorPicker.Hue")),
280                    )
281                    .child(
282                        div()
283                            .relative()
284                            .flex()
285                            .items_center()
286                            .flex_1()
287                            .h_8()
288                            .child(self.render_slider_track(hue_colors, cx))
289                            .child(
290                                Slider::new(sliders.hue())
291                                    .flex_1()
292                                    .bg(cx.theme().transparent),
293                            ),
294                    )
295                    .child(
296                        div()
297                            .w_10()
298                            .text_xs()
299                            .text_color(label_color)
300                            .text_align(TextAlign::Right)
301                            .child(format!("{:.0}", slider_color.h * 360.)),
302                    ),
303            )
304            .child(
305                h_flex()
306                    .gap_2()
307                    .items_center()
308                    .child(
309                        div()
310                            .min_w_16()
311                            .text_xs()
312                            .text_color(label_color)
313                            .child(t!("ColorPicker.Saturation")),
314                    )
315                    .child(
316                        div()
317                            .relative()
318                            .flex()
319                            .items_center()
320                            .flex_1()
321                            .h_8()
322                            .child(self.render_slider_track_gradient(
323                                saturation_start,
324                                saturation_end,
325                                cx,
326                            ))
327                            .child(
328                                Slider::new(sliders.saturation())
329                                    .flex_1()
330                                    .bg(cx.theme().transparent),
331                            ),
332                    )
333                    .child(
334                        div()
335                            .w_10()
336                            .text_xs()
337                            .text_color(label_color)
338                            .text_align(TextAlign::Right)
339                            .child(format!("{:.0}", slider_color.s * 100.)),
340                    ),
341            )
342            .child(
343                h_flex()
344                    .gap_2()
345                    .items_center()
346                    .child(
347                        div()
348                            .min_w_16()
349                            .text_xs()
350                            .text_color(label_color)
351                            .child(t!("ColorPicker.Lightness")),
352                    )
353                    .child(
354                        div()
355                            .relative()
356                            .flex()
357                            .items_center()
358                            .flex_1()
359                            .h_8()
360                            .child(self.render_slider_track(lightness_colors, cx))
361                            .child(
362                                Slider::new(sliders.lightness())
363                                    .flex_1()
364                                    .bg(cx.theme().transparent),
365                            ),
366                    )
367                    .child(
368                        div()
369                            .w_10()
370                            .text_xs()
371                            .text_color(label_color)
372                            .text_align(TextAlign::Right)
373                            .child(format!("{:.0}", slider_color.l * 100.)),
374                    ),
375            )
376            .child(
377                h_flex()
378                    .gap_2()
379                    .items_center()
380                    .child(
381                        div()
382                            .min_w_16()
383                            .text_xs()
384                            .text_color(label_color)
385                            .child(t!("ColorPicker.Alpha")),
386                    )
387                    .child(
388                        div()
389                            .relative()
390                            .flex()
391                            .items_center()
392                            .flex_1()
393                            .h_8()
394                            .child(self.render_slider_track_gradient(alpha_start, alpha_end, cx))
395                            .child(
396                                Slider::new(sliders.alpha())
397                                    .flex_1()
398                                    .bg(cx.theme().transparent),
399                            ),
400                    )
401                    .child(
402                        div()
403                            .w_10()
404                            .text_xs()
405                            .text_color(label_color)
406                            .text_align(TextAlign::Right)
407                            .child(format!("{:.0}", slider_color.a * 100.)),
408                    ),
409            )
410    }
411
412    fn render_slider_track(&self, colors: Vec<Hsla>, _: &App) -> impl IntoElement {
413        h_flex()
414            .absolute()
415            .left_0()
416            .right_0()
417            .h_2_5()
418            .overflow_hidden()
419            .children(
420                colors
421                    .into_iter()
422                    .map(|color| div().flex_1().h_full().bg(color)),
423            )
424    }
425
426    fn render_slider_track_gradient(&self, start: Hsla, end: Hsla, _: &App) -> impl IntoElement {
427        div()
428            .absolute()
429            .left_0()
430            .right_0()
431            .h_2_5()
432            .overflow_hidden()
433            .bg(linear_gradient(
434                90.,
435                linear_color_stop(start, 0.),
436                linear_color_stop(end, 1.),
437            ))
438    }
439}
440
441impl Sizable for ColorPicker {
442    fn with_size(mut self, size: impl Into<Size>) -> Self {
443        self.size = size.into();
444        self
445    }
446}
447
448impl Focusable for ColorPicker {
449    fn focus_handle(&self, cx: &App) -> FocusHandle {
450        self.state.focus_handle(cx)
451    }
452}
453
454impl Styled for ColorPicker {
455    fn style(&mut self) -> &mut StyleRefinement {
456        &mut self.style
457    }
458}
459
460impl RenderOnce for ColorPicker {
461    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
462        let state = self.state.read(cx);
463        let display_title: SharedString = if let Some(value) = state.value() {
464            value.to_hex()
465        } else {
466            "".to_string()
467        }
468        .into();
469
470        let open = state.is_open();
471        let value = state.value();
472        let focus_handle = self.state.focus_handle(cx);
473        let open_state = self.state.clone();
474        let popover_state = self.state.clone();
475
476        BaseColorPicker::new(self.id.clone())
477            .open(open)
478            .track_focus(&focus_handle)
479            .when_some(
480                self.accessibility_label
481                    .clone()
482                    .or_else(|| self.label.clone()),
483                |this, label| this.accessibility_label(label),
484            )
485            .on_open_change(move |open, _, cx| {
486                open_state.update(cx, |state, cx| state.set_open(open, cx));
487            })
488            .child(
489                Popover::new("popover")
490                    .open(open)
491                    .w_72()
492                    .on_open_change(move |open: &bool, _, cx| {
493                        popover_state.update(cx, |state, cx| state.set_open(*open, cx));
494                    })
495                    .trigger(ColorPickerButton {
496                        id: "trigger".into(),
497                        size: self.size,
498                        label: self.label.clone(),
499                        value,
500                        tooltip: if display_title.is_empty() {
501                            None
502                        } else {
503                            Some(display_title.clone())
504                        },
505                        icon: self.icon.clone(),
506                        selected: false,
507                    })
508                    .child(self.render_colors(window, cx)),
509            )
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use gpui::{AppContext as _, TestAppContext};
516
517    use super::*;
518
519    #[gpui::test]
520    fn an_explicit_accessibility_label_replaces_the_visible_one(cx: &mut TestAppContext) {
521        cx.update(crate::init);
522        let cx = cx.add_empty_window();
523        cx.update(|window, cx| {
524            let state = cx.new(|cx| ColorPickerState::new(window, cx));
525
526            let plain = ColorPicker::new(&state).label("Color");
527            assert_eq!(plain.accessibility_label, None);
528            assert_eq!(plain.label.as_deref(), Some("Color"));
529
530            let named = ColorPicker::new(&state)
531                .label("Color")
532                .accessibility_label("Text color");
533            assert_eq!(
534                named.accessibility_label.as_deref(),
535                Some("Text color"),
536                "an explicit name must win over the visible label"
537            );
538            assert_eq!(
539                named.label.as_deref(),
540                Some("Color"),
541                "and must not change what is drawn"
542            );
543        });
544    }
545}
546
547#[derive(IntoElement)]
548struct ColorPickerButton {
549    id: ElementId,
550    selected: bool,
551    icon: Option<Icon>,
552    value: Option<Hsla>,
553    size: Size,
554    label: Option<SharedString>,
555    tooltip: Option<SharedString>,
556}
557
558impl Selectable for ColorPickerButton {
559    fn selected(mut self, selected: bool) -> Self {
560        self.selected = selected;
561        self
562    }
563
564    fn is_selected(&self) -> bool {
565        self.selected
566    }
567}
568
569impl Sizable for ColorPickerButton {
570    fn with_size(mut self, size: impl Into<Size>) -> Self {
571        self.size = size.into();
572        self
573    }
574}
575
576impl RenderOnce for ColorPickerButton {
577    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
578        let has_icon = self.icon.is_some();
579        h_flex()
580            .id(self.id)
581            .gap_2()
582            .children(self.icon)
583            .when(!has_icon, |this| {
584                this.child(
585                    div()
586                        .id("square")
587                        .bg(cx.theme().tokens.background)
588                        .border_1()
589                        .border_color(cx.theme().input)
590                        .rounded(cx.theme().radius)
591                        .overflow_hidden()
592                        .size_with(self.size)
593                        .when_some(self.value, |this, value| {
594                            this.bg(value)
595                                .border_color(value.darken(0.3))
596                                .when(self.selected, |this| this.border_2())
597                        })
598                        .when_some(self.tooltip, |this, tooltip| {
599                            this.managed_tooltip(move |window, cx| {
600                                Tooltip::new(tooltip.clone()).build(window, cx)
601                            })
602                        }),
603                )
604            })
605            .when_some(self.label, |this, label| this.child(label))
606    }
607}