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, 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
55/// A path field with a **Browse…** button.
56///
57/// `directories` and `files` are passed through to [`PathPromptOptions`].
58/// `value` reads the current path from the application, `set_value` writes the
59/// chosen or typed path back. The input is keyed by its setting position, so
60/// rebuilding the page on each frame keeps the same editor state.
61pub 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
83/// A path input with a **Browse…** button, for use inside a [`SettingRow`].
84pub 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
110/// A path control that shows paths below the home directory as `~/…` and
111/// writes a leading `~` back as an absolute path.
112///
113/// `value` reads the stored path and `set_value` receives an absolute path.
114/// Unlike [`path_control`], the value is applied on Enter or blur rather than
115/// on every keystroke, so expanding `~` cannot scaffold a half-typed folder.
116/// `width` is the whole row (input and **Browse…**).
117pub 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        // A bare `~` or `~/` is not a committed path: expanding it on blur
164        // would point the workspace at the home directory while the user is
165        // still typing. Empty stays empty so a field can be cleared.
166        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                            // Blur also fires when nothing was edited (for
203                            // example on a Browse click); re-applying the
204                            // stored path would re-adopt the workspace.
205                            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
294/// A slider over powers of two.
295///
296/// `min_exponent` and `max_exponent` are base-2 exponents; the field shows
297/// `2^min ..= 2^max` (for example 512, 1024, 2048, …). The value is written
298/// back as a token count, so the stored setting is always an exact power.
299pub 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
319/// A power-of-two token slider, for use inside a [`SettingRow`].
320pub 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        // Keep the thumb honest when a profile or the agent changes the value.
369        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    // Round to the nearest power of two rather than always down.
404    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
426/// A control rendered beside a [`SettingRow`] title.
427pub type Control = Box<dyn Fn(&RenderOptions, &mut Window, &mut App) -> AnyElement>;
428
429/// Box a control closure.
430pub 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
437/// The bold violet that marks unsaved settings.
438///
439/// Light surfaces take a deep violet; dark surfaces take a lighter tint of the
440/// same hue so the mark keeps contrast.
441pub 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
449/// A titled setting row whose title marks unsaved changes.
450///
451/// The stock `SettingItem` renders its title through `Label`, which fixes the
452/// colour before any caller style applies. A row draws both halves itself, so
453/// the title can turn bold violet while the field differs from the applied
454/// configuration.
455pub 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    /// Create a row with a title and a control.
465    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    /// Add the muted second line.
476    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
477        self.description = Some(description.into());
478        self
479    }
480
481    /// Put the control below the title instead of beside it.
482    pub fn vertical(mut self) -> Self {
483        self.layout = Some(Axis::Vertical);
484        self
485    }
486
487    /// Mark the title when this predicate reports an unsaved change.
488    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    /// Build the item GPUI Component's settings page expects.
497    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
554/// The style refinement that marks a page header as unsaved.
555pub 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
574/// A one-line text control.
575pub 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
626/// A numeric control that commits on Enter or blur.
627///
628/// Typing is never clamped or rewritten mid-draft: `0.03` stays `0.0`+
629/// `3` until it is committed. Stepper clicks and Up/Down apply one step
630/// immediately. An invalid draft keeps the previous value and shows the bound
631/// under the field.
632pub 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                // Bounds and step stay off the engine: it would clamp on blur
659                // before this control can warn and restore the previous value.
660                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                            // A same-value write redraws the row so the
683                            // warning appears.
684                            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                        // `set_value` reports a Change of its own. Keep the
700                        // warning while the field still shows the restored
701                        // value; a real edit differs and dismisses it.
702                        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                        // The engine may already have clamped the text; judge
713                        // the draft the user actually typed.
714                        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        // Sync only when the committed value moved. Comparing the displayed
756        // text on every render would eat the trailing dot of a draft.
757        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
826/// Drop binary noise such as `0.30000000000000004` after repeated stepping.
827fn 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
840/// A switch control.
841pub 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
862/// A dropdown control built from `(value, label)` pairs.
863pub 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        // The debug wrapper spans the row; the 128 px number field sits at its
972        // left edge. Click inside that field, not the wrapper's centre.
973        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}