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

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

  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     /// Whether it has the keyboard. Enter ends editing but not focus, and a focused box that
 35     /// is not editing goes back into it on the keys that edit or step it.
 36     focused: bool,
 37     /// Char-index → x offsets of the value text, recorded by [`Paint::prepare_text`]
 38     /// from the same shaped buffer the renderer draws (`ctx.text`, size 14, default
 39     /// family). The caret and click→index math read these; the `8.4` px/char guess
 40     /// they used before drifted off the glyphs. Empty until the first shape.
 41     glyph_offsets: Vec<f32>,
 42 }
 43 
 44 /// The zone geometry shared by paint and input, derived from the content rect.
 45 /// A spinbox's relief: one field, its well and its -/+ run — see
 46 /// [`Spinbox::relief_parts`].
 47 #[derive(Clone, Copy, Debug)]
 48 pub struct SpinRelief {
 49     /// The field's outline, as the host was handed it.
 50     pub rect: Rect,
 51     pub radius: f32,
 52     pub depth: f32,
 53     /// Where the -/+ run begins (an x), and the engraved seam dividing the minus from the
 54     /// plus as `(top, bottom, width, host)` — `None` when the button zone has no area, and
 55     /// the field is the well alone.
 56     pub run: Option<(f32, ((f32, f32), (f32, f32), f32, Rect))>,
 57 }
 58 
 59 struct SpinGeom {
 60     x: f32,
 61     y: f32,
 62     w: f32,
 63     h: f32,
 64     split_dec: f32,
 65     btn_y: f32,
 66     btn_h: f32,
 67     btn_w: f32,
 68     pad: f32,
 69     /// The -/+ run: where it begins (under the relief, the field's seam,
 70     /// where the well's floor ends and its wall meets the run's lip), the
 71     /// line between - and +, and where it ends. Hit zones, hover washes,
 72     /// glyphs and the relief all read these, so they cannot disagree.
 73     run_x: f32,
 74     seam_x: f32,
 75     run_end: f32,
 76     /// The middles of the - and + halves, where the glyphs stand.
 77     dec_c: f32,
 78     inc_c: f32,
 79     /// Where the value's text is clipped: the end of the well's floor.
 80     text_end: f32,
 81 }
 82 
 83 impl Spinbox {
 84     pub fn new(value: i32, min: i32, max: i32, step: i32) -> Adapted<Spinbox> {
 85         Adapted::new(Spinbox {
 86             value,
 87             min,
 88             max,
 89             step,
 90             editing: false,
 91             edit_buffer: String::new(),
 92             cursor_idx: 0,
 93             hover_dec: false,
 94             hover_inc: false,
 95             hovered: false,
 96             unit: None,
 97             decimals: 0,
 98             editor_state: TextEditorState::new(String::new()),
 99             just_changed: false,
100             label: None,
101             wheel_accum: 0.0,
102             focused: false,
103             glyph_offsets: Vec::new(),
104         })
105     }
106 
107     /// The caret x offset for a char index, from the shaped offsets when present
108     /// (falling back to the legacy estimate only if nothing shaped yet).
109     fn caret_offset(&self, idx: usize) -> f32 {
110         self.glyph_offsets
111             .get(idx)
112             .copied()
113             .unwrap_or(idx as f32 * 8.4)
114     }
115 
116     /// Click x (relative to the text origin) → char index, nearest shaped offset.
117     fn x_to_idx(&self, relative_x: f32) -> usize {
118         if self.glyph_offsets.is_empty() {
119             return ((relative_x / 8.4).round() as isize)
120                 .max(0)
121                 .min(self.edit_buffer.chars().count() as isize) as usize;
122         }
123         let mut closest = 0;
124         let mut min_diff = f32::MAX;
125         for (i, &pos) in self.glyph_offsets.iter().enumerate() {
126             let diff = (pos - relative_x).abs();
127             if diff < min_diff {
128                 min_diff = diff;
129                 closest = i;
130             }
131         }
132         closest
133     }
134 
135     pub fn set_unit(&mut self, unit: &str) {
136         self.unit = Some(unit.to_string());
137     }
138 
139     pub fn range(&self) -> (i32, i32) {
140         (self.min, self.max)
141     }
142 
143     fn geom(&self, rect: Rect) -> SpinGeom {
144         let x = rect.x;
145         let w = rect.width;
146         let pad = crate::layout::spinbox_button_padding();
147         let split_dec = x + w * 0.55;
148         let btn_w = ((w * 0.45 - 2.0 * pad).max(0.0)) / 2.0;
149         let flat_run = split_dec + pad;
150         let (run_x, seam_x, run_end, dec_c, inc_c, text_end) = if crate::layout::control_relief() && btn_w > 0.0 {
151             // The run's face is inset half a wall from its outline on every
152             // side (`Prim::Field`), so it is halved at its own middle and
153             // each glyph stands in the middle of its half: the padding about
154             // - and + is even. The run begins a wall before the flat
155             // layout's buttons, taking the wall a ridge stood on for a few
156             // hours on 2026-10-02 (the well's floor ends where it did then).
157             let depth = crate::layout::bevel_width().min(rect.height * 0.2);
158             let hw = 0.5 * depth;
159             let run_x = flat_run - depth;
160             let end = x + w;
161             let seam = 0.5 * (run_x + end);
162             (run_x, seam, end, 0.5 * (run_x + hw + seam), 0.5 * (seam + end - hw), (run_x - hw).min(split_dec))
163         } else {
164             let seam = flat_run + btn_w;
165             (flat_run, seam, seam + btn_w, flat_run + 0.5 * btn_w, seam + 0.5 * btn_w, split_dec)
166         };
167         SpinGeom {
168             x,
169             y: rect.y,
170             w,
171             h: rect.height,
172             split_dec,
173             btn_y: rect.y + pad,
174             btn_h: (rect.height - 2.0 * pad).max(0.0),
175             btn_w,
176             pad,
177             run_x,
178             seam_x,
179             run_end,
180             dec_c,
181             inc_c,
182             text_end,
183         }
184     }
185 
186     /// The control's relief set under `control_relief`, shared by the widget's
187     /// own `paint` and flat hosts (`ParametersBg`) that must re-emit carves
188     /// (their rounded-quad bridge keeps only flat prims — the slider's
189     /// `track_relief` precedent). Faces are transparent in this style; the
190     /// relief IS the chrome, and it is ONE field ([`crate::scene::paint::Prim::Field`]):
191     /// - the value sits in a sunken well (the TextBox language),
192     /// - the -/+ pair is a flush run, the field's right end — out to the
193     ///   control's edge on the top, right and bottom as a dropdown trigger's
194     ///   plate is — and the field's one outline runs round both, its wall
195     ///   blending from the well's step to the run's valley at the seam, so
196     ///   the edge does not break there. Until 2026-10-01 the run was nested
197     ///   inside a full-width well (its face stopping at the base of the
198     ///   well's wall), and for a few hours after that the well and the run
199     ///   were two carves side by side, each turning its own corner at the
200     ///   seam,
201     /// - the two buttons divide by an engraved seam, not a wall pair — the
202     ///   breadcrumb run's segment language at miniature scale.
203     ///
204     /// The field's outline is `rect` as given — a host carves it inside or
205     /// not by its own rule, as it does every well. `None` when the control
206     /// has no area. The caller gates on `control_relief`.
207     pub fn relief_parts(&self, rect: Rect) -> Option<SpinRelief> {
208         let g = self.geom(rect);
209         if g.w <= 0.0 || g.h <= 0.0 {
210             return None;
211         }
212         let radius = crate::layout::spinbox_corner_radius();
213         let depth = crate::layout::bevel_width().min(g.h * 0.2);
214         let whole = Rect { x: g.x, y: g.y, width: g.w, height: g.h };
215         if g.btn_w <= 0.0 || g.btn_h <= 0.0 {
216             return Some(SpinRelief { rect: whole, radius, depth, run: None });
217         }
218         // The run begins at the button hit zone and spans the whole band to
219         // the control's right edge.
220         let split = g.run_x;
221         // Seam floor width: the breadcrumb's SEAM_WIDTH — a hair of flat
222         // floor so the crease doesn't alias into a dotted line. It sits on
223         // the -/+ hit boundary, not the painted run's midpoint, and crosses
224         // the run's face between its outline's walls.
225         let sx = g.seam_x;
226         let host = Rect { x: split, y: g.y, width: g.x + g.w - split, height: g.h };
227         let seam = ((sx, g.y + depth), (sx, g.y + g.h - depth), 0.75, host);
228         Some(SpinRelief { rect: whole, radius, depth, run: Some((split, seam)) })
229     }
230 
231     fn value_text(&self) -> String {
232         if self.editing {
233             self.edit_buffer.clone()
234         } else if self.decimals > 0 {
235             let divisor = 10.0f32.powi(self.decimals as i32);
236             format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize)
237         } else {
238             self.value.to_string()
239         }
240     }
241 
242     fn formatted_value(&self) -> String {
243         if self.decimals > 0 {
244             let divisor = 10.0f32.powi(self.decimals as i32);
245             format!("{:.width$}", self.value as f32 / divisor, width = self.decimals as usize)
246         } else {
247             self.value.to_string()
248         }
249     }
250 
251     fn parse_into_value(&mut self, text: &str) {
252         let old_val = self.value;
253         if self.decimals > 0 {
254             if let Ok(val_f) = text.parse::<f32>() {
255                 let divisor = 10.0f32.powi(self.decimals as i32);
256                 self.value = ((val_f * divisor).round() as i32).clamp(self.min, self.max);
257             }
258         } else if let Ok(val) = text.parse::<i32>() {
259             self.value = val.clamp(self.min, self.max);
260         }
261         if self.value != old_val {
262             self.just_changed = true;
263         }
264     }
265 
266     fn begin_edit(&mut self, cursor_at_end: bool) {
267         self.editing = true;
268         self.edit_buffer = self.formatted_value();
269         if cursor_at_end {
270             self.cursor_idx = self.edit_buffer.chars().count();
271         }
272     }
273 
274     /// Step the value by `delta` steps. While editing (the display shows
275     /// `edit_buffer`), commit the typed text first and refresh the buffer
276     /// after — otherwise the value moves invisibly behind a frozen buffer,
277     /// and the next commit (FocusOut/Enter) resets it to the stale text.
278     fn step_by(&mut self, delta: i32) {
279         if self.editing {
280             let text = self.edit_buffer.clone();
281             self.parse_into_value(&text);
282         }
283         let old_val = self.value;
284         self.value = (self.value + delta * self.step).clamp(self.min, self.max);
285         if self.value != old_val {
286             self.just_changed = true;
287         }
288         if self.editing {
289             self.edit_buffer = self.formatted_value();
290             self.cursor_idx = self.edit_buffer.chars().count();
291         }
292     }
293 }
294 
295 impl Adapted<Spinbox> {
296     pub fn with_unit(mut self, unit: &str) -> Self {
297         self.set_unit(unit);
298         self
299     }
300 
301     pub fn with_decimals(mut self, decimals: u32) -> Self {
302         self.decimals = decimals;
303         self
304     }
305 }
306 
307 impl Layout for Spinbox {
308 
309 
310     fn intrinsic_size(&self) -> Option<Size> {
311         Some(Size::new(0.0, crate::layout::spinbox_height()))
312     }
313 }
314 
315 impl Paint for Spinbox {
316     fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
317         // Shape the displayed value exactly as `ctx.text` draws it (size 14,
318         // default family) and record char-index → x. Cluster offsets arrive
319         // keyed by byte; the editor state is char-indexed.
320         let text = self.value_text();
321         let clusters =
322             crate::backend::text::shaped_cluster_offsets(fs, &text, 14.0, None);
323         let mut offsets = vec![0.0f32; text.chars().count() + 1];
324         for (byte, x) in clusters {
325             let ci = text[..byte.min(text.len())].chars().count();
326             if ci < offsets.len() {
327                 offsets[ci] = x;
328             }
329         }
330         let mut current = 0.0;
331         for off in offsets.iter_mut() {
332             if *off == 0.0 {
333                 *off = current;
334             } else {
335                 current = *off;
336             }
337         }
338         self.glyph_offsets = offsets;
339     }
340 
341     fn color(&self) -> [f32; 4] {
342         [0.0, 0.0, 0.0, 0.0]
343     }
344 
345     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
346         let r = crate::layout::spinbox_corner_radius();
347         if r > 0.0 {
348             Some((r, (true, true, true, true)))
349         } else {
350             None
351         }
352     }
353 
354     fn widget_font(&self) -> Option<String> {
355         Some(crate::layout::control_label_font_detached())
356     }
357 
358     fn sync_label(&mut self, label: &str) {
359         self.label = Some(label.to_string());
360     }
361 
362     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
363         let g = self.geom(rect);
364         // Typing a value: the on-screen keyboard follows (`crate::text_input`).
365         if self.editing {
366             let (ox, oy) = ctx.offset();
367             crate::text_input::claim(g.x + ox, g.y + oy, g.w, g.h);
368         }
369         let radius = crate::layout::spinbox_corner_radius();
370         let rounded = radius > 0.0;
371         let display_bg = if self.editing { [0.06, 0.10, 0.18, 1.0] } else { colors::spinbox_display() };
372         let inc_col = if self.hover_inc { colors::spinbox_button_hover() } else { colors::spinbox_button() };
373         let dec_col = if self.hover_dec { colors::spinbox_button_hover() } else { colors::spinbox_button() };
374 
375         if crate::layout::control_relief() {
376             // The DE relief style: transparent faces, the relief is the
377             // chrome (see [`Self::relief_parts`]). Flat prims first — hover
378             // washes and the editing cue survive a flat host's rounded-quad
379             // bridge, the carves are re-emitted host-side.
380             if let Some(rel) = self.relief_parts(rect) {
381                 // Carved inside the control as every well is.
382                 let r = rel.radius;
383                 let (field, radii) = crate::layout::carve_inside(rel.rect, (r, r, r, r), rel.depth);
384                 if let Some((split, _)) = rel.run {
385                     let wash = [1.0, 1.0, 1.0, 0.06];
386                     let sx = g.seam_x;
387                     let (top, h) = (field.y, field.height);
388                     if self.hover_dec {
389                         ctx.rounded_rect(Rect { x: split, y: top, width: sx - split, height: h }, 0.0, (false, false, false, false), wash);
390                     }
391                     if self.hover_inc {
392                         ctx.rounded_rect(
393                             Rect { x: sx, y: top, width: field.x + field.width - sx, height: h },
394                             radii.1,
395                             (false, true, true, false),
396                             wash,
397                         );
398                     }
399                 }
400                 if self.editing {
401                     // Editing cue: an accent hairline on the well floor under
402                     // the value, plus the caret — a flat stand-in for the
403                     // tinted-recess focus treatment the pane's relief tuple
404                     // cannot carry.
405                     let accent = colors::highlight_primary_color();
406                     ctx.quad(
407                         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 },
408                         accent,
409                     );
410                     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);
411                     let cursor_y = g.y + (g.h - 14.0) / 2.0;
412                     ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
413                 }
414                 // A field in its two forms: the value's well ending in the
415                 // -/+ run, or (no room for the buttons) all well.
416                 use crate::scene::paint::Field;
417                 match rel.run {
418                     Some((split, (sa, sb, sw, host))) => {
419                         ctx.field(&Field::ending_in_run(field, radii, rel.depth, split));
420                         ctx.groove(sa, sb, sw, rel.depth, host);
421                     }
422                     None => ctx.field(&Field::well(field, radii, rel.depth)),
423                 }
424             }
425         } else if rounded {
426             let rc = (true, true, true, true);
427             let border_color = if self.editing {
428                 [0.20, 0.50, 0.85, 1.0]
429             } else if self.hovered {
430                 [0.25, 0.25, 0.35, 1.0]
431             } else {
432                 [0.18, 0.18, 0.24, 1.0]
433             };
434             ctx.rounded_rect(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, radius, rc, border_color);
435             ctx.rounded_rect(
436                 Rect { x: g.x + 1.0, y: g.y + 1.0, width: g.w - 2.0, height: g.h - 2.0 },
437                 radius - 1.0,
438                 rc,
439                 display_bg,
440             );
441             if g.btn_h > 0.0 && g.btn_w > 0.0 {
442                 ctx.rounded_rect(
443                     Rect { x: g.split_dec + g.pad, y: g.btn_y, width: g.btn_w, height: g.btn_h },
444                     0.0,
445                     (false, false, false, false),
446                     dec_col,
447                 );
448                 ctx.rounded_rect(
449                     Rect { x: g.split_dec + g.pad + g.btn_w, y: g.btn_y, width: g.btn_w, height: g.btn_h },
450                     radius,
451                     (false, true, true, false),
452                     inc_col,
453                 );
454             }
455             if self.editing {
456                 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);
457                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
458                 ctx.rounded_rect(
459                     Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 },
460                     0.0,
461                     (false, false, false, false),
462                     [0.80, 0.80, 0.85, 1.0],
463                 );
464             }
465         } else {
466             ctx.quad(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, display_bg);
467             if g.btn_h > 0.0 && g.btn_w > 0.0 {
468                 ctx.quad(Rect { x: g.split_dec + g.pad, y: g.btn_y, width: g.btn_w, height: g.btn_h }, dec_col);
469                 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);
470             }
471             if self.editing {
472                 let border_color = [0.20, 0.50, 0.85, 1.0];
473                 ctx.quad(Rect { x: g.x, y: g.y, width: g.w, height: 1.0 }, border_color);
474                 ctx.quad(Rect { x: g.x, y: g.y + g.h - 1.0, width: g.w, height: 1.0 }, border_color);
475                 ctx.quad(Rect { x: g.x, y: g.y, width: 1.0, height: g.h }, border_color);
476                 ctx.quad(Rect { x: g.x + g.w - 1.0, y: g.y, width: 1.0, height: g.h }, border_color);
477 
478                 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);
479                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
480                 ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
481             }
482         }
483 
484         // Value, unit, and the minus/plus glyphs.
485         let tc = colors::spinbox_text_color();
486         let text_color = [(tc[0] * 255.0) as u8, (tc[1] * 255.0) as u8, (tc[2] * 255.0) as u8];
487         // The value and its unit live in the FIELD, which ends where the -/+
488         // buttons begin (`split_dec`). The caret above is already clamped to
489         // that field; the text it belongs to was not, so a long value ran
490         // under the buttons and out of the control.
491         let field = Some([g.x, g.y, g.text_end, g.y + g.h]);
492         let value_y = crate::layout::align_text_y(g.y, g.h, 14.0, 0.0);
493         ctx.text_with(self.value_text(), g.x + crate::layout::CONTROL_TEXT_INSET, value_y, 14.0, text_color, None, field);
494         if let Some(ref unit) = self.unit {
495             // The unit follows the value at a word space, measured off the
496             // value's own shaped width (the caret's offsets, see
497             // `prepare_text`). It stood at a fixed 36px, which left "5" and
498             // its "s" a gap apart and would have run a wide value into it.
499             // It shares the value's line top rather than being centred on its
500             // own: the renderer centres each glyph run in a box one font size
501             // tall, so the smaller unit, centred separately, sat ~2px below
502             // the value's baseline. From one top, the two baselines meet.
503             let value_w = self.caret_offset(self.value_text().chars().count());
504             const UNIT_GAP: f32 = 4.0;
505             let ux = g.x + crate::layout::CONTROL_TEXT_INSET + value_w + UNIT_GAP;
506             ctx.text_with(unit.clone(), ux, value_y, 11.0, [0x73, 0x73, 0x7a], None, field);
507         }
508         if g.btn_w > 0.0 {
509             let dec_center_x = g.dec_c;
510             let inc_center_x = g.inc_c;
511             // The `minus` and `plus` glyphs, in the value's colour, centred
512             // on their buttons (they were "-" and "+" in the 12px face).
513             const SIDE: f32 = 8.0;
514             let gy = g.y + 0.5 * (g.h - SIDE);
515             let tint = [tc[0], tc[1], tc[2], 1.0];
516             let glyph = |cx: f32| crate::scene::layout::Rect { x: cx - 0.5 * SIDE, y: gy, width: SIDE, height: SIDE };
517             ctx.icon("minus", glyph(dec_center_x), tint);
518             ctx.icon("plus", glyph(inc_center_x), tint);
519         }
520     }
521 }
522 
523 impl Input for Spinbox {
524     fn focus_role(&self) -> crate::widget::FocusRole {
525         crate::widget::FocusRole::Well
526     }
527     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
528         match event {
529             Event::PointerMove { x: px, y: py, .. } => {
530                 let r = ectx.rect;
531                 let was = self.hovered;
532                 self.hovered = *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
533                 if !self.hovered {
534                     let changed = self.hover_dec || self.hover_inc;
535                     self.hover_dec = false;
536                     self.hover_inc = false;
537                     return changed || was != self.hovered;
538                 }
539                 let g = self.geom(r);
540                 let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
541                 let hd = in_y && *px >= g.run_x && *px < g.seam_x;
542                 let hi = in_y && *px >= g.seam_x && *px < g.run_end;
543                 let changed = hd != self.hover_dec || hi != self.hover_inc;
544                 self.hover_dec = hd;
545                 self.hover_inc = hi;
546                 changed || was != self.hovered
547             }
548             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
549                 let g = self.geom(ectx.rect);
550                 let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
551                 if in_y && *px >= g.run_x && *px < g.seam_x {
552                     self.step_by(-1);
553                     true
554                 } else if in_y && *px >= g.seam_x && *px < g.run_end {
555                     self.step_by(1);
556                     true
557                 } else if *px < g.split_dec {
558                     self.begin_edit(false);
559                     self.cursor_idx = self
560                         .x_to_idx(px - (g.x + crate::layout::CONTROL_TEXT_INSET))
561                         .min(self.edit_buffer.chars().count());
562                     ectx.request_focus();
563                     true
564                 } else {
565                     false
566                 }
567             }
568             Event::MouseWheel { delta, .. } => {
569                 // Wheel up steps up, wheel down steps down, one step per notch;
570                 // fractional (trackpad) notches accumulate. Always consumed, so
571                 // a host's page never scrolls under a spinbox mid-gesture.
572                 self.wheel_accum += delta.value_notches_y();
573                 while self.wheel_accum >= 1.0 {
574                     self.wheel_accum -= 1.0;
575                     self.step_by(1);
576                 }
577                 while self.wheel_accum <= -1.0 {
578                     self.wheel_accum += 1.0;
579                     self.step_by(-1);
580                 }
581                 true
582             }
583             Event::KeyInput(key_event) => {
584                 if key_event.state != ElementState::Pressed {
585                     return false;
586                 }
587                 if !self.editing {
588                     // Focused but done editing (Enter committed): the keys that edit or step
589                     // the box take it back into editing; Enter just reopens it.
590                     let reopens = match &key_event.logical_key {
591                         Key::Named(NamedKey::Enter | NamedKey::ArrowUp | NamedKey::ArrowDown | NamedKey::Backspace) => true,
592                         _ => key_event.text.as_deref().is_some_and(|t| t.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.') && !t.is_empty()),
593                     };
594                     if !self.focused || !reopens {
595                         return false;
596                     }
597                     self.begin_edit(true);
598                     if key_event.logical_key == Key::Named(NamedKey::Enter) {
599                         return true;
600                     }
601                 }
602                 let mut state = TextEditorState {
603                     buffer: self.edit_buffer.clone(),
604                     cursor_idx: self.cursor_idx,
605                     select_anchor: None,
606                     all_selected: false,
607                 };
608                 let mut handled = false;
609                 match &key_event.logical_key {
610                     Key::Named(NamedKey::Backspace) => handled = state.delete_backwards(),
611                     Key::Named(NamedKey::Delete) => handled = state.delete_forwards(),
612                     Key::Named(NamedKey::ArrowLeft) => handled = state.move_cursor_left(false),
613                     Key::Named(NamedKey::ArrowRight) => handled = state.move_cursor_right(false),
614                     // Up / Down step the value, as the -/+ run does (and an assistive
615                     // tool's Increment / Decrement, which presses them).
616                     Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowDown) => {
617                         self.step_by(if key_event.logical_key == Key::Named(NamedKey::ArrowUp) { 1 } else { -1 });
618                         return true;
619                     }
620                     Key::Named(NamedKey::Enter) => {
621                         let text = state.buffer.clone();
622                         self.parse_into_value(&text);
623                         self.editing = false;
624                         handled = true;
625                     }
626                     Key::Named(NamedKey::Escape) => {
627                         self.editing = false;
628                         handled = true;
629                     }
630                     _ => {
631                         if let Some(text) = &key_event.text {
632                             for ch in text.chars() {
633                                 match ch {
634                                     '-' if state.cursor_idx == 0 && !state.buffer.starts_with('-') => {
635                                         state.insert_text("-");
636                                         handled = true;
637                                     }
638                                     '.' if self.decimals > 0 && !state.buffer.contains('.') => {
639                                         state.insert_text(".");
640                                         handled = true;
641                                     }
642                                     '0'..='9' => {
643                                         state.insert_text(&ch.to_string());
644                                         handled = true;
645                                     }
646                                     _ => {}
647                                 }
648                             }
649                         }
650                     }
651                 }
652                 if self.editing {
653                     self.edit_buffer = state.buffer;
654                     self.cursor_idx = state.cursor_idx;
655                 }
656                 handled
657             }
658             // Focus gained programmatically enters edit mode (legacy `focus()` override);
659             // focus loss commits (legacy `unfocus`).
660             Event::FocusIn => {
661                 self.focused = true;
662                 self.begin_edit(true);
663                 false
664             }
665             Event::FocusOut => {
666                 self.focused = false;
667                 if self.editing {
668                     self.editing = false;
669                     let text = self.edit_buffer.clone();
670                     self.parse_into_value(&text);
671                 }
672                 false
673             }
674             _ => false,
675         }
676     }
677 
678     fn opens_context_menu(&self) -> bool {
679         true
680     }
681 
682     fn take_change(&mut self) -> bool {
683         std::mem::take(&mut self.just_changed)
684     }
685 
686     fn value_string(&self) -> Option<String> {
687         Some(self.formatted_value())
688     }
689 
690     fn a11y_range(&self) -> Option<(f64, f64, f64)> {
691         let unit = 10f64.powi(self.decimals as i32);
692         Some((self.min as f64 / unit, self.max as f64 / unit, self.step as f64 / unit))
693     }
694 
695     fn a11y_set_value(&mut self, value: f64) -> bool {
696         let old_val = self.value;
697         let unit = 10f64.powi(self.decimals as i32);
698         self.value = ((value * unit).round() as i32).clamp(self.min, self.max);
699         if self.value == old_val {
700             return false;
701         }
702         self.just_changed = true;
703         if self.editing {
704             self.edit_buffer = self.formatted_value();
705             self.cursor_idx = self.edit_buffer.chars().count();
706         }
707         true
708     }
709 
710     fn set_value_string(&mut self, val: &str) -> bool {
711         let old_val = self.value;
712         self.parse_into_value(val.trim());
713         if self.value != old_val {
714             if self.editing {
715                 self.edit_buffer = self.formatted_value();
716                 self.cursor_idx = self.edit_buffer.chars().count();
717             }
718             true
719         } else {
720             false
721         }
722     }
723 
724     fn value(&self) -> i32 {
725         self.value
726     }
727 }
728 
729 
730 #[cfg(test)]
731 mod tests {
732     use super::*;
733     use crate::widget::{WidgetHost, UiContext};
734 
735     /// The value's well and the -/+ run are ONE field: one outline round
736     /// both, its seam where the run begins — not a well and a trough side by
737     /// side, each turning its own corner there — with the engraved seam
738     /// between - and + on top.
739     #[test]
740     fn a_spinbox_is_one_field_with_its_run_at_the_right_end() {
741         use crate::scene::paint::{PaintCtx, Prim};
742         if !crate::layout::control_relief() {
743             return;
744         }
745         let sb = Spinbox::new(0, -100, 100, 1);
746         let rect = Rect { x: 10.0, y: 20.0, width: 200.0, height: 26.0 };
747         let rel = sb.inner().relief_parts(rect).expect("a relief");
748         assert_eq!(rel.rect, rect, "the outline is the control as handed over");
749         let (split, (sa, sb2, _, _)) = rel.run.expect("a -/+ run");
750         assert!(split > rect.x + rect.width * 0.5 && split < rect.x + rect.width, "the run is the right end");
751         assert!(sa.0 > split && sa.0 == sb2.0, "the -/+ seam crosses the run");
752         let mut pc = PaintCtx::new();
753         Paint::paint(sb.inner(), rect, &mut pc);
754         let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
755         let fields: Vec<&Prim> = prims.iter().filter(|p| matches!(p, Prim::Field { .. })).collect();
756         assert_eq!(fields.len(), 1, "{prims:?}");
757         if let Prim::Field { split: s, .. } = fields[0] {
758             assert_eq!(*s, split);
759         }
760         assert!(
761             !prims.iter().any(|p| matches!(p, Prim::Recess { .. } | Prim::Trough { .. })),
762             "no well or ring of its own beside the field"
763         );
764         assert!(prims.iter().any(|p| matches!(p, Prim::Groove { .. })), "the -/+ seam");
765     }
766 
767     /// Up / Down step a focused spinbox, as its -/+ run does: the keys a keyboard user
768     /// steps it with, and what an assistive tool's Increment / Decrement press.
769     #[test]
770     fn up_and_down_step_a_focused_spinbox() {
771         let key = |k| {
772             Event::KeyInput(crate::widget::KeyEvent { logical_key: Key::Named(k), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
773         };
774         let mut ctx = UiContext::new();
775         let mut sb = Spinbox::new(5, 0, 10, 2);
776         WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
777         assert!(!sb.handle_event(&key(NamedKey::ArrowUp), &mut ctx), "unfocused: not this box's key");
778         sb.handle_event(&Event::FocusIn, &mut ctx);
779         assert!(sb.handle_event(&key(NamedKey::ArrowUp), &mut ctx));
780         assert_eq!(sb.value, 7);
781         assert!(sb.take_change());
782         assert!(sb.handle_event(&key(NamedKey::ArrowDown), &mut ctx));
783         assert!(sb.handle_event(&key(NamedKey::ArrowDown), &mut ctx));
784         assert_eq!(sb.value, 3);
785         assert_eq!(sb.edit_buffer, "3", "the shown text follows while editing");
786         for _ in 0..5 {
787             sb.handle_event(&key(NamedKey::ArrowDown), &mut ctx);
788         }
789         assert_eq!(sb.value, 0, "clamped at the bottom");
790     }
791 
792     /// Enter commits and leaves the box focused; the keys that edit or step it take it back
793     /// into editing, where until 2026-10-08 a focused box ignored every key after Enter.
794     #[test]
795     fn a_focused_spinbox_takes_keys_again_after_enter() {
796         let key = |k: Key, text: Option<&str>| {
797             Event::KeyInput(crate::widget::KeyEvent { logical_key: k, state: ElementState::Pressed, text: text.map(str::to_string), repeat: false, ctrl: false, shift: false, alt: false })
798         };
799         let mut ctx = UiContext::new();
800         let mut sb = Spinbox::new(5, 0, 100, 1);
801         sb.handle_event(&Event::FocusIn, &mut ctx);
802         assert!(sb.handle_event(&key(Key::Named(NamedKey::Enter), None), &mut ctx));
803         assert!(!sb.editing, "Enter commits");
804         assert!(sb.handle_event(&key(Key::Named(NamedKey::ArrowUp), None), &mut ctx), "Up steps it again");
805         assert_eq!(sb.value, 6);
806         sb.handle_event(&key(Key::Named(NamedKey::Enter), None), &mut ctx);
807         assert!(sb.handle_event(&key(Key::Character("4".into()), Some("4")), &mut ctx), "a digit reopens it");
808         assert_eq!(sb.edit_buffer, "64", "typed at the end of the shown value");
809         sb.handle_event(&Event::FocusOut, &mut ctx);
810         assert!(!sb.handle_event(&key(Key::Named(NamedKey::ArrowUp), None), &mut ctx), "unfocused: not its key");
811     }
812 
813     /// What a screen reader sets (AT-SPI SetCurrentValue) lands in the box's own units,
814     /// clamped, and is reported as a change; the range is read in the same units.
815     #[test]
816     fn a_reader_sets_a_spinbox_in_its_units() {
817         let mut sb = Spinbox::new(150, 0, 500, 25).with_decimals(2);
818         assert_eq!(sb.inner().a11y_range(), Some((0.0, 5.0, 0.25)));
819         assert!(Input::a11y_set_value(sb.inner_mut(), 2.5));
820         assert_eq!(sb.value, 250);
821         assert!(sb.take_change());
822         assert!(Input::a11y_set_value(sb.inner_mut(), 9.0));
823         assert_eq!(sb.value, 500, "clamped to the top");
824         assert!(!Input::a11y_set_value(sb.inner_mut(), 5.0), "no change, none reported");
825     }
826 
827     #[test]
828     fn spinbox_button_zones_step_the_value() {
829         let mut ctx = UiContext::new();
830         let sb = ctx.insert(Spinbox::new(0, -100, 100, 1));
831         WidgetHost::set_rect(&mut ctx[sb], 10.0, 20.0, 100.0, 26.0);
832 
833         // Legacy test: click at (75, 33) lands in the decrement zone.
834         assert!(ctx.lend_h(sb, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 75.0, 33.0, ctx)).unwrap());
835         assert_eq!(ctx[sb].value, -1);
836         assert!(ctx[sb].take_change());
837 
838         // Increment zone (past 77.5% of the width).
839         assert!(ctx.lend_h(sb, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 92.0, 33.0, ctx)).unwrap());
840         assert_eq!(ctx[sb].value, 0);
841     }
842 
843     #[test]
844     fn spinbox_buttons_step_visibly_while_editing() {
845         // Row-selection focus puts the spinbox in edit mode (FocusIn →
846         // begin_edit): the display shows edit_buffer. Stepping must commit
847         // and refresh the buffer, or the value moves invisibly and the next
848         // FocusOut commit resets it to the stale text.
849         let mut ctx = UiContext::new();
850         let sb = ctx.insert(Spinbox::new(6, 0, 100, 1));
851         WidgetHost::set_rect(&mut ctx[sb], 10.0, 20.0, 100.0, 26.0);
852         ctx[sb].begin_edit(true);
853         assert_eq!(ctx[sb].edit_buffer, "6");
854 
855         assert!(ctx.lend_h(sb, |w, ctx| w.mouse_input(MouseButton::Left, ElementState::Pressed, 92.0, 33.0, ctx)).unwrap());
856         assert_eq!(ctx[sb].value, 7);
857         assert_eq!(ctx[sb].edit_buffer, "7");
858         assert!(ctx[sb].take_change());
859 
860         // FocusOut now commits the refreshed buffer — the step survives.
861         ctx.lend_h(sb, |w, ctx| w.handle_event(&Event::FocusOut, ctx)).unwrap();
862         assert_eq!(ctx[sb].value, 7);
863     }
864 
865     #[test]
866     fn spinbox_wheel_steps_by_notch_and_accumulates_fractions() {
867         use crate::widget::MouseScrollDelta;
868         let mut ctx = UiContext::new();
869         let sb = ctx.insert(Spinbox::new(10, 0, 100, 5));
870         WidgetHost::set_rect(&mut ctx[sb], 10.0, 20.0, 100.0, 26.0);
871 
872         // One notch up steps up, one notch down steps down — and the wheel is consumed.
873         assert!(ctx.lend_h(sb, |w, ctx| w.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 50.0, 33.0, ctx)).unwrap());
874         assert_eq!(ctx[sb].value, 15);
875         assert!(ctx.lend_h(sb, |w, ctx| w.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 33.0, ctx)).unwrap());
876         assert_eq!(ctx[sb].value, 10);
877         assert!(ctx[sb].take_change());
878 
879         // Fractional (trackpad) notches accumulate to a whole step, consumed all the while.
880         assert!(ctx.lend_h(sb, |w, ctx| w.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), 50.0, 33.0, ctx)).unwrap());
881         assert_eq!(ctx[sb].value, 10, "half a notch: no step yet");
882         assert!(ctx.lend_h(sb, |w, ctx| w.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), 50.0, 33.0, ctx)).unwrap());
883         assert_eq!(ctx[sb].value, 15, "the second half completes the notch");
884 
885         // Outside the rect the wheel is not the spinbox's (hit-gated by the adapter).
886         assert!(!ctx.lend_h(sb, |w, ctx| w.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 200.0, 200.0, ctx)).unwrap());
887         assert_eq!(ctx[sb].value, 15);
888     }
889 
890     #[test]
891     fn spinbox_value_string_decimals_round_trip() {
892         let mut sb = Spinbox::new(150, 0, 1000, 5).with_decimals(2);
893         assert_eq!(sb.get_value_string(), Some("1.50".to_string()));
894         assert!(sb.set_value_string("2.75"));
895         assert_eq!(sb.value, 275);
896         assert_eq!(sb.value(), 275);
897     }
898 }