1use std::{
2 fmt::Display,
3 ops::{Add, Sub},
4 rc::Rc,
5 str::FromStr,
6};
7
8use editor::{Editor, EditorStyle};
9use gpui::{ClickEvent, Entity, FocusHandle, Focusable, Modifiers};
10
11use ui::{IconButtonShape, prelude::*};
12
13#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum NumericStepperStyle {
15 Outlined,
16 #[default]
17 Ghost,
18}
19
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum NumericStepperMode {
22 #[default]
23 Read,
24 Edit,
25}
26
27pub trait NumericStepperType:
28 Display
29 + Add<Output = Self>
30 + Sub<Output = Self>
31 + Copy
32 + Clone
33 + Sized
34 + PartialOrd
35 + FromStr
36 + 'static
37{
38 fn default_format(value: &Self) -> String {
39 format!("{}", value)
40 }
41 fn default_step() -> Self;
42 fn large_step() -> Self;
43 fn small_step() -> Self;
44 fn min_value() -> Self;
45 fn max_value() -> Self;
46}
47
48macro_rules! impl_numeric_stepper_int {
49 ($type:ident) => {
50 impl NumericStepperType for $type {
51 fn default_step() -> Self {
52 1
53 }
54
55 fn large_step() -> Self {
56 10
57 }
58
59 fn small_step() -> Self {
60 1
61 }
62
63 fn min_value() -> Self {
64 <$type>::MIN
65 }
66
67 fn max_value() -> Self {
68 <$type>::MAX
69 }
70 }
71 };
72}
73
74macro_rules! impl_numeric_stepper_float {
75 ($type:ident) => {
76 impl NumericStepperType for $type {
77 fn default_format(value: &Self) -> String {
78 format!("{:^4}", value)
79 .trim_end_matches('0')
80 .trim_end_matches('.')
81 .to_string()
82 }
83
84 fn default_step() -> Self {
85 1.0
86 }
87
88 fn large_step() -> Self {
89 10.0
90 }
91
92 fn small_step() -> Self {
93 0.1
94 }
95
96 fn min_value() -> Self {
97 <$type>::MIN
98 }
99
100 fn max_value() -> Self {
101 <$type>::MAX
102 }
103 }
104 };
105}
106
107impl_numeric_stepper_float!(f32);
108impl_numeric_stepper_float!(f64);
109impl_numeric_stepper_int!(isize);
110impl_numeric_stepper_int!(usize);
111impl_numeric_stepper_int!(i32);
112impl_numeric_stepper_int!(u32);
113impl_numeric_stepper_int!(i64);
114impl_numeric_stepper_int!(u64);
115
116#[derive(RegisterComponent)]
117pub struct NumericStepper<T = usize> {
118 id: ElementId,
119 value: T,
120 style: NumericStepperStyle,
121 focus_handle: FocusHandle,
122 mode: Entity<NumericStepperMode>,
123 format: Box<dyn FnOnce(&T) -> String>,
124 large_step: T,
125 small_step: T,
126 step: T,
127 min_value: T,
128 max_value: T,
129 on_reset: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
130 on_change: Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>,
131 tab_index: Option<isize>,
132}
133
134impl<T: NumericStepperType> NumericStepper<T> {
135 pub fn new(id: impl Into<ElementId>, value: T, window: &mut Window, cx: &mut App) -> Self {
136 let id = id.into();
137
138 let (mode, focus_handle) = window.with_id(id.clone(), |window| {
139 let mode = window.use_state(cx, |_, _| NumericStepperMode::default());
140 let focus_handle = window.use_state(cx, |_, cx| cx.focus_handle());
141 (mode, focus_handle)
142 });
143
144 Self {
145 id,
146 mode,
147 value,
148 focus_handle: focus_handle.read(cx).clone(),
149 style: NumericStepperStyle::default(),
150 format: Box::new(T::default_format),
151 large_step: T::large_step(),
152 step: T::default_step(),
153 small_step: T::small_step(),
154 min_value: T::min_value(),
155 max_value: T::max_value(),
156 on_reset: None,
157 on_change: Rc::new(|_, _, _| {}),
158 tab_index: None,
159 }
160 }
161
162 pub fn format(mut self, format: impl FnOnce(&T) -> String + 'static) -> Self {
163 self.format = Box::new(format);
164 self
165 }
166
167 pub fn small_step(mut self, step: T) -> Self {
168 self.small_step = step;
169 self
170 }
171
172 pub fn normal_step(mut self, step: T) -> Self {
173 self.step = step;
174 self
175 }
176
177 pub fn large_step(mut self, step: T) -> Self {
178 self.large_step = step;
179 self
180 }
181
182 pub fn min(mut self, min: T) -> Self {
183 self.min_value = min;
184 self
185 }
186
187 pub fn max(mut self, max: T) -> Self {
188 self.max_value = max;
189 self
190 }
191
192 pub fn style(mut self, style: NumericStepperStyle) -> Self {
193 self.style = style;
194 self
195 }
196
197 pub fn on_reset(
198 mut self,
199 on_reset: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
200 ) -> Self {
201 self.on_reset = Some(Box::new(on_reset));
202 self
203 }
204
205 pub fn tab_index(mut self, tab_index: isize) -> Self {
206 self.tab_index = Some(tab_index);
207 self
208 }
209
210 pub fn on_change(mut self, on_change: impl Fn(&T, &mut Window, &mut App) + 'static) -> Self {
211 self.on_change = Rc::new(on_change);
212 self
213 }
214}
215
216impl<T: NumericStepperType> IntoElement for NumericStepper<T> {
217 type Element = gpui::Component<Self>;
218
219 fn into_element(self) -> Self::Element {
220 gpui::Component::new(self)
221 }
222}
223
224impl<T: NumericStepperType> RenderOnce for NumericStepper<T> {
225 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
226 let shape = IconButtonShape::Square;
227 let icon_size = IconSize::Small;
228
229 let is_outlined = matches!(self.style, NumericStepperStyle::Outlined);
230 let mut tab_index = self.tab_index;
231
232 let get_step = {
233 let large_step = self.large_step;
234 let step = self.step;
235 let small_step = self.small_step;
236 move |modifiers: Modifiers| -> T {
237 if modifiers.shift {
238 large_step
239 } else if modifiers.alt {
240 small_step
241 } else {
242 step
243 }
244 }
245 };
246
247 h_flex()
248 .id(self.id.clone())
249 .track_focus(&self.focus_handle)
250 .gap_1()
251 .when_some(self.on_reset, |this, on_reset| {
252 this.child(
253 IconButton::new("reset", IconName::RotateCcw)
254 .shape(shape)
255 .icon_size(icon_size)
256 .when_some(tab_index.as_mut(), |this, tab_index| {
257 *tab_index += 1;
258 this.tab_index(*tab_index - 1)
259 })
260 .on_click(on_reset),
261 )
262 })
263 .child(
264 h_flex()
265 .gap_1()
266 .rounded_sm()
267 .map(|this| {
268 if is_outlined {
269 this.overflow_hidden()
270 .bg(cx.theme().colors().surface_background)
271 .border_1()
272 .border_color(cx.theme().colors().border_variant)
273 } else {
274 this.px_1().bg(cx.theme().colors().editor_background)
275 }
276 })
277 .map(|decrement| {
278 let decrement_handler = {
279 let value = self.value;
280 let on_change = self.on_change.clone();
281 let min = self.min_value;
282 move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
283 let step = get_step(click.modifiers());
284 let new_value = value - step;
285 let new_value = if new_value < min { min } else { new_value };
286 on_change(&new_value, window, cx);
287 window.focus_prev();
288 }
289 };
290
291 if is_outlined {
292 decrement.child(
293 h_flex()
294 .id("decrement_button")
295 .p_1p5()
296 .size_full()
297 .justify_center()
298 .hover(|s| s.bg(cx.theme().colors().element_hover))
299 .border_r_1()
300 .border_color(cx.theme().colors().border_variant)
301 .child(Icon::new(IconName::Dash).size(IconSize::Small))
302 .when_some(tab_index.as_mut(), |this, tab_index| {
303 *tab_index += 1;
304 this.tab_index(*tab_index - 1).focus(|style| {
305 style.bg(cx.theme().colors().element_hover)
306 })
307 })
308 .on_click(decrement_handler),
309 )
310 } else {
311 decrement.child(
312 IconButton::new("decrement", IconName::Dash)
313 .shape(shape)
314 .icon_size(icon_size)
315 .when_some(tab_index.as_mut(), |this, tab_index| {
316 *tab_index += 1;
317 this.tab_index(*tab_index - 1)
318 })
319 .on_click(decrement_handler),
320 )
321 }
322 })
323 .child(
324 h_flex()
325 .h_8()
326 .min_w_16()
327 .w_full()
328 .border_1()
329 .border_color(cx.theme().colors().border_transparent)
330 .in_focus(|this| this.border_color(cx.theme().colors().border_focused))
331 .child(match *self.mode.read(cx) {
332 NumericStepperMode::Read => h_flex()
333 .id("numeric_stepper_label")
334 .flex_1()
335 .justify_center()
336 .child(Label::new((self.format)(&self.value)).mx_3())
337 .when_some(tab_index.as_mut(), |this, tab_index| {
338 *tab_index += 1;
339 this.tab_index(*tab_index - 1).focus(|style| {
340 style.bg(cx.theme().colors().element_hover)
341 })
342 })
343 .on_click({
344 let _mode = self.mode.clone();
345 move |click, _, _cx| {
346 if click.click_count() == 2 || click.is_keyboard() {
347 // Edit mode is disabled until we implement center text alignment for editor
348 // mode.write(cx, NumericStepperMode::Edit);
349 }
350 }
351 })
352 .into_any_element(),
353 NumericStepperMode::Edit => h_flex()
354 .flex_1()
355 .child(window.use_state(cx, {
356 |window, cx| {
357 let previous_focus_handle = window.focused(cx);
358 let mut editor = Editor::single_line(window, cx);
359 let mut style = EditorStyle::default();
360 style.text.text_align = gpui::TextAlign::Right;
361 editor.set_style(style, window, cx);
362
363 editor.set_text(format!("{}", self.value), window, cx);
364 cx.on_focus_out(&editor.focus_handle(cx), window, {
365 let mode = self.mode.clone();
366 let min = self.min_value;
367 let max = self.max_value;
368 let on_change = self.on_change.clone();
369 move |this, _, window, cx| {
370 if let Ok(new_value) =
371 this.text(cx).parse::<T>()
372 {
373 let new_value = if new_value < min {
374 min
375 } else if new_value > max {
376 max
377 } else {
378 new_value
379 };
380
381 if let Some(previous) =
382 previous_focus_handle.as_ref()
383 {
384 window.focus(previous);
385 }
386 on_change(&new_value, window, cx);
387 };
388 mode.write(cx, NumericStepperMode::Read);
389 }
390 })
391 .detach();
392
393 window.focus(&editor.focus_handle(cx));
394
395 editor
396 }
397 }))
398 .on_action::<menu::Confirm>({
399 move |_, window, _| {
400 window.blur();
401 }
402 })
403 .into_any_element(),
404 }),
405 )
406 .map(|increment| {
407 let increment_handler = {
408 let value = self.value;
409 let on_change = self.on_change.clone();
410 let max = self.max_value;
411 move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
412 let step = get_step(click.modifiers());
413 let new_value = value + step;
414 let new_value = if new_value > max { max } else { new_value };
415 on_change(&new_value, window, cx);
416 }
417 };
418
419 if is_outlined {
420 increment.child(
421 h_flex()
422 .id("increment_button")
423 .p_1p5()
424 .size_full()
425 .justify_center()
426 .hover(|s| s.bg(cx.theme().colors().element_hover))
427 .border_l_1()
428 .border_color(cx.theme().colors().border_variant)
429 .child(Icon::new(IconName::Plus).size(IconSize::Small))
430 .when_some(tab_index.as_mut(), |this, tab_index| {
431 *tab_index += 1;
432 this.tab_index(*tab_index - 1).focus(|style| {
433 style.bg(cx.theme().colors().element_hover)
434 })
435 })
436 .on_click(increment_handler),
437 )
438 } else {
439 increment.child(
440 IconButton::new("increment", IconName::Plus)
441 .shape(shape)
442 .icon_size(icon_size)
443 .when_some(tab_index.as_mut(), |this, tab_index| {
444 *tab_index += 1;
445 this.tab_index(*tab_index - 1)
446 })
447 .on_click(increment_handler),
448 )
449 }
450 }),
451 )
452 }
453}
454
455impl Component for NumericStepper<usize> {
456 fn scope() -> ComponentScope {
457 ComponentScope::Input
458 }
459
460 fn name() -> &'static str {
461 "Numeric Stepper"
462 }
463
464 fn sort_name() -> &'static str {
465 Self::name()
466 }
467
468 fn description() -> Option<&'static str> {
469 Some("A button used to increment or decrement a numeric value.")
470 }
471
472 fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
473 let first_stepper = window.use_state(cx, |_, _| 100usize);
474 let second_stepper = window.use_state(cx, |_, _| 100.0);
475 Some(
476 v_flex()
477 .gap_6()
478 .children(vec![example_group_with_title(
479 "Styles",
480 vec![
481 single_example(
482 "Default",
483 NumericStepper::new(
484 "numeric-stepper-component-preview",
485 *first_stepper.read(cx),
486 window,
487 cx,
488 )
489 .on_change({
490 let first_stepper = first_stepper.clone();
491 move |value, _, cx| first_stepper.write(cx, *value)
492 })
493 .into_any_element(),
494 ),
495 single_example(
496 "Outlined",
497 NumericStepper::new(
498 "numeric-stepper-with-border-component-preview",
499 *second_stepper.read(cx),
500 window,
501 cx,
502 )
503 .on_change({
504 let second_stepper = second_stepper.clone();
505 move |value, _, cx| second_stepper.write(cx, *value)
506 })
507 .min(1.0)
508 .max(100.0)
509 .style(NumericStepperStyle::Outlined)
510 .into_any_element(),
511 ),
512 ],
513 )])
514 .into_any_element(),
515 )
516 }
517}