gpui_component/
color_picker.rs

1use gpui::{
2    anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, relative, App, AppContext,
3    Bounds, ClickEvent, Context, Corner, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
4    Hsla, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels,
5    Point, Render, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
6    Styled, Subscription, Window,
7};
8
9use crate::{
10    actions::{Cancel, Confirm},
11    button::{Button, ButtonVariants},
12    divider::Divider,
13    h_flex,
14    input::{Input, InputEvent, InputState},
15    tooltip::Tooltip,
16    v_flex, ActiveTheme as _, Colorize as _, FocusableExt as _, Icon, Selectable as _, Sizable,
17    Size, StyleSized, StyledExt,
18};
19
20const CONTEXT: &'static str = "ColorPicker";
21pub fn init(cx: &mut App) {
22    cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
23}
24
25/// Events emitted by the [`ColorPicker`].
26#[derive(Clone)]
27pub enum ColorPickerEvent {
28    Change(Option<Hsla>),
29}
30
31fn color_palettes() -> Vec<Vec<Hsla>> {
32    use crate::theme::DEFAULT_COLORS;
33    use itertools::Itertools as _;
34
35    macro_rules! c {
36        ($color:tt) => {
37            DEFAULT_COLORS
38                .$color
39                .keys()
40                .sorted()
41                .map(|k| DEFAULT_COLORS.$color.get(k).map(|c| c.hsla).unwrap())
42                .collect::<Vec<_>>()
43        };
44    }
45
46    vec![
47        c!(stone),
48        c!(red),
49        c!(orange),
50        c!(yellow),
51        c!(green),
52        c!(cyan),
53        c!(blue),
54        c!(purple),
55        c!(pink),
56    ]
57}
58
59/// State of the [`ColorPicker`].
60pub struct ColorPickerState {
61    focus_handle: FocusHandle,
62    value: Option<Hsla>,
63    hovered_color: Option<Hsla>,
64    state: Entity<InputState>,
65    open: bool,
66    bounds: Bounds<Pixels>,
67    _subscriptions: Vec<Subscription>,
68}
69
70impl ColorPickerState {
71    /// Create a new [`ColorPickerState`].
72    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
73        let state = cx.new(|cx| InputState::new(window, cx));
74
75        let _subscriptions = vec![cx.subscribe_in(
76            &state,
77            window,
78            |this, state, ev: &InputEvent, window, cx| match ev {
79                InputEvent::Change => {
80                    let value = state.read(cx).value();
81                    if let Ok(color) = Hsla::parse_hex(value.as_str()) {
82                        this.value = Some(color);
83                        this.hovered_color = Some(color);
84                    }
85                }
86                InputEvent::PressEnter { .. } => {
87                    let val = this.state.read(cx).value();
88                    if let Ok(color) = Hsla::parse_hex(&val) {
89                        this.open = false;
90                        this.update_value(Some(color), true, window, cx);
91                    }
92                }
93                _ => {}
94            },
95        )];
96
97        Self {
98            focus_handle: cx.focus_handle(),
99            value: None,
100            hovered_color: None,
101            state,
102            open: false,
103            bounds: Bounds::default(),
104            _subscriptions,
105        }
106    }
107
108    /// Set default color value.
109    pub fn default_value(mut self, value: impl Into<Hsla>) -> Self {
110        self.value = Some(value.into());
111        self
112    }
113
114    /// Set current color value.
115    pub fn set_value(
116        &mut self,
117        value: impl Into<Hsla>,
118        window: &mut Window,
119        cx: &mut Context<Self>,
120    ) {
121        self.update_value(Some(value.into()), false, window, cx)
122    }
123
124    /// Get current color value.
125    pub fn value(&self) -> Option<Hsla> {
126        self.value
127    }
128
129    fn on_escape(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context<Self>) {
130        if !self.open {
131            cx.propagate();
132        }
133
134        self.open = false;
135        cx.notify();
136    }
137
138    fn on_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
139        self.open = !self.open;
140        cx.notify();
141    }
142
143    fn toggle_picker(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
144        self.open = !self.open;
145        cx.notify();
146    }
147
148    fn update_value(
149        &mut self,
150        value: Option<Hsla>,
151        emit: bool,
152        window: &mut Window,
153        cx: &mut Context<Self>,
154    ) {
155        self.value = value;
156        self.hovered_color = value;
157        self.state.update(cx, |view, cx| {
158            if let Some(value) = value {
159                view.set_value(value.to_hex(), window, cx);
160            } else {
161                view.set_value("", window, cx);
162            }
163        });
164        if emit {
165            cx.emit(ColorPickerEvent::Change(value));
166        }
167        cx.notify();
168    }
169}
170
171impl EventEmitter<ColorPickerEvent> for ColorPickerState {}
172
173impl Render for ColorPickerState {
174    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
175        self.state.clone()
176    }
177}
178
179impl Focusable for ColorPickerState {
180    fn focus_handle(&self, _: &App) -> FocusHandle {
181        self.focus_handle.clone()
182    }
183}
184
185/// A color picker element.
186#[derive(IntoElement)]
187pub struct ColorPicker {
188    id: ElementId,
189    style: StyleRefinement,
190    state: Entity<ColorPickerState>,
191    featured_colors: Option<Vec<Hsla>>,
192    label: Option<SharedString>,
193    icon: Option<Icon>,
194    size: Size,
195    anchor: Corner,
196}
197
198impl ColorPicker {
199    /// Create a new color picker element with the given [`ColorPickerState`].
200    pub fn new(state: &Entity<ColorPickerState>) -> Self {
201        Self {
202            id: ("color-picker", state.entity_id()).into(),
203            style: StyleRefinement::default(),
204            state: state.clone(),
205            featured_colors: None,
206            size: Size::Medium,
207            label: None,
208            icon: None,
209            anchor: Corner::TopLeft,
210        }
211    }
212
213    /// Set the featured colors to be displayed in the color picker.
214    ///
215    /// This is used to display a set of colors that the user can quickly select from,
216    /// for example provided user's last used colors.
217    pub fn featured_colors(mut self, colors: Vec<Hsla>) -> Self {
218        self.featured_colors = Some(colors);
219        self
220    }
221
222    /// Set the icon to the color picker button.
223    ///
224    /// If this is set the color picker button will display the icon.
225    /// Else it will display the square color of the current value.
226    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
227        self.icon = Some(icon.into());
228        self
229    }
230
231    /// Set the label to be displayed above the color picker.
232    ///
233    /// Default is `None`.
234    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
235        self.label = Some(label.into());
236        self
237    }
238
239    /// Set the anchor corner of the color picker.
240    ///
241    /// Default is `Corner::TopLeft`.
242    pub fn anchor(mut self, anchor: Corner) -> Self {
243        self.anchor = anchor;
244        self
245    }
246
247    fn render_item(
248        &self,
249        color: Hsla,
250        clickable: bool,
251        window: &mut Window,
252        _: &mut App,
253    ) -> impl IntoElement {
254        let state = self.state.clone();
255        div()
256            .id(SharedString::from(format!("color-{}", color.to_hex())))
257            .h_5()
258            .w_5()
259            .bg(color)
260            .border_1()
261            .border_color(color.darken(0.1))
262            .when(clickable, |this| {
263                this.hover(|this| {
264                    this.border_color(color.darken(0.3))
265                        .bg(color.lighten(0.1))
266                        .shadow_xs()
267                })
268                .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
269                .on_mouse_move(window.listener_for(&state, move |state, _, window, cx| {
270                    state.hovered_color = Some(color);
271                    state.state.update(cx, |input, cx| {
272                        input.set_value(color.to_hex(), window, cx);
273                    });
274                    cx.notify();
275                }))
276                .on_click(window.listener_for(
277                    &state,
278                    move |state, _, window, cx| {
279                        state.update_value(Some(color), true, window, cx);
280                        state.open = false;
281                        cx.notify();
282                    },
283                ))
284            })
285    }
286
287    fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
288        let featured_colors = self.featured_colors.clone().unwrap_or(vec![
289            cx.theme().red,
290            cx.theme().red_light,
291            cx.theme().blue,
292            cx.theme().blue_light,
293            cx.theme().green,
294            cx.theme().green_light,
295            cx.theme().yellow,
296            cx.theme().yellow_light,
297            cx.theme().cyan,
298            cx.theme().cyan_light,
299            cx.theme().magenta,
300            cx.theme().magenta_light,
301        ]);
302
303        let state = self.state.clone();
304        v_flex()
305            .gap_3()
306            .child(
307                h_flex().gap_1().children(
308                    featured_colors
309                        .iter()
310                        .map(|color| self.render_item(*color, true, window, cx)),
311                ),
312            )
313            .child(Divider::horizontal())
314            .child(
315                v_flex()
316                    .gap_1()
317                    .children(color_palettes().iter().map(|sub_colors| {
318                        h_flex().gap_1().children(
319                            sub_colors
320                                .iter()
321                                .rev()
322                                .map(|color| self.render_item(*color, true, window, cx)),
323                        )
324                    })),
325            )
326            .when_some(state.read(cx).hovered_color, |this, hovered_color| {
327                this.child(Divider::horizontal()).child(
328                    h_flex()
329                        .gap_2()
330                        .items_center()
331                        .child(
332                            div()
333                                .bg(hovered_color)
334                                .flex_shrink_0()
335                                .border_1()
336                                .border_color(hovered_color.darken(0.2))
337                                .size_5()
338                                .rounded(cx.theme().radius),
339                        )
340                        .child(Input::new(&state.read(cx).state).small()),
341                )
342            })
343    }
344
345    fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
346        bounds.corner(match self.anchor {
347            Corner::TopLeft => Corner::BottomLeft,
348            Corner::TopRight => Corner::BottomRight,
349            Corner::BottomLeft => Corner::TopLeft,
350            Corner::BottomRight => Corner::TopRight,
351        })
352    }
353}
354
355impl Sizable for ColorPicker {
356    fn with_size(mut self, size: impl Into<Size>) -> Self {
357        self.size = size.into();
358        self
359    }
360}
361
362impl Focusable for ColorPicker {
363    fn focus_handle(&self, cx: &App) -> FocusHandle {
364        self.state.read(cx).focus_handle.clone()
365    }
366}
367
368impl Styled for ColorPicker {
369    fn style(&mut self) -> &mut StyleRefinement {
370        &mut self.style
371    }
372}
373
374impl RenderOnce for ColorPicker {
375    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
376        let state = self.state.read(cx);
377        let bounds = state.bounds;
378        let display_title: SharedString = if let Some(value) = state.value {
379            value.to_hex()
380        } else {
381            "".to_string()
382        }
383        .into();
384
385        let is_focused = state.focus_handle.is_focused(window);
386        let focus_handle = state.focus_handle.clone().tab_stop(true);
387
388        div()
389            .id(self.id.clone())
390            .key_context(CONTEXT)
391            .track_focus(&focus_handle)
392            .on_action(window.listener_for(&self.state, ColorPickerState::on_escape))
393            .on_action(window.listener_for(&self.state, ColorPickerState::on_confirm))
394            .child(
395                h_flex()
396                    .id("color-picker-input")
397                    .gap_2()
398                    .items_center()
399                    .input_text_size(self.size)
400                    .line_height(relative(1.))
401                    .refine_style(&self.style)
402                    .when_some(self.icon.clone(), |this, icon| {
403                        this.child(
404                            Button::new("btn")
405                                .track_focus(&focus_handle)
406                                .ghost()
407                                .selected(state.open)
408                                .with_size(self.size)
409                                .icon(icon.clone()),
410                        )
411                    })
412                    .when_none(&self.icon, |this| {
413                        this.child(
414                            div()
415                                .id("color-picker-square")
416                                .bg(cx.theme().background)
417                                .border_1()
418                                .m_1()
419                                .border_color(cx.theme().input)
420                                .rounded(cx.theme().radius)
421                                .shadow_xs()
422                                .rounded(cx.theme().radius)
423                                .overflow_hidden()
424                                .size_with(self.size)
425                                .when_some(state.value, |this, value| {
426                                    this.bg(value)
427                                        .border_color(value.darken(0.3))
428                                        .when(state.open, |this| this.border_2())
429                                })
430                                .when(!display_title.is_empty(), |this| {
431                                    this.tooltip(move |_, cx| {
432                                        cx.new(|_| Tooltip::new(display_title.clone())).into()
433                                    })
434                                }),
435                        )
436                        .focus_ring(is_focused, px(0.), window, cx)
437                    })
438                    .when_some(self.label.clone(), |this, label| this.child(label))
439                    .on_click(window.listener_for(&self.state, ColorPickerState::toggle_picker))
440                    .child(
441                        canvas(
442                            {
443                                let state = self.state.clone();
444                                move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds)
445                            },
446                            |_, _, _, _| {},
447                        )
448                        .absolute()
449                        .size_full(),
450                    ),
451            )
452            .when(state.open, |this| {
453                this.child(
454                    deferred(
455                        anchored()
456                            .anchor(self.anchor)
457                            .snap_to_window_with_margin(px(8.))
458                            .position(self.resolved_corner(bounds))
459                            .child(
460                                div()
461                                    .occlude()
462                                    .map(|this| match self.anchor {
463                                        Corner::TopLeft | Corner::TopRight => this.mt_1p5(),
464                                        Corner::BottomLeft | Corner::BottomRight => this.mb_1p5(),
465                                    })
466                                    .w_72()
467                                    .overflow_hidden()
468                                    .rounded(cx.theme().radius)
469                                    .p_3()
470                                    .border_1()
471                                    .border_color(cx.theme().border)
472                                    .shadow_lg()
473                                    .rounded(cx.theme().radius)
474                                    .bg(cx.theme().background)
475                                    .child(self.render_colors(window, cx))
476                                    .on_mouse_up_out(
477                                        MouseButton::Left,
478                                        window.listener_for(&self.state, |state, _, window, cx| {
479                                            state.on_escape(&Cancel, window, cx)
480                                        }),
481                                    ),
482                            ),
483                    )
484                    .with_priority(1),
485                )
486            })
487    }
488}