Skip to main content

ragrig_gpui/
setting_fields.rs

1//! Reusable setting fields for host applications.
2//!
3//! GPUI Component's stock input is text-only. Hosts that store filesystem
4//! paths need a picker beside the input, so this module supplies one field for
5//! directories and one for files, plus a slider for token budgets.
6//!
7//! The stock [`SettingItem`](gpui_kit::component::setting::SettingItem) cannot
8//! style its title, so unsaved settings use [`SettingRow`], which draws the
9//! title itself and marks it while it differs from the applied configuration.
10
11use 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
45/// A path field with a **Browse…** button.
46///
47/// `directories` and `files` are passed through to [`PathPromptOptions`].
48/// `value` reads the current path from the application, `set_value` writes the
49/// chosen or typed path back. The input is keyed by its setting position, so
50/// rebuilding the page on each frame keeps the same editor state.
51pub 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
73/// A path input with a **Browse…** button, for use inside a [`SettingRow`].
74pub 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
170/// A slider over powers of two.
171///
172/// `min_exponent` and `max_exponent` are base-2 exponents; the field shows
173/// `2^min ..= 2^max` (for example 512, 1024, 2048, …). The value is written
174/// back as a token count, so the stored setting is always an exact power.
175pub 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
195/// A power-of-two token slider, for use inside a [`SettingRow`].
196pub 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        // Keep the thumb honest when a profile or the agent changes the value.
245        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    // Round to the nearest power of two rather than always down.
280    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
302/// A control rendered beside a [`SettingRow`] title.
303pub type Control = Box<dyn Fn(&RenderOptions, &mut Window, &mut App) -> AnyElement>;
304
305/// Box a control closure.
306pub 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
313/// The bold violet that marks unsaved settings.
314///
315/// Light surfaces take a deep violet; dark surfaces take a lighter tint of the
316/// same hue so the mark keeps contrast.
317pub 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
325/// A titled setting row whose title marks unsaved changes.
326///
327/// The stock `SettingItem` renders its title through `Label`, which fixes the
328/// colour before any caller style applies. A row draws both halves itself, so
329/// the title can turn bold violet while the field differs from the applied
330/// configuration.
331pub 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    /// Create a row with a title and a control.
341    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    /// Add the muted second line.
352    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
353        self.description = Some(description.into());
354        self
355    }
356
357    /// Put the control below the title instead of beside it.
358    pub fn vertical(mut self) -> Self {
359        self.layout = Some(Axis::Vertical);
360        self
361    }
362
363    /// Mark the title when this predicate reports an unsaved change.
364    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    /// Build the item GPUI Component's settings page expects.
373    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
430/// The style refinement that marks a page header as unsaved.
431pub 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
450/// A one-line text control.
451pub 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
502/// A numeric control that commits on Enter or blur.
503///
504/// Typing is never clamped or rewritten mid-draft: `0.03` stays `0.0`+
505/// `3` until it is committed. Stepper clicks and Up/Down apply one step
506/// immediately. An invalid draft keeps the previous value and shows the bound
507/// under the field.
508pub 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                // Bounds and step stay off the engine: it would clamp on blur
535                // before this control can warn and restore the previous value.
536                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                            // A same-value write redraws the row so the
559                            // warning appears.
560                            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                        // `set_value` reports a Change of its own. Keep the
576                        // warning while the field still shows the restored
577                        // value; a real edit differs and dismisses it.
578                        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                        // The engine may already have clamped the text; judge
589                        // the draft the user actually typed.
590                        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        // Sync only when the committed value moved. Comparing the displayed
632        // text on every render would eat the trailing dot of a draft.
633        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
702/// Drop binary noise such as `0.30000000000000004` after repeated stepping.
703fn 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
716/// A switch control.
717pub 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
738/// A dropdown control built from `(value, label)` pairs.
739pub 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        // The debug wrapper spans the row; the 128 px number field sits at its
848        // left edge. Click inside that field, not the wrapper's centre.
849        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}