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}