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 }