Skip to main content

gpui_component/setting/fields/
number.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, AppContext as _, Entity, IntoElement, SharedString, StyleRefinement, Styled,
5    Subscription, Window, prelude::FluentBuilder as _,
6};
7
8use crate::{
9    AxisExt, Disableable, Sizable, StyledExt,
10    input::{InputEvent, InputState, NumberInput},
11    setting::{
12        AnySettingField, RenderOptions,
13        fields::{SettingFieldRender, get_value, set_value},
14    },
15};
16
17#[derive(Clone, Debug)]
18pub struct NumberFieldOptions {
19    /// The minimum value for the number input, default is `f64::MIN`.
20    pub min: f64,
21    /// The maximum value for the number input, default is `f64::MAX`.
22    pub max: f64,
23    /// The step value for the number input, default is `1.0`.
24    pub step: f64,
25}
26
27impl Default for NumberFieldOptions {
28    fn default() -> Self {
29        Self {
30            min: f64::MIN,
31            max: f64::MAX,
32            step: 1.0,
33        }
34    }
35}
36
37pub(crate) struct NumberField {
38    options: NumberFieldOptions,
39    /// The setting's title, the input's accessible name.
40    title: SharedString,
41}
42
43impl NumberField {
44    pub(crate) fn new(options: Option<&NumberFieldOptions>, title: SharedString) -> Self {
45        Self {
46            options: options.cloned().unwrap_or_default(),
47            title,
48        }
49    }
50}
51
52struct State {
53    input: Entity<InputState>,
54    initial_value: f64,
55    _subscriptions: Vec<Subscription>,
56}
57
58impl SettingFieldRender for NumberField {
59    fn render(
60        &self,
61        field: Rc<dyn AnySettingField>,
62        options: &RenderOptions,
63        style: &StyleRefinement,
64        window: &mut Window,
65        cx: &mut App,
66    ) -> AnyElement {
67        let value = get_value::<f64>(&field, cx);
68        let set_value = set_value::<f64>(&field, cx);
69        let num_options = self.options.clone();
70
71        let state_entity = window.use_keyed_state(
72            SharedString::from(format!(
73                "number-state-{}-{}-{}",
74                options.page_ix(),
75                options.group_ix(),
76                options.item_ix()
77            )),
78            cx,
79            |window, cx| {
80                // Configure stepping and bounds on the engine itself: `+`/`-`
81                // and Up/Down step by `step` with precision handling, and
82                // out-of-range text is tolerated while typing and clamped on
83                // blur. Re-implementing either on `Change` breaks both.
84                let input = cx.new(|cx| {
85                    InputState::new(window, cx)
86                        .default_value(value.to_string())
87                        .step(num_options.step)
88                        .min(num_options.min)
89                        .max(num_options.max)
90                });
91                let _subscriptions = vec![cx.subscribe_in(&input, window, {
92                    move |state: &mut State, input, event: &InputEvent, _window, cx| match event {
93                        InputEvent::Change => {
94                            input.update(cx, |input, cx| {
95                                let text = input.value();
96                                if text == state.initial_value.to_string() {
97                                    return;
98                                }
99
100                                // Unparsable intermediates ("-", "", "1.") are
101                                // left alone so the next keystroke can complete
102                                // them. Out-of-range text stays too (the engine
103                                // clamps it on blur), but the setting only ever
104                                // receives a value inside `min..=max`.
105                                if let Ok(parsed) = text.parse::<f64>() {
106                                    let clamped = parsed.clamp(num_options.min, num_options.max);
107                                    set_value(clamped, cx);
108                                    state.initial_value = clamped;
109                                }
110                            });
111                        }
112                        _ => {}
113                    }
114                })];
115
116                State {
117                    input,
118                    initial_value: value,
119                    _subscriptions,
120                }
121            },
122        );
123
124        // Sync engine config and displayed value when options or the
125        // underlying setting changed externally.
126        let sync_options = self.options.clone();
127        state_entity.update(cx, |state, cx| {
128            state.input.update(cx, |input, cx| {
129                input.set_step(Some(sync_options.step.into()), window, cx);
130                input.set_min(Some(sync_options.min), window, cx);
131                input.set_max(Some(sync_options.max), window, cx);
132            });
133            if state.initial_value != value {
134                state.initial_value = value;
135                state.input.update(cx, |input, cx| {
136                    input.set_value(SharedString::from(value.to_string()), window, cx);
137                });
138            }
139        });
140
141        let state = state_entity.read(cx);
142
143        NumberInput::new(&state.input)
144            .accessibility_label(self.title.clone())
145            .disabled(options.is_disabled())
146            .with_size(options.size())
147            .map(|this| {
148                if options.layout().is_horizontal() {
149                    this.w_32()
150                } else {
151                    this.w_full()
152                }
153            })
154            .refine_style(style)
155            .into_any_element()
156    }
157}