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