Skip to main content

liora_components/
input_number.rs

1use crate::Input;
2use gpui::{
3    App, Context, Entity, FocusHandle, Focusable, MouseButton, Render, Window, prelude::*, px,
4};
5use liora_core::Config;
6use liora_icons::Icon;
7use liora_icons_lucide::IconName;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum InputNumberControlsPosition {
11    Horizontal,
12    Right,
13}
14
15pub struct InputNumber {
16    value: f64,
17    min: f64,
18    max: f64,
19    step: f64,
20    precision: usize,
21    disabled: bool,
22    controls_position: InputNumberControlsPosition,
23    input: Entity<Input>,
24    focus_handle: FocusHandle,
25    on_change: Option<Box<dyn Fn(f64, &mut Window, &mut App) + 'static>>,
26}
27
28impl InputNumber {
29    pub fn new(value: f64, cx: &mut Context<Self>) -> Self {
30        let input = cx.new(|cx| {
31            Input::new(format!("{:.*}", 0, value), cx).filter(|text| {
32                if text.is_empty() {
33                    return true;
34                }
35                let Some(first) = text.chars().next() else {
36                    return true;
37                };
38                if first == '+' || first == '-' {
39                    let rest: String = text.chars().skip(1).collect();
40                    if rest.is_empty() {
41                        return true;
42                    }
43                    rest.chars().all(|c| c.is_ascii_digit() || c == '.')
44                        && rest.matches('.').count() <= 1
45                } else {
46                    text.chars().all(|c| c.is_ascii_digit() || c == '.')
47                        && text.matches('.').count() <= 1
48                }
49            })
50        });
51
52        let focus_handle = cx.focus_handle();
53
54        Self {
55            value,
56            min: f64::MIN,
57            max: f64::MAX,
58            step: 1.0,
59            precision: 0,
60            disabled: false,
61            controls_position: InputNumberControlsPosition::Horizontal,
62            input,
63            focus_handle,
64            on_change: None,
65        }
66    }
67
68    pub fn min(mut self, min: f64) -> Self {
69        self.min = min;
70        self
71    }
72    pub fn max(mut self, max: f64) -> Self {
73        self.max = max;
74        self
75    }
76    pub fn step(mut self, step: f64) -> Self {
77        self.step = step;
78        self
79    }
80    pub fn precision(mut self, p: usize) -> Self {
81        self.precision = p;
82        self
83    }
84    pub fn disabled(mut self, d: bool, cx: &mut Context<Self>) -> Self {
85        self.disabled = d;
86        self.input.update(cx, |input, cx| {
87            input.set_disabled(d, cx);
88        });
89        self
90    }
91    pub fn controls_position(mut self, pos: InputNumberControlsPosition) -> Self {
92        self.controls_position = pos;
93        self
94    }
95
96    pub fn on_change(mut self, cb: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
97        self.on_change = Some(Box::new(cb));
98        self
99    }
100
101    fn set_value(&mut self, val: f64, window: &mut Window, cx: &mut Context<Self>) {
102        let val = val.clamp(self.min, self.max);
103        if (val - self.value).abs() > f64::EPSILON || self.value == 0.0 {
104            self.value = val;
105            let formatted = format!("{:.*}", self.precision, self.value);
106            self.input.update(cx, |input, cx| {
107                input.set_value(formatted, cx);
108            });
109            if let Some(ref cb) = self.on_change {
110                cb(self.value, window, cx);
111            }
112            cx.notify();
113        }
114    }
115
116    fn increment(&mut self, window: &mut Window, cx: &mut Context<Self>) {
117        if !self.disabled && self.value < self.max {
118            self.set_value(self.value + self.step, window, cx);
119        }
120    }
121
122    fn decrement(&mut self, window: &mut Window, cx: &mut Context<Self>) {
123        if !self.disabled && self.value > self.min {
124            self.set_value(self.value - self.step, window, cx);
125        }
126    }
127}
128
129impl Focusable for InputNumber {
130    fn focus_handle(&self, _cx: &App) -> FocusHandle {
131        self.focus_handle.clone()
132    }
133}
134
135impl Render for InputNumber {
136    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
137        let theme = cx.global::<Config>().theme.clone();
138
139        match self.controls_position {
140            InputNumberControlsPosition::Horizontal => {
141                self.render_horizontal(&theme, cx).into_any_element()
142            }
143            InputNumberControlsPosition::Right => self.render_right(&theme, cx).into_any_element(),
144        }
145    }
146}
147
148impl InputNumber {
149    fn render_horizontal(
150        &self,
151        theme: &liora_theme::Theme,
152        cx: &mut Context<Self>,
153    ) -> impl IntoElement {
154        let icon_sz = 12.0;
155        let can_inc = !self.disabled && self.value < self.max;
156        let can_dec = !self.disabled && self.value > self.min;
157
158        let mut row = gpui::div()
159            .flex()
160            .flex_row()
161            .items_center()
162            .h(px(34.0))
163            .rounded(px(theme.radius.md))
164            .border_1()
165            .border_color(theme.neutral.border)
166            .bg(theme.neutral.card)
167            .overflow_hidden();
168
169        let mut dec_btn = gpui::div()
170            .flex()
171            .items_center()
172            .justify_center()
173            .w(px(32.0))
174            .h_full()
175            .bg(theme.neutral.hover)
176            .border_color(theme.neutral.border)
177            .border_r_1();
178
179        if can_dec {
180            dec_btn = dec_btn
181                .cursor_pointer()
182                .hover(|s| s.bg(theme.neutral.border))
183                .on_mouse_down(
184                    MouseButton::Left,
185                    cx.listener(|this, _, window, cx| {
186                        this.decrement(window, cx);
187                    }),
188                );
189        } else {
190            dec_btn = dec_btn.cursor_not_allowed().opacity(0.5);
191        }
192
193        row = row.child(
194            dec_btn.child(
195                Icon::new(IconName::Minus)
196                    .size(px(icon_sz))
197                    .color(if can_dec {
198                        theme.neutral.text_1
199                    } else {
200                        theme.neutral.text_disabled
201                    }),
202            ),
203        );
204        row = row.child(gpui::div().flex_1().child(self.input.clone()));
205
206        let mut inc_btn = gpui::div()
207            .flex()
208            .items_center()
209            .justify_center()
210            .w(px(32.0))
211            .h_full()
212            .bg(theme.neutral.hover)
213            .border_color(theme.neutral.border)
214            .border_l_1();
215
216        if can_inc {
217            inc_btn = inc_btn
218                .cursor_pointer()
219                .hover(|s| s.bg(theme.neutral.border))
220                .on_mouse_down(
221                    MouseButton::Left,
222                    cx.listener(|this, _, window, cx| {
223                        this.increment(window, cx);
224                    }),
225                );
226        } else {
227            inc_btn = inc_btn.cursor_not_allowed().opacity(0.5);
228        }
229
230        row = row.child(
231            inc_btn.child(
232                Icon::new(IconName::Plus)
233                    .size(px(icon_sz))
234                    .color(if can_inc {
235                        theme.neutral.text_1
236                    } else {
237                        theme.neutral.text_disabled
238                    }),
239            ),
240        );
241
242        row
243    }
244
245    fn render_right(&self, theme: &liora_theme::Theme, cx: &mut Context<Self>) -> impl IntoElement {
246        let icon_sz = 10.0;
247        let can_inc = !self.disabled && self.value < self.max;
248        let can_dec = !self.disabled && self.value > self.min;
249
250        let mut row = gpui::div()
251            .flex()
252            .flex_row()
253            .items_center()
254            .h(px(34.0))
255            .rounded(px(theme.radius.md))
256            .border_1()
257            .border_color(theme.neutral.border)
258            .bg(theme.neutral.card)
259            .overflow_hidden();
260
261        row = row.child(gpui::div().flex_1().child(self.input.clone()));
262
263        let mut controls = gpui::div()
264            .flex()
265            .flex_col()
266            .w(px(32.0))
267            .h_full()
268            .border_color(theme.neutral.border)
269            .border_l_1();
270
271        let mut inc_btn = gpui::div()
272            .flex_1()
273            .flex()
274            .items_center()
275            .justify_center()
276            .bg(theme.neutral.hover)
277            .border_color(theme.neutral.border)
278            .border_b_1();
279
280        if can_inc {
281            inc_btn = inc_btn
282                .cursor_pointer()
283                .hover(|s| s.bg(theme.neutral.border))
284                .on_mouse_down(
285                    MouseButton::Left,
286                    cx.listener(|this, _, window, cx| {
287                        this.increment(window, cx);
288                    }),
289                );
290        } else {
291            inc_btn = inc_btn.cursor_not_allowed().opacity(0.5);
292        }
293
294        let mut dec_btn = gpui::div()
295            .flex_1()
296            .flex()
297            .items_center()
298            .justify_center()
299            .bg(theme.neutral.hover);
300
301        if can_dec {
302            dec_btn = dec_btn
303                .cursor_pointer()
304                .hover(|s| s.bg(theme.neutral.border))
305                .on_mouse_down(
306                    MouseButton::Left,
307                    cx.listener(|this, _, window, cx| {
308                        this.decrement(window, cx);
309                    }),
310                );
311        } else {
312            dec_btn = dec_btn.cursor_not_allowed().opacity(0.5);
313        }
314
315        controls = controls.child(
316            inc_btn.child(
317                Icon::new(IconName::ChevronUp)
318                    .size(px(icon_sz))
319                    .color(if can_inc {
320                        theme.neutral.text_1
321                    } else {
322                        theme.neutral.text_disabled
323                    }),
324            ),
325        );
326        controls =
327            controls.child(
328                dec_btn.child(Icon::new(IconName::ChevronDown).size(px(icon_sz)).color(
329                    if can_dec {
330                        theme.neutral.text_1
331                    } else {
332                        theme.neutral.text_disabled
333                    },
334                )),
335            );
336
337        row = row.child(controls);
338
339        row
340    }
341}