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}