Skip to main content

egui_macview/controls/
stepper.rs

1//! Authentic macOS AppKit Stepper Control (NSStepper equivalent).
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via `MacTheme`.
4//! Fully data-driven: zero hardcoded colors or dimensions.
5
6use crate::layout::subpixel_snap_rect;
7use crate::theme::MacTheme;
8use egui::{CornerRadius, Key, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget};
9use std::ops::RangeInclusive;
10
11/// Authentic macOS AppKit Stepper Control (`NSStepper` equivalent).
12///
13/// Features authentic dual stacked increment/decrement arrow chevrons,
14/// continuous auto-repeat when held down, keyboard arrow support, and optional value wrap-around.
15///
16/// # Example
17///
18/// ```rust
19/// use egui_macview::{MacStepper, MacTheme};
20///
21/// # let ctx = egui::Context::default();
22/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
23/// let theme = MacTheme::default();
24/// let mut count = 5.0f64;
25///
26/// MacStepper::new(&mut count, 0.0..=100.0, 1.0)
27///     .theme(&theme)
28///     .show(ui);
29/// # });
30/// # output.textures_delta.clear();
31/// ```
32pub struct MacStepper<'a> {
33    value: &'a mut f64,
34    range: RangeInclusive<f64>,
35    step: f64,
36    theme: Option<&'a MacTheme>,
37    width: Option<f32>,
38    height: Option<f32>,
39    corner_radius: Option<f32>,
40    wraps: bool,
41    enabled: bool,
42}
43
44impl<'a> MacStepper<'a> {
45    /// Creates a new stepper bound to a value, inclusive range, and step delta.
46    pub fn new(value: &'a mut f64, range: RangeInclusive<f64>, step: f64) -> Self {
47        Self {
48            value,
49            range,
50            step,
51            theme: None,
52            width: None,
53            height: None,
54            corner_radius: None,
55            wraps: false,
56            enabled: true,
57        }
58    }
59
60    /// Sets the dynamic theme.
61    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
62        self.theme = Some(theme);
63        self
64    }
65
66    /// Overrides default width.
67    pub fn width(mut self, width: f32) -> Self {
68        self.width = Some(width);
69        self
70    }
71
72    /// Overrides default height.
73    pub fn height(mut self, height: f32) -> Self {
74        self.height = Some(height);
75        self
76    }
77
78    /// Overrides default corner radius.
79    pub fn corner_radius(mut self, r: f32) -> Self {
80        self.corner_radius = Some(r);
81        self
82    }
83
84    /// Configures wrap-around at range boundaries.
85    pub fn wraps(mut self, wraps: bool) -> Self {
86        self.wraps = wraps;
87        self
88    }
89
90    /// Enables or disables the control.
91    pub fn enabled(mut self, enabled: bool) -> Self {
92        self.enabled = enabled;
93        self
94    }
95
96    /// Renders the stepper inside the specified UI container.
97    pub fn show(self, ui: &mut Ui) -> Response {
98        ui.add(self)
99    }
100}
101
102/// Consumes an arrow key (`ArrowUp` / `ArrowDown`) with optional `Cmd`/`Ctrl` (`cmd_mult`)
103/// or `Shift` (`shift_mult`) accelerator multiplier.
104pub(crate) fn consume_step_arrow_mult(
105    ui: &mut Ui,
106    key: Key,
107    cmd_mult: f64,
108    shift_mult: f64,
109) -> Option<f64> {
110    ui.input_mut(|i| {
111        if i.consume_key(egui::Modifiers::COMMAND, key)
112            || i.consume_key(egui::Modifiers::CTRL, key)
113            || i.consume_key(egui::Modifiers::MAC_CMD, key)
114        {
115            Some(cmd_mult)
116        } else if i.consume_key(egui::Modifiers::SHIFT, key) {
117            Some(shift_mult)
118        } else if i.consume_key(egui::Modifiers::NONE, key) {
119            Some(1.0)
120        } else {
121            None
122        }
123    })
124}
125
126fn eval_auto_repeat(
127    ui: &mut Ui,
128    held: bool,
129    state_id: egui::Id,
130    now: f64,
131    repeat_delay: f64,
132    repeat_interval: f64,
133) -> bool {
134    if held {
135        ui.ctx().request_repaint_after_secs(repeat_interval as f32);
136        match ui.data_mut(|d| d.get_temp::<(f64, f64)>(state_id)) {
137            None => {
138                ui.data_mut(|d| d.insert_temp(state_id, (now, now)));
139                false
140            }
141            Some((press_start, last_repeat)) => {
142                if now - press_start >= repeat_delay && now - last_repeat >= repeat_interval {
143                    ui.data_mut(|d| d.insert_temp(state_id, (press_start, now)));
144                    true
145                } else {
146                    false
147                }
148            }
149        }
150    } else {
151        ui.data_mut(|d| d.remove_temp::<(f64, f64)>(state_id));
152        false
153    }
154}
155
156impl<'a> Widget for MacStepper<'a> {
157    fn ui(self, ui: &mut Ui) -> Response {
158        let theme = MacTheme::resolve(self.theme, ui.ctx());
159
160        let metrics = theme.stepper();
161        let width = self.width.unwrap_or_else(|| metrics.width.points());
162        let height = self.height.unwrap_or_else(|| metrics.height.points());
163        let corner_radius = self
164            .corner_radius
165            .unwrap_or_else(|| metrics.corner_radius.points());
166        let divider_thickness = metrics.divider_thickness.points();
167        let arrow_size = metrics.arrow_size.points();
168        let arrow_stroke = metrics.arrow_stroke_width.points();
169
170        let (raw_rect, mut response) =
171            ui.allocate_exact_size(Vec2::new(width, height), Sense::click());
172        let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
173
174        let top_rect = Rect::from_min_max(rect.min, Pos2::new(rect.max.x, rect.center().y));
175        let bottom_rect = Rect::from_min_max(Pos2::new(rect.min.x, rect.center().y), rect.max);
176
177        let top_id = response.id.with("mac_stepper_up");
178        let bottom_id = response.id.with("mac_stepper_down");
179
180        let top_resp = ui.interact(
181            top_rect,
182            top_id,
183            if self.enabled {
184                Sense::click()
185            } else {
186                Sense::hover()
187            },
188        );
189        let bottom_resp = ui.interact(
190            bottom_rect,
191            bottom_id,
192            if self.enabled {
193                Sense::click()
194            } else {
195                Sense::hover()
196            },
197        );
198
199        let min_val = *self.range.start();
200        let max_val = *self.range.end();
201        if *self.value < min_val {
202            *self.value = min_val;
203        } else if *self.value > max_val {
204            *self.value = max_val;
205        }
206        let mut changed = false;
207
208        let clicked_top = top_resp.clicked();
209        let clicked_bottom = bottom_resp.clicked();
210        if clicked_top
211            || clicked_bottom
212            || top_resp.is_pointer_button_down_on()
213            || bottom_resp.is_pointer_button_down_on()
214        {
215            response.request_focus();
216        }
217
218        let now = ui.input(|i| i.time);
219        let repeat_delay = metrics.auto_repeat_delay.points() as f64;
220        let repeat_interval = metrics.auto_repeat_interval.points() as f64;
221
222        let repeat_top = eval_auto_repeat(
223            ui,
224            self.enabled && top_resp.is_pointer_button_down_on(),
225            top_id,
226            now,
227            repeat_delay,
228            repeat_interval,
229        );
230        let repeat_bottom = eval_auto_repeat(
231            ui,
232            self.enabled && bottom_resp.is_pointer_button_down_on(),
233            bottom_id,
234            now,
235            repeat_delay,
236            repeat_interval,
237        );
238
239        let ptr_mods = ui.input(|i| {
240            let mut m = i.modifiers;
241            for ev in &i.raw.events {
242                if let egui::Event::PointerButton { modifiers, .. } = ev {
243                    m |= *modifiers;
244                }
245            }
246            m
247        });
248        let shift_mult = metrics.shift_multiplier.points() as f64;
249        let cmd_mult = metrics.cmd_multiplier.points() as f64;
250        let ptr_mult = if ptr_mods.command || ptr_mods.ctrl || ptr_mods.mac_cmd {
251            cmd_mult
252        } else if ptr_mods.shift {
253            shift_mult
254        } else {
255            1.0
256        };
257
258        let key_up_mult = (self.enabled && response.has_focus())
259            .then(|| consume_step_arrow_mult(ui, Key::ArrowUp, cmd_mult, shift_mult))
260            .flatten();
261        let key_down_mult = (self.enabled && response.has_focus())
262            .then(|| consume_step_arrow_mult(ui, Key::ArrowDown, cmd_mult, shift_mult))
263            .flatten();
264
265        let step_up_mult = if self.enabled && (clicked_top || repeat_top) {
266            Some(ptr_mult)
267        } else {
268            key_up_mult
269        };
270
271        let step_down_mult = if self.enabled && (clicked_bottom || repeat_bottom) {
272            Some(ptr_mult)
273        } else {
274            key_down_mult
275        };
276
277        let eps = 1e-9 * self.step.abs().max(1.0);
278
279        // Up arrow click / hold-to-repeat / keyboard handling
280        if let Some(mult) = step_up_mult {
281            let next_val = *self.value + self.step * mult;
282            if next_val <= max_val + eps {
283                *self.value = next_val.min(max_val);
284            } else if self.wraps {
285                *self.value = min_val;
286            } else {
287                *self.value = max_val;
288            }
289            changed = true;
290        }
291
292        // Down arrow click / hold-to-repeat / keyboard handling
293        if let Some(mult) = step_down_mult {
294            let next_val = *self.value - self.step * mult;
295            if next_val >= min_val - eps {
296                *self.value = next_val.max(min_val);
297            } else if self.wraps {
298                *self.value = max_val;
299            } else {
300                *self.value = min_val;
301            }
302            changed = true;
303        }
304
305        if changed {
306            response.mark_changed();
307        }
308
309        let painter = ui.painter_at(rect);
310
311        // Outer focus ring
312        if self.enabled && response.has_focus() {
313            theme.paint_focus_ring(&painter, rect, corner_radius);
314        }
315
316        // Outer bezel
317        let bg_color = theme.control_background();
318        let border_stroke = if response.has_focus() {
319            Stroke::new(metrics.border_width.points(), theme.control_accent())
320        } else {
321            Stroke::new(metrics.border_width.points(), theme.separator())
322        };
323
324        painter.rect(
325            rect,
326            CornerRadius::same(corner_radius as u8),
327            bg_color,
328            border_stroke,
329            StrokeKind::Inside,
330        );
331
332        // Highlight compartments on hover / click
333        if self.enabled {
334            if top_resp.is_pointer_button_down_on() {
335                painter.rect_filled(
336                    top_rect,
337                    CornerRadius {
338                        nw: corner_radius as u8,
339                        ne: corner_radius as u8,
340                        sw: 0,
341                        se: 0,
342                    },
343                    theme.selected_control_inactive(),
344                );
345            } else if top_resp.hovered() {
346                painter.rect_filled(
347                    top_rect,
348                    CornerRadius {
349                        nw: corner_radius as u8,
350                        ne: corner_radius as u8,
351                        sw: 0,
352                        se: 0,
353                    },
354                    theme.control(),
355                );
356            }
357
358            if bottom_resp.is_pointer_button_down_on() {
359                painter.rect_filled(
360                    bottom_rect,
361                    CornerRadius {
362                        nw: 0,
363                        ne: 0,
364                        sw: corner_radius as u8,
365                        se: corner_radius as u8,
366                    },
367                    theme.selected_control_inactive(),
368                );
369            } else if bottom_resp.hovered() {
370                painter.rect_filled(
371                    bottom_rect,
372                    CornerRadius {
373                        nw: 0,
374                        ne: 0,
375                        sw: corner_radius as u8,
376                        se: corner_radius as u8,
377                    },
378                    theme.control(),
379                );
380            }
381        }
382
383        // Divider line between segments
384        let divider_y = rect.center().y;
385        painter.line_segment(
386            [
387                Pos2::new(rect.left(), divider_y),
388                Pos2::new(rect.right(), divider_y),
389            ],
390            Stroke::new(divider_thickness, theme.separator()),
391        );
392
393        // Chevrons
394        let arrow_color = if !self.enabled {
395            theme.placeholder_text()
396        } else {
397            theme.label()
398        };
399        let arrow_stroke_style = Stroke::new(arrow_stroke, arrow_color);
400        let arrow_half_w = arrow_size * 0.5;
401        let arrow_delta = metrics.arrow_delta.points();
402
403        // Up arrow ^
404        let up_cx = top_rect.center().x;
405        let up_cy = top_rect.center().y;
406        let up_tip = Pos2::new(up_cx, up_cy - arrow_delta);
407        let up_left = Pos2::new(up_cx - arrow_half_w, up_cy + arrow_delta);
408        let up_right = Pos2::new(up_cx + arrow_half_w, up_cy + arrow_delta);
409        painter.line_segment([up_left, up_tip], arrow_stroke_style);
410        painter.line_segment([up_tip, up_right], arrow_stroke_style);
411
412        // Down arrow v
413        let down_cx = bottom_rect.center().x;
414        let down_cy = bottom_rect.center().y;
415        let down_tip = Pos2::new(down_cx, down_cy + arrow_delta);
416        let down_left = Pos2::new(down_cx - arrow_half_w, down_cy - arrow_delta);
417        let down_right = Pos2::new(down_cx + arrow_half_w, down_cy - arrow_delta);
418        painter.line_segment([down_left, down_tip], arrow_stroke_style);
419        painter.line_segment([down_tip, down_right], arrow_stroke_style);
420
421        response
422    }
423}