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