Skip to main content

guise/input/
colorinput.rs

1//! `ColorInput` — a color picker field (gpui entity).
2//!
3//! A swatch plus an editable hex/CSS text field; clicking the swatch opens a
4//! deferred dropdown with the full theme palette (14 colors x 10 shades).
5//! Typing any [`css`](crate::theme::css)-parsable color — `#40c057`,
6//! `rgb(64, 192, 87)`, `teal` — updates the swatch live. Emits
7//! [`ColorInputEvent`] whenever the value changes.
8//!
9//! ```ignore
10//! let brand = cx.new(|cx| ColorInput::new(cx).label("Brand color").value(rgb(34, 139, 230)));
11//! cx.subscribe(&brand, |_this, _input, event: &ColorInputEvent, _cx| {
12//!     let color: Hsla = event.0;
13//! })
14//! .detach();
15//! ```
16
17use gpui::prelude::*;
18use gpui::{
19    deferred, div, px, App, Context, Entity, EventEmitter, FocusHandle, Hsla, IntoElement,
20    KeyDownEvent, MouseButton, SharedString, Window,
21};
22
23use super::{apply_key, control_metrics, edit::TextEdit, Field, KeyOutcome};
24use crate::reactive::Signal;
25use crate::theme::{css, theme, Color, ColorName, Size};
26
27/// Emitted when the color changes (typed, picked, or bound). Carries the color.
28#[derive(Debug, Clone, Copy)]
29pub struct ColorInputEvent(pub Hsla);
30
31/// A color field with a palette dropdown. Create with
32/// `cx.new(|cx| ColorInput::new(cx))`.
33pub struct ColorInput {
34    edit: TextEdit,
35    value: Hsla,
36    open: bool,
37    focus: FocusHandle,
38    label: Option<SharedString>,
39    description: Option<SharedString>,
40    error: Option<SharedString>,
41    size: Size,
42    disabled: bool,
43}
44
45impl EventEmitter<ColorInputEvent> for ColorInput {}
46
47/// `#rrggbb` for a color, dropping alpha (the buffer holds opaque hex).
48fn to_hex(color: Hsla) -> String {
49    let c = Color::from_hsla(color);
50    format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
51}
52
53/// This input's value is always opaque: the buffer renders `#rrggbb`, so a
54/// value carrying alpha would desync the text from the swatch and the emitted
55/// color. Every value entering the field passes through here.
56fn opaque(color: Hsla) -> Hsla {
57    Hsla { a: 1.0, ..color }
58}
59
60impl ColorInput {
61    pub fn new(cx: &mut Context<Self>) -> Self {
62        let value = gpui::black();
63        ColorInput {
64            edit: TextEdit::new(&to_hex(value)),
65            value,
66            open: false,
67            focus: cx.focus_handle(),
68            label: None,
69            description: None,
70            error: None,
71            size: Size::Sm,
72            disabled: false,
73        }
74    }
75
76    /// The initial color (alpha is dropped). Also rewrites the text buffer
77    /// as hex.
78    pub fn value(mut self, color: impl Into<Hsla>) -> Self {
79        let color = opaque(color.into());
80        self.value = color;
81        self.edit = TextEdit::new(&to_hex(color));
82        self
83    }
84
85    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
86        self.label = Some(label.into());
87        self
88    }
89
90    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
91        self.description = Some(description.into());
92        self
93    }
94
95    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
96        self.error = Some(error.into());
97        self
98    }
99
100    pub fn size(mut self, size: Size) -> Self {
101        self.size = size;
102        self
103    }
104
105    pub fn disabled(mut self, disabled: bool) -> Self {
106        self.disabled = disabled;
107        self
108    }
109
110    /// The current color.
111    pub fn color_value(&self) -> Hsla {
112        self.value
113    }
114
115    /// Two-way bind this input's color to a `Signal<Hsla>`. The signal is the
116    /// source of truth: the input adopts its value now, picks and valid typed
117    /// colors write back through [`Signal::set_if_changed`], and signal writes
118    /// update the swatch and buffer without emitting [`ColorInputEvent`].
119    /// Equality guards on both directions prevent update loops.
120    pub fn bind(entity: &Entity<ColorInput>, signal: &Signal<Hsla>, cx: &mut App) {
121        let initial = signal.get(cx);
122        entity.update(cx, |this, cx| this.sync_value(initial, cx));
123        let sink = signal.clone();
124        cx.subscribe(entity, move |_input, event: &ColorInputEvent, cx| {
125            sink.set_if_changed(cx, event.0);
126        })
127        .detach();
128        let input = entity.downgrade();
129        cx.observe(signal.entity(), move |observed, cx| {
130            let value = *observed.read(cx);
131            input.update(cx, |this, cx| this.sync_value(value, cx)).ok();
132        })
133        .detach();
134    }
135
136    /// Programmatic set: update swatch + buffer without emitting an event.
137    fn sync_value(&mut self, color: Hsla, cx: &mut Context<Self>) {
138        let color = opaque(color);
139        if self.value != color {
140            self.value = color;
141            self.edit = TextEdit::new(&to_hex(color));
142            cx.notify();
143        }
144    }
145
146    /// A palette pick: set, normalize the buffer to hex, close, emit.
147    fn choose(&mut self, color: Hsla, cx: &mut Context<Self>) {
148        self.open = false;
149        self.edit = TextEdit::new(&to_hex(color));
150        if self.value != color {
151            self.value = color;
152            cx.emit(ColorInputEvent(color));
153        }
154        cx.notify();
155    }
156
157    /// Re-parse the buffer after an edit; a valid color updates the swatch.
158    fn adopt_buffer(&mut self, cx: &mut Context<Self>) {
159        if let Ok(color) = css(&self.edit.text()).map(opaque) {
160            if self.value != color {
161                self.value = color;
162                cx.emit(ColorInputEvent(color));
163            }
164        }
165    }
166
167    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
168        if self.disabled {
169            return;
170        }
171        match apply_key(&mut self.edit, &event.keystroke) {
172            KeyOutcome::Submit => {
173                self.adopt_buffer(cx);
174                // Normalize whatever parsed (or the last valid color) to hex.
175                self.edit = TextEdit::new(&to_hex(self.value));
176                self.open = false;
177                cx.notify();
178                cx.stop_propagation();
179            }
180            KeyOutcome::Edited => {
181                self.adopt_buffer(cx);
182                cx.notify();
183                cx.stop_propagation();
184            }
185            KeyOutcome::Cancel => {
186                // Escape closes the dropdown; bubbles when already closed.
187                if self.open {
188                    self.open = false;
189                    cx.notify();
190                    cx.stop_propagation();
191                }
192            }
193            KeyOutcome::Pass => {}
194        }
195    }
196}
197
198impl Render for ColorInput {
199    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
200        let t = theme(cx);
201        let (height, pad_x, font) = control_metrics(self.size);
202        let radius = t.radius(t.default_radius);
203        let focused = self.focus.is_focused(window) && !self.disabled;
204
205        let border = if self.error.is_some() {
206            t.color(ColorName::Red, 6)
207        } else if focused {
208            t.primary()
209        } else {
210            t.border()
211        }
212        .hsla();
213        let plain_border = t.border().hsla();
214        let text_color = t.text().hsla();
215        let surface = t.surface().hsla();
216        let caret = t.primary().hsla();
217        let swatch_px = height - 16.0;
218
219        let swatch = div()
220            .id("guise-colorinput-swatch")
221            .flex_none()
222            .w(px(swatch_px))
223            .h(px(swatch_px))
224            .rounded(px(4.0))
225            .border_1()
226            .border_color(plain_border)
227            .bg(self.value)
228            .cursor_pointer()
229            .on_click(cx.listener(|this, _ev, window, cx| {
230                if !this.disabled {
231                    this.open = !this.open;
232                    window.focus(&this.focus);
233                    cx.notify();
234                }
235            }));
236
237        let interior = if focused {
238            let (before, after) = self.edit.split();
239            div()
240                .flex()
241                .items_center()
242                .text_color(text_color)
243                .child(SharedString::from(before))
244                .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
245                .child(SharedString::from(after))
246        } else {
247            div()
248                .text_color(text_color)
249                .child(SharedString::from(self.edit.text()))
250        };
251
252        let field = div()
253            .id("guise-colorinput")
254            .track_focus(&self.focus)
255            .on_key_down(cx.listener(Self::on_key))
256            .on_mouse_down(
257                MouseButton::Left,
258                cx.listener(|this, _ev, window, cx| {
259                    window.focus(&this.focus);
260                    cx.notify();
261                }),
262            )
263            .flex()
264            .items_center()
265            .gap(px(8.0))
266            .h(px(height))
267            .px(px(pad_x))
268            .rounded(px(radius))
269            .border_1()
270            .border_color(border)
271            .bg(surface)
272            .text_size(px(font))
273            .child(swatch)
274            .child(interior);
275
276        let mut wrap = div().relative().child(field);
277
278        if self.open && !self.disabled {
279            let current = Color::from_hsla(self.value);
280            let mut grid = div()
281                .occlude()
282                .absolute()
283                .top(px(height + 6.0))
284                .left(px(0.0))
285                .flex()
286                .flex_col()
287                .gap(px(2.0))
288                .p(px(6.0))
289                .rounded(px(radius))
290                .border_1()
291                .border_color(plain_border)
292                .bg(surface)
293                .shadow_md();
294
295            for (row, name) in ColorName::ALL.into_iter().enumerate() {
296                let mut cells = div().flex().flex_row().gap(px(2.0));
297                for shade in 0..10 {
298                    let cell_color = t.color(name, shade);
299                    let cell_hsla = cell_color.hsla();
300                    let mut cell = div()
301                        .id(("guise-colorinput-cell", row * 10 + shade))
302                        .w(px(14.0))
303                        .h(px(14.0))
304                        .rounded(px(3.0))
305                        .bg(cell_hsla)
306                        .cursor_pointer()
307                        .on_click(cx.listener(move |this, _ev, _window, cx| {
308                            this.choose(cell_hsla, cx);
309                        }));
310                    if cell_color == current {
311                        cell = cell
312                            .border_2()
313                            .border_color(cell_color.contrasting().hsla());
314                    }
315                    cells = cells.child(cell);
316                }
317                grid = grid.child(cells);
318            }
319
320            wrap = wrap.child(deferred(grid));
321        }
322
323        let mut chrome = Field::new().child(if self.disabled {
324            wrap.opacity(0.6)
325        } else {
326            wrap
327        });
328        if let Some(label) = self.label.clone() {
329            chrome = chrome.label(label);
330        }
331        if let Some(error) = self.error.clone() {
332            chrome = chrome.error(error);
333        } else if let Some(description) = self.description.clone() {
334            chrome = chrome.description(description);
335        }
336        chrome
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::to_hex;
343    use crate::theme::css;
344
345    #[test]
346    fn hex_round_trips_through_css_parsing() {
347        for hex in [
348            "#ff0000", "#00ff00", "#0000ff", "#ffffff", "#000000", "#808080",
349        ] {
350            assert_eq!(to_hex(css(hex).unwrap()), hex);
351        }
352    }
353
354    #[test]
355    fn alpha_is_dropped() {
356        let translucent = css("rgba(255, 0, 0, 0.5)").unwrap();
357        assert_eq!(to_hex(translucent), "#ff0000");
358    }
359
360    #[test]
361    fn adopted_colors_are_opaque() {
362        // The value the field adopts must match the hex it displays: an
363        // rgba() input and its opaque rgb() twin resolve to the same color.
364        let adopted = super::opaque(css("rgba(255, 0, 0, 0.5)").unwrap());
365        assert_eq!(adopted, css("rgb(255, 0, 0)").unwrap());
366        assert_eq!(adopted.a, 1.0);
367    }
368}