git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/input/spinbox.rs (28.7K)

  1 //! Narrow-trait `Spinbox` (Phase 5i). The adapter's detached label sits above the content rect
  2 //! the geometry here works in. Sub-zone
  3 //! hover (the -/+ buttons) is tracked from `PointerMove` against the content rect; a click on
  4 //! the display area enters edit mode and takes focus via `EventCtx::request_focus`.
  5 
  6 use crate::colors;
  7 use crate::scene::layout::{Rect, Size};
  8 use crate::scene::paint::PaintCtx;
  9 use crate::widget::{
 10     Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton, NamedKey,
 11     Paint, TextEditorState,
 12 };
 13 
 14 #[derive(Debug, Clone)]
 15 pub struct Spinbox {
 16     pub value: i32,
 17     pub(crate) min: i32,
 18     pub(crate) max: i32,
 19     pub(crate) step: i32,
 20     pub editing: bool,
 21     pub edit_buffer: String,
 22     pub cursor_idx: usize,
 23     hover_dec: bool,
 24     hover_inc: bool,
 25     hovered: bool,
 26     unit: Option<String>,
 27     pub decimals: u32,
 28     pub editor_state: TextEditorState,
 29     pub just_changed: bool,
 30     label: Option<String>,
 31     /// Wheel notches carried between events: a trackpad's fractional notches
 32     /// add up to whole steps instead of being dropped.
 33     wheel_accum: f32,
 34     /// Char-index → x offsets of the value text, recorded by [`Paint::prepare_text`]
 35     /// from the same shaped buffer the renderer draws (`ctx.text`, size 14, default
 36     /// family). The caret and click→index math read these; the `8.4` px/char guess
 37     /// they used before drifted off the glyphs. Empty until the first shape.
 38     glyph_offsets: Vec<f32>,
 39 }
 40 
 41 /// The zone geometry shared by paint and input, derived from the content rect.
 42 struct SpinGeom {
 43     x: f32,
 44     y: f32,
 45     w: f32,
 46     h: f32,
 47     split_dec: f32,
 48     btn_y: f32,
 49     btn_h: f32,
 50     btn_w: f32,
 51     pad: f32,
 52 }
 53 
 54 impl Spinbox {
 55     pub fn new(value: i32, min: i32, max: i32, step: i32) -> Adapted<Spinbox> {
 56         Adapted::new(Spinbox {
 57             value,
 58             min,
 59             max,
 60             step,
 61             editing: false,
 62             edit_buffer: String::new(),
 63             cursor_idx: 0,
 64             hover_dec: false,
 65             hover_inc: false,
 66             hovered: false,
 67             unit: None,
 68             decimals: 0,
 69             editor_state: TextEditorState::new(String::new()),
 70             just_changed: false,
 71             label: None,
 72             wheel_accum: 0.0,
 73             glyph_offsets: Vec::new(),
 74         })
 75     }
 76 
 77     /// The caret x offset for a char index, from the shaped offsets when present
 78     /// (falling back to the legacy estimate only if nothing shaped yet).
 79     fn caret_offset(&self, idx: usize) -> f32 {
 80         self.glyph_offsets
 81             .get(idx)
 82             .copied()
 83             .unwrap_or(idx as f32 * 8.4)
 84     }
 85 
 86     /// Click x (relative to the text origin) → char index, nearest shaped offset.
 87     fn x_to_idx(&self, relative_x: f32) -> usize {
 88         if self.glyph_offsets.is_empty() {
 89             return ((relative_x / 8.4).round() as isize)
 90                 .max(0)
 91                 .min(self.edit_buffer.chars().count() as isize) as usize;
 92         }
 93         let mut closest = 0;
 94         let mut min_diff = f32::MAX;
 95         for (i, &pos) in self.glyph_offsets.iter().enumerate() {
 96             let diff = (pos - relative_x).abs();
 97             if diff < min_diff {
 98                 min_diff = diff;
 99                 closest = i;
100             }
101         }
102         closest
103     }
104 
105     pub fn set_unit(&mut self, unit: &str) {
106         self.unit = Some(unit.to_string());
107     }
108 
109     pub fn range(&self) -> (i32, i32) {
110         (self.min, self.max)
111     }
112 
113     fn geom(&self, rect: Rect) -> SpinGeom {
114         let x = rect.x;
115         let w = rect.width;
116         let pad = crate::layout::spinbox_button_padding();
117         SpinGeom {
118             x,
119             y: rect.y,
120             w,
121             h: rect.height,
122             split_dec: x + w * 0.55,
123             btn_y: rect.y + pad,
124             btn_h: (rect.height - 2.0 * pad).max(0.0),
125             btn_w: ((w * 0.45 - 2.0 * pad).max(0.0)) / 2.0,
126             pad,
127         }
128     }
129 
130     /// The control's relief set under `control_relief`, shared by the widget's
131     /// own `paint` and flat hosts (`ParametersBg`) that must re-emit carves
132     /// (their rounded-quad bridge keeps only flat prims — the slider's
133     /// `track_relief` precedent). Faces are transparent in this style; the
134     /// relief IS the chrome:
135     /// - the whole control is a recessed well (the TextBox language — the
136     ///   value sits on the well floor),
137     /// - the -/+ pair is ONE flush inset run in the well's right end. Where
138     ///   the run adjoins the well (top, right, bottom) the WELL'S OWN WALL is
139     ///   the seam's far side — the face reaches exactly to the wall's base
140     ///   (inset = the carve depth) and those trough edges are suppressed; a
141     ///   second lip inside the bevel would double the valley. Only the left
142     ///   edge, which faces open floor, carries its own trough wall,
143     /// - the two buttons divide by an engraved seam, not a wall pair — the
144     ///   breadcrumb run's segment language at miniature scale.
145     ///
146     /// Returns the well `(rect, radius, depth)`, plus the run
147     /// `(rect, radii, depth, trough edges)` and seam `(top, bottom, width,
148     /// host)` when the button zone is non-degenerate. `None` when the control
149     /// has no area. The caller gates on `control_relief`.
150     #[allow(clippy::type_complexity)]
151     pub fn relief_parts(
152         &self,
153         rect: Rect,
154     ) -> Option<(
155         (Rect, f32, f32),
156         Option<(
157             (Rect, (f32, f32, f32, f32), f32, (bool, bool, bool, bool)),
158             ((f32, f32), (f32, f32), f32, Rect),
159         )>,
160     )> {
161         let g = self.geom(rect);
162         if g.w <= 0.0 || g.h <= 0.0 {
163             return None;
164         }
165         let radius = crate::layout::spinbox_corner_radius();
166         let depth = crate::layout::bevel_width().min(g.h * 0.2);
167         let (well_rect, well_radii) =
168             crate::layout::carve_inside(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, (radius, radius, radius, radius), depth);
169         let well = (well_rect, well_radii.0, depth);
170         if g.btn_w <= 0.0 || g.btn_h <= 0.0 {
171             return Some((well, None));
172         }
173         // Face flush against the wall base on the adjoining sides; the left
174         // edge starts at the button hit zone. Right corners follow the well
175         // radius's parallel curve at the wall base; left corners stay tight —
176         // the run's left edge is interior.
177         let run_rect = Rect {
178             x: g.split_dec + g.pad,
179             y: g.y + depth,
180             width: (g.x + g.w - depth) - (g.split_dec + g.pad),
181             height: g.h - 2.0 * depth,
182         };
183         let rr = (radius - depth).max(2.0);
184         let run = (run_rect, (2.0, rr, rr, 2.0), depth, (false, false, false, true));
185         // Seam floor width: the breadcrumb's SEAM_WIDTH — a hair of flat
186         // floor so the crease doesn't alias into a dotted line. It sits on
187         // the -/+ hit boundary, not the painted run's midpoint.
188         let sx = g.split_dec + g.pad + g.btn_w;
189         let seam = ((sx, run_rect.y), (sx, run_rect.y + run_rect.height), 0.75, run_rect);
190         Some((well, Some((run, seam))))
191     }
192 
193     fn value_text(&self) -> String {
194         if self.editing {
195             self.edit_buffer.clone()
196         } else if self.decimals > 0 {
197             let divisor = 10.0f32.powi(self.decimals as i32);
198             format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize)
199         } else {
200             self.value.to_string()
201         }
202     }
203 
204     fn formatted_value(&self) -> String {
205         if self.decimals > 0 {
206             let divisor = 10.0f32.powi(self.decimals as i32);
207             format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize)
208         } else {
209             self.value.to_string()
210         }
211     }
212 
213     fn parse_into_value(&mut self, text: &str) {
214         let old_val = self.value;
215         if self.decimals > 0 {
216             if let Ok(val_f) = text.parse::<f32>() {
217                 let divisor = 10.0f32.powi(self.decimals as i32);
218                 self.value = ((val_f * divisor).round() as i32).clamp(self.min, self.max);
219             }
220         } else if let Ok(val) = text.parse::<i32>() {
221             self.value = val.clamp(self.min, self.max);
222         }
223         if self.value != old_val {
224             self.just_changed = true;
225         }
226     }
227 
228     fn begin_edit(&mut self, cursor_at_end: bool) {
229         self.editing = true;
230         self.edit_buffer = self.formatted_value();
231         if cursor_at_end {
232             self.cursor_idx = self.edit_buffer.chars().count();
233         }
234     }
235 
236     /// Step the value by `delta` steps. While editing (the display shows
237     /// `edit_buffer`), commit the typed text first and refresh the buffer
238     /// after — otherwise the value moves invisibly behind a frozen buffer,
239     /// and the next commit (FocusOut/Enter) resets it to the stale text.
240     fn step_by(&mut self, delta: i32) {
241         if self.editing {
242             let text = self.edit_buffer.clone();
243             self.parse_into_value(&text);
244         }
245         let old_val = self.value;
246         self.value = (self.value + delta * self.step).clamp(self.min, self.max);
247         if self.value != old_val {
248             self.just_changed = true;
249         }
250         if self.editing {
251             self.edit_buffer = self.formatted_value();
252             self.cursor_idx = self.edit_buffer.chars().count();
253         }
254     }
255 }
256 
257 impl Adapted<Spinbox> {
258     pub fn with_unit(mut self, unit: &str) -> Self {
259         self.set_unit(unit);
260         self
261     }
262 
263     pub fn with_decimals(mut self, decimals: u32) -> Self {
264         self.decimals = decimals;
265         self
266     }
267 }
268 
269 impl Layout for Spinbox {
270 
271 
272     fn intrinsic_size(&self) -> Option<Size> {
273         Some(Size::new(0.0, crate::layout::spinbox_height()))
274     }
275 }
276 
277 impl Paint for Spinbox {
278     fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
279         // Shape the displayed value exactly as `ctx.text` draws it (size 14,
280         // default family) and record char-index → x. Cluster offsets arrive
281         // keyed by byte; the editor state is char-indexed.
282         let text = self.value_text();
283         let clusters =
284             crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 14.0, None);
285         let mut offsets = vec![0.0f32; text.chars().count() + 1];
286         for (byte, x) in clusters {
287             let ci = text[..byte.min(text.len())].chars().count();
288             if ci < offsets.len() {
289                 offsets[ci] = x;
290             }
291         }
292         let mut current = 0.0;
293         for off in offsets.iter_mut() {
294             if *off == 0.0 {
295                 *off = current;
296             } else {
297                 current = *off;
298             }
299         }
300         self.glyph_offsets = offsets;
301     }
302 
303     fn color(&self) -> [f32; 4] {
304         [0.0, 0.0, 0.0, 0.0]
305     }
306 
307     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
308         let r = crate::layout::spinbox_corner_radius();
309         if r > 0.0 {
310             Some((r, (true, true, true, true)))
311         } else {
312             None
313         }
314     }
315 
316     fn widget_font(&self) -> Option<String> {
317         Some(crate::layout::control_label_font_detached())
318     }
319 
320     fn sync_label(&mut self, label: &str) {
321         self.label = Some(label.to_string());
322     }
323 
324     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
325         let g = self.geom(rect);
326         let radius = crate::layout::spinbox_corner_radius();
327         let rounded = radius > 0.0;
328         let display_bg = if self.editing { [0.06, 0.10, 0.18, 1.0] } else { colors::spinbox_display() };
329         let inc_col = if self.hover_inc { colors::spinbox_button_hover() } else { colors::spinbox_button() };
330         let dec_col = if self.hover_dec { colors::spinbox_button_hover() } else { colors::spinbox_button() };
331 
332         if crate::layout::control_relief() {
333             // The DE relief style: transparent faces, the relief is the
334             // chrome (see [`Self::relief_parts`]). Flat prims first — hover
335             // washes and the editing cue survive a flat host's rounded-quad
336             // bridge, the carves are re-emitted host-side.
337             if let Some((well, buttons)) = self.relief_parts(rect) {
338                 if let Some(((run, radii, _, _), (seam_top, _, _, _))) = buttons {
339                     let wash = [1.0, 1.0, 1.0, 0.06];
340                     if self.hover_dec {
341                         ctx.rounded_rect(
342                             Rect { x: run.x, y: run.y, width: seam_top.0 - run.x, height: run.height },
343                             radii.0,
344                             (true, false, false, true),
345                             wash,
346                         );
347                     }
348                     if self.hover_inc {
349                         ctx.rounded_rect(
350                             Rect { x: seam_top.0, y: run.y, width: run.x + run.width - seam_top.0, height: run.height },
351                             radii.1,
352                             (false, true, true, false),
353                             wash,
354                         );
355                     }
356                 }
357                 if self.editing {
358                     // Editing cue: an accent hairline on the well floor under
359                     // the value, plus the caret — a flat stand-in for the
360                     // tinted-recess focus treatment the pane's relief tuple
361                     // cannot carry.
362                     let accent = colors::highlight_primary_color();
363                     ctx.quad(
364                         Rect { x: g.x + crate::layout::CONTROL_TEXT_INSET, y: g.y + g.h - 4.0, width: g.w * 0.55 - 8.0, height: 1.5 },
365                         accent,
366                     );
367                     let cursor_x = (g.x + crate::layout::CONTROL_TEXT_INSET + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
368                     let cursor_y = g.y + (g.h - 14.0) / 2.0;
369                     ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
370                 }
371                 let (wr, wrad, wd) = well;
372                 ctx.recess(wr, (wrad, wrad, wrad, wrad), wd);
373                 if let Some(((run, radii, rd, edges), (sa, sb, sw, host))) = buttons {
374                     ctx.trough_edges(run, radii, rd, edges);
375                     ctx.groove(sa, sb, sw, rd, host);
376                 }
377             }
378         } else if rounded {
379             let rc = (true, true, true, true);
380             let border_color = if self.editing {
381                 [0.20, 0.50, 0.85, 1.0]
382             } else if self.hovered {
383                 [0.25, 0.25, 0.35, 1.0]
384             } else {
385                 [0.18, 0.18, 0.24, 1.0]
386             };
387             ctx.rounded_rect(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, radius, rc, border_color);
388             ctx.rounded_rect(
389                 Rect { x: g.x + 1.0, y: g.y + 1.0, width: g.w - 2.0, height: g.h - 2.0 },
390                 radius - 1.0,
391                 rc,
392                 display_bg,
393             );
394             if g.btn_h > 0.0 && g.btn_w > 0.0 {
395                 ctx.rounded_rect(
396                     Rect { x: g.split_dec + g.pad, y: g.btn_y, width: g.btn_w, height: g.btn_h },
397                     0.0,
398                     (false, false, false, false),
399                     dec_col,
400                 );
401                 ctx.rounded_rect(
402                     Rect { x: g.split_dec + g.pad + g.btn_w, y: g.btn_y, width: g.btn_w, height: g.btn_h },
403                     radius,
404                     (false, true, true, false),
405                     inc_col,
406                 );
407             }
408             if self.editing {
409                 let cursor_x = (g.x + crate::layout::CONTROL_TEXT_INSET + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
410                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
411                 ctx.rounded_rect(
412                     Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 },
413                     0.0,
414                     (false, false, false, false),
415                     [0.80, 0.80, 0.85, 1.0],
416                 );
417             }
418         } else {
419             ctx.quad(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, display_bg);
420             if g.btn_h > 0.0 && g.btn_w > 0.0 {
421                 ctx.quad(Rect { x: g.split_dec + g.pad, y: g.btn_y, width: g.btn_w, height: g.btn_h }, dec_col);
422                 ctx.quad(Rect { x: g.split_dec + g.pad + g.btn_w, y: g.btn_y, width: g.btn_w, height: g.btn_h }, inc_col);
423             }
424             if self.editing {
425                 let border_color = [0.20, 0.50, 0.85, 1.0];
426                 ctx.quad(Rect { x: g.x, y: g.y, width: g.w, height: 1.0 }, border_color);
427                 ctx.quad(Rect { x: g.x, y: g.y + g.h - 1.0, width: g.w, height: 1.0 }, border_color);
428                 ctx.quad(Rect { x: g.x, y: g.y, width: 1.0, height: g.h }, border_color);
429                 ctx.quad(Rect { x: g.x + g.w - 1.0, y: g.y, width: 1.0, height: g.h }, border_color);
430 
431                 let cursor_x = (g.x + crate::layout::CONTROL_TEXT_INSET + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
432                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
433                 ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
434             }
435         }
436 
437         // Value, unit, and -/+ glyphs.
438         let tc = colors::spinbox_text_color();
439         let text_color = [(tc[0] * 255.0) as u8, (tc[1] * 255.0) as u8, (tc[2] * 255.0) as u8];
440         // The value and its unit live in the FIELD, which ends where the -/+
441         // buttons begin (`split_dec`). The caret above is already clamped to
442         // that field; the text it belongs to was not, so a long value ran
443         // under the buttons and out of the control.
444         let field = Some([g.x, g.y, g.split_dec, g.y + g.h]);
445         ctx.text_with(self.value_text(), g.x + crate::layout::CONTROL_TEXT_INSET, crate::layout::align_text_y(g.y, g.h, 14.0, 0.0), 14.0, text_color, None, field);
446         if let Some(ref unit) = self.unit {
447             ctx.text_with(unit.clone(), g.x + crate::layout::CONTROL_TEXT_INSET + 36.0, crate::layout::align_text_y(g.y, g.h, 11.0, 0.0), 11.0, [0x73, 0x73, 0x7a], None, field);
448         }
449         if g.btn_w > 0.0 {
450             let dec_center_x = g.split_dec + g.pad + g.btn_w * 0.5;
451             let inc_center_x = g.split_dec + g.pad + g.btn_w * 1.5;
452             let ty = crate::layout::align_text_y(g.y, g.h, 12.0, 0.0);
453             let dec_box = Some([g.split_dec + g.pad, g.y, g.split_dec + g.pad + g.btn_w, g.y + g.h]);
454             let inc_box = Some([g.split_dec + g.pad + g.btn_w, g.y, g.split_dec + g.pad + 2.0 * g.btn_w, g.y + g.h]);
455             ctx.text_with("-".to_string(), dec_center_x - 4.0, ty, 12.0, text_color, None, dec_box);
456             ctx.text_with("+".to_string(), inc_center_x - 4.0, ty, 12.0, text_color, None, inc_box);
457         }
458     }
459 }
460 
461 impl Input for Spinbox {
462     fn focus_role(&self) -> crate::widget::FocusRole {
463         crate::widget::FocusRole::Well
464     }
465     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
466         match event {
467             Event::PointerMove { x: px, y: py, .. } => {
468                 let r = ectx.rect;
469                 let was = self.hovered;
470                 self.hovered = *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
471                 if !self.hovered {
472                     let changed = self.hover_dec || self.hover_inc;
473                     self.hover_dec = false;
474                     self.hover_inc = false;
475                     return changed || was != self.hovered;
476                 }
477                 let g = self.geom(r);
478                 let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
479                 let hd = in_y && *px >= g.split_dec + g.pad && *px < g.x + g.w * 0.775;
480                 let hi = in_y && *px >= g.x + g.w * 0.775 && *px < g.x + g.w - g.pad;
481                 let changed = hd != self.hover_dec || hi != self.hover_inc;
482                 self.hover_dec = hd;
483                 self.hover_inc = hi;
484                 changed || was != self.hovered
485             }
486             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
487                 let g = self.geom(ectx.rect);
488                 let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
489                 if in_y && *px >= g.split_dec + g.pad && *px < g.x + g.w * 0.775 {
490                     self.step_by(-1);
491                     true
492                 } else if in_y && *px >= g.x + g.w * 0.775 && *px < g.x + g.w - g.pad {
493                     self.step_by(1);
494                     true
495                 } else if *px < g.split_dec {
496                     self.begin_edit(false);
497                     self.cursor_idx = self
498                         .x_to_idx(px - (g.x + crate::layout::CONTROL_TEXT_INSET))
499                         .min(self.edit_buffer.chars().count());
500                     ectx.request_focus();
501                     true
502                 } else {
503                     false
504                 }
505             }
506             Event::MouseWheel { delta, .. } => {
507                 // Wheel up steps up, wheel down steps down, one step per notch;
508                 // fractional (trackpad) notches accumulate. Always consumed, so
509                 // a host's page never scrolls under a spinbox mid-gesture.
510                 self.wheel_accum += delta.notches_y();
511                 while self.wheel_accum >= 1.0 {
512                     self.wheel_accum -= 1.0;
513                     self.step_by(1);
514                 }
515                 while self.wheel_accum <= -1.0 {
516                     self.wheel_accum += 1.0;
517                     self.step_by(-1);
518                 }
519                 true
520             }
521             Event::KeyInput(key_event) => {
522                 if !self.editing || key_event.state != ElementState::Pressed {
523                     return false;
524                 }
525                 let mut state = TextEditorState {
526                     buffer: self.edit_buffer.clone(),
527                     cursor_idx: self.cursor_idx,
528                     select_anchor: None,
529                     all_selected: false,
530                 };
531                 let mut handled = false;
532                 match &key_event.logical_key {
533                     Key::Named(NamedKey::Backspace) => handled = state.delete_backwards(),
534                     Key::Named(NamedKey::Delete) => handled = state.delete_forwards(),
535                     Key::Named(NamedKey::ArrowLeft) => handled = state.move_cursor_left(false),
536                     Key::Named(NamedKey::ArrowRight) => handled = state.move_cursor_right(false),
537                     Key::Named(NamedKey::Enter) => {
538                         let text = state.buffer.clone();
539                         self.parse_into_value(&text);
540                         self.editing = false;
541                         handled = true;
542                     }
543                     Key::Named(NamedKey::Escape) => {
544                         self.editing = false;
545                         handled = true;
546                     }
547                     _ => {
548                         if let Some(text) = &key_event.text {
549                             for ch in text.chars() {
550                                 match ch {
551                                     '-' if state.cursor_idx == 0 && !state.buffer.starts_with('-') => {
552                                         state.insert_text("-");
553                                         handled = true;
554                                     }
555                                     '.' if self.decimals > 0 && !state.buffer.contains('.') => {
556                                         state.insert_text(".");
557                                         handled = true;
558                                     }
559                                     '0'..='9' => {
560                                         state.insert_text(&ch.to_string());
561                                         handled = true;
562                                     }
563                                     _ => {}
564                                 }
565                             }
566                         }
567                     }
568                 }
569                 if self.editing {
570                     self.edit_buffer = state.buffer;
571                     self.cursor_idx = state.cursor_idx;
572                 }
573                 handled
574             }
575             // Focus gained programmatically enters edit mode (legacy `focus()` override);
576             // focus loss commits (legacy `unfocus`).
577             Event::FocusIn => {
578                 self.begin_edit(true);
579                 false
580             }
581             Event::FocusOut => {
582                 if self.editing {
583                     self.editing = false;
584                     let text = self.edit_buffer.clone();
585                     self.parse_into_value(&text);
586                 }
587                 false
588             }
589             _ => false,
590         }
591     }
592 
593     fn opens_context_menu(&self) -> bool {
594         true
595     }
596 
597     fn take_change(&mut self) -> bool {
598         std::mem::take(&mut self.just_changed)
599     }
600 
601     fn value_string(&self) -> Option<String> {
602         Some(self.formatted_value())
603     }
604 
605     fn set_value_string(&mut self, val: &str) -> bool {
606         let old_val = self.value;
607         self.parse_into_value(val.trim());
608         if self.value != old_val {
609             if self.editing {
610                 self.edit_buffer = self.formatted_value();
611                 self.cursor_idx = self.edit_buffer.chars().count();
612             }
613             true
614         } else {
615             false
616         }
617     }
618 
619     fn value(&self) -> i32 {
620         self.value
621     }
622 }
623 
624 
625 #[cfg(test)]
626 mod tests {
627     use super::*;
628     use crate::widget::{WidgetHost, UiContext};
629 
630     #[test]
631     fn spinbox_button_zones_step_the_value() {
632         let mut ctx = UiContext::new();
633         let mut sb = Spinbox::new(0, -100, 100, 1);
634         let (id, ptr) = (sb.id(), sb.as_ptr_mut());
635         ctx.register_widget(id, ptr);
636         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
637 
638         // Legacy test: click at (75, 33) lands in the decrement zone.
639         assert!(sb.mouse_input(MouseButton::Left, ElementState::Pressed, 75.0, 33.0, &mut ctx));
640         assert_eq!(sb.value, -1);
641         assert!(sb.take_change());
642 
643         // Increment zone (past 77.5% of the width).
644         assert!(sb.mouse_input(MouseButton::Left, ElementState::Pressed, 92.0, 33.0, &mut ctx));
645         assert_eq!(sb.value, 0);
646     }
647 
648     #[test]
649     fn spinbox_buttons_step_visibly_while_editing() {
650         // Row-selection focus puts the spinbox in edit mode (FocusIn →
651         // begin_edit): the display shows edit_buffer. Stepping must commit
652         // and refresh the buffer, or the value moves invisibly and the next
653         // FocusOut commit resets it to the stale text.
654         let mut ctx = UiContext::new();
655         let mut sb = Spinbox::new(6, 0, 100, 1);
656         let (id, ptr) = (sb.id(), sb.as_ptr_mut());
657         ctx.register_widget(id, ptr);
658         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
659         sb.begin_edit(true);
660         assert_eq!(sb.edit_buffer, "6");
661 
662         assert!(sb.mouse_input(MouseButton::Left, ElementState::Pressed, 92.0, 33.0, &mut ctx));
663         assert_eq!(sb.value, 7);
664         assert_eq!(sb.edit_buffer, "7");
665         assert!(sb.take_change());
666 
667         // FocusOut now commits the refreshed buffer — the step survives.
668         sb.handle_event(&Event::FocusOut, &mut ctx);
669         assert_eq!(sb.value, 7);
670     }
671 
672     #[test]
673     fn spinbox_wheel_steps_by_notch_and_accumulates_fractions() {
674         use crate::widget::MouseScrollDelta;
675         let mut ctx = UiContext::new();
676         let mut sb = Spinbox::new(10, 0, 100, 5);
677         let (id, ptr) = (sb.id(), sb.as_ptr_mut());
678         ctx.register_widget(id, ptr);
679         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
680 
681         // One notch up steps up, one notch down steps down — and the wheel is consumed.
682         assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 50.0, 33.0, &mut ctx));
683         assert_eq!(sb.value, 15);
684         assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 33.0, &mut ctx));
685         assert_eq!(sb.value, 10);
686         assert!(sb.take_change());
687 
688         // Fractional (trackpad) notches accumulate to a whole step, consumed all the while.
689         assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), 50.0, 33.0, &mut ctx));
690         assert_eq!(sb.value, 10, "half a notch: no step yet");
691         assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), 50.0, 33.0, &mut ctx));
692         assert_eq!(sb.value, 15, "the second half completes the notch");
693 
694         // Outside the rect the wheel is not the spinbox's (hit-gated by the adapter).
695         assert!(!sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 200.0, 200.0, &mut ctx));
696         assert_eq!(sb.value, 15);
697     }
698 
699     #[test]
700     fn spinbox_value_string_decimals_round_trip() {
701         let mut sb = Spinbox::new(150, 0, 1000, 5).with_decimals(2);
702         assert_eq!(sb.get_value_string(), Some("1.50".to_string()));
703         assert!(sb.set_value_string("2.75"));
704         assert_eq!(sb.value, 275);
705         assert_eq!(sb.value(), 275);
706     }
707 }