Skip to main content

herogpui_components/color_picker/
field.rs

1//! ColorField.
2
3use super::*;
4
5// ColorField
6// ---------------------------------------------------------------------------
7
8/// The complete state passed to [`ColorField::content`].
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10#[non_exhaustive]
11pub struct ColorFieldRenderState {
12    /// The field cannot receive focus or input.
13    pub is_disabled: bool,
14    /// Controlled, server, or custom validation currently fails.
15    pub is_invalid: bool,
16    /// The value can be selected but not changed.
17    pub is_read_only: bool,
18    /// The field must contain a value before native form submission.
19    pub is_required: bool,
20    /// The input itself owns keyboard focus.
21    pub is_focused: bool,
22    /// The input or another composed child owns keyboard focus.
23    pub is_focus_within: bool,
24    /// Focus was reached through keyboard navigation.
25    pub is_focus_visible: bool,
26}
27
28/// ColorField — enters a color as text.
29///
30/// With no `channel` it edits the hex value; with one it edits that channel's
31/// numeric value.
32#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
33#[derive(IntoElement)]
34pub struct ColorField {
35    /// See [`ColorField::content`].
36    content: Option<Arc<dyn Fn(ColorFieldRenderState) -> gpui::AnyElement + 'static>>,
37    /// `ColorField.Suffix` — the `me-3` slot after the value, in the
38    /// placeholder colour. v3's own example fills the *prefix* with a swatch and
39    /// leaves this to the caller (a channel unit, a lock icon).
40    suffix: Option<gpui::AnyElement>,
41    /// `validationBehavior` — carried on this control's form field.
42    validation_behavior: crate::form::ValidationBehavior,
43    /// `name` — the name this control submits under; read back by
44    /// [`Self::form_field`].
45    name: Option<SharedString>,
46    /// `defaultValue` — set it to hand this component its own state. The
47    /// outer option distinguishes an omitted prop from an explicit `null`
48    /// seed, matching React Aria's `Color | null` contract.
49    default_value: Option<Option<PickerColor>>,
50    id: ElementId,
51    /// The controlled value. `None` is the documented empty color state.
52    value: Option<PickerColor>,
53    channel: Option<ColorChannel>,
54    /// `colorSpace` — how a `channel` value is interpreted.
55    color_space: ColorSpace,
56    /// `validate` — run by the component, not the caller.
57    validate: Option<crate::validation::Validator<PickerColor>>,
58    /// `validationErrors` — messages from a server round-trip.
59    validation_errors: Vec<SharedString>,
60    /// `isWheelDisabled` — stops the scroll wheel from stepping the channel.
61    is_wheel_disabled: bool,
62    /// `autoFocus` — take focus on the first render.
63    auto_focus: bool,
64    placeholder: Option<SharedString>,
65    /// The value text's family; unset keeps the inherited family.
66    font_family: Option<SharedString>,
67    /// Supplying an `InputState` makes the field editable; without one it is a
68    /// read-only display of `value`.
69    state: Option<Entity<crate::input::InputState>>,
70    on_change: Option<OnColorFieldChange>,
71    /// `onBlur` — focus left the editable field. Fired just before the
72    /// built-in revert-on-blur, the way React Aria chains the caller's
73    /// `onBlur` ahead of its `commit` on the same event.
74    on_blur: Option<Arc<dyn Fn(&mut Window, &mut App) + 'static>>,
75    label: Option<SharedString>,
76    description: Option<SharedString>,
77    variant: FieldVariant,
78    full_width: bool,
79    is_disabled: bool,
80    is_invalid: bool,
81    is_read_only: bool,
82    is_required: bool,
83    /// Optional box geometry/chrome overrides; defaults are the stock box.
84    field: util::FieldBox,
85    /// The corner radius, in place of the owning `field_radius` helper.
86    radius: Option<Pixels>,
87    /// The `sx` slot, refined over the root style at the end of render.
88    sx: Option<Box<gpui::StyleRefinement>>,
89    form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
90}
91
92impl ColorField {
93    /// Sets whether the field is read-only (`isReadOnly`).
94    pub fn is_read_only(mut self, v: bool) -> Self {
95        self.is_read_only = v;
96        self
97    }
98
99    /// Sets whether the field is required (`isRequired`).
100    pub fn is_required(mut self, v: bool) -> Self {
101        self.is_required = v;
102        self
103    }
104
105    /// v3's field `children`-as-a-function, handed the complete resolved field
106    /// state.
107    pub fn content(
108        mut self,
109        render: impl Fn(ColorFieldRenderState) -> gpui::AnyElement + 'static,
110    ) -> Self {
111        self.content = Some(Arc::new(render));
112        self
113    }
114
115    /// `ColorField.Suffix` — the slot after the value.
116    pub fn suffix(mut self, el: impl IntoElement) -> Self {
117        self.suffix = Some(el.into_any_element());
118        self
119    }
120
121    /// The family the value text is drawn with, on both paths: the editable
122    /// field forwards it to the [`crate::Input`] it composes, so the caret
123    /// measurement uses it too (see [`crate::Input::font_family`]), and the
124    /// static display sets it on its box. Unset keeps the inherited family.
125    /// Not a v3 prop; v3 sets it with a class.
126    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
127        self.font_family = Some(family.into());
128        self
129    }
130
131    /// The one slot for caller-owned low-level styling: GPUI's styling methods
132    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
133    /// applied to the field's root element — the column holding the label,
134    /// the box and the description or error — after every value the variant
135    /// and the active theme chose, so they win. Both paths land on that
136    /// column: the editable field hands the slot to the [`crate::Input`] it
137    /// composes, whose standalone root is the same column, and the static
138    /// display refines its own. The box's chrome stays with the variant.
139    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
140        util::refine_sx(&mut self.sx, style);
141        self
142    }
143
144    /// Creates a color field with the id `id`, showing `value` (or empty for `None`).
145    pub fn new(id: impl Into<ElementId>, value: impl Into<Option<PickerColor>>) -> Self {
146        Self {
147            content: None,
148            suffix: None,
149            validation_behavior: crate::form::ValidationBehavior::Native,
150            name: None,
151            default_value: None,
152            id: id.into(),
153            value: value.into(),
154            channel: None,
155            color_space: ColorSpace::default(),
156            validate: None,
157            validation_errors: Vec::new(),
158            is_wheel_disabled: false,
159            auto_focus: false,
160            placeholder: None,
161            font_family: None,
162            state: None,
163            on_change: None,
164            on_blur: None,
165            label: None,
166            description: None,
167            variant: FieldVariant::Primary,
168            full_width: false,
169            is_disabled: false,
170            is_invalid: false,
171            is_read_only: false,
172            is_required: false,
173            field: util::FieldBox::default(),
174            radius: None,
175            sx: None,
176            form_state: live_color_form_state(
177                crate::form::FormValue::Text(SharedString::default()),
178            ),
179        }
180    }
181
182    /// `validationBehavior` — `Allow` shows the message without blocking form
183    /// submission. Carried on the [`Self::form_field`] this control produces.
184    pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
185        self.validation_behavior = behavior;
186        self
187    }
188
189    /// `name` — the name this control submits under.
190    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
191        self.name = Some(name.into());
192        self
193    }
194
195    /// The `Form` field this control submits, when it has a `name`.
196    ///
197    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
198    /// its ancestor, so the control hands the pair over instead. Borrows, so the
199    /// control is still yours to place:
200    ///
201    /// ```
202    /// # use gpui::{prelude::*, Window};
203    /// # use herogpui_components::{ColorField, Form, PickerColor};
204    /// # struct Demo;
205    /// # impl Render for Demo {
206    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
207    /// #         let form = Form::new();
208    /// #         let color = PickerColor::hsb(210., 0.8, 0.9);
209    /// #         let control = ColorField::new("brand", color).name("brand");
210    /// let field = control.form_field();
211    /// form.field(field.unwrap()).child(control)
212    /// #     }
213    /// # }
214    /// # let mut tcx = gpui::TestAppContext::single();
215    /// # tcx.update(herogpui_theme::ThemeProvider::init);
216    /// # let _ = tcx.add_window_view(|_, _| Demo);
217    /// ```
218    pub fn form_field(&self) -> Option<crate::form::FormField> {
219        let name = self.name.clone()?;
220        let value = self.default_value.unwrap_or(self.value);
221        let validity = crate::validation::resolve(
222            self.is_invalid,
223            &self.validation_errors,
224            self.validate
225                .as_ref()
226                .and_then(|f| value.as_ref().and_then(|value| f(value))),
227            None,
228        );
229        sync_color_form_state(
230            &self.form_state,
231            color_field_form_value(value, self.channel, self.color_space),
232            !self.is_disabled,
233            validity.is_invalid,
234        );
235        Some(
236            crate::form::FormField::live(name, self.form_state.clone())
237                .is_required(self.is_required)
238                .validation_behavior(self.validation_behavior),
239        )
240    }
241
242    /// `defaultValue` — the uncontrolled initial colour. Passing `None`
243    /// explicitly seeds the field empty; omitting this builder keeps the
244    /// component controlled by the value supplied to [`Self::new`].
245    ///
246    /// Supplying it hands the component its own state: the constructor's
247    /// `value` becomes the seed, and a change moves the component's copy.
248    pub fn default_value(mut self, value: impl Into<Option<PickerColor>>) -> Self {
249        self.default_value = Some(value.into());
250        self
251    }
252
253    /// `colorSpace` — the space a `channel` value is read in.
254    pub fn color_space(mut self, space: ColorSpace) -> Self {
255        self.color_space = space;
256        self
257    }
258
259    /// `validate` — returns the message to show, or `None` when the colour is
260    /// fine. The component runs it and surfaces the result.
261    pub fn validate(mut self, f: impl Fn(&PickerColor) -> Option<SharedString> + 'static) -> Self {
262        self.validate = Some(Arc::new(f));
263        self
264    }
265
266    /// `validationErrors` — messages produced elsewhere, shown ahead of
267    /// whatever `validate` returns.
268    pub fn validation_errors(
269        mut self,
270        errors: impl IntoIterator<Item = impl Into<SharedString>>,
271    ) -> Self {
272        self.validation_errors = errors.into_iter().map(Into::into).collect();
273        self
274    }
275
276    /// `autoFocus` — take focus on the first render. Only meaningful in the
277    /// editable mode; see [`ColorField::state`].
278    pub fn auto_focus(mut self, v: bool) -> Self {
279        self.auto_focus = v;
280        self
281    }
282
283    /// `isWheelDisabled` — stops the wheel from stepping the channel.
284    ///
285    /// Only a single-channel field steps: there is no sensible increment for a
286    /// hex value.
287    pub fn is_wheel_disabled(mut self, v: bool) -> Self {
288        self.is_wheel_disabled = v;
289        self
290    }
291
292    /// `placeholder` on `ColorField.Input`.
293    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
294        self.placeholder = Some(text.into());
295        self
296    }
297
298    /// Makes the field editable, backed by this text state.
299    pub fn state(mut self, state: Entity<crate::input::InputState>) -> Self {
300        self.state = Some(state);
301        self
302    }
303
304    /// `onChange` — the parsed colour, or `None` when the text is not one.
305    ///
306    /// Only fires in the editable mode; see [`ColorField::state`].
307    pub fn on_change(
308        mut self,
309        f: impl Fn(&Option<PickerColor>, &mut Window, &mut App) + 'static,
310    ) -> Self {
311        self.on_change = Some(Arc::new(f));
312        self
313    }
314
315    /// `onBlur` — the editable field lost focus.
316    ///
317    /// React Aria chains the caller's `onBlur` with its own blur `commit` on
318    /// one event, caller first, so this hook runs ahead of the built-in
319    /// revert-on-blur and still sees the raw text. The revert itself is built
320    /// in and needs no hook. Only meaningful in the editable mode; see
321    /// [`ColorField::state`].
322    pub fn on_blur(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
323        self.on_blur = Some(Arc::new(f));
324        self
325    }
326
327    /// Edit one channel instead of the hex value.
328    pub fn channel(mut self, channel: ColorChannel) -> Self {
329        self.channel = Some(channel);
330        self
331    }
332
333    /// Sets the label shown above the field.
334    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
335        self.label = Some(text.into());
336        self
337    }
338
339    /// Sets the description shown below the field.
340    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
341        self.description = Some(text.into());
342        self
343    }
344
345    /// Sets the field variant.
346    pub fn variant(mut self, variant: FieldVariant) -> Self {
347        self.variant = variant;
348        self
349    }
350
351    /// Sets whether the field fills the available width.
352    pub fn full_width(mut self, v: bool) -> Self {
353        self.full_width = v;
354        self
355    }
356
357    /// Sets whether the field is disabled (`isDisabled`).
358    pub fn is_disabled(mut self, v: bool) -> Self {
359        self.is_disabled = v;
360        self
361    }
362
363    /// Sets whether the field is invalid (`isInvalid`).
364    pub fn is_invalid(mut self, v: bool) -> Self {
365        self.is_invalid = v;
366        self
367    }
368
369    /// Replaces the 36px box height. The editable path forwards it to the
370    /// inner Input; the static display box changes its own height.
371    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
372        self.field.height = Some(h.into());
373        self
374    }
375
376    /// Replaces the box's `px-3` horizontal padding.
377    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
378        self.field.padding_x = Some(p.into());
379        self
380    }
381
382    /// Renders the box with no background, border, field shadow or focus ring,
383    /// for a caller painting around it. The field stays editable and focusable.
384    pub fn is_bare(mut self, v: bool) -> Self {
385        self.field.is_bare = v;
386        self.field.is_bare_is_set = true;
387        self
388    }
389
390    /// Shows or hides only the field's visual focus ring. The editable color
391    /// field remains focusable and the static display keeps its normal chrome.
392    pub fn focus_ring(mut self, v: bool) -> Self {
393        self.field.focus_ring = Some(v);
394        self
395    }
396
397    /// The corner radius, in place of the owning `field_radius` helper. Not a
398    /// v3 prop; the removed v2 `radius` prop is prohibited and this is a
399    /// per-component repository extension.
400    ///
401    /// The static box and shared field chrome use the same resolved radius;
402    /// a bare box retains it without painting chrome. The editable path
403    /// forwards the override to its inner field alongside `height` and
404    /// `padding_x`.
405    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
406        self.radius = Some(radius.into());
407        self
408    }
409}
410
411impl ColorField {
412    /// The text form of the current value, honouring `channel` and
413    /// `colorSpace`.
414    fn display_text(&self) -> String {
415        color_field_display_text(self.value, self.channel, self.color_space)
416    }
417}
418
419pub(super) fn parse_color_field(
420    value: PickerColor,
421    channel: Option<ColorChannel>,
422    color_space: ColorSpace,
423    text: &str,
424) -> Option<PickerColor> {
425    match channel {
426        None => PickerColor::from_hex(text),
427        Some(channel) => {
428            let text = text.trim();
429            let mut number: f32 = text.trim_end_matches('%').trim().parse().ok()?;
430            if is_normalized_channel(channel) {
431                number /= 100.0;
432            }
433            let (min, max) = channel.range();
434            if number < min || number > max {
435                return None;
436            }
437            Some(value.with_channel_in(channel, color_space, number))
438        }
439    }
440}
441
442pub(super) fn step_color_channel(
443    value: PickerColor,
444    channel: ColorChannel,
445    color_space: ColorSpace,
446    direction: f32,
447) -> PickerColor {
448    let (min, max) = channel.range();
449    let step = color_channel_step(channel);
450    let current = value.channel_in(channel, color_space);
451    let next = snap_color_channel(channel, (current + direction * step).clamp(min, max));
452    value.with_channel_in(channel, color_space, next)
453}
454
455pub(super) fn color_channel_step(channel: ColorChannel) -> f32 {
456    let (min, max) = channel.range();
457    if max - min > 2.0 {
458        1.0
459    } else {
460        0.01
461    }
462}
463
464pub(super) fn snap_color_channel(channel: ColorChannel, value: f32) -> f32 {
465    let (min, max) = channel.range();
466    let step = color_channel_step(channel);
467    (((value - min) / step).round() * step + min).clamp(min, max)
468}
469
470pub(super) fn is_normalized_channel(channel: ColorChannel) -> bool {
471    matches!(
472        channel,
473        ColorChannel::Saturation
474            | ColorChannel::Brightness
475            | ColorChannel::Lightness
476            | ColorChannel::Alpha
477    )
478}
479
480pub(super) fn format_color_channel_value(
481    value: PickerColor,
482    channel: ColorChannel,
483    color_space: ColorSpace,
484) -> String {
485    let value = value.channel_in(channel, color_space);
486    if is_normalized_channel(channel) {
487        format!("{}%", (value * 100.0).round())
488    } else {
489        format!("{}", value.round())
490    }
491}
492
493#[allow(clippy::too_many_arguments)] // mirrors the state, callback and field channels in one event
494pub(super) fn report_color_field_change(
495    next: PickerColor,
496    channel: ColorChannel,
497    color_space: ColorSpace,
498    state: &Entity<crate::input::InputState>,
499    own: &Option<Entity<Option<PickerColor>>>,
500    on_change: &Option<OnColorFieldChange>,
501    window: &mut Window,
502    cx: &mut App,
503) {
504    let text = format_color_channel_value(next, channel, color_space);
505    state.update(cx, |state, cx| {
506        state.set_value(text);
507        cx.notify();
508    });
509    if let Some(held) = own {
510        held.update(cx, |value, cx| {
511            *value = Some(next);
512            cx.notify();
513        });
514    }
515    if let Some(callback) = on_change {
516        callback(&Some(next), window, cx);
517    }
518}
519
520impl RenderOnce for ColorField {
521    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
522        // `defaultValue` opts into the component holding its own colour;
523        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
524        let (resolved, own) = util::controlled(
525            window,
526            cx,
527            element_id::scoped(&self.id, "field-value"),
528            self.default_value.is_none().then_some(self.value),
529            self.default_value.unwrap_or(self.value),
530        );
531        self.value = resolved;
532        // v3 order: the controlled flag, then server errors, then `validate`.
533        let validity = crate::validation::resolve(
534            self.is_invalid,
535            &self.validation_errors,
536            self.validate
537                .as_ref()
538                .and_then(|f| self.value.as_ref().and_then(|value| f(value))),
539            None,
540        );
541        if let Some(render) = self.content.clone() {
542            // v3's field children-as-a-function: the caller builds the parts.
543            let focused = self
544                .state
545                .as_ref()
546                .is_some_and(|s| s.read(cx).focus_handle.is_focused(window));
547            let within = self
548                .state
549                .as_ref()
550                .is_some_and(|s| s.read(cx).focus_handle.contains_focused(window, cx));
551            return render(ColorFieldRenderState {
552                is_disabled: self.is_disabled,
553                is_invalid: validity.is_invalid,
554                is_read_only: self.is_read_only,
555                is_required: self.is_required,
556                is_focused: focused,
557                is_focus_within: within,
558                is_focus_visible: focused && util::focus_visible(cx),
559            })
560            .into_any_element();
561        }
562        let form_default = window.use_keyed_state(
563            element_id::scoped(&self.id, "field-form-default"),
564            cx,
565            |_, _| None::<Option<PickerColor>>,
566        );
567        if form_default.read(cx).is_none() {
568            let initial = self.value;
569            form_default.update(cx, |slot, cx| {
570                *slot = Some(initial);
571                cx.notify();
572            });
573        }
574        let restore_default = form_default.read(cx).unwrap_or(self.value);
575        // Submit the resolved colour. Uncontrolled keyed state is current after
576        // a parsed change; a controlled owner must accept it first.
577        sync_color_form_state(
578            &self.form_state,
579            color_field_form_value(self.value, self.channel, self.color_space),
580            !self.is_disabled,
581            validity.is_invalid,
582        );
583        let restore_own = own.clone();
584        let restore_on_change = self.on_change.clone();
585        // The field stores this callback; owning it back would retain every
586        // rendered InputState after the field is removed.
587        let restore_form_state = Rc::downgrade(&self.form_state);
588        let restore_input = self.state.clone();
589        let restore_channel = self.channel;
590        let restore_space = self.color_space;
591        let restore_is_disabled = self.is_disabled;
592        let restore: Arc<dyn Fn(&mut Window, &mut App)> = util::shared(move |window, cx| {
593            if let Some(own) = &restore_own {
594                own.update(cx, |current, cx| {
595                    *current = restore_default;
596                    cx.notify();
597                });
598            }
599            if let Some(state) = &restore_input {
600                let text =
601                    color_field_display_text(restore_default, restore_channel, restore_space);
602                state.update(cx, |state, cx| {
603                    state.set_value(text);
604                    cx.notify();
605                });
606            }
607            if let Some(callback) = &restore_on_change {
608                callback(&restore_default, window, cx);
609            }
610            if let Some(state) = restore_form_state.upgrade() {
611                sync_color_form_state(
612                    &state,
613                    color_field_form_value(restore_default, restore_channel, restore_space),
614                    !restore_is_disabled,
615                    false,
616                );
617            }
618        });
619        self.form_state.borrow_mut().restore = Some(restore);
620        if let Some(state) = &self.state {
621            self.form_state.borrow_mut().focus = Some(state.read(cx).focus_handle.clone());
622        }
623        // The shared hover tween borrows the window/app mutably while the
624        // final field chrome still needs theme tokens below, so copy the
625        // active snapshots before constructing the element tree.
626        let colors = cx.colors().clone();
627        let layout = cx.layout().clone();
628        let text = self.display_text();
629
630        // Editable mode: delegate the text handling to Input and parse on every
631        // keystroke, so `onChange` reports exactly what v3's does.
632        if let Some(state) = self.state.clone() {
633            // React Aria also commits on blur: `useColorField` merges
634            // `onBlur: commit` into the input's props, and a channel field
635            // inherits NumberField's blur commit. `commit` restores text that
636            // no longer parses to the formatted last-committed value, leaves
637            // valid text alone, and keeps an emptied field empty (its
638            // committed null). The revert itself runs in the blur path —
639            // event handlers may mutate keyed state; render must not — so the
640            // only render-time work below is observing that a blur happened.
641            // GPUI blanks focus-event paths for inactive windows, including
642            // its headless test platform, so the observation has two legs,
643            // the same split `util::on_focus_leave` makes: a
644            // `Window::on_focus_out` on the input's own focus handle serves
645            // active windows, and a render-time edge catches the focus move
646            // to another tab stop everywhere else and defers the same revert
647            // out of render. Both consume the same flag, so one blur reverts
648            // exactly once.
649            let blur_focus = state.read(cx).focus_handle.clone();
650            let blur_seen = window.use_keyed_state(
651                element_id::scoped(&self.id, "field-blur-seen"),
652                cx,
653                |_, _| false,
654            );
655            let blur_subscription = window.use_keyed_state(
656                element_id::scoped(&self.id, "field-blur-subscription"),
657                cx,
658                |_, _| None::<gpui::Subscription>,
659            );
660            let revert: Rc<dyn Fn(&mut Window, &mut App)> = {
661                let hook = self.on_blur.clone();
662                let seen = blur_seen.clone();
663                let input = state.downgrade();
664                let committed = self.value;
665                let channel = self.channel;
666                let space = self.color_space;
667                Rc::new(move |window, cx| {
668                    seen.update(cx, |seen, _| *seen = false);
669                    // React Aria chains the caller's `onBlur` ahead of its
670                    // `commit`, so the hook still sees the raw text.
671                    if let Some(hook) = &hook {
672                        hook(window, cx);
673                    }
674                    let Some(input) = input.upgrade() else {
675                        return;
676                    };
677                    let text = input.read(cx).value().to_owned();
678                    // An emptied field is React Aria's committed null: the
679                    // text stays empty instead of restoring the old colour.
680                    if text.is_empty()
681                        || parse_color_field(committed.unwrap_or_default(), channel, space, &text)
682                            .is_some()
683                    {
684                        return;
685                    }
686                    let restored = color_field_display_text(committed, channel, space);
687                    input.update(cx, |state, cx| {
688                        state.set_value(restored);
689                        cx.notify();
690                    });
691                })
692            };
693            if !self.is_disabled {
694                // The event listener is frame-scoped: every render re-arms it
695                // with the `revert` built from this frame's committed value.
696                // Arming once would pin the first render's seed onto every
697                // later blur while the keyed colour advances per keystroke;
698                // storing the fresh subscription drops the stale one. The
699                // listener also clears the slot when it fires — the same
700                // one-shot transition `util::on_focus_leave` draws — so a
701                // blur owns its revert exactly once and the next frame
702                // re-arms.
703                let disarmer = blur_subscription.downgrade();
704                let listener = window.on_focus_out(&blur_focus, cx, {
705                    let revert = Rc::clone(&revert);
706                    move |_, window, cx| {
707                        if let Some(disarmer) = disarmer.upgrade() {
708                            disarmer.update(cx, |slot, _| *slot = None);
709                        }
710                        revert(window, cx);
711                    }
712                });
713                blur_subscription.update(cx, |slot, _| *slot = Some(listener));
714                if blur_focus.is_focused(window) {
715                    blur_seen.update(cx, |seen, _| *seen = true);
716                } else if *blur_seen.read(cx)
717                    && window.focused(cx).is_some_and(|focused| focused.tab_stop)
718                {
719                    // The inactive-window leg: the armed event never fires
720                    // there, so this frame's render observed the departure.
721                    // Consuming the flag here is what keeps exactly one
722                    // deferred revert per blur, and the revert itself runs
723                    // out of render, the way the event leg above does.
724                    blur_seen.update(cx, |seen, _| *seen = false);
725                    window.defer(cx, {
726                        let revert = Rc::clone(&revert);
727                        move |window, cx| revert(window, cx)
728                    });
729                }
730            } else if blur_subscription.read(cx).is_some() {
731                blur_subscription.update(cx, |slot, _| *slot = None);
732            }
733            let mut input = Input::new(state.clone())
734                .variant(self.variant)
735                .is_disabled(self.is_disabled)
736                .is_read_only(self.is_read_only)
737                .is_required(self.is_required)
738                .is_invalid(validity.is_invalid)
739                .validation_errors(self.validation_errors.clone())
740                .auto_focus(self.auto_focus);
741            if let Some(value) = self.value {
742                input = input.start_content(ColorSwatch::new(value).size(SizeXl::Xs));
743            }
744            input = input
745                .with_field_box(self.field)
746                .with_sx_refinement(self.sx.take());
747            if let Some(family) = self.font_family.clone() {
748                input = input.font_family(family);
749            }
750            // The editable box is the inner field's own, so the radius rides
751            // along with the field box, the way its `height` and `padding_x`
752            // do; the static box below paints its own.
753            input = match self.radius {
754                Some(radius) => input.radius(radius),
755                None => input,
756            };
757            if let Some(message) = validity.first() {
758                input = input.error_message(message);
759            }
760            if let Some(ph) = self.placeholder.clone() {
761                input = input.placeholder(ph);
762            } else {
763                input = input.placeholder(text);
764            }
765            if let Some(label) = self.label.clone() {
766                input = input.label(label);
767            }
768            if let Some(description) = self.description.clone() {
769                input = input.description(description);
770            }
771            if let Some(suffix) = self.suffix.take() {
772                input = input.end_content(
773                    div()
774                        .flex()
775                        .items_center()
776                        .flex_shrink_0()
777                        .text_color(colors.field.placeholder)
778                        .child(suffix),
779                );
780            }
781            if self.full_width {
782                input = input.full_width();
783            }
784            if self.on_change.is_some() || own.is_some() {
785                let cb = self.on_change.clone();
786                let own = own.clone();
787                let parse_value = self.value.unwrap_or_default();
788                let parse_channel = self.channel;
789                let parse_space = self.color_space;
790                input = input.on_change(move |text, window, cx| {
791                    let next = parse_color_field(parse_value, parse_channel, parse_space, text);
792                    // Uncontrolled: keep what was typed, or the swatch would
793                    // never follow the text.
794                    if let (Some(held), Some(c)) = (&own, next) {
795                        held.update(cx, |v, cx| {
796                            *v = Some(c);
797                            cx.notify();
798                        });
799                    }
800                    if let Some(cb) = &cb {
801                        cb(&next, window, cx);
802                    }
803                });
804            }
805            let rendered = input.render(window, cx).into_any_element();
806            let Some(channel) = self.channel else {
807                return rendered;
808            };
809            if self.is_disabled || self.is_read_only {
810                return rendered;
811            }
812
813            let mut field = div()
814                .id(element_id::scoped(&self.id, "channel-events"))
815                .child(rendered);
816            if self.on_change.is_some() || own.is_some() {
817                let key_value = self.value.unwrap_or_default();
818                let key_space = self.color_space;
819                let key_state = state.clone();
820                let key_own = own.clone();
821                let key_change = self.on_change.clone();
822                field = field.on_key_down(move |event, window, cx| {
823                    let direction = match event.keystroke.key.as_str() {
824                        "up" => 1.0,
825                        "down" => -1.0,
826                        _ => return,
827                    };
828                    let next = step_color_channel(key_value, channel, key_space, direction);
829                    report_color_field_change(
830                        next,
831                        channel,
832                        key_space,
833                        &key_state,
834                        &key_own,
835                        &key_change,
836                        window,
837                        cx,
838                    );
839                    cx.stop_propagation();
840                });
841
842                if !self.is_wheel_disabled {
843                    let wheel_value = self.value.unwrap_or_default();
844                    let wheel_space = self.color_space;
845                    let wheel_state = state;
846                    let wheel_own = own;
847                    let wheel_change = self.on_change;
848                    field = field.on_scroll_wheel(move |event, window, cx| {
849                        if !wheel_state
850                            .read(cx)
851                            .focus_handle
852                            .contains_focused(window, cx)
853                        {
854                            return;
855                        }
856                        let (dx, dy) = match event.delta {
857                            gpui::ScrollDelta::Pixels(point) => {
858                                (f32::from(point.x), f32::from(point.y))
859                            }
860                            gpui::ScrollDelta::Lines(point) => (point.x, point.y),
861                        };
862                        if dy == 0.0 || dy.abs() <= dx.abs() {
863                            return;
864                        }
865                        let next =
866                            step_color_channel(wheel_value, channel, wheel_space, dy.signum());
867                        report_color_field_change(
868                            next,
869                            channel,
870                            wheel_space,
871                            &wheel_state,
872                            &wheel_own,
873                            &wheel_change,
874                            window,
875                            cx,
876                        );
877                        cx.stop_propagation();
878                    });
879                }
880            }
881            return field.into_any_element();
882        }
883
884        let field_box = self.field;
885        // The box and shared field chrome use the same resolved radius.
886        let radius = self.radius.unwrap_or_else(|| util::field_radius(cx));
887        let mut field = div()
888            .id(self.id.clone())
889            .when_some(self.font_family.clone(), |field, family| {
890                field.font_family(family)
891            })
892            .flex()
893            .flex_row()
894            .items_center()
895            .gap(px(8.))
896            .px(field_box.resolved_padding_x())
897            .h(field_box.resolved_height())
898            .rounded(radius)
899            .text_size(util::FIELD_TEXT)
900            .line_height(px(20.))
901            .text_color(colors.field.foreground);
902        // `.color-input-group__prefix` is `shrink-0 ms-3` in the placeholder
903        // colour. An empty ColorField omits the swatch, matching
904        // `ColorSwatch color={value ?? undefined}` in the pinned example.
905        if let Some(value) = self.value {
906            field = field.child(ColorSwatch::new(value).size(SizeXl::Xs));
907        }
908        field = field.child(div().flex_1().child(text));
909        // `.color-input-group__suffix` is `shrink-0 text-field-placeholder
910        // me-3 flex items-center`; the trailing inset is the field box's
911        // resolved padding, since this port renders the value itself.
912        field = field.children(self.suffix.map(|el| {
913            div()
914                .flex()
915                .items_center()
916                .flex_shrink_0()
917                .text_color(colors.field.placeholder)
918                .child(el)
919        }));
920
921        if !field_box.is_bare {
922            let focused = self
923                .state
924                .as_ref()
925                .is_some_and(|s| s.read(cx).focus_handle.is_focused(window));
926            // The colour field box does not clip its children, so its focus
927            // and invalid rings are painted as concentric overlay children
928            // rather than as blurred spread shadows.
929            field = util::apply_field_chrome_overlay(
930                field,
931                self.variant,
932                self.is_invalid,
933                focused,
934                field_box.focus_ring.unwrap_or(true),
935                Some(radius),
936                cx,
937            );
938
939            // HeroUI's invalid color-input group keeps the danger chrome and
940            // swaps the surface to the field-focus endpoint. Secondary uses
941            // its neutral default focus endpoint, just like InputGroup.
942            if validity.is_invalid {
943                field = field.bg(match self.variant {
944                    FieldVariant::Primary => colors.field.focus(),
945                    FieldVariant::Secondary => colors.default.color,
946                });
947            }
948
949            if !self.is_disabled && !self.is_invalid && !focused {
950                let idle_bg = match self.variant {
951                    FieldVariant::Primary => colors.field.background,
952                    FieldVariant::Secondary => colors.default.color,
953                };
954                let hover_bg = match self.variant {
955                    FieldVariant::Primary => colors.field.hover(),
956                    // `.color-input-group--secondary` uses the default-hover
957                    // endpoint rather than the field-hover token.
958                    FieldVariant::Secondary => colors.default.hover(),
959                };
960                let hover_border = colors.field.border_hover();
961                // Keep the group identity and focus/scroll listeners stable;
962                // only the visual fill interpolates over HeroUI's 150ms
963                // ease-smooth hover transition. The border endpoint remains
964                // an immediate hover refinement, matching the field family.
965                field = crate::anim::hover_fade_with_duration_and_easing(
966                    field,
967                    element_id::scoped(&self.id, "field-hover-fade"),
968                    (idle_bg, hover_bg),
969                    None,
970                    Some(hover_border),
971                    |fill| fill.rounded(radius),
972                    Some(150),
973                    crate::anim::HoverFadeEasing::EaseSmooth,
974                    window,
975                    cx,
976                );
977            }
978        }
979
980        // v3's ColorField steps its channel on scroll; `isWheelDisabled` turns
981        // that off. There is no sensible increment for a hex value, so only a
982        // single-channel field responds.
983        if let (Some(channel), false, Some(cb)) = (
984            self.channel,
985            self.is_wheel_disabled || self.is_disabled || self.is_read_only,
986            self.on_change.clone(),
987        ) {
988            let value = self.value.unwrap_or_default();
989            let space = self.color_space;
990            field = field.on_scroll_wheel(move |ev: &gpui::ScrollWheelEvent, window, cx| {
991                let dy = match ev.delta {
992                    gpui::ScrollDelta::Pixels(p) => f32::from(p.y),
993                    gpui::ScrollDelta::Lines(p) => p.y,
994                };
995                if dy == 0.0 {
996                    return;
997                }
998                let (min, max) = channel.range();
999                // One notch is a percent of the channel's range, so hue moves
1000                // in degrees and an 8-bit channel in whole steps.
1001                let step = ((max - min) / 100.0).max(1.0);
1002                let next = (value.channel_in(channel, space) + step * dy.signum()).clamp(min, max);
1003                cb(
1004                    &Some(value.with_channel_in(channel, space, next)),
1005                    window,
1006                    cx,
1007                );
1008            });
1009        }
1010
1011        if validity.is_invalid && !field_box.is_bare {
1012            field = field.border_1().border_color(colors.danger.color);
1013        }
1014        // A read-only field is legible but not interactive, so it reads the
1015        // same as disabled here (there is no editing affordance to remove).
1016        if self.is_disabled || self.is_read_only {
1017            field = field.opacity(layout.disabled_opacity);
1018        }
1019        if self.full_width {
1020            field = field.w_full();
1021        } else {
1022            field = field.w(px(200.));
1023        }
1024        // `useColorField` is `role: 'textbox'` on the input. The hex path
1025        // that composes `Input` lets that field carry the node instead.
1026        field = field.a11y_named(
1027            a11y::Role::TextInput,
1028            &a11y::Name::field(self.name.as_ref(), None, &validity),
1029        );
1030
1031        // `.color-field` is `flex flex-col gap-1`.
1032        let mut root = div()
1033            .flex()
1034            .flex_col()
1035            .gap(px(4.))
1036            .when(self.full_width, |root| root.w_full());
1037        if let Some(label) = self.label {
1038            root = root.child(
1039                crate::field::Label::new(label)
1040                    .is_required(self.is_required)
1041                    .is_disabled(self.is_disabled)
1042                    .is_invalid(self.is_invalid),
1043            );
1044        }
1045        root = root.child(field);
1046        if let Some(description) = self.description {
1047            root = root.child(crate::field::Description::new(description));
1048        }
1049        util::apply_sx(root, &self.sx).into_any_element()
1050    }
1051}
1052
1053// ---------------------------------------------------------------------------
1054
1055crate::util::impl_component_styled!(ColorField);