1use std::{cell::RefCell, rc::Rc};
12
13use gpui_kit::{
14 Anchor, AnyElement, App, AppContext as _, Axis, ElementId, Entity, FontWeight, Hsla,
15 InteractiveElement as _, IntoElement, ParentElement as _, PathPromptOptions, SharedString,
16 Styled as _, Subscription, Window,
17 component::{
18 ActiveTheme as _, AxisExt as _, Disableable as _, Sizable as _,
19 button::Button,
20 h_flex,
21 input::{Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction},
22 menu::{DropdownMenu, PopupMenuItem},
23 setting::{NumberFieldOptions, RenderOptions, SettingField, SettingItem},
24 slider::{Slider, SliderEvent, SliderState},
25 switch::Switch,
26 v_flex,
27 },
28 div, hsla,
29 prelude::FluentBuilder as _,
30 px,
31};
32
33struct PathInputState {
34 input: Entity<InputState>,
35 _subscription: Subscription,
36}
37
38type PathSetter = Rc<dyn Fn(String, &mut App)>;
39type TokenSetter = Rc<dyn Fn(usize, &mut App)>;
40type TextSetter = Rc<dyn Fn(String, &mut App)>;
41type NumberSetter = Rc<dyn Fn(f64, &mut App)>;
42type NumberReader = Rc<dyn Fn(&App) -> f64>;
43type SwitchSetter = Rc<dyn Fn(bool, &mut App)>;
44
45pub fn path_setting<G, S>(
52 key: &'static str,
53 prompt: &'static str,
54 directories: bool,
55 files: bool,
56 value: G,
57 set_value: S,
58) -> SettingField<SharedString>
59where
60 G: Fn(&App) -> String + 'static,
61 S: Fn(String, &mut App) + 'static,
62{
63 SettingField::render(path_control(
64 key,
65 prompt,
66 directories,
67 files,
68 value,
69 set_value,
70 ))
71}
72
73pub fn path_control<G, S>(
75 key: &'static str,
76 prompt: &'static str,
77 directories: bool,
78 files: bool,
79 value: G,
80 set_value: S,
81) -> Control
82where
83 G: Fn(&App) -> String + 'static,
84 S: Fn(String, &mut App) + 'static,
85{
86 let setter: PathSetter = Rc::new(set_value);
87 control(move |options, window, cx| {
88 let current: SharedString = value(cx).into();
89 let keyed = SharedString::from(format!(
90 "path-field-{key}-{}-{}-{}",
91 options.page_ix(),
92 options.group_ix(),
93 options.item_ix()
94 ));
95 let setter_for_input = setter.clone();
96 let state = window.use_keyed_state(keyed.clone(), cx, {
97 let current = current.clone();
98 move |window, cx| {
99 let input = cx.new(|cx| InputState::new(window, cx).default_value(current.clone()));
100 let subscription = cx.subscribe(&input, move |_, input, event: &InputEvent, cx| {
101 if matches!(event, InputEvent::Change) {
102 setter_for_input(input.read(cx).value().trim().to_owned(), cx);
103 }
104 });
105 PathInputState {
106 input,
107 _subscription: subscription,
108 }
109 }
110 });
111
112 let input = state.read(cx).input.clone();
113 if input.read(cx).value() != current {
114 input.update(cx, |input, cx| {
115 input.set_value(current, window, cx);
116 });
117 }
118
119 let browse_id = ElementId::Name(SharedString::from(format!("{keyed}-browse")));
120 let setter_for_browse = setter.clone();
121 let browse = {
122 let input = input.clone();
123 Button::new(browse_id)
124 .label("Browse…")
125 .small()
126 .outline()
127 .disabled(options.is_disabled())
128 .on_click(move |_, window, cx| {
129 let receiver = cx.prompt_for_paths(PathPromptOptions {
130 files,
131 directories,
132 multiple: false,
133 prompt: Some(prompt.into()),
134 });
135 let input = input.clone();
136 let setter = setter_for_browse.clone();
137 window
138 .spawn(cx, async move |cx| {
139 let Ok(Ok(Some(paths))) = receiver.await else {
140 return;
141 };
142 let Some(path) = paths.into_iter().next() else {
143 return;
144 };
145 let value = path.display().to_string();
146 let _ = input.update_in(cx, |input, window, cx| {
147 input.set_value(value.clone(), window, cx);
148 });
149 let _ = cx.update(|_window, cx| setter(value, cx));
150 })
151 .detach();
152 })
153 };
154
155 h_flex()
156 .w(px(340.))
157 .gap_2()
158 .items_center()
159 .child(
160 Input::new(&input)
161 .disabled(options.is_disabled())
162 .with_size(options.size())
163 .flex_1(),
164 )
165 .child(browse)
166 .into_any_element()
167 })
168}
169
170pub fn token_slider<G, S>(
176 key: &'static str,
177 min_exponent: u32,
178 max_exponent: u32,
179 value: G,
180 set_value: S,
181) -> SettingField<SharedString>
182where
183 G: Fn(&App) -> usize + 'static,
184 S: Fn(usize, &mut App) + 'static,
185{
186 SettingField::render(token_control(
187 key,
188 min_exponent,
189 max_exponent,
190 value,
191 set_value,
192 ))
193}
194
195pub fn token_control<G, S>(
197 key: &'static str,
198 min_exponent: u32,
199 max_exponent: u32,
200 value: G,
201 set_value: S,
202) -> Control
203where
204 G: Fn(&App) -> usize + 'static,
205 S: Fn(usize, &mut App) + 'static,
206{
207 let setter: TokenSetter = Rc::new(set_value);
208 let min = min_exponent as f32;
209 let max = max_exponent as f32;
210 control(move |options, window, cx| {
211 let tokens = value(cx);
212 let exponent = exponent_for(tokens, min_exponent, max_exponent);
213 let keyed = SharedString::from(format!(
214 "token-slider-{key}-{}-{}-{}",
215 options.page_ix(),
216 options.group_ix(),
217 options.item_ix()
218 ));
219 let setter_for_slider = setter.clone();
220 let state = window.use_keyed_state(keyed, cx, move |_, cx| {
221 let slider = cx.new(|_| {
222 SliderState::new()
223 .min(min)
224 .max(max)
225 .step(1.0)
226 .default_value(exponent as f32)
227 });
228 let subscription = cx.subscribe(&slider, move |_, _, event: &SliderEvent, cx| {
229 let exponent = match event {
230 SliderEvent::Change(value) | SliderEvent::Release(value) => {
231 value.end().round() as u32
232 }
233 };
234 let tokens = 1usize << exponent;
235 setter_for_slider(tokens, cx);
236 cx.notify();
237 });
238 SliderFieldState {
239 slider,
240 _subscription: subscription,
241 }
242 });
243 let slider = state.read(cx).slider.clone();
244 let expected = exponent as f32;
246 if (slider.read(cx).value().end() - expected).abs() > f32::EPSILON {
247 slider.update(cx, |slider, cx| {
248 slider.set_value(expected, window, cx);
249 });
250 }
251 let shown = 1usize << exponent;
252 h_flex()
253 .w(px(280.))
254 .gap_3()
255 .items_center()
256 .child(
257 Slider::new(&slider)
258 .disabled(options.is_disabled())
259 .flex_1(),
260 )
261 .child(
262 gpui_kit::div()
263 .w(px(96.))
264 .text_sm()
265 .child(format_tokens(shown)),
266 )
267 .into_any_element()
268 })
269}
270
271struct SliderFieldState {
272 slider: Entity<SliderState>,
273 _subscription: Subscription,
274}
275
276fn exponent_for(tokens: usize, min: u32, max: u32) -> u32 {
277 let clamped = tokens.max(1);
278 let exponent = usize::BITS - 1 - clamped.leading_zeros();
279 let lower = 1usize << exponent;
281 let upper = lower.saturating_mul(2);
282 let rounded = if clamped.saturating_sub(lower) > upper.saturating_sub(clamped) {
283 exponent + 1
284 } else {
285 exponent
286 };
287 rounded.clamp(min, max)
288}
289
290fn format_tokens(tokens: usize) -> String {
291 let digits = tokens.to_string();
292 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
293 for (index, digit) in digits.chars().enumerate() {
294 if index > 0 && (digits.len() - index).is_multiple_of(3) {
295 out.push(',');
296 }
297 out.push(digit);
298 }
299 out
300}
301
302pub type Control = Box<dyn Fn(&RenderOptions, &mut Window, &mut App) -> AnyElement>;
304
305pub fn control<E>(render: impl Fn(&RenderOptions, &mut Window, &mut App) -> E + 'static) -> Control
307where
308 E: IntoElement + 'static,
309{
310 Box::new(move |options, window, cx| render(options, window, cx).into_any_element())
311}
312
313pub fn dirty_color(cx: &App) -> Hsla {
318 if cx.theme().is_dark() {
319 hsla(266.0 / 360.0, 0.62, 0.74, 1.0)
320 } else {
321 hsla(266.0 / 360.0, 0.55, 0.36, 1.0)
322 }
323}
324
325pub struct SettingRow {
332 title: SharedString,
333 description: Option<SharedString>,
334 control: Control,
335 is_dirty: Rc<dyn Fn(&App) -> bool>,
336 layout: Option<Axis>,
337}
338
339impl SettingRow {
340 pub fn new(title: impl Into<SharedString>, control: Control) -> Self {
342 Self {
343 title: title.into(),
344 description: None,
345 control,
346 is_dirty: Rc::new(|_| false),
347 layout: None,
348 }
349 }
350
351 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
353 self.description = Some(description.into());
354 self
355 }
356
357 pub fn vertical(mut self) -> Self {
359 self.layout = Some(Axis::Vertical);
360 self
361 }
362
363 pub fn dirty<F>(mut self, is_dirty: F) -> Self
365 where
366 F: Fn(&App) -> bool + 'static,
367 {
368 self.is_dirty = Rc::new(is_dirty);
369 self
370 }
371
372 pub fn into_item(self) -> SettingItem {
374 let title = self.title;
375 let keywords = [title.clone()];
376 let description = self.description;
377 let control = self.control;
378 let is_dirty = self.is_dirty;
379 let layout = self.layout;
380 SettingItem::render(move |options, window, cx| {
381 let dirty = is_dirty(cx);
382 let layout = layout.unwrap_or(options.layout());
383 let title = gpui_kit::div()
384 .text_sm()
385 .when(dirty, |this| {
386 this.font_weight(FontWeight::BOLD)
387 .text_color(dirty_color(cx))
388 })
389 .child(title.clone());
390 let text = v_flex()
391 .map(|this| {
392 if layout.is_horizontal() {
393 this.flex_1().max_w(gpui_kit::relative(0.6))
394 } else {
395 this.w_full()
396 }
397 })
398 .child(title)
399 .when_some(description.clone(), |this, description| {
400 this.child(
401 gpui_kit::div()
402 .text_sm()
403 .text_color(cx.theme().muted_foreground)
404 .child(description),
405 )
406 });
407 let control = (control)(&options.with_layout(layout), window, cx);
408 if layout.is_horizontal() {
409 h_flex()
410 .w_full()
411 .justify_between()
412 .items_center()
413 .gap_3()
414 .child(text)
415 .child(control)
416 .into_any_element()
417 } else {
418 v_flex()
419 .w_full()
420 .gap_3()
421 .child(text)
422 .child(control)
423 .into_any_element()
424 }
425 })
426 .keywords(keywords)
427 }
428}
429
430pub fn dirty_header(cx: &App) -> gpui_kit::StyleRefinement {
432 let mut style = gpui_kit::StyleRefinement::default();
433 style.text.color = Some(dirty_color(cx));
434 style.text.font_weight = Some(FontWeight::BOLD);
435 style
436}
437
438struct TextControlState {
439 input: Entity<InputState>,
440 _subscription: Subscription,
441}
442
443struct NumberControlState {
444 input: Entity<InputState>,
445 shown: f64,
446 warning: Rc<RefCell<Option<SharedString>>>,
447 _subscriptions: Vec<Subscription>,
448}
449
450pub fn text_control<V, W>(key: &'static str, value: V, write: W) -> Control
452where
453 V: Fn(&App) -> String + 'static,
454 W: Fn(String, &mut App) + 'static,
455{
456 let write: TextSetter = Rc::new(write);
457 let key = SharedString::from(key);
458 control(move |options, window, cx| {
459 let current: SharedString = value(cx).into();
460 let keyed = SharedString::from(format!(
461 "row-input-{key}-{}-{}-{}",
462 options.page_ix(),
463 options.group_ix(),
464 options.item_ix()
465 ));
466 let setter = write.clone();
467 let state = window.use_keyed_state(keyed, cx, {
468 let current = current.clone();
469 move |window, cx| {
470 let input = cx.new(|cx| InputState::new(window, cx).default_value(current.clone()));
471 let subscription = cx.subscribe(&input, move |_, input, event: &InputEvent, cx| {
472 if matches!(event, InputEvent::Change) {
473 setter(input.read(cx).value().to_string(), cx);
474 }
475 });
476 TextControlState {
477 input,
478 _subscription: subscription,
479 }
480 }
481 });
482 let input = state.read(cx).input.clone();
483 if input.read(cx).value() != current {
484 input.update(cx, |input, cx| {
485 input.set_value(current, window, cx);
486 });
487 }
488 Input::new(&input)
489 .disabled(options.is_disabled())
490 .with_size(options.size())
491 .map(|this| {
492 if options.layout().is_horizontal() {
493 this.w_64()
494 } else {
495 this.w_full()
496 }
497 })
498 .into_any_element()
499 })
500}
501
502pub fn number_control<V, W>(
509 key: &'static str,
510 options: NumberFieldOptions,
511 value: V,
512 write: W,
513) -> Control
514where
515 V: Fn(&App) -> f64 + 'static,
516 W: Fn(f64, &mut App) + 'static,
517{
518 let write: NumberSetter = Rc::new(write);
519 let read: NumberReader = Rc::new(value);
520 let key = SharedString::from(key);
521 control(move |render_options, window, cx| {
522 let current = read(cx);
523 let keyed = SharedString::from(format!(
524 "row-number-{key}-{}-{}-{}",
525 render_options.page_ix(),
526 render_options.group_ix(),
527 render_options.item_ix()
528 ));
529 let options_for_state = options.clone();
530 let setter = write.clone();
531 let reader = read.clone();
532 let state = window.use_keyed_state(keyed, cx, move |window, cx| {
533 let input = cx.new(|cx| {
534 InputState::new(window, cx).default_value(current.to_string())
537 });
538 let draft = Rc::new(RefCell::new(current.to_string()));
539 let warning = Rc::new(RefCell::new(None::<SharedString>));
540
541 let commit = {
542 let setter = setter.clone();
543 let reader = reader.clone();
544 let warning = warning.clone();
545 let options = options_for_state.clone();
546 move |text: &str, input: &Entity<InputState>, window: &mut Window, cx: &mut App| {
547 let previous = reader(cx);
548 match validate_number(text, options.min, options.max) {
549 Ok(value) => {
550 *warning.borrow_mut() = None;
551 setter(value, cx);
552 }
553 Err(message) => {
554 input.update(cx, |input, cx| {
555 input.set_value(previous.to_string(), window, cx);
556 });
557 *warning.borrow_mut() = Some(message.into());
558 setter(previous, cx);
561 }
562 }
563 }
564 };
565
566 let input_events = cx.subscribe_in(&input, window, {
567 let setter = setter.clone();
568 let reader = reader.clone();
569 let warning = warning.clone();
570 let draft = draft.clone();
571 move |_, input, event: &InputEvent, window, cx| match event {
572 InputEvent::Change => {
573 let text = input.read(cx).value().to_string();
574 *draft.borrow_mut() = text.clone();
575 if warning.borrow().is_some() && text != reader(cx).to_string() {
579 *warning.borrow_mut() = None;
580 setter(reader(cx), cx);
581 }
582 }
583 InputEvent::PressEnter { .. } => {
584 let text = input.read(cx).value().to_string();
585 commit(&text, input, window, cx);
586 }
587 InputEvent::Blur => {
588 let text = draft.borrow().clone();
591 commit(&text, input, window, cx);
592 }
593 InputEvent::Focus => {}
594 }
595 });
596
597 let step_events = cx.subscribe_in(&input, window, {
598 let setter = setter.clone();
599 let reader = reader.clone();
600 let warning = warning.clone();
601 let draft = draft.clone();
602 let options = options_for_state.clone();
603 move |_, input, event: &NumberInputEvent, window, cx| {
604 let NumberInputEvent::Step(action) = event;
605 let next = step_number(reader(cx), *action, &options);
606 *warning.borrow_mut() = None;
607 *draft.borrow_mut() = next.to_string();
608 input.update(cx, |input, cx| {
609 input.set_value(next.to_string(), window, cx);
610 });
611 setter(next, cx);
612 }
613 });
614
615 NumberControlState {
616 input,
617 shown: current,
618 warning,
619 _subscriptions: vec![input_events, step_events],
620 }
621 });
622
623 let (input, shown, warning) = {
624 let state = state.read(cx);
625 (
626 state.input.clone(),
627 state.shown,
628 state.warning.borrow().clone(),
629 )
630 };
631 if shown != current {
634 input.update(cx, |input, cx| {
635 input.set_value(current.to_string(), window, cx);
636 });
637 state.update(cx, |state, _| state.shown = current);
638 }
639
640 let field = div().debug_selector(|| format!("{key}-number")).child(
641 NumberInput::new(&input)
642 .disabled(render_options.is_disabled())
643 .with_size(render_options.size())
644 .map(|this| {
645 if render_options.layout().is_horizontal() {
646 this.w_32()
647 } else {
648 this.w_full()
649 }
650 }),
651 );
652 match warning {
653 Some(message) => v_flex()
654 .gap_1()
655 .child(field)
656 .child(
657 div()
658 .debug_selector(|| format!("{key}-number-warning"))
659 .text_xs()
660 .text_color(cx.theme().danger)
661 .child(message),
662 )
663 .into_any_element(),
664 None => field.into_any_element(),
665 }
666 })
667}
668
669fn validate_number(text: &str, min: f64, max: f64) -> Result<f64, String> {
670 let Ok(value) = text.trim().parse::<f64>() else {
671 return Err(number_range_message(min, max));
672 };
673 if !value.is_finite() {
674 return Err("Enter a finite number.".to_owned());
675 }
676 if !(min..=max).contains(&value) {
677 return Err(number_range_message(min, max));
678 }
679 Ok(value)
680}
681
682fn number_range_message(min: f64, max: f64) -> String {
683 match (min > f64::MIN, max < f64::MAX) {
684 (true, true) => format!("Enter a number between {min} and {max}."),
685 (true, false) => format!("Enter a number of at least {min}."),
686 (false, true) => format!("Enter a number of at most {max}."),
687 (false, false) => "Enter a valid number.".to_owned(),
688 }
689}
690
691fn step_number(current: f64, action: StepAction, options: &NumberFieldOptions) -> f64 {
692 let delta = match action {
693 StepAction::Increment => options.step,
694 StepAction::Decrement => -options.step,
695 };
696 round_to_step(
697 (current + delta).clamp(options.min, options.max),
698 options.step,
699 )
700}
701
702fn round_to_step(value: f64, step: f64) -> f64 {
704 let step = step.abs();
705 if !step.is_finite() || step == 0.0 {
706 return value;
707 }
708 let decimals = step
709 .to_string()
710 .split_once('.')
711 .map_or(0, |(_, fraction)| fraction.len());
712 let factor = 10f64.powi(decimals as i32);
713 (value * factor).round() / factor
714}
715
716pub fn switch_control<V, W>(key: &'static str, value: V, write: W) -> Control
718where
719 V: Fn(&App) -> bool + 'static,
720 W: Fn(bool, &mut App) + 'static,
721{
722 let key = SharedString::from(key);
723 let write: SwitchSetter = Rc::new(write);
724 control(move |options, _window, cx| {
725 let checked = value(cx);
726 let setter = write.clone();
727 Switch::new(ElementId::Name(
728 format!("row-switch-{key}-{}", options.item_ix()).into(),
729 ))
730 .checked(checked)
731 .disabled(options.is_disabled())
732 .with_size(options.size())
733 .on_change(move |value, _window, cx| setter(*value, cx))
734 .into_any_element()
735 })
736}
737
738pub fn dropdown_control<O, V, W>(key: &'static str, options: O, value: V, write: W) -> Control
740where
741 O: Fn(&App) -> Vec<(SharedString, SharedString)> + 'static,
742 V: Fn(&App) -> String + 'static,
743 W: Fn(String, &mut App) + 'static,
744{
745 let key = SharedString::from(key);
746 let write: TextSetter = Rc::new(write);
747 control(move |render_options, _window, cx| {
748 let current: SharedString = value(cx).into();
749 let options = options(cx);
750 let label = options
751 .iter()
752 .find(|(value, _)| *value == current)
753 .map(|(_, label)| label.clone())
754 .unwrap_or_else(|| {
755 if current.is_empty() {
756 SharedString::from("Choose…")
757 } else {
758 current.clone()
759 }
760 });
761 let setter = write.clone();
762 Button::new(ElementId::Name(
763 format!("row-dropdown-{key}-{}", render_options.item_ix()).into(),
764 ))
765 .when(render_options.layout().is_vertical(), |this| this.w_full())
766 .label(label)
767 .dropdown_caret(true)
768 .outline()
769 .disabled(render_options.is_disabled())
770 .with_size(render_options.size())
771 .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
772 options
773 .iter()
774 .fold(menu, |menu, (value, label)| {
775 let checked = *value == current;
776 let value = value.clone();
777 let setter = setter.clone();
778 menu.item(
779 PopupMenuItem::new(label.clone())
780 .checked(checked)
781 .on_click(move |_, _, cx| setter(value.to_string(), cx)),
782 )
783 })
784 .scrollable(true)
785 })
786 .into_any_element()
787 })
788}
789
790#[cfg(test)]
791mod tests {
792 use gpui_kit::{Context, Modifiers, Render, TestAppContext, VisualTestContext};
793
794 use super::*;
795
796 struct NumberModel {
797 value: f64,
798 }
799
800 struct NumberHost {
801 model: Entity<NumberModel>,
802 control: Control,
803 }
804
805 impl NumberHost {
806 fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
807 let model = cx.new(|_| NumberModel { value: 0.5 });
808 let reader = model.clone();
809 let writer = model.clone();
810 let control = number_control(
811 "test-threshold",
812 NumberFieldOptions {
813 min: 0.0,
814 max: 1.0,
815 step: 0.01,
816 },
817 move |cx| reader.read(cx).value,
818 move |value, cx| {
819 writer.update(cx, |model, cx| {
820 model.value = value;
821 cx.notify();
822 });
823 },
824 );
825 cx.observe(&model, |_, _, cx| cx.notify()).detach();
826 Self { model, control }
827 }
828 }
829
830 impl Render for NumberHost {
831 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
832 div()
833 .w(px(320.))
834 .p_4()
835 .child((self.control)(&RenderOptions::new(), window, cx))
836 }
837 }
838
839 fn value(host: &Entity<NumberHost>, cx: &mut VisualTestContext) -> f64 {
840 cx.update(|_, cx| host.read(cx).model.read(cx).value)
841 }
842
843 fn focus_and_replace(cx: &mut VisualTestContext) {
844 let bounds = cx
845 .debug_bounds("test-threshold-number")
846 .expect("number field bounds");
847 cx.simulate_click(
850 gpui_kit::point(bounds.origin.x + px(64.), bounds.center().y),
851 Modifiers::default(),
852 );
853 cx.simulate_keystrokes("ctrl-a");
854 }
855
856 #[test]
857 fn token_values_snap_to_powers_of_two() {
858 assert_eq!(exponent_for(512, 9, 20), 9);
859 assert_eq!(exponent_for(700, 9, 20), 9);
860 assert_eq!(exponent_for(900, 9, 20), 10);
861 assert_eq!(exponent_for(262_144, 9, 20), 18);
862 assert_eq!(exponent_for(1, 9, 20), 9);
863 assert_eq!(exponent_for(usize::MAX, 9, 20), 20);
864 }
865
866 #[test]
867 fn token_labels_use_thousands_separators() {
868 assert_eq!(format_tokens(512), "512");
869 assert_eq!(format_tokens(1_048_576), "1,048,576");
870 }
871
872 #[test]
873 fn a_non_power_value_rounds_to_the_nearest_step() {
874 assert_eq!(exponent_for(1_500, 9, 20), 10);
875 assert_eq!(exponent_for(1_800, 9, 20), 11);
876 }
877
878 #[test]
879 fn decimal_drafts_are_judged_only_when_complete() {
880 assert_eq!(validate_number("0.03", 0.0, 1.0), Ok(0.03));
881 assert_eq!(validate_number("0.", 0.0, 1.0), Ok(0.0));
882 assert!(validate_number("", 0.0, 1.0).is_err());
883 assert!(validate_number("1.5", 0.0, 1.0).is_err());
884 assert!(validate_number("abc", 0.0, 1.0).is_err());
885 }
886
887 #[test]
888 fn an_invalid_draft_explains_the_bound() {
889 assert_eq!(
890 number_range_message(0.0, 1.0),
891 "Enter a number between 0 and 1."
892 );
893 }
894
895 #[test]
896 fn stepping_clamps_and_drops_binary_noise() {
897 let options = NumberFieldOptions {
898 min: 0.0,
899 max: 1.0,
900 step: 0.01,
901 };
902 assert_eq!(step_number(0.5, StepAction::Increment, &options), 0.51);
903 assert_eq!(step_number(0.0, StepAction::Decrement, &options), 0.0);
904 assert_eq!(step_number(1.0, StepAction::Increment, &options), 1.0);
905 }
906
907 #[gpui_kit::test]
908 fn a_decimal_commits_on_enter_not_per_keystroke(cx: &mut TestAppContext) {
909 cx.update(crate::init);
910 let (host, cx) = cx.add_window_view(NumberHost::new);
911 cx.update(|window, cx| window.draw(cx).clear(cx));
912 focus_and_replace(cx);
913
914 cx.simulate_input("0.03");
915 cx.update(|window, cx| window.draw(cx).clear(cx));
916 assert_eq!(
917 value(&host, cx),
918 0.5,
919 "the setting must not see intermediate keystrokes"
920 );
921
922 cx.simulate_keystrokes("enter");
923 cx.run_until_parked();
924 cx.update(|window, cx| window.draw(cx).clear(cx));
925 assert_eq!(value(&host, cx), 0.03);
926 }
927
928 #[gpui_kit::test]
929 fn an_out_of_range_decimal_warns_and_restores(cx: &mut TestAppContext) {
930 cx.update(crate::init);
931 let (host, cx) = cx.add_window_view(NumberHost::new);
932 cx.update(|window, cx| window.draw(cx).clear(cx));
933 focus_and_replace(cx);
934
935 cx.simulate_input("1.5");
936 cx.simulate_keystrokes("enter");
937 cx.run_until_parked();
938 for _ in 0..2 {
939 cx.update(|window, cx| window.draw(cx).clear(cx));
940 }
941 assert_eq!(value(&host, cx), 0.5);
942 assert!(
943 cx.debug_bounds("test-threshold-number-warning").is_some(),
944 "the row must explain the bound"
945 );
946 }
947}