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