GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/slider.rs (52.9K)
1 //! Narrow-trait `Slider` and `RangeSlider` (Phase 5h). Detached-label widgets: the adapter
2 //! draws the control label in the strip above the content rect the geometry here works in.
3 //! Drags are host-driven through the `Input` drag hooks; the
4 //! readout edit mode uses `EventCtx::request_focus` and the wheel gating uses the legacy scroll
5 //! gesture state through `EventCtx::ui`.
6
7 use crate::colors;
8 use crate::scene::layout::{Rect, Size};
9 use crate::scene::paint::PaintCtx;
10 use crate::widget::{
11 Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
12 NamedKey, Paint, TextEditorState,
13 };
14
15 /// The track/readout/thumb geometry shared by the paint and input paths, derived from the
16 /// content rect (the legacy code re-derived this in five places from the base rect).
17 struct SliderGeom {
18 x: f32,
19 y: f32,
20 w: f32,
21 h: f32,
22 track_x: f32,
23 track_w: f32,
24 }
25
26 #[derive(Debug, Clone)]
27 pub struct Slider {
28 dragging: bool,
29 pub(crate) value: f32,
30 drag_offset: f32,
31 pub(crate) scroll_enabled: bool,
32 show_readout: bool,
33 pub(crate) editing: bool,
34 edit_buffer: String,
35 min: f32,
36 max: f32,
37 /// A SOFT range: a value typed into the readout past either end widens
38 /// the range to hold it, where a hard range clamps it to the end. For
39 /// a value with no natural bounds, whose range is only a scale to drag
40 /// over — the host re-chooses it around the value. Off by default; a
41 /// drag and the wheel stop at the ends either way.
42 soft: bool,
43 pub editor_state: TextEditorState,
44 pub just_changed: bool,
45 label: Option<String>,
46 /// Wheel-scroll glide velocity (normalized value units/sec) and the last
47 /// wheel-event instant — the Ramp hover-scroll idiom: when the event
48 /// stream stops (fingers lifted), `tick` keeps the value coasting with
49 /// exponential decay instead of stopping dead.
50 scroll_vel: f32,
51 last_wheel: Option<web_time::Instant>,
52 /// Keyboard focus (FocusIn / FocusOut): the band lights in the highlight;
53 /// the arrows adjust, Home / End go to the ends, Enter opens the readout.
54 focused: bool,
55 /// Readout / edit-buffer display precision (decimal places).
56 decimals: usize,
57 /// The pointer is over the row (`MouseEnter` / `MouseLeave`, synthesized
58 /// by the adapter's hover bookkeeping): the band lifts, the way a well's
59 /// frame or a dropdown's border does. A slider had no hover at all until
60 /// 2026-09-28, so a params pane answered the pointer on every row but
61 /// its sliders.
62 hovered: bool,
63 }
64
65 impl Slider {
66 pub fn new() -> Adapted<Slider> {
67 Adapted::new(Slider {
68 dragging: false,
69 value: 0.5,
70 drag_offset: 0.0,
71 scroll_enabled: true,
72 show_readout: false,
73 editing: false,
74 edit_buffer: String::new(),
75 min: 0.0,
76 max: 1.0,
77 soft: false,
78 editor_state: TextEditorState::new(String::new()),
79 just_changed: false,
80 label: None,
81 hovered: false,
82 scroll_vel: 0.0,
83 last_wheel: None,
84 focused: false,
85 decimals: 2,
86 })
87 }
88
89 pub fn set_range(&mut self, min: f32, max: f32) {
90 self.min = min;
91 self.max = max;
92 }
93
94 /// See the `soft` field.
95 pub fn set_soft(&mut self, soft: bool) {
96 self.soft = soft;
97 }
98
99 pub fn set_scroll(&mut self, enabled: bool) {
100 self.scroll_enabled = enabled;
101 }
102
103 pub fn set_value(&mut self, val: f32) {
104 self.value = val.clamp(0.0, 1.0);
105 }
106
107 pub fn set_readout(&mut self, enabled: bool) {
108 self.show_readout = enabled;
109 }
110
111 pub fn value(&self) -> f32 {
112 self.value
113 }
114
115 pub fn get_scaled_value(&self) -> f32 {
116 self.min + self.value * (self.max - self.min)
117 }
118
119 pub fn range(&self) -> (f32, f32) {
120 (self.min, self.max)
121 }
122
123 /// Readout precision, for a host that changes it after construction
124 /// (a float3 group gaining a trackball reads to a third decimal).
125 pub fn set_decimals(&mut self, decimals: usize) {
126 self.decimals = decimals;
127 }
128
129 /// The widest range a notch steps a flat 2% of. Wider than this, the
130 /// step follows the value's magnitude instead ([`Self::notch_step`]).
131 pub const FINE_SPAN: f32 = 20.0;
132
133 /// What one wheel notch, or one arrow press, moves the value by, as a
134 /// fraction of the range.
135 ///
136 /// 2% of the range — for a range up to [`Self::FINE_SPAN`] wide, which
137 /// is every slider the toolkit had until a host asked for one over
138 /// -1000..1000 (the designer's pull vector, 2026-09-28): there 2% is 40
139 /// units a notch, and a value of 0.06 cannot be reached by scrolling at
140 /// all. A wide range steps 2% of a SPAN that grows with the value —
141 /// `FINE_SPAN` times its magnitude, never less than `FINE_SPAN` itself
142 /// and never more than the range — so near zero it moves as a 20-wide
143 /// slider does (0.4 a notch, under a hundredth per pixel of trackpad
144 /// travel) and far from zero by the full 2%, and the whole range is
145 /// still a few dozen notches end to end. By magnitude rather than by a
146 /// finer flat step, because a flat step fine enough for 0.06 is one
147 /// that takes thousands of notches to reach 1000.
148 pub fn notch_step(&self) -> f32 {
149 let range = (self.max - self.min).abs();
150 if range <= Self::FINE_SPAN {
151 return 0.02;
152 }
153 let span = (Self::FINE_SPAN * self.get_scaled_value().abs().max(1.0)).min(range);
154 0.02 * span / range
155 }
156
157 pub fn set_scaled_value(&mut self, val: f32) {
158 let range = self.max - self.min;
159 if range != 0.0 {
160 self.value = ((val - self.min) / range).clamp(0.0, 1.0);
161 } else {
162 self.value = 0.0;
163 }
164 }
165
166 /// The width the value maps over: the whole track — the band has no thumb
167 /// to keep inside the ends.
168 fn value_span(&self, g: &SliderGeom) -> f32 {
169 g.track_w
170 }
171
172
173 /// Width of the value readout at the slider's right end.
174 pub const READOUT_W: f32 = 60.0;
175 /// Gap between the track and the readout.
176 pub const READOUT_GAP: f32 = 8.0;
177
178 /// What a slider's rect spends on everything but its TRACK: the readout
179 /// and its gap when it shows one. `rect width - chrome` is the track a
180 /// host gets for a rect — what `ParametersBg` measures to decide whether
181 /// a row's label can sit beside the control.
182 pub const fn readout_chrome() -> f32 {
183 Self::READOUT_W + Self::READOUT_GAP
184 }
185
186 fn geom(&self, rect: Rect) -> SliderGeom {
187 let x = rect.x;
188 let w = rect.width;
189 let (track_x, track_w) = if self.show_readout {
190 let readout_w = Self::READOUT_W;
191 let gap = Self::READOUT_GAP;
192 ((x), (w - readout_w - gap).max(10.0))
193 } else {
194 (x, w)
195 };
196 SliderGeom { x, y: rect.y, w, h: rect.height, track_x, track_w }
197 }
198
199 /// The band's height profile at `x` (`band_profile`, one swell at the value).
200 fn band_height_at(&self, g: &SliderGeom, x: f32) -> f32 {
201 let vx = g.track_x + self.value * g.track_w;
202 band_profile(g.track_x, g.track_w, g.h, x, &[vx], None)
203 }
204
205 /// The wheel-capture zone. Band style: an inset halo around the DRAWN
206 /// shape — the thin band and the bulge, which travels with the value — so
207 /// a scroll near the visible slider adjusts it while the rest of the row
208 /// stays the host pane's to scroll. Otherwise: plain rect containment.
209 pub fn scroll_hit(&self, rect: Rect, px: f32, py: f32) -> bool {
210 const SCROLL_INSET: f32 = 14.0;
211 let g = self.geom(rect);
212 if px < g.track_x - SCROLL_INSET || px > g.track_x + g.track_w + SCROLL_INSET {
213 return false;
214 }
215 let cy = g.y + g.h * 0.5;
216 let x = px.clamp(g.track_x, g.track_x + g.track_w);
217 (py - cy).abs() <= self.band_height_at(&g, x) * 0.5 + SCROLL_INSET
218 }
219
220 /// The slider: the band spanning the whole track, swelling at the value
221 /// (`paint_band_shape`).
222 fn paint_band(&self, g: &SliderGeom, ctx: &mut PaintCtx) {
223 // A band has no rim to light: focused, the band itself is the
224 // highlight; hovered, it lifts by the dropdown border's step.
225 let color = if self.dragging {
226 colors::slider_thumb_drag()
227 } else if self.focused {
228 crate::color::highlight_primary_color()
229 } else if self.hovered {
230 let c = colors::slider_thumb();
231 [(c[0] + 0.15).min(1.0), (c[1] + 0.15).min(1.0), (c[2] + 0.15).min(1.0), c[3]]
232 } else {
233 colors::slider_thumb()
234 };
235 paint_band_shape(ctx, g.track_x, g.track_w, g.y + g.h * 0.5, color, &|x| self.band_height_at(g, x));
236 }
237
238 /// The pointer is over the row.
239 pub fn hovered(&self) -> bool {
240 self.hovered
241 }
242
243 /// Set the hover directly, for a host that paints one slider as a STAMP
244 /// over several rows and does its own hit-testing — the designer's
245 /// dialog — rather than routing `MouseEnter` / `MouseLeave` to it.
246 pub fn set_hovered(&mut self, hovered: bool) {
247 self.hovered = hovered;
248 }
249
250 fn scaled_string(&self) -> String {
251 format!("{:.*}", self.decimals, self.min + self.value * (self.max - self.min))
252 }
253
254 fn set_value_marking(&mut self, new_val: f32) -> bool {
255 if (new_val - self.value).abs() > 0.0001 {
256 self.value = new_val;
257 self.just_changed = true;
258 if self.editing {
259 self.edit_buffer = self.scaled_string();
260 }
261 true
262 } else {
263 false
264 }
265 }
266
267 fn commit_edit(&mut self) {
268 if self.editing {
269 self.editing = false;
270 let old_val = self.value;
271 if let Ok(new_val) = self.edit_buffer.parse::<f32>() {
272 // A soft range widens to hold what was typed; the value is
273 // the number, not the end it would clamp to.
274 if self.soft && new_val.is_finite() && (new_val < self.min || new_val > self.max) {
275 self.min = self.min.min(new_val);
276 self.max = self.max.max(new_val);
277 self.just_changed = true;
278 }
279 let range = self.max - self.min;
280 if range != 0.0 {
281 self.value = ((new_val - self.min) / range).clamp(0.0, 1.0);
282 } else {
283 self.value = 0.0;
284 }
285 }
286 if (self.value - old_val).abs() > 0.0001 {
287 self.just_changed = true;
288 }
289 }
290 }
291 }
292
293 impl Adapted<Slider> {
294 pub fn with_range(mut self, min: f32, max: f32) -> Self {
295 self.set_range(min, max);
296 self
297 }
298
299 pub fn with_scroll(mut self, enabled: bool) -> Self {
300 self.scroll_enabled = enabled;
301 self
302 }
303
304
305 pub fn with_value(mut self, val: f32) -> Self {
306 self.set_value(val);
307 self
308 }
309
310 pub fn with_readout(mut self, enabled: bool) -> Self {
311 self.show_readout = enabled;
312 self
313 }
314
315 /// Readout display precision in decimal places (default 2).
316 pub fn with_decimals(mut self, decimals: usize) -> Self {
317 self.decimals = decimals;
318 self
319 }
320
321 /// See the `soft` field.
322 pub fn with_soft(mut self, soft: bool) -> Self {
323 self.set_soft(soft);
324 self
325 }
326
327 }
328
329 impl Layout for Slider {
330 fn intrinsic_size(&self) -> Option<Size> {
331 Some(Size::new(0.0, crate::layout::slider_height()))
332 }
333 }
334
335 impl Paint for Slider {
336 fn color(&self) -> [f32; 4] {
337 [0.0, 0.0, 0.0, 0.0]
338 }
339
340 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
341 let r = crate::layout::slider_corner_radius();
342 if r > 0.0 {
343 Some((r, (true, true, true, true)))
344 } else {
345 None
346 }
347 }
348
349 fn widget_font(&self) -> Option<String> {
350 Some(crate::layout::control_label_font_detached())
351 }
352
353 fn sync_label(&mut self, label: &str) {
354 self.label = Some(label.to_string());
355 }
356
357 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
358 let g = self.geom(rect);
359 // Typing into the readout: the on-screen keyboard follows
360 // (`crate::text_input`).
361 if self.editing {
362 let (ox, oy) = ctx.offset();
363 crate::text_input::claim(g.x + ox, g.y + oy, g.w, g.h);
364 }
365 let radius = crate::layout::slider_corner_radius();
366 let rounded = radius > 0.0;
367 let rc = (rounded, rounded, rounded, rounded);
368 let rrect = |r: Rect, rad: f32, corners: (bool, bool, bool, bool), c: [f32; 4], ctx: &mut PaintCtx| {
369 if rounded {
370 ctx.rounded_rect(r, rad, corners, c);
371 } else {
372 ctx.quad(r, c);
373 }
374 };
375
376 // Readout box (+ focus border) and its text.
377 if self.show_readout {
378 let readout_w = 60.0;
379 let rx = g.x + g.w - readout_w;
380 let bg_color = if self.editing { [0.06, 0.10, 0.18, 1.0] } else { [0.10, 0.10, 0.13, 1.0] };
381 // NOTE: the legacy square path drew the focus border as 4 edge strips and the
382 // rounded path as border+inset; replicate the rounded shape for both (visually
383 // identical at 1px) — acceptable divergence flagged in the Phase 5h notes.
384 if self.editing {
385 rrect(Rect { x: rx, y: g.y, width: readout_w, height: g.h }, radius, rc, [0.20, 0.50, 0.85, 1.0], ctx);
386 rrect(
387 Rect { x: rx + 1.0, y: g.y + 1.0, width: readout_w - 2.0, height: g.h - 2.0 },
388 (radius - 1.0).max(0.0),
389 rc,
390 bg_color,
391 ctx,
392 );
393 } else {
394 rrect(Rect { x: rx, y: g.y, width: readout_w, height: g.h }, radius, rc, bg_color, ctx);
395 }
396
397 let text = if self.editing { self.edit_buffer.clone() } else { self.scaled_string() };
398 // Clipped to the readout well. While `editing` this is whatever the
399 // user has typed, which has no length limit at all — unbounded it
400 // ran straight out of the readout and across the band beside it.
401 ctx.text_with(
402 text,
403 rx + 8.0,
404 crate::layout::align_text_y(g.y, g.h, 12.0, 0.0),
405 12.0,
406 [0xee, 0xee, 0xf0],
407 None,
408 Some([rx, g.y, rx + readout_w, g.y + g.h]),
409 );
410 }
411
412 self.paint_band(&g, ctx);
413 }
414 }
415
416 impl Input for Slider {
417 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
418 match event {
419 Event::MouseButton { button: MouseButton::Left, state, x: px, y: py, .. } => {
420 let g = self.geom(ectx.rect);
421 // Readout click enters edit mode and takes focus.
422 if self.show_readout {
423 let readout_w = 60.0;
424 let rx = g.x + g.w - readout_w;
425 if *px >= rx && *px <= rx + readout_w && *py >= g.y && *py <= g.y + g.h {
426 if *state == ElementState::Pressed && !self.editing {
427 self.editing = true;
428 self.edit_buffer = self.scaled_string();
429 ectx.request_focus();
430 }
431 return true;
432 }
433 }
434 match state {
435 ElementState::Pressed => {
436 let thumb_x = g.track_x + self.value * self.value_span(&g);
437 if *px >= g.track_x && *px <= g.track_x + g.track_w && *py >= g.y && *py <= g.y + g.h {
438 self.dragging = true;
439 self.drag_offset = px - thumb_x;
440 // A grab overrides any wheel glide in flight.
441 self.scroll_vel = 0.0;
442 self.last_wheel = None;
443 return true;
444 }
445 false
446 }
447 ElementState::Released => std::mem::take(&mut self.dragging),
448 }
449 }
450 Event::MouseWheel { delta, x: px, y: py, .. } => {
451 if !self.scroll_enabled {
452 return false;
453 }
454 if let Some(ui) = ectx.ui.as_deref_mut() {
455 // Band style: recognition is purely SPATIAL — anywhere in
456 // the shape halo adjusts, mid-gesture included. Trackpad
457 // swipes are one long gesture (kinetic tail included), so
458 // the initiator gate below would reject every event whose
459 // gesture began outside the halo no matter where the
460 // pointer is now — the "slider won't take my scroll" feel.
461 // The default style keeps the gate: only the widget that
462 // initiated a gesture keeps it.
463 let r = ectx.rect;
464 // Band: spatial acquisition + gesture LATCH. The halo travels
465 // with the bulge, so adjusting slides it away from the pointer
466 // — without the latch the value moves a little and stalls
467 // mid-scroll. Once a gesture engages this slider it keeps it
468 // until the gesture ends; a new gesture re-acquires by halo.
469 let latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(ectx.id);
470 if latched || self.scroll_hit(r, *px, *py) {
471 ui.scroll_initiate_widget_id = Some(ectx.id);
472 // Up is more, for a wheel and for a finger alike.
473 let scroll_amount = delta.value_notches_y();
474 let new_val = (self.value + scroll_amount * self.notch_step()).clamp(0.0, 1.0);
475 let applied = new_val - self.value;
476 self.set_value_marking(new_val);
477 // Velocity estimate for the release glide (the Ramp
478 // hover-scroll idiom): EMA of applied delta over
479 // inter-event time. A leisurely wheel produces
480 // negligible velocity (big gaps clamp to 0.1s); fast
481 // trackpad streams build real speed. Hitting an end
482 // stops dead — no glide pinned at the bounds.
483 let now = web_time::Instant::now();
484 let idt = self
485 .last_wheel
486 .map_or(0.1, |l| now.duration_since(l).as_secs_f32())
487 .clamp(0.008, 0.1);
488 self.last_wheel = Some(now);
489 self.scroll_vel = if new_val == 0.0 || new_val == 1.0 {
490 0.0
491 } else {
492 self.scroll_vel * 0.65 + (applied / idt) * 0.35
493 };
494 if crate::scroll_debug() {
495 eprintln!(
496 "[scroll] slider {:?}: APPLY notches={scroll_amount:.3} applied={applied:.4} value={new_val:.4} idt={idt:.3} vel={:.3}",
497 self.label, self.scroll_vel
498 );
499 }
500 return true;
501 }
502 if crate::scroll_debug() {
503 eprintln!(
504 "[scroll] slider {:?}: MISS scroll_hit at ({px:.0},{py:.0}) rect={:?}",
505 self.label, ectx.rect
506 );
507 }
508 }
509 false
510 }
511 Event::MouseEnter => {
512 self.hovered = true;
513 true
514 }
515 Event::MouseLeave => {
516 self.hovered = false;
517 true
518 }
519 Event::FocusIn => {
520 self.focused = true;
521 true
522 }
523 Event::KeyInput(key_event) if !self.editing => {
524 // A focused band: the arrows step the value by a wheel notch,
525 // Home / End go to the ends, Enter opens the readout for typing.
526 if !self.focused || key_event.state != ElementState::Pressed {
527 return false;
528 }
529 let target = match key_event.logical_key {
530 Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowDown) => self.value - self.notch_step(),
531 Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowUp) => self.value + self.notch_step(),
532 Key::Named(NamedKey::Home) => 0.0,
533 Key::Named(NamedKey::End) => 1.0,
534 Key::Named(NamedKey::Enter) if self.show_readout => {
535 self.editing = true;
536 self.edit_buffer = self.scaled_string();
537 return true;
538 }
539 _ => return false,
540 };
541 self.scroll_vel = 0.0;
542 self.last_wheel = None;
543 self.set_value_marking(target.clamp(0.0, 1.0));
544 true
545 }
546 Event::KeyInput(key_event) => {
547 if !self.editing || key_event.state != ElementState::Pressed {
548 return false;
549 }
550 let mut state = TextEditorState {
551 buffer: self.edit_buffer.clone(),
552 cursor_idx: self.edit_buffer.chars().count(),
553 select_anchor: None,
554 all_selected: false,
555 };
556 let mut handled = false;
557 match &key_event.logical_key {
558 Key::Named(NamedKey::Backspace) => {
559 state.delete_backwards();
560 handled = true;
561 }
562 Key::Named(NamedKey::Enter) => {
563 self.commit_edit();
564 handled = true;
565 }
566 Key::Named(NamedKey::Escape) => {
567 self.editing = false;
568 handled = true;
569 }
570 Key::Character(s) => {
571 for ch in s.chars() {
572 if ch.is_ascii_digit() || ch == '.' || (ch == '-' && state.buffer.is_empty()) {
573 state.insert_text(&ch.to_string());
574 }
575 }
576 handled = true;
577 }
578 _ => {}
579 }
580 if self.editing {
581 self.edit_buffer = state.buffer;
582 }
583 handled
584 }
585 // Focus loss commits the readout edit (legacy `unfocus` override).
586 Event::FocusOut => {
587 self.focused = false;
588 self.commit_edit();
589 true
590 }
591 _ => false,
592 }
593 }
594
595 fn focus_role(&self) -> crate::widget::FocusRole {
596 crate::widget::FocusRole::Well
597 }
598
599 fn opens_context_menu(&self) -> bool {
600 true
601 }
602
603 /// Wheel-glide inertia: once the event stream stops (>60ms), the value
604 /// coasts on the estimated velocity with exponential decay — the same
605 /// release feel as the pane scrolls and the Ramp's hover-scroll.
606 fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
607 let Some(last) = self.last_wheel else { return false };
608 if last.elapsed().as_secs_f32() <= 0.06 {
609 return false;
610 }
611 // Animations off: the value stops where the wheel left it.
612 if self.scroll_vel.abs() > 0.02 && !self.dragging && !self.editing && crate::motion::enabled() {
613 let new_val = (self.value + self.scroll_vel * dt).clamp(0.0, 1.0);
614 let moved = self.set_value_marking(new_val);
615 if crate::scroll_debug() {
616 eprintln!(
617 "[scroll] slider {:?}: GLIDE dt={dt:.3} vel={:.3} value={new_val:.4}",
618 self.label, self.scroll_vel
619 );
620 }
621 if new_val == 0.0 || new_val == 1.0 {
622 self.scroll_vel = 0.0;
623 self.last_wheel = None;
624 } else {
625 self.scroll_vel *= (-5.0 * dt).exp();
626 }
627 moved
628 } else {
629 self.scroll_vel = 0.0;
630 self.last_wheel = None;
631 false
632 }
633 }
634
635 fn draggable(&self, _rect: Rect) -> bool {
636 true
637 }
638 fn is_dragging(&self) -> bool {
639 self.dragging
640 }
641 fn drag_begin(&mut self, px: f32, _py: f32, rect: Rect) {
642 self.dragging = true;
643 // A grab overrides any wheel glide in flight.
644 self.scroll_vel = 0.0;
645 self.last_wheel = None;
646 let g = self.geom(rect);
647 let thumb_x = g.track_x + self.value * self.value_span(&g);
648 self.drag_offset = px - thumb_x;
649 }
650 fn drag_update(&mut self, px: f32, _py: f32, rect: Rect) -> bool {
651 let g = self.geom(rect);
652 let range = self.value_span(&g);
653 if range > 0.0 {
654 let new_val = ((px - self.drag_offset - g.track_x) / range).clamp(0.0, 1.0);
655 return self.set_value_marking(new_val);
656 }
657 false
658 }
659 fn drag_end(&mut self) {
660 self.dragging = false;
661 }
662
663 fn take_change(&mut self) -> bool {
664 std::mem::take(&mut self.just_changed)
665 }
666
667 fn value_string(&self) -> Option<String> {
668 Some(self.scaled_string())
669 }
670
671 fn a11y_range(&self) -> Option<(f64, f64, f64)> {
672 let (lo, hi) = (self.min.min(self.max) as f64, self.min.max(self.max) as f64);
673 Some((lo, hi, (self.notch_step() * (self.max - self.min).abs()) as f64))
674 }
675
676 fn a11y_set_value(&mut self, value: f64) -> bool {
677 self.set_value_string(&value.to_string())
678 }
679
680 fn set_value_string(&mut self, val: &str) -> bool {
681 if let Ok(new_val) = val.trim().parse::<f32>() {
682 let range = self.max - self.min;
683 let mapped = if range != 0.0 { ((new_val - self.min) / range).clamp(0.0, 1.0) } else { 0.0 };
684 return self.set_value_marking(mapped);
685 }
686 false
687 }
688
689 fn value(&self) -> i32 {
690 (self.value * 100.0) as i32
691 }
692 }
693
694
695 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
696 pub enum ActiveThumb {
697 Low,
698 High,
699 }
700
701 #[derive(Debug, Clone)]
702 pub struct RangeSlider {
703 value_low: f32,
704 value_high: f32,
705 pub(crate) active_thumb: Option<ActiveThumb>,
706 drag_offset: f32,
707 label: Option<String>,
708 /// Keyboard focus (FocusIn / FocusOut): the band lights; `focus_end` is
709 /// the end the arrows move (Up / Down switch it), starting at the low end.
710 focused: bool,
711 focus_end: ActiveThumb,
712 }
713
714 impl RangeSlider {
715 pub fn new() -> Adapted<RangeSlider> {
716 Adapted::new(RangeSlider {
717 value_low: 0.2,
718 value_high: 0.8,
719 active_thumb: None,
720 drag_offset: 0.0,
721 label: None,
722 focused: false,
723 focus_end: ActiveThumb::Low,
724 })
725 }
726
727 pub fn set_values(&mut self, low: f32, high: f32) {
728 self.value_low = low.clamp(0.0, 1.0);
729 self.value_high = high.clamp(self.value_low, 1.0);
730 }
731
732 pub fn values(&self) -> (f32, f32) {
733 (self.value_low, self.value_high)
734 }
735 }
736
737 impl Adapted<RangeSlider> {
738 pub fn with_values(mut self, low: f32, high: f32) -> Self {
739 self.set_values(low, high);
740 self
741 }
742 }
743
744 /// The band's height at `x`: the flat band thickness (`style.control.slider.
745 /// band_thickness`), rising through a raised-cosine bell to the bulge height
746 /// around each of `centers` (`bulge_width` half-span, `bulge_height` peak), the
747 /// bell raised to a power so the flanks taper long and the crest stays plump —
748 /// mid-digestion, not a triangle; and, for a RangeSlider, one band thickness
749 /// more across `range` (between its two swells). Capsule tips: the profile
750 /// shrinks over a circular cap inside each track end — the band ends round, not
751 /// square-cut, and the well contour and wheel halo (both measured from here)
752 /// round with it. Public with `paint_band_shape` so app-owned scrubbers (the
753 /// designer's playbar) draw the same band.
754 pub fn band_profile(track_x: f32, track_w: f32, h: f32, x: f32, centers: &[f32], range: Option<(f32, f32)>) -> f32 {
755 let band_t = crate::layout::slider_band_thickness().max(0.5);
756 let bulge_h = crate::layout::slider_bulge_height().clamp(band_t, h);
757 let bulge_w = crate::layout::slider_bulge_width().max(2.0);
758 let mut bell = 0.0f32;
759 for &vx in centers {
760 let t = ((x - vx) / bulge_w).clamp(-1.0, 1.0);
761 bell = bell.max(0.5 * (1.0 + (std::f32::consts::PI * t).cos()));
762 }
763 let base = if range.is_some_and(|(lo, hi)| x >= lo && x <= hi) { 2.0 * band_t } else { band_t };
764 let h = base + (bulge_h - base) * bell.powf(1.35);
765 let d = (x - track_x).min(track_x + track_w - x);
766 let r = (h * 0.5).max(0.5);
767 if d < r {
768 let t = ((r - d.max(0.0)) / r).min(1.0);
769 return h * (1.0 - t * t).max(0.0).sqrt();
770 }
771 h
772 }
773
774 /// The band, drawn from its height `profile` (`band_profile`): the well first —
775 /// the band appears INSET, a carve whose contour follows the drawn shape a small
776 /// gap outside it. The rect recess prims can't follow a bell, so the walls are
777 /// hand-shaded per column from the same profile the fill samples: a shadow band
778 /// hugging the top contour, a lit band along the bottom (the DE light sits
779 /// upper-left), stepped alphas like the legacy banded bevels, amplitude riding
780 /// `bevel_depth` like every other relief wall's. Then the band itself, one
781 /// column per pixel with a hair of overlap so AA seams can't open. The one
782 /// painter behind Slider, RangeSlider and Float3's rows.
783 pub fn paint_band_shape(ctx: &mut PaintCtx, track_x: f32, track_w: f32, cy: f32, color: [f32; 4], profile: &dyn Fn(f32) -> f32) {
784 paint_band_shape_colored(ctx, track_x, track_w, cy, &|_| color, profile);
785 }
786
787 /// [`paint_band_shape`] with the band's colour sampled per column
788 /// (`color_at(x)`): a RangeSlider lights only the swell the keyboard is on.
789 pub fn paint_band_shape_colored(ctx: &mut PaintCtx, track_x: f32, track_w: f32, cy: f32, color_at: &dyn Fn(f32) -> [f32; 4], profile: &dyn Fn(f32) -> f32) {
790 const WELL_GAP: f32 = 4.0;
791 const WELL_WALL: f32 = 3.0;
792 const WALL_STEPS: usize = 3;
793 let strength = (crate::layout::bevel_depth() / 0.15).clamp(0.0, 2.0);
794 let a_dark = 0.32 * strength;
795 let a_light = 0.16 * strength;
796 let wx0 = track_x - WELL_GAP;
797 let wx1 = track_x + track_w + WELL_GAP;
798 // 1px columns, EXACT widths: translucent shading quads must not overlap (a
799 // seam double-blends into a visible tick) — unlike the opaque band columns
800 // below, which overlap on purpose against AA gaps.
801 let cols = (wx1 - wx0).ceil().max(1.0) as i32;
802 let colw = (wx1 - wx0) / cols as f32;
803 let sub = WELL_WALL / WALL_STEPS as f32;
804 for i in 0..cols {
805 let x = wx0 + i as f32 * colw;
806 let xm = x + colw * 0.5;
807 // Inside the track the contour rides the profile; past the tips it wraps
808 // around them on a WELL_GAP circle — rounded well ends, not square-cut.
809 let c = if xm < track_x {
810 let e = track_x - xm;
811 (WELL_GAP * WELL_GAP - e * e).max(0.0).sqrt()
812 } else if xm > track_x + track_w {
813 let e = xm - (track_x + track_w);
814 (WELL_GAP * WELL_GAP - e * e).max(0.0).sqrt()
815 } else {
816 profile(xm) * 0.5 + WELL_GAP
817 };
818 for k in 0..WALL_STEPS {
819 let fade = 1.0 - k as f32 / WALL_STEPS as f32;
820 // Shadow INSIDE the well below the top contour; the lit lip OUTSIDE
821 // below the bottom contour — the textbox-recess read.
822 ctx.quad(Rect { x, y: cy - c + k as f32 * sub, width: colw, height: sub }, [0.0, 0.0, 0.0, a_dark * fade]);
823 ctx.quad(Rect { x, y: cy + c + k as f32 * sub, width: colw, height: sub }, [1.0, 1.0, 1.0, a_light * fade]);
824 }
825 }
826 let steps = (track_w.ceil() as i32).max(1);
827 let step_w = track_w / steps as f32;
828 for i in 0..steps {
829 let x = track_x + i as f32 * step_w;
830 let h = profile(x + step_w * 0.5);
831 ctx.quad(Rect { x, y: cy - h * 0.5, width: step_w + 0.3, height: h }, color_at(x + step_w * 0.5));
832 }
833 }
834
835 /// The detached-label strip height above a content rect (zero unlabeled) — the
836 /// adapter's `Widget::label_offset` over a model's synced label, for models whose
837 /// cached rect is the whole block.
838 pub(crate) fn detached_strip(label: &Option<String>) -> f32 {
839 // An EMPTY label is no label: `Adapted::clear_label` syncs one to take a
840 // label off a widget that stores whatever it is handed.
841 if label.as_deref().is_some_and(|l| !l.is_empty()) { crate::layout::control_label_strip() } else { 0.0 }
842 }
843
844 impl Layout for RangeSlider {
845 fn intrinsic_size(&self) -> Option<Size> {
846 Some(Size::new(0.0, crate::layout::rangeslider_height()))
847 }
848 }
849
850 impl Paint for RangeSlider {
851 fn color(&self) -> [f32; 4] {
852 [0.0, 0.0, 0.0, 0.0]
853 }
854
855 fn sync_label(&mut self, label: &str) {
856 self.label = Some(label.to_string());
857 }
858
859 /// The band with two swells — one at each end of the range — and the band
860 /// itself a thickness heavier between them, so the range reads as the
861 /// swallowed length. The swells sit where the thumbs' centres were, so the
862 /// drag geometry below is unchanged.
863 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
864 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
865 let thumb_size = h * 0.9;
866 let range = w - thumb_size;
867 let lo = x + self.value_low * range + thumb_size / 2.0;
868 let hi = x + self.value_high * range + thumb_size / 2.0;
869 // A band has no rim to light: focused, the swell the keyboard is on
870 // is the highlight — the colour rides the swell's own bell, so it
871 // blooms over that end and fades back to the band along its flanks.
872 let base = if self.active_thumb.is_some() { colors::rangeslider_thumb_drag() } else { colors::rangeslider_thumb() };
873 let focus_center = self.focused.then_some(if matches!(self.focus_end, ActiveThumb::Low) { lo } else { hi });
874 let hl = crate::color::highlight_primary_color();
875 let bulge_w = crate::layout::slider_bulge_width().max(2.0);
876 let color_at = |px: f32| -> [f32; 4] {
877 let Some(c) = focus_center else { return base };
878 let t = ((px - c) / bulge_w).clamp(-1.0, 1.0);
879 let bell = 0.5 * (1.0 + (std::f32::consts::PI * t).cos());
880 let mut out = base;
881 for k in 0..4 {
882 out[k] = base[k] + (hl[k] - base[k]) * bell;
883 }
884 out
885 };
886 paint_band_shape_colored(ctx, x, w, y + h * 0.5, &color_at, &|px| band_profile(x, w, h, px, &[lo, hi], Some((lo, hi))));
887 }
888 }
889
890 impl Input for RangeSlider {
891 fn focus_role(&self) -> crate::widget::FocusRole {
892 crate::widget::FocusRole::Well
893 }
894
895 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
896 match event {
897 Event::FocusIn => {
898 self.focused = true;
899 self.focus_end = ActiveThumb::Low;
900 true
901 }
902 Event::FocusOut => {
903 self.focused = false;
904 true
905 }
906 Event::KeyInput(key_event) => {
907 // One stop, two ends: Left / Right step the focused end by a
908 // wheel notch inside the other end's bound, Up / Down switch
909 // ends, Home / End send the focused end to its limit.
910 if !self.focused || key_event.state != ElementState::Pressed {
911 return false;
912 }
913 let low = matches!(self.focus_end, ActiveThumb::Low);
914 let (cur, min, max) = if low {
915 (self.value_low, 0.0, self.value_high)
916 } else {
917 (self.value_high, self.value_low, 1.0)
918 };
919 let target = match key_event.logical_key {
920 Key::Named(NamedKey::ArrowLeft) => cur - 0.02,
921 Key::Named(NamedKey::ArrowRight) => cur + 0.02,
922 Key::Named(NamedKey::Home) => min,
923 Key::Named(NamedKey::End) => max,
924 Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowDown) => {
925 self.focus_end = if low { ActiveThumb::High } else { ActiveThumb::Low };
926 return true;
927 }
928 _ => return false,
929 };
930 let target = target.clamp(min, max);
931 if low {
932 self.value_low = target;
933 } else {
934 self.value_high = target;
935 }
936 true
937 }
938 Event::MouseWheel { delta, x: px, y: py, .. } => {
939 let Some(ui) = ectx.ui.as_deref_mut() else { return false };
940 if !ui.scroll_gesture_new && ui.scroll_initiate_widget_id != Some(ectx.id) {
941 return false;
942 }
943 let r = ectx.rect;
944 let (x, y, w, h) = (r.x, r.y, r.width, r.height);
945 if *px >= r.x && *px <= r.x + r.width && *py >= y && *py <= y + h {
946 if ui.scroll_gesture_new {
947 ui.scroll_initiate_widget_id = Some(ectx.id);
948 }
949 let thumb_size = h * 0.9;
950 let range = w - thumb_size;
951 let center_low = x + self.value_low * range + thumb_size / 2.0;
952 let center_high = x + self.value_high * range + thumb_size / 2.0;
953 let dist_low = (px - center_low).abs();
954 let dist_high = (px - center_high).abs();
955 let scroll_amount = delta.value_notches_y();
956 let step = 0.02;
957 let adjust_low = if dist_low < dist_high {
958 true
959 } else if dist_high < dist_low {
960 false
961 } else {
962 scroll_amount > 0.0
963 };
964 if adjust_low {
965 let new_val = (self.value_low + scroll_amount * step).clamp(0.0, self.value_high);
966 if (new_val - self.value_low).abs() > 0.0001 {
967 self.value_low = new_val;
968 }
969 } else {
970 let new_val = (self.value_high + scroll_amount * step).clamp(self.value_low, 1.0);
971 if (new_val - self.value_high).abs() > 0.0001 {
972 self.value_high = new_val;
973 }
974 }
975 return true;
976 }
977 false
978 }
979 _ => false,
980 }
981 }
982
983 fn draggable(&self, _rect: Rect) -> bool {
984 true
985 }
986 fn is_dragging(&self) -> bool {
987 self.active_thumb.is_some()
988 }
989 fn drag_begin(&mut self, px: f32, _py: f32, rect: Rect) {
990 let (x, w) = (rect.x, rect.width);
991 let thumb_size = rect.height * 0.9;
992 let range = w - thumb_size;
993 let thumb_low_x = x + self.value_low * range;
994 let thumb_high_x = x + self.value_high * range;
995 let center_low = thumb_low_x + thumb_size / 2.0;
996 let center_high = thumb_high_x + thumb_size / 2.0;
997
998 let active = if (self.value_low - self.value_high).abs() < 0.001 {
999 if px < center_low { ActiveThumb::Low } else { ActiveThumb::High }
1000 } else if (px - center_low).abs() < (px - center_high).abs() {
1001 ActiveThumb::Low
1002 } else {
1003 ActiveThumb::High
1004 };
1005 self.active_thumb = Some(active);
1006 let active_x = match active {
1007 ActiveThumb::Low => thumb_low_x,
1008 ActiveThumb::High => thumb_high_x,
1009 };
1010 self.drag_offset = px - active_x;
1011 }
1012 fn drag_update(&mut self, px: f32, _py: f32, rect: Rect) -> bool {
1013 let Some(active) = self.active_thumb else { return false };
1014 let (x, w) = (rect.x, rect.width);
1015 let thumb_size = rect.height * 0.9;
1016 let range = w - thumb_size;
1017 if range <= 0.0 {
1018 return false;
1019 }
1020 let new_val = ((px - self.drag_offset - x) / range).clamp(0.0, 1.0);
1021 match active {
1022 ActiveThumb::Low => {
1023 let constrained = new_val.min(self.value_high);
1024 if (constrained - self.value_low).abs() > 0.001 {
1025 self.value_low = constrained;
1026 return true;
1027 }
1028 }
1029 ActiveThumb::High => {
1030 let constrained = new_val.max(self.value_low);
1031 if (constrained - self.value_high).abs() > 0.001 {
1032 self.value_high = constrained;
1033 return true;
1034 }
1035 }
1036 }
1037 false
1038 }
1039 fn drag_end(&mut self) {
1040 self.active_thumb = None;
1041 }
1042
1043 fn value(&self) -> i32 {
1044 ((self.value_low * 100.0) as i32) | (((self.value_high * 100.0) as i32) << 16)
1045 }
1046 }
1047
1048
1049 #[cfg(test)]
1050 mod tests {
1051 use super::*;
1052 use crate::widget::{MouseScrollDelta, WidgetHost, UiContext};
1053
1054 /// A soft range takes a typed value past its end and widens to hold
1055 /// it; a hard one clamps the same number to the end.
1056 #[test]
1057 fn a_soft_range_widens_to_a_typed_value() {
1058 let typed = |soft: bool, text: &str| {
1059 let mut sl = Slider::new().with_range(-10.0, 10.0).with_readout(true).with_soft(soft);
1060 sl.editing = true;
1061 sl.edit_buffer = text.to_string();
1062 sl.commit_edit();
1063 (sl.get_scaled_value(), sl.range(), sl.just_changed)
1064 };
1065 let (v, range, changed) = typed(true, "500");
1066 assert!((v - 500.0).abs() < 1e-3 && range == (-10.0, 500.0) && changed, "{v} {range:?}");
1067 let (v, range, _) = typed(true, "-42");
1068 assert!((v + 42.0).abs() < 1e-3 && range == (-42.0, 10.0));
1069 let (v, range, _) = typed(true, "3");
1070 assert!((v - 3.0).abs() < 1e-3 && range == (-10.0, 10.0), "inside, nothing widens");
1071 let (v, range, _) = typed(false, "500");
1072 assert!((v - 10.0).abs() < 1e-3 && range == (-10.0, 10.0), "a hard range clamps");
1073 }
1074
1075 /// The legacy rangeslider interaction test, driven through the WidgetHost drag forwards
1076 /// (hosts call these directly): thumb selection by proximity, constrained updates.
1077 #[test]
1078 fn rangeslider_interaction() {
1079 let mut rs = RangeSlider::new();
1080 WidgetHost::set_rect(&mut rs, 10.0, 10.0, 200.0, 20.0);
1081 assert_eq!(rs.values(), (0.2, 0.8));
1082
1083 // Thumb size 18, range 182; low center = 55.4.
1084 rs.drag_begin(55.4, 20.0);
1085 assert_eq!(rs.active_thumb, Some(ActiveThumb::Low));
1086 assert!(rs.drag_update(100.9, 20.0));
1087 assert!((rs.values().0 - 0.45).abs() < 0.01);
1088 assert_eq!(rs.values().1, 0.8);
1089 rs.drag_end();
1090 assert_eq!(rs.active_thumb, None);
1091
1092 // High thumb 0.8 -> 0.6.
1093 rs.drag_begin(164.6, 20.0);
1094 assert_eq!(rs.active_thumb, Some(ActiveThumb::High));
1095 assert!(rs.drag_update(128.2, 20.0));
1096 assert!((rs.values().1 - 0.6).abs() < 0.01);
1097 rs.drag_end();
1098 }
1099
1100 #[test]
1101 fn rangeslider_overlap_and_constraint() {
1102 let mut rs = RangeSlider::new().with_values(0.5, 0.5);
1103 WidgetHost::set_rect(&mut rs, 10.0, 10.0, 200.0, 20.0);
1104
1105 rs.drag_begin(109.0, 20.0);
1106 assert_eq!(rs.active_thumb, Some(ActiveThumb::Low));
1107 rs.drag_end();
1108
1109 rs.drag_begin(111.0, 20.0);
1110 assert_eq!(rs.active_thumb, Some(ActiveThumb::High));
1111 rs.drag_end();
1112
1113 rs.drag_begin(110.0, 20.0);
1114 rs.drag_update(150.0, 20.0);
1115 assert_eq!(rs.values().0, 0.5, "low constrained to high");
1116 rs.drag_end();
1117 }
1118
1119 #[test]
1120 fn probe_slider_bridge() {
1121
1122
1123 let mut sl = Slider::new().with_label("Slider");
1124 WidgetHost::set_rect(&mut sl, 20.0, 220.0, 200.0, 40.0);
1125 eprintln!("rect = {:?}", WidgetHost::rect(&sl));
1126 eprintln!("quads = {:?}", crate::widget::shown_quads(&sl));
1127 eprintln!("rounded = {:?}", crate::widget::shown_rounded_quads(&sl));
1128 eprintln!("labels = {:?}", sl.own_text_labels().iter().map(|l| l.text.clone()).collect::<Vec<_>>());
1129 }
1130
1131 /// Slider press-on-track begins a drag through the routed path; wheel adjusts the value
1132 /// with the scroll-gesture gating intact.
1133 #[test]
1134 fn slider_press_drag_and_wheel() {
1135 let mut ctx = UiContext::new();
1136 let sl = ctx.insert(Slider::new().with_value(0.5));
1137 let id = sl.id();
1138 WidgetHost::set_rect(&mut ctx[sl], 0.0, 0.0, 100.0, 20.0);
1139
1140 // Press on the track grabs the thumb.
1141 assert!(ctx.propagate_event(
1142 &Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: 50.0, y: 10.0, local_x: 50.0, local_y: 10.0 },
1143 id,
1144 ));
1145 assert!(ctx[sl].is_dragging());
1146 assert!(ctx[sl].drag_update(80.0, 10.0));
1147 assert!(ctx[sl].inner().value() > 0.5);
1148 ctx[sl].drag_end();
1149
1150 // Wheel adjusts value when the gesture starts fresh.
1151 ctx.scroll_gesture_new = true;
1152 let before = ctx[sl].inner().value();
1153 assert!(ctx.lend_h(sl, |w, ctx| w.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 50.0, 10.0, ctx)).unwrap());
1154 assert!(ctx[sl].inner().value() > before, "a wheel notch up is more");
1155 assert!(ctx[sl].take_change());
1156 }
1157
1158 /// A notch steps 2% of the range up to a 20-wide one, exactly as it
1159 /// always did; a wider range steps by the value's magnitude — as a
1160 /// 20-wide slider near zero, by the full 2% far from it — for the wheel
1161 /// and the arrow keys alike.
1162 #[test]
1163 fn a_wide_range_steps_by_the_values_magnitude() {
1164 let notch = |min: f32, max: f32, at: f32| -> f32 {
1165 let mut ctx = UiContext::new();
1166 let sl = ctx.insert(Slider::new().with_range(min, max));
1167 WidgetHost::set_rect(&mut ctx[sl], 0.0, 0.0, 200.0, 20.0);
1168 ctx[sl].inner_mut().set_scaled_value(at);
1169 let before = ctx[sl].inner().get_scaled_value();
1170 ctx.scroll_gesture_new = true;
1171 // Over the band at the value, where the halo is.
1172 let x = 200.0 * ctx[sl].inner().value();
1173 assert!(ctx.lend_h(sl, |w, ctx| w.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), x.clamp(1.0, 199.0), 10.0, ctx)).unwrap());
1174 ctx[sl].inner().get_scaled_value() - before
1175 };
1176 let close = |got: f32, want: f32| (got - want).abs() <= want * 0.01 + 1e-3;
1177 // Ordinary ranges: 2% of the range, untouched.
1178 assert!(close(notch(0.0, 2.0, 1.0), 0.04), "{}", notch(0.0, 2.0, 1.0));
1179 assert!(close(notch(-10.0, 10.0, 0.0), 0.4), "{}", notch(-10.0, 10.0, 0.0));
1180 assert!(close(notch(-10.0, 10.0, 8.0), 0.4));
1181 // -1000..1000: near zero a notch is what a 20-wide slider's is…
1182 assert!(close(notch(-1000.0, 1000.0, 0.0), 0.4), "{}", notch(-1000.0, 1000.0, 0.0));
1183 assert!(close(notch(-1000.0, 1000.0, 0.5), 0.4));
1184 // …it grows with the magnitude, on either side of zero…
1185 assert!(close(notch(-1000.0, 1000.0, 10.0), 4.0), "{}", notch(-1000.0, 1000.0, 10.0));
1186 assert!(close(notch(-1000.0, 1000.0, -10.0), 4.0));
1187 // …and is capped at the 2% of the range it used to be everywhere.
1188 assert!(close(notch(-1000.0, 1000.0, 500.0), 40.0), "{}", notch(-1000.0, 1000.0, 500.0));
1189
1190 // End to end it is still a few dozen notches, not thousands.
1191 let mut sl = Slider::new().with_range(-1000.0, 1000.0);
1192 sl.inner_mut().set_scaled_value(0.0);
1193 let mut notches = 0;
1194 while sl.inner().get_scaled_value() < 999.0 && notches < 1000 {
1195 let step = sl.inner().notch_step();
1196 let v = (sl.inner().value() + step).clamp(0.0, 1.0);
1197 sl.inner_mut().set_value(v);
1198 notches += 1;
1199 }
1200 assert!(notches < 60, "0 to 1000 took {notches} notches");
1201 }
1202
1203 /// A slider hovers like every other control: the adapter's hover
1204 /// bookkeeping turns a move over the row into `MouseEnter`, a move away
1205 /// into `MouseLeave`, and the band reads the flag. A float3's three rows
1206 /// each hover on their own, since the group forwards the move to them.
1207 #[test]
1208 fn a_slider_hovers_under_the_pointer() {
1209 let mut ctx = UiContext::new();
1210 let sl = ctx.insert(Slider::new());
1211 WidgetHost::set_rect(&mut ctx[sl], 0.0, 0.0, 100.0, 20.0);
1212 assert!(!ctx[sl].inner().hovered());
1213 assert!(ctx.lend_h(sl, |w, ctx| w.on_cursor_moved(50.0, 10.0, ctx)).unwrap(), "entering is a change");
1214 assert!(ctx[sl].inner().hovered());
1215 assert!(!ctx.lend_h(sl, |w, ctx| w.on_cursor_moved(60.0, 10.0, ctx)).unwrap(), "moving within is not");
1216 assert!(ctx.lend_h(sl, |w, ctx| w.on_cursor_moved(500.0, 10.0, ctx)).unwrap(), "leaving is");
1217 assert!(!ctx[sl].inner().hovered());
1218
1219 let mut f = crate::widget::display::Float3::new();
1220 WidgetHost::set_rect(&mut f, 0.0, 0.0, 200.0, crate::widget::display::Float3::preferred_height(false));
1221 let rows = f.inner().get_row_rects();
1222 let (_, y1, _, h1) = rows[1];
1223 assert!(f.on_cursor_moved(100.0, y1 + h1 * 0.5, &mut ctx));
1224 let hovered: Vec<bool> = f.inner().sliders().iter().map(|s| s.inner().hovered()).collect();
1225 assert_eq!(hovered, vec![false, true, false], "the row under the pointer, and only it");
1226 }
1227 }
1228
1229 #[cfg(test)]
1230 mod focus_tests {
1231 use super::*;
1232 use crate::widget::{Event, KeyEvent, UiContext, WidgetHost};
1233
1234 fn press(key: NamedKey) -> Event {
1235 Event::KeyInput(KeyEvent { logical_key: Key::Named(key), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
1236 }
1237
1238 /// A focused range: Right steps the low end, Down switches to the high end,
1239 /// Left steps it, End sends it to 1, and the low end can never pass the high.
1240 #[test]
1241 fn range_arrows_step_the_focused_end_and_up_down_switch() {
1242 let mut ctx = UiContext::new();
1243 let mut r = RangeSlider::new().with_values(0.2, 0.8);
1244 WidgetHost::set_rect(&mut r, 0.0, 0.0, 200.0, 16.0);
1245 assert!(!r.handle_event(&press(NamedKey::ArrowRight), &mut ctx), "unfocused: not this range's key");
1246 r.handle_event(&Event::FocusIn, &mut ctx);
1247 assert!(r.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
1248 let (lo, hi) = r.inner().values();
1249 assert!((lo - 0.22).abs() < 1e-5 && (hi - 0.8).abs() < 1e-5, "the low end moved");
1250 assert!(r.handle_event(&press(NamedKey::ArrowDown), &mut ctx));
1251 assert!(r.handle_event(&press(NamedKey::ArrowLeft), &mut ctx));
1252 let (lo, hi) = r.inner().values();
1253 assert!((lo - 0.22).abs() < 1e-5 && (hi - 0.78).abs() < 1e-5, "then the high end");
1254 assert!(r.handle_event(&press(NamedKey::End), &mut ctx));
1255 assert_eq!(r.inner().values().1, 1.0);
1256 assert!(r.handle_event(&press(NamedKey::ArrowUp), &mut ctx));
1257 assert!(r.handle_event(&press(NamedKey::End), &mut ctx));
1258 assert_eq!(r.inner().values(), (1.0, 1.0), "the low end stops at the high end");
1259 }
1260
1261 /// A focused band steps by a wheel notch on the arrows, jumps on Home / End,
1262 /// and opens its readout on Enter; unfocused it ignores the keys.
1263 /// The arrows step a wide range by the wheel's own notch.
1264 #[test]
1265 fn arrows_step_a_wide_range_by_the_values_magnitude() {
1266 let mut ctx = UiContext::new();
1267 let mut s = Slider::new().with_range(-1000.0, 1000.0).with_readout(true);
1268 WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 16.0);
1269 s.inner_mut().set_scaled_value(0.0);
1270 s.handle_event(&Event::FocusIn, &mut ctx);
1271 assert!(s.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
1272 assert!((s.inner().get_scaled_value() - 0.4).abs() < 1e-2, "{}", s.inner().get_scaled_value());
1273 }
1274
1275 #[test]
1276 fn arrows_step_the_band_and_enter_opens_the_readout() {
1277 let mut ctx = UiContext::new();
1278 let mut s = Slider::new().with_readout(true);
1279 WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 16.0);
1280 let v0 = s.inner().value;
1281 assert!(!s.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
1282 assert_eq!(s.inner().value, v0, "unfocused: untouched");
1283 s.handle_event(&Event::FocusIn, &mut ctx);
1284 assert!(s.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
1285 assert!((s.inner().value - (v0 + 0.02)).abs() < 1e-5);
1286 assert!(s.handle_event(&press(NamedKey::End), &mut ctx));
1287 assert_eq!(s.inner().value, 1.0);
1288 assert!(s.handle_event(&press(NamedKey::Home), &mut ctx));
1289 assert_eq!(s.inner().value, 0.0);
1290 assert!(s.handle_event(&press(NamedKey::Enter), &mut ctx));
1291 assert!(s.inner().editing, "Enter opens the readout for typing");
1292 }
1293 }