git.lucas.co / cce-ui
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 }