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 }