1use 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
16pub struct NumberState {
18 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 format: Option<NumberFormat>,
29}
30
31impl NumberState {
32 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 pub fn with_value(cx: &mut App, value: f64) -> Self {
56 Self::new(cx, value)
57 }
58
59 pub fn value(&self) -> f64 {
61 self.value
62 }
63
64 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 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 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 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 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 pub fn step_size(&self) -> f64 {
126 self.step
127 }
128
129 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 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
221pub 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#[derive(Clone, Copy, Debug)]
256#[non_exhaustive]
257pub struct NumberFieldRenderState {
258 pub is_disabled: bool,
260 pub is_invalid: bool,
262 pub is_read_only: bool,
264 pub is_required: bool,
266 pub is_focused: bool,
268 pub is_focus_within: bool,
270 pub is_focus_visible: bool,
272 pub value: f64,
274 pub min_value: Option<f64>,
276 pub max_value: Option<f64>,
278 pub step: f64,
280}
281
282#[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 content: Option<Arc<dyn Fn(NumberFieldRenderState) -> gpui::AnyElement + 'static>>,
289 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 field: crate::util::FieldBox,
301 group_hover_bg: Option<gpui::Hsla>,
303 min_value: Option<f64>,
304 max_value: Option<f64>,
305 step: Option<f64>,
306 format: Option<NumberFormat>,
308 name: Option<SharedString>,
310 default_value: Option<f64>,
312 value: Option<f64>,
315 validation_behavior: Option<crate::form::ValidationBehavior>,
317 validate: Option<crate::validation::Validator<f64>>,
319 validation_errors: Vec<SharedString>,
321 is_invalid: bool,
322 is_required: bool,
323 is_read_only: bool,
324 is_wheel_disabled: bool,
326 auto_focus: bool,
328 font_family: Option<SharedString>,
330 on_change: Option<OnChange>,
331 sx: Option<Box<gpui::StyleRefinement>>,
333}
334
335impl NumberField {
336 pub fn min_value(mut self, v: f64) -> Self {
338 self.min_value = Some(v);
339 self
340 }
341
342 pub fn max_value(mut self, v: f64) -> Self {
344 self.max_value = Some(v);
345 self
346 }
347
348 pub fn step(mut self, v: f64) -> Self {
350 self.step = Some(v);
351 self
352 }
353
354 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
359 self.name = Some(name.into());
360 self
361 }
362
363 pub fn default_value(mut self, value: f64) -> Self {
368 self.default_value = Some(value);
369 self
370 }
371
372 pub fn value(mut self, value: f64) -> Self {
380 self.value = Some(value);
381 self
382 }
383
384 pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
386 self.validation_behavior = Some(behavior);
387 self
388 }
389
390 pub fn format_options(mut self, format: NumberFormat) -> Self {
407 self.format = Some(format);
408 self
409 }
410
411 pub fn variant(mut self, variant: FieldVariant) -> Self {
413 self.variant = variant;
414 self
415 }
416
417 pub fn full_width(mut self, v: bool) -> Self {
419 self.full_width = v;
420 self
421 }
422
423 pub fn height(mut self, h: impl Into<gpui::Pixels>) -> Self {
426 self.field.height = Some(h.into());
427 self
428 }
429
430 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 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 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 pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
462 self.font_family = Some(family.into());
463 self
464 }
465
466 pub fn focus_ring(mut self, v: bool) -> Self {
469 self.field.focus_ring = Some(v);
470 self
471 }
472
473 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 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 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 pub fn is_invalid(mut self, v: bool) -> Self {
503 self.is_invalid = v;
504 self
505 }
506
507 pub fn is_required(mut self, v: bool) -> Self {
509 self.is_required = v;
510 self
511 }
512
513 pub fn auto_focus(mut self, v: bool) -> Self {
515 self.auto_focus = v;
516 self
517 }
518
519 pub fn is_read_only(mut self, v: bool) -> Self {
521 self.is_read_only = v;
522 self
523 }
524
525 pub fn is_wheel_disabled(mut self, v: bool) -> Self {
527 self.is_wheel_disabled = v;
528 self
529 }
530
531 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 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 pub fn description(mut self, text: impl Into<SharedString>) -> Self {
582 self.description = Some(text.into());
583 self
584 }
585
586 pub fn label(mut self, l: impl Into<SharedString>) -> Self {
588 self.label = Some(l.into());
589 self
590 }
591
592 pub fn hide_steppers(mut self, v: bool) -> Self {
594 self.hide_steppers = v;
595 self
596 }
597
598 pub fn increment_icon(mut self, icon: impl IntoElement) -> Self {
600 self.increment_icon = Some(icon.into_any_element());
601 self
602 }
603
604 pub fn decrement_icon(mut self, icon: impl IntoElement) -> Self {
606 self.decrement_icon = Some(icon.into_any_element());
607 self
608 }
609
610 pub fn vertical_steppers(mut self, v: bool) -> Self {
613 self.vertical_steppers = v;
614 self
615 }
616
617 pub fn is_disabled(mut self, v: bool) -> Self {
619 self.is_disabled = v;
620 self
621 }
622
623 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 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 let base_id =
649 gpui::ElementId::named_usize("number", self.state.entity_id().as_u64() as usize);
650
651 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 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 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 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 let h = field_box.resolved_height();
747 let btn_px = px(40.);
750
751 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 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 let steppers = !self.hide_steppers;
786 let group_radius = crate::util::field_radius(cx);
787 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 let a11y_name =
805 crate::a11y::Name::field(self.label.as_ref(), self.description.as_ref(), &validity);
806 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 .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 let focused = focus_handle.is_focused(window);
828 let show_focus_ring = field_box.focus_ring.unwrap_or(true);
829 carries_ring = true;
830 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 let ringed = focused && show_focus_ring;
856 let outlined = validity.is_invalid && !ringed;
860 let idle = crate::anim::FieldChrome {
861 bg: match self.variant {
862 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 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 true,
917 window,
918 cx,
919 );
920 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 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 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 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 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 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#[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 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 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 .border(field_border_width)
1249 .border_color(colors.field.border);
1250 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
1384fn 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)] fn 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#[cfg(test)]
1454mod hover_tokens {
1455 #[test]
1456 fn secondary_group_uses_the_pinned_default_hover_token() {
1457 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);