Skip to main content

ccf_gpui_widgets/widgets/
number_stepper.rs

1//! Number stepper widget
2//!
3//! A numeric input with increment/decrement buttons. Supports min/max constraints,
4//! step size, value resolution, and display precision.
5//!
6//! # Features
7//!
8//! - **Double-click** the value display to edit directly
9//! - **Click and drag** horizontally on the value to scrub/adjust smoothly
10//!   - Drag sensitivity auto-scales to widget width when min/max are set
11//!   - Dragging across the full value display covers the entire range
12//!   - Hold **Shift** for fast adjustment (5x speed)
13//!   - Hold **Alt/Option** for slow/fine adjustment (0.1x speed)
14//! - **Up/Down arrow keys** to increment/decrement when focused
15//! - **Value resolution**: Values snap to multiples of resolution relative to min
16//! - **Display precision**: Control how many decimal places are shown
17//!
18//! # Example
19//!
20//! ```ignore
21//! use ccf_gpui_widgets::widgets::NumberStepper;
22//!
23//! let stepper = cx.new(|cx| {
24//!     NumberStepper::new(cx)
25//!         .with_value(50.0)
26//!         .min(0.0)
27//!         .max(100.0)
28//!         .step(5.0)
29//!         .resolution(0.25)      // Values snap to 0.25 increments
30//!         .display_precision(2)  // Show 2 decimal places
31//! });
32//!
33//! // Subscribe to changes
34//! cx.subscribe(&stepper, |this, _stepper, event: &NumberStepperEvent, cx| {
35//!     if let NumberStepperEvent::Change(value) = event {
36//!         println!("Value: {}", value);
37//!     }
38//! }).detach();
39//! ```
40
41use std::cell::Cell;
42use std::rc::Rc;
43
44use gpui::prelude::*;
45use gpui::*;
46
47use crate::theme::{get_theme_or, Theme};
48use crate::utils::format_display_value;
49use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
50use super::text_input::{TextInput, TextInputEvent};
51
52/// Events emitted by NumberStepper
53#[derive(Clone, Debug)]
54pub enum NumberStepperEvent {
55    /// Value changed
56    Change(f64),
57}
58
59/// Marker type for number scrubbing drag operations
60#[doc(hidden)]
61#[derive(Clone)]
62struct NumberDragState;
63
64/// Empty view used as drag visual (we don't want a visible drag indicator)
65#[doc(hidden)]
66struct EmptyDragView;
67
68impl Render for EmptyDragView {
69    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
70        div().size_0()
71    }
72}
73
74/// Number stepper widget with +/- buttons
75pub struct NumberStepper {
76    value: f64,
77    min: Option<f64>,
78    max: Option<f64>,
79    step: Option<f64>,
80    /// Value resolution - values snap to (min + n * resolution) where n is an integer
81    resolution: Option<f64>,
82    /// Number of decimal places to display (value is rounded for display only)
83    display_precision: Option<usize>,
84    focus_handle: FocusHandle,
85    custom_theme: Option<Theme>,
86    /// Whether the stepper is enabled
87    enabled: bool,
88
89    // Drag sensitivity: value change per pixel of mouse movement
90    /// Normal drag: value change per pixel (no modifier key)
91    value_per_pixel_normal: f64,
92    /// Fast drag: value change per pixel (Shift held)
93    value_per_pixel_fast: f64,
94    /// Slow/fine drag: value change per pixel (Alt/Option held)
95    value_per_pixel_slow: f64,
96    /// Whether to auto-scale drag sensitivity based on widget width and value range
97    auto_scale_drag: bool,
98    /// Measured width of the value display area (for auto-scaling)
99    value_display_width: Rc<Cell<f32>>,
100
101    // Edit mode state
102    /// Whether we're in text editing mode
103    editing: bool,
104    /// The embedded text input for editing
105    edit_input: Entity<TextInput>,
106    /// Value when edit started (for cancel)
107    original_value: f64,
108    /// Whether we need to refocus the stepper in the next render
109    pending_refocus: bool,
110
111    // Drag state
112    /// Whether we're currently dragging
113    dragging: bool,
114    /// Starting x position of drag
115    drag_start_x: f32,
116    /// Value when drag started
117    drag_start_value: f64,
118
119    // Step multipliers for button clicks
120    /// Multiplier for small step (Alt/Option + click), default 0.1
121    step_small_multiplier: f64,
122    /// Multiplier for large step (Shift + click), default 10.0
123    step_large_multiplier: f64,
124}
125
126impl EventEmitter<NumberStepperEvent> for NumberStepper {}
127
128impl Focusable for NumberStepper {
129    fn focus_handle(&self, _cx: &App) -> FocusHandle {
130        self.focus_handle.clone()
131    }
132}
133
134impl NumberStepper {
135    /// Create a new number stepper
136    pub fn new(cx: &mut Context<Self>) -> Self {
137        // Create the embedded text input for editing
138        let edit_input = cx.new(|cx| {
139            TextInput::new(cx)
140                .borderless(true)
141                .select_on_focus(true)
142                .input_filter(|c| c.is_ascii_digit() || c == '.' || c == '-')
143                .emit_tab_events(true)  // Let NumberStepper handle Tab
144        });
145
146        // Subscribe to TextInput events
147        cx.subscribe(&edit_input, |this: &mut Self, _input, event: &TextInputEvent, cx| {
148            match event {
149                TextInputEvent::Enter => this.commit_edit(cx),
150                TextInputEvent::Escape => this.cancel_edit(cx),
151                TextInputEvent::Blur => {
152                    if this.editing {
153                        this.commit_edit(cx);
154                    }
155                }
156                TextInputEvent::Tab | TextInputEvent::ShiftTab => {
157                    // Treat Tab same as Enter - commit and stay on stepper
158                    this.commit_edit(cx);
159                }
160                _ => {}
161            }
162        }).detach();
163
164        Self {
165            value: 0.0,
166            min: None,
167            max: None,
168            step: None,
169            resolution: None,
170            display_precision: None,
171            focus_handle: cx.focus_handle().tab_stop(true),
172            custom_theme: None,
173            enabled: true,
174            value_per_pixel_normal: 0.5,
175            value_per_pixel_fast: 2.5,   // 5x normal
176            value_per_pixel_slow: 0.05,  // 0.1x normal
177            auto_scale_drag: true,
178            value_display_width: Rc::new(Cell::new(0.0)),
179            editing: false,
180            edit_input,
181            original_value: 0.0,
182            pending_refocus: false,
183            dragging: false,
184            drag_start_x: 0.0,
185            drag_start_value: 0.0,
186            step_small_multiplier: 0.1,  // Alt/Option = 0.1x step
187            step_large_multiplier: 10.0, // Shift = 10x step
188        }
189    }
190
191    /// Set initial value (builder pattern)
192    #[must_use]
193    pub fn with_value(mut self, value: f64) -> Self {
194        self.value = value;
195        self
196    }
197
198    /// Set minimum value (builder pattern)
199    #[must_use]
200    pub fn min(mut self, min: f64) -> Self {
201        self.min = Some(min);
202        self
203    }
204
205    /// Set maximum value (builder pattern)
206    #[must_use]
207    pub fn max(mut self, max: f64) -> Self {
208        self.max = Some(max);
209        self
210    }
211
212    /// Set step value for +/- buttons (builder pattern)
213    #[must_use]
214    pub fn step(mut self, step: f64) -> Self {
215        self.step = Some(step);
216        self
217    }
218
219    /// Set value resolution (builder pattern)
220    ///
221    /// Values will snap to (min + n * resolution) where n is an integer.
222    /// For example, with min=0.5 and resolution=0.25, valid values are 0.5, 0.75, 1.0, 1.25, etc.
223    #[must_use]
224    pub fn resolution(mut self, resolution: f64) -> Self {
225        self.resolution = Some(resolution);
226        self
227    }
228
229    /// Set display precision - number of decimal places to show (builder pattern)
230    ///
231    /// The displayed value is rounded to this many decimal places.
232    /// This is independent of the actual stored value.
233    #[must_use]
234    pub fn display_precision(mut self, precision: usize) -> Self {
235        self.display_precision = Some(precision);
236        self
237    }
238
239    /// Set custom theme (builder pattern)
240    #[must_use]
241    pub fn theme(mut self, theme: Theme) -> Self {
242        self.custom_theme = Some(theme);
243        self
244    }
245
246    /// Set enabled state (builder pattern)
247    #[must_use]
248    pub fn with_enabled(mut self, enabled: bool) -> Self {
249        self.enabled = enabled;
250        self
251    }
252
253    /// Set drag sensitivities as value change per pixel (builder pattern)
254    ///
255    /// Each parameter specifies how much the value changes per pixel of mouse movement:
256    /// - `normal`: Value per pixel with no modifier key
257    /// - `fast`: Value per pixel when Shift is held
258    /// - `slow`: Value per pixel when Alt/Option is held
259    ///
260    /// Example for an integer 0-100 range:
261    /// ```ignore
262    /// .drag_sensitivities(1.0, 10.0, 1.0)  // 1, 10, or 1 unit per pixel
263    /// ```
264    ///
265    /// Example for a float with fine control:
266    /// ```ignore
267    /// .drag_sensitivities(1.0, 2.0, 0.1)  // 10 pixels of slow drag = 1.0 change
268    /// ```
269    #[must_use]
270    pub fn drag_sensitivities(mut self, normal: f64, fast: f64, slow: f64) -> Self {
271        self.value_per_pixel_normal = normal;
272        self.value_per_pixel_fast = fast;
273        self.value_per_pixel_slow = slow;
274        self
275    }
276
277    /// Set normal drag sensitivity, scaling fast (5x) and slow (0.1x) proportionally
278    #[must_use]
279    pub fn drag_sensitivity(mut self, value_per_pixel: f64) -> Self {
280        self.value_per_pixel_normal = value_per_pixel;
281        self.value_per_pixel_fast = value_per_pixel * 5.0;
282        self.value_per_pixel_slow = value_per_pixel * 0.1;
283        self
284    }
285
286    /// Disable auto-scaling of drag sensitivity based on widget width (builder pattern)
287    ///
288    /// By default, when both min and max are set, the drag sensitivity is automatically
289    /// calculated so that dragging across the value display covers the full range.
290    /// Call this method to use the configured fixed sensitivities instead.
291    #[must_use]
292    pub fn manual_drag_sensitivity(mut self) -> Self {
293        self.auto_scale_drag = false;
294        self
295    }
296
297    /// Set step multipliers for button clicks (builder pattern)
298    ///
299    /// - `small`: Multiplier when Alt/Option is held (default 0.1)
300    /// - `large`: Multiplier when Shift is held (default 10.0)
301    ///
302    /// Example: For a step of 100, with multipliers (0.01, 10.0):
303    /// - Normal click: step by 100
304    /// - Alt+click: step by 1 (100 * 0.01)
305    /// - Shift+click: step by 1000 (100 * 10.0)
306    #[must_use]
307    pub fn step_multipliers(mut self, small: f64, large: f64) -> Self {
308        self.step_small_multiplier = small;
309        self.step_large_multiplier = large;
310        self
311    }
312
313    /// Set small step multiplier for Alt/Option + click (builder pattern)
314    #[must_use]
315    pub fn step_small(mut self, multiplier: f64) -> Self {
316        self.step_small_multiplier = multiplier;
317        self
318    }
319
320    /// Set large step multiplier for Shift + click (builder pattern)
321    #[must_use]
322    pub fn step_large(mut self, multiplier: f64) -> Self {
323        self.step_large_multiplier = multiplier;
324        self
325    }
326
327    /// Get the focus handle
328    pub fn focus_handle(&self) -> &FocusHandle {
329        &self.focus_handle
330    }
331
332    /// Check if the stepper is enabled
333    pub fn is_enabled(&self) -> bool {
334        self.enabled
335    }
336
337    /// Set enabled state programmatically
338    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
339        if self.enabled != enabled {
340            self.enabled = enabled;
341            cx.notify();
342        }
343    }
344
345    /// Get the current value
346    pub fn value(&self) -> f64 {
347        self.value
348    }
349
350    /// Get the minimum value constraint
351    pub fn get_min(&self) -> Option<f64> {
352        self.min
353    }
354
355    /// Get the maximum value constraint
356    pub fn get_max(&self) -> Option<f64> {
357        self.max
358    }
359
360    /// Get the step value for +/- buttons
361    pub fn get_step(&self) -> Option<f64> {
362        self.step
363    }
364
365    /// Get the value resolution
366    pub fn get_resolution(&self) -> Option<f64> {
367        self.resolution
368    }
369
370    /// Get the display precision (decimal places)
371    pub fn get_display_precision(&self) -> Option<usize> {
372        self.display_precision
373    }
374
375    /// Set value programmatically
376    pub fn set_value(&mut self, value: f64, cx: &mut Context<Self>) {
377        let normalized = self.normalize_value(value);
378        if (self.value - normalized).abs() > f64::EPSILON {
379            self.value = normalized;
380            cx.emit(NumberStepperEvent::Change(self.value));
381            cx.notify();
382        }
383    }
384
385    /// Format the value for display, applying display_precision rounding
386    fn format_value(&self) -> String {
387        format_display_value(self.value, self.display_precision)
388    }
389
390    /// Snap value to resolution and clamp to min/max range
391    fn normalize_value(&self, value: f64) -> f64 {
392        let min = self.min.unwrap_or(f64::NEG_INFINITY);
393        let max = self.max.unwrap_or(f64::INFINITY);
394
395        // First snap to resolution if specified
396        let snapped = if let Some(resolution) = self.resolution {
397            if resolution > 0.0 {
398                // Round to nearest multiple of resolution relative to min
399                let offset = value - min;
400                let n = (offset / resolution).round();
401                min + n * resolution
402            } else {
403                value
404            }
405        } else {
406            value
407        };
408
409        // Then clamp to range
410        snapped.clamp(min, max)
411    }
412
413    fn adjust_value(&mut self, direction: f64, multiplier: f64, cx: &mut Context<Self>) {
414        let step = self.step.unwrap_or(1.0) * multiplier * direction;
415        let new_value = self.normalize_value(self.value + step);
416        if (self.value - new_value).abs() > f64::EPSILON {
417            self.value = new_value;
418            cx.emit(NumberStepperEvent::Change(self.value));
419            cx.notify();
420        }
421    }
422
423    fn increment(&mut self, multiplier: f64, cx: &mut Context<Self>) {
424        self.adjust_value(1.0, multiplier, cx);
425    }
426
427    fn decrement(&mut self, multiplier: f64, cx: &mut Context<Self>) {
428        self.adjust_value(-1.0, multiplier, cx);
429    }
430
431    // ===== Edit mode methods =====
432
433    /// Enter text editing mode
434    fn enter_edit_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
435        self.editing = true;
436        self.original_value = self.value;
437
438        // Set the TextInput value and focus it
439        let formatted = self.format_value();
440        self.edit_input.update(cx, |input, cx| {
441            input.set_value(&formatted, cx);
442        });
443
444        // Focus the TextInput
445        self.edit_input.read(cx).focus_handle().focus(window);
446        cx.notify();
447    }
448
449    /// Exit text editing mode without committing changes
450    fn cancel_edit(&mut self, cx: &mut Context<Self>) {
451        self.editing = false;
452        self.pending_refocus = true;
453        cx.notify();
454    }
455
456    /// Commit the edited text value
457    fn commit_edit(&mut self, cx: &mut Context<Self>) {
458        self.apply_edit_value(cx);
459        self.editing = false;
460        self.pending_refocus = true;
461        cx.notify();
462    }
463
464    /// Apply the value from the edit input (shared logic)
465    fn apply_edit_value(&mut self, cx: &mut Context<Self>) {
466        // Get the content from TextInput
467        let content = self.edit_input.read(cx).content().to_string();
468
469        if let Ok(parsed) = content.trim().parse::<f64>() {
470            let normalized = self.normalize_value(parsed);
471            if (self.value - normalized).abs() > f64::EPSILON {
472                self.value = normalized;
473                cx.emit(NumberStepperEvent::Change(self.value));
474            }
475        }
476        // If parse fails, value reverts to original (unchanged)
477    }
478
479    // ===== Drag methods =====
480
481    /// Start drag scrubbing
482    fn start_drag(&mut self, x: f32) {
483        self.dragging = true;
484        self.drag_start_x = x;
485        self.drag_start_value = self.value;
486    }
487
488    /// Update value based on drag delta with modifier-based sensitivity
489    fn update_drag(&mut self, x: f32, modifiers: &Modifiers, cx: &mut Context<Self>) {
490        if !self.dragging {
491            return;
492        }
493
494        // Calculate base value-per-pixel (auto-scale if enabled and range is defined)
495        let auto_scale_range = if self.auto_scale_drag {
496            self.min.zip(self.max).map(|(min, max)| max - min)
497        } else {
498            None
499        };
500
501        let base_value_per_pixel = if let Some(range) = auto_scale_range {
502            let width = self.value_display_width.get();
503            if width > 0.0 {
504                range / width as f64
505            } else {
506                self.value_per_pixel_normal
507            }
508        } else {
509            self.value_per_pixel_normal
510        };
511
512        // Apply modifier-based scaling
513        let value_per_pixel = if modifiers.shift {
514            if auto_scale_range.is_some() {
515                base_value_per_pixel * 5.0 // 5x faster
516            } else {
517                self.value_per_pixel_fast
518            }
519        } else if modifiers.alt {
520            if auto_scale_range.is_some() {
521                base_value_per_pixel * 0.1 // 0.1x slower
522            } else {
523                self.value_per_pixel_slow
524            }
525        } else {
526            base_value_per_pixel
527        };
528
529        let delta_pixels = (x - self.drag_start_x) as f64;
530        let new_value = self.normalize_value(self.drag_start_value + delta_pixels * value_per_pixel);
531        if (self.value - new_value).abs() > f64::EPSILON {
532            self.value = new_value;
533            cx.emit(NumberStepperEvent::Change(self.value));
534            cx.notify();
535        }
536    }
537
538    /// End drag scrubbing
539    fn end_drag(&mut self) {
540        self.dragging = false;
541    }
542}
543
544impl Render for NumberStepper {
545    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
546        let theme = get_theme_or(cx, self.custom_theme.as_ref());
547        let display_value = self.format_value();
548        let focus_handle = self.focus_handle.clone();
549        let editing = self.editing;
550        let enabled = self.enabled;
551
552        // Handle pending refocus (after Enter/Escape/Tab from TextInput)
553        if self.pending_refocus {
554            self.pending_refocus = false;
555            self.focus_handle.focus(window);
556        }
557
558        // Check focus state after handling pending refocus
559        let is_focused = self.focus_handle.is_focused(window);
560
561        // Colors for the unified control (use disabled colors when disabled)
562        let bg_color = if enabled { theme.bg_input } else { theme.disabled_bg };
563        let border_color = if !enabled {
564            theme.disabled_bg
565        } else if is_focused || editing {
566            theme.border_focus
567        } else {
568            theme.border_input
569        };
570        let separator_color = if enabled { theme.text_muted } else { theme.disabled_text };
571        let text_color = if enabled { theme.text_value } else { theme.disabled_text };
572        let button_text_color = if enabled { theme.text_value } else { theme.disabled_text };
573
574        // Build the center value element (without its own border/background)
575        let value_element = if editing && enabled {
576            // Text edit mode - use embedded TextInput
577            div()
578                .id("ccf_number_value")
579                .px_2()
580                .py_1()
581                .flex_1()
582                .flex()
583                .items_center()
584                .overflow_hidden()
585                .child(self.edit_input.clone())
586        } else {
587            // Normal display mode
588            // Clone the width cell for the canvas closure
589            let width_cell = self.value_display_width.clone();
590
591            let mut value_div = div()
592                .id("ccf_number_value")
593                .relative()
594                .px_2()
595                .py_1()
596                .flex_1()
597                .flex()
598                .items_center()
599                .justify_center()
600                .text_sm()
601                .text_color(rgb(text_color))
602                .when(enabled, |d| d.cursor(CursorStyle::ResizeLeftRight))
603                .when(!enabled, |d| d.cursor_default());
604
605            // Only add mouse/drag handlers when enabled
606            if enabled {
607                value_div = value_div
608                    // Double-click to edit, single-click starts drag state tracking
609                    .on_mouse_down(MouseButton::Left, cx.listener(|stepper, event: &MouseDownEvent, window, cx| {
610                        if !stepper.enabled {
611                            return;
612                        }
613                        stepper.focus_handle.focus(window);
614                        if event.click_count == 2 {
615                            // Double-click: enter edit mode
616                            stepper.enter_edit_mode(window, cx);
617                        } else {
618                            // Single click: record drag start position for on_drag_move
619                            let x: f32 = event.position.x.into();
620                            stepper.start_drag(x);
621                        }
622                    }))
623                    // Initiate drag - this enables on_drag_move to track outside element bounds
624                    .on_drag(NumberDragState, |_state, _position, _window, cx| {
625                        cx.new(|_| EmptyDragView)
626                    })
627                    // Track drag movement even outside element bounds (Shift=fast, Alt/Option=slow)
628                    .on_drag_move(cx.listener(|stepper, event: &DragMoveEvent<NumberDragState>, _window, cx| {
629                        if stepper.dragging {
630                            let x: f32 = event.event.position.x.into();
631                            stepper.update_drag(x, &event.event.modifiers, cx);
632                        }
633                    }))
634                    // End drag on mouse up (inside element)
635                    .on_mouse_up(MouseButton::Left, cx.listener(|stepper, _event: &MouseUpEvent, _window, _cx| {
636                        stepper.end_drag();
637                    }))
638                    // End drag on mouse up outside element
639                    .on_mouse_up_out(MouseButton::Left, cx.listener(|stepper, _event: &MouseUpEvent, _window, _cx| {
640                        stepper.end_drag();
641                    }));
642            }
643
644            value_div
645                // Canvas to measure width for auto-scaling drag sensitivity
646                .child(
647                    canvas(
648                        move |bounds, _window, _cx| {
649                            width_cell.set(bounds.size.width.into());
650                            bounds
651                        },
652                        |_, _, _, _| {},
653                    )
654                    .size_full()
655                    .absolute()
656                )
657                .child(display_value)
658        };
659
660        // Vertical separator element
661        let separator = || {
662            div()
663                .w(px(1.0))
664                .h_full()
665                .bg(rgb(separator_color))
666        };
667
668        // Build decrement button
669        let mut decrement_button = div()
670            .id("ccf_number_decrement")
671            .flex()
672            .items_center()
673            .justify_center()
674            .px_2()
675            .py_1()
676            .text_color(rgb(button_text_color))
677            .cursor_for_enabled(enabled)
678            .when(enabled, |d| d.hover(|h| h.bg(rgb(theme.bg_hover))))
679            .child("\u{2212}");  // Using proper minus sign
680
681        if enabled {
682            decrement_button = decrement_button.on_click(cx.listener(|stepper, event: &ClickEvent, window, cx| {
683                if !stepper.enabled {
684                    return;
685                }
686                stepper.focus_handle.focus(window);
687                if stepper.editing {
688                    // Set editing to false before anything that could trigger blur
689                    stepper.editing = false;
690                }
691                // Shift = large step, Alt/Option = small step, Normal = 1x
692                let multiplier = if event.modifiers().shift {
693                    stepper.step_large_multiplier
694                } else if event.modifiers().alt {
695                    stepper.step_small_multiplier
696                } else {
697                    1.0
698                };
699                stepper.decrement(multiplier, cx);
700            }));
701        }
702
703        // Build increment button
704        let mut increment_button = div()
705            .id("ccf_number_increment")
706            .flex()
707            .items_center()
708            .justify_center()
709            .px_2()
710            .py_1()
711            .text_color(rgb(button_text_color))
712            .cursor_for_enabled(enabled)
713            .when(enabled, |d| d.hover(|h| h.bg(rgb(theme.bg_hover))))
714            .child("+");
715
716        if enabled {
717            increment_button = increment_button.on_click(cx.listener(|stepper, event: &ClickEvent, window, cx| {
718                if !stepper.enabled {
719                    return;
720                }
721                stepper.focus_handle.focus(window);
722                if stepper.editing {
723                    // Set editing to false before anything that could trigger blur
724                    stepper.editing = false;
725                }
726                // Shift = large step, Alt/Option = small step, Normal = 1x
727                let multiplier = if event.modifiers().shift {
728                    stepper.step_large_multiplier
729                } else if event.modifiers().alt {
730                    stepper.step_small_multiplier
731                } else {
732                    1.0
733                };
734                stepper.increment(multiplier, cx);
735            }));
736        }
737
738        // Unified container with all three parts
739        with_focus_actions(
740            div()
741                .id("ccf_number_stepper")
742                .track_focus(&focus_handle)
743                .tab_stop(enabled),
744            cx,
745        )
746        .on_key_down(cx.listener(|stepper, event: &KeyDownEvent, window, cx| {
747                // Don't handle keys when disabled or editing (TextInput handles them)
748                if !stepper.enabled || stepper.editing {
749                    return;
750                }
751                if handle_tab_navigation(event, window) {
752                    return;
753                }
754                let multiplier = if event.keystroke.modifiers.shift { 10.0 } else { 1.0 };
755                match event.keystroke.key.as_str() {
756                    "enter" => stepper.enter_edit_mode(window, cx),
757                    "up" => stepper.increment(multiplier, cx),
758                    "down" => stepper.decrement(multiplier, cx),
759                    _ => {}
760                }
761            }))
762            // Unified styling - single rounded box
763            .flex()
764            .flex_row()
765            .items_center()
766            .h(px(28.0))  // Fixed height for uniform appearance
767            .bg(rgb(bg_color))
768            .border_1()
769            .border_color(rgb(border_color))
770            .rounded_md()
771            .overflow_hidden()
772            // Decrement button
773            .child(decrement_button)
774            // Left separator
775            .child(separator())
776            // Value display
777            .child(value_element)
778            // Right separator
779            .child(separator())
780            // Increment button
781            .child(increment_button)
782    }
783}