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 }