Skip to main content

herogpui_components/
number_field.rs

1//! NumberField — port of `@heroui/number-field` (v3).
2
3use std::{sync::Arc, time::Duration};
4
5use gpui::{
6    prelude::*, px, App, Entity, IntoElement, MouseDownEvent, MouseUpEvent, RenderOnce,
7    SharedString, Styled, Window,
8};
9use herogpui_core::{element_id, FieldVariant, NumberFormat};
10use herogpui_theme::ActiveTheme;
11
12use crate::a11y::A11y as _;
13
14use crate::{icons, input::InputState};
15
16/// State for a numeric input: text + parsed value.
17pub struct NumberState {
18    /// The text input entity that displays and edits the number.
19    pub input: Entity<InputState>,
20    value: f64,
21    min: f64,
22    max: f64,
23    has_min: bool,
24    has_max: bool,
25    step: f64,
26    /// `formatOptions`, written in by [`NumberField::format_options`]. The
27    /// state owns it because the state is what turns a value into text.
28    format: Option<NumberFormat>,
29}
30
31impl NumberState {
32    /// Creates a state seeded with `initial`, with no bounds and a step of 1.
33    pub fn new(cx: &mut App, initial: f64) -> Self {
34        let input = cx.new(|cx| {
35            let mut s = InputState::new(cx);
36            s.set_value(format_number(initial));
37            s
38        });
39        Self {
40            input,
41            value: initial,
42            min: f64::MIN,
43            max: f64::MAX,
44            has_min: false,
45            has_max: false,
46            step: 1.0,
47            format: None,
48        }
49    }
50
51    /// `defaultValue` — a state seeded with an initial number.
52    ///
53    /// The uncontrolled entry point; `new` already takes the value, so this is
54    /// its documented alias.
55    pub fn with_value(cx: &mut App, value: f64) -> Self {
56        Self::new(cx, value)
57    }
58
59    /// The current numeric value.
60    pub fn value(&self) -> f64 {
61        self.value
62    }
63
64    /// Writes a clamped value and syncs the text field.
65    pub fn set_value(&mut self, v: f64, cx: &mut App) {
66        self.value = v.clamp(self.min, self.max);
67        let text = self.display_text();
68        self.input.update(cx, |i, _| i.set_value(text));
69    }
70
71    /// The value as the field shows it: through `formatOptions` when one is
72    /// set, otherwise the plain number.
73    pub fn display_text(&self) -> String {
74        match &self.format {
75            Some(f) => f.format(self.value),
76            None => format_number(self.value),
77        }
78    }
79
80    /// Installs `formatOptions` and reformats the text to match.
81    ///
82    /// Typing is untouched — the raw text stays exactly as entered until
83    /// something writes a value back, which is when v3 reformats too.
84    pub fn set_format(&mut self, format: Option<NumberFormat>, cx: &mut App) {
85        if self.format == format {
86            return;
87        }
88        self.format = format;
89        let text = self.display_text();
90        self.input.update(cx, |i, _| i.set_value(text));
91    }
92
93    /// Sets the minimum and maximum bounds.
94    pub fn set_range(&mut self, min: f64, max: f64) {
95        self.min = min;
96        self.max = max;
97        self.has_min = true;
98        self.has_max = true;
99    }
100
101    /// The current `(min, max)` bounds.
102    pub fn range(&self) -> (f64, f64) {
103        (self.min, self.max)
104    }
105
106    fn set_component_range(&mut self, min: Option<f64>, max: Option<f64>) {
107        if let Some(min) = min {
108            self.min = min;
109            self.has_min = true;
110        }
111        if let Some(max) = max {
112            self.max = max;
113            self.has_max = true;
114        }
115    }
116
117    fn bounds(&self) -> (Option<f64>, Option<f64>) {
118        (
119            self.has_min.then_some(self.min),
120            self.has_max.then_some(self.max),
121        )
122    }
123
124    /// The current step size.
125    pub fn step_size(&self) -> f64 {
126        self.step
127    }
128
129    /// Sets the step size; non-finite or non-positive values fall back to 1.
130    pub fn set_step(&mut self, step: f64) {
131        self.step = if step.is_finite() && step > 0.0 {
132            step
133        } else {
134            1.0
135        };
136    }
137
138    /// Re-reads the text field and updates the numeric value (or restores the
139    /// previous formatted value when unparsable).
140    ///
141    /// The text may be what `formatOptions` produced (`$1,200.00`), so the
142    /// group separators and affixes come off before parsing — the field would
143    /// otherwise reject its own output.
144    pub fn sync_from_input(&mut self, cx: &mut App) {
145        if let Some(v) = parse_number(self.input.read(cx).value()) {
146            self.value = v.clamp(self.min, self.max);
147        } else {
148            let text = self.display_text();
149            self.input.update(cx, |i, _| i.set_value(text));
150        }
151    }
152
153    fn bump(&mut self, dir: f64, cx: &mut App) -> Option<f64> {
154        let min = self.has_min.then_some(self.min);
155        let max = self.has_max.then_some(self.max);
156        let snapped = snap_to_step(self.value, min, max, self.step);
157        let next = if (dir > 0.0 && snapped > self.value) || (dir < 0.0 && snapped < self.value) {
158            snapped
159        } else {
160            snap_to_step(self.value + dir * self.step, min, max, self.step)
161        };
162        if next.to_bits() == self.value.to_bits() {
163            return None;
164        }
165        self.set_value(next, cx);
166        Some(next)
167    }
168
169    fn snap(&self, value: f64) -> f64 {
170        let snapped = snap_to_step(
171            value,
172            self.has_min.then_some(self.min),
173            self.has_max.then_some(self.max),
174            self.step,
175        );
176        if snapped.is_finite() {
177            snapped
178        } else {
179            value.clamp(self.min, self.max)
180        }
181    }
182}
183
184fn snap_to_step(value: f64, min: Option<f64>, max: Option<f64>, step: f64) -> f64 {
185    let anchor = min.unwrap_or(0.0);
186    let mut snapped = ((value - anchor) / step).round() * step + anchor;
187    if let Some(min) = min {
188        snapped = snapped.max(min);
189    }
190    if let Some(max) = max {
191        if snapped > max {
192            snapped = anchor + ((max - anchor) / step).floor() * step;
193        }
194    }
195    round_to_step_precision(snapped, step)
196}
197
198fn round_to_step_precision(value: f64, step: f64) -> f64 {
199    let step_text = step.to_string().to_ascii_lowercase();
200    let precision = if let Some((mantissa, exponent)) = step_text.split_once('e') {
201        let exponent = exponent.parse::<i32>().unwrap_or(0);
202        let fraction = mantissa
203            .split_once('.')
204            .map_or(0, |(_, fraction)| fraction.len() as i32);
205        (fraction - exponent).max(0)
206    } else {
207        step_text
208            .split_once('.')
209            .map_or(0, |(_, fraction)| fraction.len() as i32)
210    };
211    if precision == 0 {
212        return value;
213    }
214    let scale = 10_f64.powi(precision);
215    if !scale.is_finite() {
216        return value;
217    }
218    (value * scale).round() / scale
219}
220
221/// Reads a number back out of formatted text: `$1,200.00` -> `1200.0`,
222/// `(€12.00)` -> `-12.0`, `42%` -> `42.0`.
223///
224/// Only the digits, sign and decimal point survive; a formatted value has to
225/// round-trip or the field rejects what it just rendered.
226pub fn parse_number(text: &str) -> Option<f64> {
227    let text = text.trim();
228    if text.is_empty() {
229        return None;
230    }
231    let accounting = text.starts_with('(') && text.ends_with(')');
232    let mut digits = String::with_capacity(text.len());
233    for c in text.chars() {
234        match c {
235            '0'..='9' | '.' => digits.push(c),
236            '-' if digits.is_empty() => digits.push('-'),
237            _ => {}
238        }
239    }
240    let v: f64 = digits.parse().ok()?;
241    Some(if accounting { -v } else { v })
242}
243
244fn format_number(v: f64) -> String {
245    if (v - v.round()).abs() < 1e-9 && v.abs() < 1e15 {
246        format!("{}", v.round() as i64)
247    } else {
248        format!("{v}")
249    }
250}
251
252type OnChange = Arc<dyn Fn(&f64, &mut Window, &mut App) + 'static>;
253
254/// Values HeroUI supplies to a NumberField root render function.
255#[derive(Clone, Copy, Debug)]
256#[non_exhaustive]
257pub struct NumberFieldRenderState {
258    /// Whether the field is disabled.
259    pub is_disabled: bool,
260    /// Whether the field is invalid.
261    pub is_invalid: bool,
262    /// Whether the field is read-only.
263    pub is_read_only: bool,
264    /// Whether the field is required.
265    pub is_required: bool,
266    /// Whether the field has focus.
267    pub is_focused: bool,
268    /// Whether focus is within the field.
269    pub is_focus_within: bool,
270    /// Whether focus is visible (keyboard focus).
271    pub is_focus_visible: bool,
272    /// The current value.
273    pub value: f64,
274    /// The minimum bound, if any.
275    pub min_value: Option<f64>,
276    /// The maximum bound, if any.
277    pub max_value: Option<f64>,
278    /// The step size.
279    pub step: f64,
280}
281
282/// HeroUI NumberField.
283#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
284#[derive(IntoElement)]
285pub struct NumberField {
286    state: Entity<NumberState>,
287    /// See [`NumberField::content`].
288    content: Option<Arc<dyn Fn(NumberFieldRenderState) -> gpui::AnyElement + 'static>>,
289    /// `Description` — v3 composes it as a sibling of `NumberField.Group`.
290    description: Option<SharedString>,
291    label: Option<SharedString>,
292    hide_steppers: bool,
293    increment_icon: Option<gpui::AnyElement>,
294    decrement_icon: Option<gpui::AnyElement>,
295    vertical_steppers: bool,
296    is_disabled: bool,
297    variant: FieldVariant,
298    full_width: bool,
299    /// Optional box geometry/chrome overrides; defaults are the stock group.
300    field: crate::util::FieldBox,
301    /// The group's hover endpoint, in place of the variant's token.
302    group_hover_bg: Option<gpui::Hsla>,
303    min_value: Option<f64>,
304    max_value: Option<f64>,
305    step: Option<f64>,
306    /// `formatOptions` — installed into the state, which owns the text.
307    format: Option<NumberFormat>,
308    /// `name` — forwarded to the inner field's state.
309    name: Option<SharedString>,
310    /// `defaultValue` — seeds the state on the first render only.
311    default_value: Option<f64>,
312    /// `value` — the controlled spelling; seeds the state on the first render
313    /// only, ahead of `default_value`. See [`NumberField::value`].
314    value: Option<f64>,
315    /// `validationBehavior` — written into the inner field's state on render.
316    validation_behavior: Option<crate::form::ValidationBehavior>,
317    /// `validate` — run by the component, not the caller.
318    validate: Option<crate::validation::Validator<f64>>,
319    /// `validationErrors` — messages from a server round-trip.
320    validation_errors: Vec<SharedString>,
321    is_invalid: bool,
322    is_required: bool,
323    is_read_only: bool,
324    /// `isWheelDisabled` — suppress focused wheel stepping.
325    is_wheel_disabled: bool,
326    /// `autoFocus` — take focus on the first render.
327    auto_focus: bool,
328    /// The field text's family, forwarded to the inner `Input`.
329    font_family: Option<SharedString>,
330    on_change: Option<OnChange>,
331    /// The `sx` slot, refined over the root style at the end of render.
332    sx: Option<Box<gpui::StyleRefinement>>,
333}
334
335impl NumberField {
336    /// `minValue` — also settable on [`NumberState::set_range`].
337    pub fn min_value(mut self, v: f64) -> Self {
338        self.min_value = Some(v);
339        self
340    }
341
342    /// `maxValue`
343    pub fn max_value(mut self, v: f64) -> Self {
344        self.max_value = Some(v);
345        self
346    }
347
348    /// `step`
349    pub fn step(mut self, v: f64) -> Self {
350        self.step = Some(v);
351        self
352    }
353
354    /// `name` — the name this field submits under.
355    ///
356    /// Forwarded to the inner text field's state, which is where
357    /// `FormField::number` looks for it.
358    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
359        self.name = Some(name.into());
360        self
361    }
362
363    /// `defaultValue` — the uncontrolled initial number.
364    ///
365    /// Written into the state on the first render only. `NumberState::with_value`
366    /// does the same at construction; this is the prop spelling.
367    pub fn default_value(mut self, value: f64) -> Self {
368        self.default_value = Some(value);
369        self
370    }
371
372    /// `value` — v3's controlled-value spelling, as a pure builder.
373    ///
374    /// Like [`NumberField::default_value`] this seeds the state on the first
375    /// render only, and it wins when both are given; calling `.value(..)`
376    /// twice keeps the last call, like every other builder here. A later
377    /// value is an imperative update rather than a builder:
378    /// `state.update(cx, |s, cx| s.set_value(v, cx))`.
379    pub fn value(mut self, value: f64) -> Self {
380        self.value = Some(value);
381        self
382    }
383
384    /// `validationBehavior` — see [`crate::input::Input::validation_behavior`].
385    pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
386        self.validation_behavior = Some(behavior);
387        self
388    }
389
390    /// `formatOptions` — how the value is written out.
391    ///
392    /// ```
393    /// # use gpui::{prelude::*, Window};
394    /// # use herogpui_components::{NumberField, NumberFormat, NumberState};
395    /// # struct Demo;
396    /// # impl Render for Demo {
397    /// #     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
398    /// #         let state = cx.new(|cx| NumberState::new(cx, 42.));
399    /// NumberField::new(state).format_options(NumberFormat::currency("USD"))
400    /// #     }
401    /// # }
402    /// # let mut tcx = gpui::TestAppContext::single();
403    /// # tcx.update(herogpui_theme::ThemeProvider::init);
404    /// # let _ = tcx.add_window_view(|_, _| Demo);
405    /// ```
406    pub fn format_options(mut self, format: NumberFormat) -> Self {
407        self.format = Some(format);
408        self
409    }
410
411    /// Sets the field variant.
412    pub fn variant(mut self, variant: FieldVariant) -> Self {
413        self.variant = variant;
414        self
415    }
416
417    /// Sets whether the field fills the available width.
418    pub fn full_width(mut self, v: bool) -> Self {
419        self.full_width = v;
420        self
421    }
422
423    /// Replaces the 36px group height. The inner field follows so its own box
424    /// cannot defeat a smaller group.
425    pub fn height(mut self, h: impl Into<gpui::Pixels>) -> Self {
426        self.field.height = Some(h.into());
427        self
428    }
429
430    /// Replaces the input's `px-3` horizontal padding. The steppers keep their
431    /// own 40px slots at the ends of the group.
432    pub fn padding_x(mut self, p: impl Into<gpui::Pixels>) -> Self {
433        self.field.padding_x = Some(p.into());
434        self
435    }
436
437    /// Renders the group with no background, border, field shadow, focus ring
438    /// or hover fill, for a caller painting around it. The field stays
439    /// editable and focusable.
440    pub fn is_bare(mut self, v: bool) -> Self {
441        self.field.is_bare = v;
442        self.field.is_bare_is_set = true;
443        self
444    }
445
446    /// The group's fill while hovered, in place of `--field-hover`
447    /// (`--default-hover` on the secondary variant). The 150ms ease-smooth
448    /// ramp and the border hover are unchanged; a focused, invalid, disabled
449    /// or bare group does not hover, so the override never reaches those
450    /// states. Not a v3 prop: v3 tints the group with a class.
451    pub fn group_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
452        self.group_hover_bg = Some(color.into());
453        self
454    }
455
456    /// The field text's font family, forwarded to the inner [`crate::Input`]
457    /// so the value, placeholder and caret measurement all use it (see
458    /// [`crate::Input::font_family`]), and set on the group so custom stepper
459    /// content inherits it; unset keeps the inherited family. Not a v3 prop;
460    /// v3 sets it with a class.
461    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
462        self.font_family = Some(family.into());
463        self
464    }
465
466    /// Shows or hides only the group's visual focus ring. The number field
467    /// remains focusable, editable and stepper-accessible when set to `false`.
468    pub fn focus_ring(mut self, v: bool) -> Self {
469        self.field.focus_ring = Some(v);
470        self
471    }
472
473    /// The one slot for caller-owned low-level styling: GPUI's styling methods
474    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
475    /// applied to the field's root element — the column holding the label, the
476    /// group and the message — after every value the variant and the active
477    /// theme chose, so they win. The group's own chrome stays with the variant.
478    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
479        crate::util::refine_sx(&mut self.sx, style);
480        self
481    }
482
483    /// `validate` — returns the message to show, or `None` when the number is fine.
484    ///
485    /// The component runs it and surfaces the result.
486    pub fn validate(mut self, f: impl Fn(&f64) -> Option<SharedString> + 'static) -> Self {
487        self.validate = Some(Arc::new(f));
488        self
489    }
490
491    /// `validationErrors` — messages produced elsewhere, shown ahead of
492    /// whatever `validate` returns.
493    pub fn validation_errors(
494        mut self,
495        errors: impl IntoIterator<Item = impl Into<SharedString>>,
496    ) -> Self {
497        self.validation_errors = errors.into_iter().map(Into::into).collect();
498        self
499    }
500
501    /// Sets whether the field is invalid (`isInvalid`).
502    pub fn is_invalid(mut self, v: bool) -> Self {
503        self.is_invalid = v;
504        self
505    }
506
507    /// Sets whether the field is required (`isRequired`).
508    pub fn is_required(mut self, v: bool) -> Self {
509        self.is_required = v;
510        self
511    }
512
513    /// `autoFocus` — take focus on the first render.
514    pub fn auto_focus(mut self, v: bool) -> Self {
515        self.auto_focus = v;
516        self
517    }
518
519    /// Sets whether the field is read-only (`isReadOnly`).
520    pub fn is_read_only(mut self, v: bool) -> Self {
521        self.is_read_only = v;
522        self
523    }
524
525    /// `isWheelDisabled` — whether focused wheel input may step the value.
526    pub fn is_wheel_disabled(mut self, v: bool) -> Self {
527        self.is_wheel_disabled = v;
528        self
529    }
530
531    /// v3's field `children`-as-a-function.
532    pub fn content(
533        mut self,
534        render: impl Fn(NumberFieldRenderState) -> gpui::AnyElement + 'static,
535    ) -> Self {
536        self.content = Some(Arc::new(render));
537        self
538    }
539
540    /// Builds the field over `state`, which may be borrowed —
541    /// `NumberField::new(&handle)` — so a caller keeping its own handle clones
542    /// nothing at the call site; an owned `Entity<NumberState>` still works,
543    /// and the cheap handle clone happens once inside either way.
544    pub fn new(state: impl std::borrow::Borrow<Entity<NumberState>>) -> Self {
545        Self {
546            content: None,
547            state: state.borrow().clone(),
548            description: None,
549            label: None,
550            hide_steppers: false,
551            increment_icon: None,
552            decrement_icon: None,
553            vertical_steppers: false,
554            is_disabled: false,
555            variant: FieldVariant::Primary,
556            full_width: false,
557            min_value: None,
558            max_value: None,
559            step: None,
560            format: None,
561            name: None,
562            default_value: None,
563            value: None,
564            validation_behavior: None,
565            validate: None,
566            validation_errors: Vec::new(),
567            is_invalid: false,
568            is_required: false,
569            is_read_only: false,
570            is_wheel_disabled: false,
571            auto_focus: false,
572            font_family: None,
573            on_change: None,
574            sx: None,
575            field: crate::util::FieldBox::default(),
576            group_hover_bg: None,
577        }
578    }
579
580    /// `Description` — help text under the field.
581    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
582        self.description = Some(text.into());
583        self
584    }
585
586    /// Sets the label shown above the field.
587    pub fn label(mut self, l: impl Into<SharedString>) -> Self {
588        self.label = Some(l.into());
589        self
590    }
591
592    /// Hides the -/+ steppers (`hideStepper`).
593    pub fn hide_steppers(mut self, v: bool) -> Self {
594        self.hide_steppers = v;
595        self
596    }
597
598    /// `NumberField.IncrementButton` children.
599    pub fn increment_icon(mut self, icon: impl IntoElement) -> Self {
600        self.increment_icon = Some(icon.into_any_element());
601        self
602    }
603
604    /// `NumberField.DecrementButton` children.
605    pub fn decrement_icon(mut self, icon: impl IntoElement) -> Self {
606        self.decrement_icon = Some(icon.into_any_element());
607        self
608    }
609
610    /// The documented chevron composition: two 24px half-height steppers at
611    /// the trailing edge instead of the default 40px side buttons.
612    pub fn vertical_steppers(mut self, v: bool) -> Self {
613        self.vertical_steppers = v;
614        self
615    }
616
617    /// Sets whether the field is disabled (`isDisabled`).
618    pub fn is_disabled(mut self, v: bool) -> Self {
619        self.is_disabled = v;
620        self
621    }
622
623    /// Sets the handler called when the value changes (`onChange`).
624    pub fn on_change(mut self, f: impl Fn(&f64, &mut Window, &mut App) + 'static) -> Self {
625        self.on_change = Some(Arc::new(f));
626        self
627    }
628}
629
630impl RenderOnce for NumberField {
631    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
632        // Component-level `minValue`/`maxValue`/`step` win over whatever the
633        // state was seeded with. Bound presence is stored separately from its
634        // number, because f64::MIN/MAX are also legitimate explicit bounds.
635        if self.min_value.is_some() || self.max_value.is_some() {
636            self.state.update(cx, |s, _| {
637                s.set_component_range(self.min_value, self.max_value);
638            });
639        }
640        if let Some(step) = self.step {
641            if step.to_bits() != self.state.read(cx).step_size().to_bits() {
642                self.state.update(cx, |s, _| s.set_step(step));
643            }
644        }
645
646        // Every keyed slot hangs off the state entity: a `NumberField` takes
647        // no id of its own, and the entity is the identity it does have.
648        let base_id =
649            gpui::ElementId::named_usize("number", self.state.entity_id().as_u64() as usize);
650
651        // `value` / `defaultValue` seed the state once, before anything reads
652        // it. `value` is v3's controlled spelling, so it outranks the
653        // uncontrolled seed; the state owns the number afterwards, and
654        // `NumberState::set_value` is the imperative update.
655        if let Some(value) = self.value.or(self.default_value) {
656            let state = self.state.clone();
657            crate::util::seed_once(
658                window,
659                cx,
660                element_id::scoped(&base_id, "default"),
661                move |cx| {
662                    state.update(cx, |s, cx| {
663                        s.set_value(value, cx);
664                        cx.notify();
665                    });
666                },
667            );
668        }
669
670        // `formatOptions` lives in the state, which owns the text. `set_format`
671        // is a no-op when it already matches, so this does not loop.
672        if let Some(format) = self.format.clone() {
673            self.state
674                .update(cx, |s, cx| s.set_format(Some(format), cx));
675        }
676
677        let input_state = self.state.read(cx).input.clone();
678        if input_state.read(cx).name() != self.name {
679            let name = self.name.clone();
680            input_state.update(cx, |state, _| state.set_name(name));
681        }
682        if let Some(behavior) = self.validation_behavior {
683            if input_state.read(cx).validation_behavior() != behavior {
684                input_state.update(cx, |state, _| state.set_validation_behavior(behavior));
685            }
686        }
687        let is_successful = !self.is_disabled;
688        if input_state.read(cx).is_successful() != is_successful {
689            input_state.update(cx, |state, _| state.set_successful(is_successful));
690        }
691
692        // v3 order: the controlled flag, then server errors, then `validate`.
693        // The server slot carries the messages the `Form`'s
694        // `validationErrors` record routed into the inner state by name,
695        // ahead of this field's own `validationErrors` prop.
696        let value_now = self.state.read(cx).value();
697        let mut server_errors = input_state.read(cx).routed_errors().to_vec();
698        server_errors.extend(self.validation_errors.iter().cloned());
699        let validity = crate::validation::resolve(
700            self.is_invalid,
701            &server_errors,
702            self.validate.as_ref().and_then(|f| f(&value_now)),
703            None,
704        );
705        if input_state.read(cx).validity() != &validity {
706            let validity = validity.clone();
707            input_state.update(cx, |state, _| state.set_validity(validity));
708        }
709
710        let focus_handle = input_state.read(cx).focus_handle.clone();
711        if self.auto_focus {
712            crate::util::focus_once(
713                window,
714                cx,
715                element_id::scoped(&base_id, "autofocus"),
716                &focus_handle,
717            );
718        }
719
720        // v3's field children-as-a-function: the caller builds the parts from
721        // the resolved field state, so only the built-in stack is skipped.
722        if let Some(render) = self.content.clone() {
723            let state = self.state.read(cx);
724            let focused = focus_handle.is_focused(window);
725            let (min_value, max_value) = state.bounds();
726            return render(NumberFieldRenderState {
727                is_disabled: self.is_disabled,
728                is_invalid: validity.is_invalid,
729                is_read_only: self.is_read_only,
730                is_required: self.is_required,
731                is_focused: focused,
732                is_focus_within: focus_handle.contains_focused(window, cx),
733                is_focus_visible: focused && crate::util::focus_visible(cx),
734                value: state.value(),
735                min_value,
736                max_value,
737                step: state.step_size(),
738            });
739        }
740
741        let colors = cx.colors().clone();
742        let layout = cx.layout().clone();
743        let field_box = self.field;
744
745        // `.number-field__group` is `h-9`, the one height every v3 field has.
746        let h = field_box.resolved_height();
747        // `.number-field__increment-button` is `h-full w-10`: a 40px square-ish
748        // slot at the end of the group, not the 26px one this used to draw.
749        let btn_px = px(40.);
750
751        // text field bound to the inner InputState
752        let text_state = self.state.clone();
753        let on_text_change = self.on_change.clone();
754        let mut field = crate::input::Input::new(self.state.read(cx).input.clone())
755            .when_some(self.name.clone(), |f, n| f.name(n))
756            .when_some(self.validation_behavior, |f, b| f.validation_behavior(b))
757            .variant(self.variant)
758            .is_disabled(self.is_disabled)
759            .is_read_only(self.is_read_only)
760            .is_required(self.is_required)
761            .auto_focus(self.auto_focus)
762            .when_some(self.font_family.clone(), |f, family| f.font_family(family))
763            .is_invalid(validity.is_invalid)
764            .when_some(self.label.clone(), |f, label| f.a11y_label(label))
765            .on_change(move |_text: &str, w, cx| {
766                // The Input already wrote its own text; re-parse here.
767                let before = text_state.read(cx).value();
768                let after = text_state.update(cx, |s, sc| {
769                    s.sync_from_input(sc);
770                    sc.notify();
771                    s.value()
772                });
773                if before.to_bits() != after.to_bits() {
774                    if let Some(cb) = &on_text_change {
775                        cb(&after, w, cx);
776                    }
777                }
778            });
779
780        // `.number-field__group` is one box -- `grid h-9 rounded-field bg-field
781        // shadow-field overflow-hidden` -- with a 40px decrement button, the
782        // input, and a 40px increment button inside it, each separated by a
783        // hairline. The steppers used to sit *outside* the field as two loose
784        // buttons, which is not a shape v3 has.
785        let steppers = !self.hide_steppers;
786        let group_radius = crate::util::field_radius(cx);
787        // NumberField.Input keeps `px-3` even beside either button. Passing
788        // false on both sides removes the standalone chrome without borrowing
789        // InputGroup's addon-padding behavior. The group owns the row and its
790        // chrome; a padding override travels through Input's crate-internal
791        // grouped seam so the public `Input::padding_x` contract is untouched.
792        field = field.in_group(false, false);
793        if let Some(padding_x) = field_box.padding_x {
794            field = field.group_padding_x(padding_x);
795        }
796        if let Some(height) = field_box.height {
797            field = field.height(height);
798        }
799
800        // `useNumberField` returns `groupProps` with `role: 'group'`; the
801        // spin button role its `useSpinButton` produces is deleted again
802        // (`role: null`) before it reaches the input, so nothing here is a
803        // spin button.
804        let a11y_name =
805            crate::a11y::Name::field(self.label.as_ref(), self.description.as_ref(), &validity);
806        // The group's settled state ring, parked for the non-clipping carrier
807        // at the tail of this render; see the chrome call below.
808        let mut settled_ring = None;
809        let mut carries_ring = false;
810        let mut group = gpui::div()
811            .id(element_id::scoped(&base_id, "group"))
812            .a11y_named(crate::a11y::Role::Group, &a11y_name)
813            // The family also reaches caller stepper content (`increment_icon`
814            // / `decrement_icon`); the inner field takes it explicitly above
815            // for its caret measurement.
816            .when_some(self.font_family.clone(), |group, family| group.font_family(family))
817            .flex()
818            .items_center()
819            .h(h)
820            .overflow_hidden()
821            .text_size(crate::util::FIELD_TEXT)
822            .line_height(px(20.));
823        if !field_box.is_bare {
824            // The settled group chrome comes from the same shared helper the
825            // other fields take — variant fill and shadow, the invalid danger
826            // outline or ring, the keyboard focus ring.
827            let focused = focus_handle.is_focused(window);
828            let show_focus_ring = field_box.focus_ring.unwrap_or(true);
829            carries_ring = true;
830            // `.number-field__group` is `overflow-hidden` around the input and
831            // the steppers, so the settled ring cannot be one of its children:
832            // the group takes the chrome without it and the ring hangs on
833            // `util::field_ring_carrier` below, outside the clip.
834            settled_ring =
835                crate::util::field_ring_color(validity.is_invalid, focused, show_focus_ring, cx);
836            group = crate::util::apply_field_chrome_ringless(
837                group,
838                self.variant,
839                validity.is_invalid,
840                focused,
841                show_focus_ring,
842                None,
843                cx,
844            );
845            // `.number-field__group` transitions all three chrome properties
846            // rather than swapping them (lines 39-43: `background-color 150ms
847            // var(--ease-smooth), border-color 150ms var(--ease-smooth),
848            // box-shadow 150ms var(--ease-out)`), so the same state table
849            // resolves again as ramp endpoints and the shared chrome ramp
850            // interpolates between them: focus-within fills `--field-focus`
851            // and hover fills `--field-hover`/`--field-border-hover` only
852            // while the group is neither focused nor invalid. The ramp owns
853            // the group's border and ring past its first flip, so the
854            // helper's instant ones stop being cast.
855            let ringed = focused && show_focus_ring;
856            // The shared helper's chain is an else-if: a ringed group carries
857            // no border at all (the ring replaces the chrome), and an invalid
858            // group that is not ringed carries the one-pixel danger outline.
859            let outlined = validity.is_invalid && !ringed;
860            let idle = crate::anim::FieldChrome {
861                bg: match self.variant {
862                    // `--field-focus: var(--field-background)` — focus and
863                    // invalid fill the group with the field token itself.
864                    FieldVariant::Primary => colors.field.background,
865                    FieldVariant::Secondary => colors.default.color,
866                },
867                border: if outlined {
868                    colors.danger.color
869                } else {
870                    colors.field.border
871                },
872                border_width: if outlined {
873                    layout.border_width.max(px(1.))
874                } else if ringed {
875                    px(0.)
876                } else {
877                    layout.field_border_width
878                },
879                ring: if ringed {
880                    Some(if validity.is_invalid {
881                        crate::anim::danger_ring_endpoint(cx)
882                    } else {
883                        crate::anim::focus_ring_endpoint(cx)
884                    })
885                } else {
886                    None
887                },
888            };
889            let hovered = (!self.is_disabled && !validity.is_invalid && !focused).then(|| {
890                crate::anim::FieldChrome {
891                    bg: self.group_hover_bg.unwrap_or(match self.variant {
892                        FieldVariant::Primary => colors.field.hover(),
893                        // `.number-field--secondary` hovers
894                        // `--number-field-group-bg-hover: var(--default-hover)`.
895                        FieldVariant::Secondary => colors.default.hover(),
896                    }),
897                    border: colors.field.border_hover(),
898                    border_width: layout.field_border_width,
899                    ring: None,
900                }
901            });
902            let ramp_owns_ring;
903            (group, ramp_owns_ring) = crate::anim::field_chrome_ramp(
904                group,
905                &element_id::scoped(&base_id, "group"),
906                idle,
907                hovered,
908                match self.variant {
909                    FieldVariant::Primary => layout.field_shadow.clone(),
910                    FieldVariant::Secondary => Vec::new(),
911                },
912                group_radius,
913                // The group is `overflow-hidden` — its own clip would cut an
914                // outset ring painted by a child, so the ring layer paints
915                // deferred instead.
916                true,
917                window,
918                cx,
919            );
920            // Past its first flip the ramp's own layer is the state ring, and
921            // painting the carrier's overlay as well would draw two.
922            if ramp_owns_ring {
923                settled_ring = None;
924                carries_ring = false;
925            }
926        }
927        if self.full_width {
928            group = group.w_full();
929        } else {
930            group = group.w(px(220.));
931        }
932
933        // `border-field-placeholder/15` is the seam between a stepper and the
934        // input; the buttons themselves are transparent.
935        let seam = colors.field.placeholder.alpha(0.15);
936        let decrement_icon = self.decrement_icon.unwrap_or_else(|| {
937            gpui::svg()
938                .size(crate::util::FIELD_ICON)
939                .path(icons::MINUS)
940                .text_color(colors.field.foreground)
941                .into_any_element()
942        });
943        let increment_icon = self.increment_icon.unwrap_or_else(|| {
944            gpui::svg()
945                .size(crate::util::FIELD_ICON)
946                .path(icons::PLUS)
947                .text_color(colors.field.foreground)
948                .into_any_element()
949        });
950        let field = gpui::div().flex_1().min_w_0().child(field);
951        // A vertical stepper column stacks two half-height buttons; an
952        // explicit group height shrinks them so both stay reachable inside
953        // the overflow-hidden box.
954        let vertical_stepper_h = field_box
955            .height
956            .map_or(px(18.), |height| px(f32::from(height) / 2.0));
957        if !steppers {
958            group = group.child(field);
959        } else if self.vertical_steppers {
960            group = group.child(field).child(
961                gpui::div()
962                    .flex()
963                    .flex_col()
964                    .flex_shrink_0()
965                    .w(px(24.))
966                    .h_full()
967                    .border_l_1()
968                    .border_color(seam)
969                    .child(stepper_btn(
970                        &self.state,
971                        &self.on_change,
972                        self.is_invalid,
973                        self.validation_errors.clone(),
974                        self.validate.clone(),
975                        &colors,
976                        layout.field_border_width,
977                        vertical_stepper_h,
978                        px(24.),
979                        increment_icon,
980                        1.0,
981                        self.is_disabled || self.is_read_only,
982                        &a11y_name,
983                        window,
984                        cx,
985                    ))
986                    .child(stepper_btn(
987                        &self.state,
988                        &self.on_change,
989                        self.is_invalid,
990                        self.validation_errors.clone(),
991                        self.validate.clone(),
992                        &colors,
993                        layout.field_border_width,
994                        vertical_stepper_h,
995                        px(24.),
996                        decrement_icon,
997                        -1.0,
998                        self.is_disabled || self.is_read_only,
999                        &a11y_name,
1000                        window,
1001                        cx,
1002                    )),
1003            );
1004        } else {
1005            group = group
1006                .child(
1007                    stepper_btn(
1008                        &self.state,
1009                        &self.on_change,
1010                        self.is_invalid,
1011                        self.validation_errors.clone(),
1012                        self.validate.clone(),
1013                        &colors,
1014                        layout.field_border_width,
1015                        h,
1016                        btn_px,
1017                        decrement_icon,
1018                        -1.0,
1019                        self.is_disabled || self.is_read_only,
1020                        &a11y_name,
1021                        window,
1022                        cx,
1023                    )
1024                    .border_r_1()
1025                    .border_color(seam),
1026                )
1027                .child(field)
1028                .child(
1029                    stepper_btn(
1030                        &self.state,
1031                        &self.on_change,
1032                        self.is_invalid,
1033                        self.validation_errors.clone(),
1034                        self.validate.clone(),
1035                        &colors,
1036                        layout.field_border_width,
1037                        h,
1038                        btn_px,
1039                        increment_icon,
1040                        1.0,
1041                        self.is_disabled || self.is_read_only,
1042                        &a11y_name,
1043                        window,
1044                        cx,
1045                    )
1046                    .border_l_1()
1047                    .border_color(seam),
1048                );
1049        }
1050        // React Aria drives a number field from `useSpinButton`: the arrows
1051        // step by `step`, Home and End run to the bounds, and Page Up/Down fall
1052        // back to a plain step because `NumberField` passes no page handlers.
1053        // The keys arrive at the focused input and bubble to here.
1054        if !self.is_disabled && !self.is_read_only {
1055            let key_state = self.state.clone();
1056            let key_change = self.on_change.clone();
1057            let key_is_invalid = self.is_invalid;
1058            let key_validation_errors = self.validation_errors.clone();
1059            let key_validate = self.validate.clone();
1060            let (min_value, max_value) = self.state.read(cx).bounds();
1061            group = group.on_key_down(move |ev: &gpui::KeyDownEvent, window, cx| {
1062                let dir = match ev.keystroke.key.as_str() {
1063                    "up" | "pageup" => 1.0,
1064                    "down" | "pagedown" => -1.0,
1065                    "home" => {
1066                        let Some(min) = min_value else {
1067                            return;
1068                        };
1069                        if set_number_value(&key_state, min, cx) {
1070                            suppress_routed_server_errors(
1071                                &key_state,
1072                                key_is_invalid,
1073                                &key_validation_errors,
1074                                key_validate.as_ref(),
1075                                cx,
1076                            );
1077                            if let Some(cb) = &key_change {
1078                                cb(&key_state.read(cx).value(), window, cx);
1079                            }
1080                        }
1081                        return;
1082                    }
1083                    "end" => {
1084                        let Some(max) = max_value else {
1085                            return;
1086                        };
1087                        if set_number_value(&key_state, max, cx) {
1088                            suppress_routed_server_errors(
1089                                &key_state,
1090                                key_is_invalid,
1091                                &key_validation_errors,
1092                                key_validate.as_ref(),
1093                                cx,
1094                            );
1095                            if let Some(cb) = &key_change {
1096                                cb(&key_state.read(cx).value(), window, cx);
1097                            }
1098                        }
1099                        return;
1100                    }
1101                    _ => return,
1102                };
1103                report_bump(
1104                    &key_state,
1105                    dir,
1106                    &key_change,
1107                    key_is_invalid,
1108                    &key_validation_errors,
1109                    key_validate.as_ref(),
1110                    window,
1111                    cx,
1112                );
1113            });
1114        }
1115        if !self.is_disabled && !self.is_read_only && !self.is_wheel_disabled {
1116            let wheel_state = self.state.clone();
1117            let wheel_change = self.on_change.clone();
1118            let wheel_is_invalid = self.is_invalid;
1119            let wheel_validation_errors = self.validation_errors.clone();
1120            let wheel_validate = self.validate.clone();
1121            let wheel_focus = self.state.read(cx).input.read(cx).focus_handle.clone();
1122            group = group.on_scroll_wheel(move |event, window, cx| {
1123                if !wheel_focus.contains_focused(window, cx) {
1124                    return;
1125                }
1126                let (dx, dy) = match event.delta {
1127                    gpui::ScrollDelta::Pixels(point) => (f32::from(point.x), f32::from(point.y)),
1128                    gpui::ScrollDelta::Lines(point) => (point.x, point.y),
1129                };
1130                if event.modifiers.control || event.modifiers.platform {
1131                    return;
1132                }
1133                let direction = if dy != 0.0 { dy.signum() } else { dx.signum() };
1134                if direction != 0.0 {
1135                    report_bump(
1136                        &wheel_state,
1137                        f64::from(direction),
1138                        &wheel_change,
1139                        wheel_is_invalid,
1140                        &wheel_validation_errors,
1141                        wheel_validate.as_ref(),
1142                        window,
1143                        cx,
1144                    );
1145                }
1146                cx.stop_propagation();
1147            });
1148        }
1149        if self.is_disabled {
1150            group = group.opacity(layout.disabled_opacity);
1151        }
1152
1153        // The label, description and error slot belong to the field, not to the
1154        // group: v3 composes them as siblings of `NumberField.Group`.
1155        let mut el = gpui::div().flex().flex_col().gap(px(4.));
1156        if self.full_width {
1157            el = el.w_full();
1158        }
1159        if let Some(label) = &self.label {
1160            el = el.child(
1161                crate::field::Label::new(label.clone())
1162                    .is_required(self.is_required)
1163                    .is_invalid(validity.is_invalid)
1164                    .is_disabled(self.is_disabled),
1165            );
1166        }
1167        el = el.child(if carries_ring {
1168            crate::util::field_ring_carrier(group, settled_ring, group_radius, cx)
1169                .into_any_element()
1170        } else {
1171            group.into_any_element()
1172        });
1173        // Every message, space-joined in upstream order — React Aria's
1174        // `FieldError` default — not just the first. The shared feedback row
1175        // retains the last message while its height and opacity settle out.
1176        let error = (!validity.messages.is_empty()).then(|| validity.joined().into());
1177        if let Some(error) = crate::anim::field_error_panel(&base_id, error, window, cx) {
1178            el = el.child(error);
1179        } else if let Some(description) = self.description.clone() {
1180            el = el.child(crate::field::Description::new(description));
1181        }
1182        el = crate::util::apply_sx(el, &self.sx);
1183        el.into_any_element()
1184    }
1185}
1186
1187/// One stepper cell: `flex h-full w-10 items-center justify-center
1188/// rounded-none bg-transparent`, pressed at `bg-field-foreground/10`.
1189#[allow(clippy::too_many_arguments)]
1190fn stepper_btn(
1191    state: &Entity<NumberState>,
1192    on_change: &Option<OnChange>,
1193    is_invalid: bool,
1194    validation_errors: Vec<SharedString>,
1195    validate: Option<crate::validation::Validator<f64>>,
1196    colors: &herogpui_theme::ThemeColors,
1197    field_border_width: gpui::Pixels,
1198    h: gpui::Pixels,
1199    btn_px: gpui::Pixels,
1200    icon: gpui::AnyElement,
1201    dir: f64,
1202    is_disabled: bool,
1203    field_name: &crate::a11y::Name,
1204    window: &mut Window,
1205    cx: &mut App,
1206) -> gpui::Stateful<gpui::Div> {
1207    let st = state.clone();
1208    let on_change = on_change.clone();
1209    let edit_validation_errors = validation_errors;
1210    let edit_validate = validate.clone();
1211    // `dir` is the +1/-1 step, so the part is named rather than formatted:
1212    // `-1` would otherwise put a second hyphen in the middle of the key.
1213    let id = element_id::scoped(
1214        &gpui::ElementId::named_usize("num", state.entity_id().as_u64() as usize),
1215        if dir >= 0.0 { "increment" } else { "decrement" },
1216    );
1217    let press = window.use_keyed_state(element_id::scoped(&id, "press"), cx, |_, _| {
1218        StepperPress::default()
1219    });
1220    let focus_handle = state.read(cx).input.read(cx).focus_handle.clone();
1221    // `useNumberField` names each stepper "Increase {label}" / "Decrease
1222    // {label}", spelling the field's name into the button's own `aria-label`
1223    // rather than pointing at the label element; the template (and its word
1224    // order) comes from the locale's catalogue.
1225    let stepper_key = if dir >= 0.0 {
1226        crate::i18n::UiString::Increase
1227    } else {
1228        crate::i18n::UiString::Decrease
1229    };
1230    let stepper_name = crate::a11y::Name::labelled(crate::i18n::ui_string_with(
1231        stepper_key,
1232        field_name.label().map_or("", |label| label.as_ref()),
1233        cx,
1234    ));
1235    let mut b = gpui::div()
1236        .id(id)
1237        .a11y_named(crate::a11y::Role::Button, &stepper_name)
1238        .flex()
1239        .items_center()
1240        .justify_center()
1241        .flex_shrink_0()
1242        .w(btn_px)
1243        .h(h)
1244        // HeroUI gives each spin button the field border token. The default
1245        // theme keeps this at zero, while custom themes can opt into a visible
1246        // field border; carrying it here prevents the stepper cells from
1247        // losing the shared shell's border treatment in that configuration.
1248        .border(field_border_width)
1249        .border_color(colors.field.border);
1250    // The icon joins the skin before the press wrap: children added after
1251    // `pressed` land on the slot and fight the skin for width.
1252    b = b.text_color(colors.field.foreground).child(icon);
1253    if !is_disabled {
1254        let release = press.clone();
1255        b = b.child(
1256            gpui::canvas(
1257                |bounds, _, _| bounds,
1258                move |_, _, window, _| {
1259                    window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| {
1260                        if phase == gpui::DispatchPhase::Capture
1261                            && event.button == gpui::MouseButton::Left
1262                        {
1263                            release.update(cx, |press, _| press.active = false);
1264                        }
1265                    });
1266                },
1267            )
1268            .absolute()
1269            .inset_0(),
1270        );
1271        let pressed_bg = colors.field.foreground.alpha(0.1);
1272        b = crate::util::cursor_interactive(b, cx).on_mouse_down(
1273            gpui::MouseButton::Left,
1274            move |_: &MouseDownEvent, window, cx| {
1275                window.focus(&focus_handle, cx);
1276                let generation = press.update(cx, |press, _| {
1277                    press.active = true;
1278                    press.generation = press.generation.wrapping_add(1);
1279                    press.generation
1280                });
1281                report_bump(
1282                    &st,
1283                    dir,
1284                    &on_change,
1285                    is_invalid,
1286                    &edit_validation_errors,
1287                    edit_validate.as_ref(),
1288                    window,
1289                    cx,
1290                );
1291
1292                let repeat_press = press.downgrade();
1293                let repeat_state = st.clone();
1294                let repeat_change = on_change.clone();
1295                let repeat_errors = edit_validation_errors.clone();
1296                let repeat_validate = edit_validate.clone();
1297                window
1298                    .spawn(cx, async move |cx| {
1299                        cx.background_executor()
1300                            .timer(Duration::from_millis(400))
1301                            .await;
1302                        loop {
1303                            let keep_repeating = cx
1304                                .update(|window, cx| {
1305                                    let Some(press) = repeat_press.upgrade() else {
1306                                        return false;
1307                                    };
1308                                    let active = {
1309                                        let press = press.read(cx);
1310                                        press.active && press.generation == generation
1311                                    };
1312                                    if !active {
1313                                        return false;
1314                                    }
1315                                    let changed = report_bump(
1316                                        &repeat_state,
1317                                        dir,
1318                                        &repeat_change,
1319                                        is_invalid,
1320                                        &repeat_errors,
1321                                        repeat_validate.as_ref(),
1322                                        window,
1323                                        cx,
1324                                    );
1325                                    if !changed {
1326                                        if let Some(press) = repeat_press.upgrade() {
1327                                            press.update(cx, |press, _| press.active = false);
1328                                        }
1329                                    }
1330                                    changed
1331                                })
1332                                .unwrap_or(false);
1333                            if !keep_repeating {
1334                                break;
1335                            }
1336                            cx.background_executor()
1337                                .timer(Duration::from_millis(60))
1338                                .await;
1339                        }
1340                    })
1341                    .detach();
1342            },
1343        );
1344        b = crate::anim::pressed_with_background(
1345            b,
1346            crate::anim::PressBox {
1347                height: h,
1348                padding_x: None,
1349                width: Some(btn_px),
1350                min_width: None,
1351                text_size: crate::util::FIELD_TEXT,
1352                line_height: px(20.),
1353                gap: px(0.),
1354                radius: px(0.),
1355                scale: crate::anim::PRESSED_SCALE,
1356                shrink_x: true,
1357            },
1358            pressed_bg,
1359            cx,
1360        );
1361    }
1362    b
1363}
1364
1365#[derive(Default)]
1366struct StepperPress {
1367    active: bool,
1368    generation: u64,
1369}
1370
1371fn set_number_value(state: &Entity<NumberState>, value: f64, cx: &mut App) -> bool {
1372    state.update(cx, |state, cx| {
1373        let before = state.value();
1374        let value = state.snap(value);
1375        state.set_value(value, cx);
1376        let changed = before.to_bits() != state.value().to_bits();
1377        if changed {
1378            cx.notify();
1379        }
1380        changed
1381    })
1382}
1383
1384/// A user-driven value change — the steppers, the arrows, Home/End, the wheel
1385/// — suppresses the routed server messages the way an edit does (v3: server
1386/// errors are "cleared when user modifies the field") and refreshes the inner
1387/// state's stored validity in the same stroke. `NumberField::render` writes
1388/// that mirror, so without the refresh it is one frame old — and `on_change`
1389/// may submit the enclosing `Form` synchronously before any frame catches up,
1390/// reading the routed error the change just answered as still blocking. The
1391/// routed slot is left out — the change suppressed it — so the inner mirror
1392/// is resolved from the builder's own sources exactly as the next render
1393/// would resolve it with an empty routed slot. Typing flows through the inner
1394/// `Input`'s own edit path, which clears the same slot and refreshes the same
1395/// mirror.
1396fn suppress_routed_server_errors(
1397    state: &Entity<NumberState>,
1398    is_invalid: bool,
1399    validation_errors: &[SharedString],
1400    validate: Option<&crate::validation::Validator<f64>>,
1401    cx: &mut App,
1402) {
1403    let input = state.read(cx).input.clone();
1404    if !input.read(cx).routed_errors().is_empty() {
1405        input.update(cx, |input, cx| {
1406            input.clear_routed_errors();
1407            cx.notify();
1408        });
1409    }
1410    let value = state.read(cx).value();
1411    let validity = crate::validation::resolve(
1412        is_invalid,
1413        validation_errors,
1414        validate.and_then(|f| f(&value)),
1415        None,
1416    );
1417    if input.read(cx).validity() != &validity {
1418        input.update(cx, |input, _| input.set_validity(validity));
1419    }
1420}
1421
1422#[allow(clippy::too_many_arguments)] // one user change: state, direction, callback and the three validity sources
1423fn report_bump(
1424    state: &Entity<NumberState>,
1425    dir: f64,
1426    on_change: &Option<OnChange>,
1427    is_invalid: bool,
1428    validation_errors: &[SharedString],
1429    validate: Option<&crate::validation::Validator<f64>>,
1430    window: &mut Window,
1431    cx: &mut App,
1432) -> bool {
1433    let next = state.update(cx, |state, cx| {
1434        let next = state.bump(dir, cx);
1435        if next.is_some() {
1436            cx.notify();
1437        }
1438        next
1439    });
1440    let Some(next) = next else { return false };
1441    suppress_routed_server_errors(state, is_invalid, validation_errors, validate, cx);
1442    if let Some(callback) = on_change {
1443        callback(&next, window, cx);
1444    }
1445    true
1446}
1447
1448// The pinned `.number-field--secondary` hover fill is
1449// `--number-field-group-bg-hover: var(--default-hover)`, which
1450// `RoleColor::hover()` computes; `soft_hover()` is a different, lighter
1451// token. The two accessors differ by one word and the wrong one still looks
1452// plausible on screen, so the check is mechanical.
1453#[cfg(test)]
1454mod hover_tokens {
1455    #[test]
1456    fn secondary_group_uses_the_pinned_default_hover_token() {
1457        // Scan the implementation only.
1458        let source = include_str!("number_field.rs")
1459            .split("#[cfg(test)]")
1460            .next()
1461            .expect("the implementation section is always present");
1462        assert!(
1463            source.contains("FieldVariant::Secondary => colors.default.hover()"),
1464            "the secondary group hover must read `colors.default.hover()` \
1465             (pinned `--number-field-group-bg-hover: var(--default-hover)`)"
1466        );
1467    }
1468
1469    #[test]
1470    fn stepper_cells_carry_the_shared_field_border_token() {
1471        let source = include_str!("number_field.rs")
1472            .split("#[cfg(test)]")
1473            .next()
1474            .expect("the implementation section is always present");
1475        assert!(
1476            source.contains(".border(field_border_width)")
1477                && source.contains(".border_color(colors.field.border)"),
1478            "spin buttons must preserve the configured field border token"
1479        );
1480    }
1481
1482    #[test]
1483    fn group_chrome_uses_the_pinned_shell_transition() {
1484        let source = include_str!("number_field.rs")
1485            .split("#[cfg(test)]")
1486            .next()
1487            .expect("the implementation section is always present");
1488        assert!(
1489            source.contains("crate::anim::field_chrome_ramp(")
1490                && source.contains("crate::anim::focus_ring_endpoint(cx)")
1491                && source.contains("crate::anim::danger_ring_endpoint(cx)"),
1492            "the group must move focus, invalid and hover chrome through the \
1493             shared keyed ramp, not one-frame endpoint swaps"
1494        );
1495    }
1496}
1497
1498crate::util::impl_component_styled!(NumberField);