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

src/widget/input/slider.rs (42K)

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