use std::panic::Location;
use crate::cursor::Cursor;
use crate::event::{KeyModifiers, UiEvent, UiEventKind, UiKey};
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
#[derive(Clone, Copy, Debug)]
pub struct ScrubberOpts {
pub min: Option<f64>,
pub max: Option<f64>,
pub step: f64,
pub sensitivity: f64,
pub decimals: Option<u8>,
}
impl Default for ScrubberOpts {
fn default() -> Self {
Self {
min: None,
max: None,
step: 1.0,
sensitivity: 4.0,
decimals: None,
}
}
}
impl ScrubberOpts {
pub fn min(mut self, v: f64) -> Self {
self.min = Some(v);
self
}
pub fn max(mut self, v: f64) -> Self {
self.max = Some(v);
self
}
pub fn step(mut self, v: f64) -> Self {
self.step = v;
self
}
pub fn sensitivity(mut self, v: f64) -> Self {
self.sensitivity = v;
self
}
pub fn decimals(mut self, v: u8) -> Self {
self.decimals = Some(v);
self
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ScrubDrag {
pub anchor_x: Option<f32>,
pub initial: f64,
}
pub const MIN_WIDTH: f32 = 64.0;
#[track_caller]
pub fn number_scrubber(value: &str, key: &str) -> El {
El::new(Kind::Custom("number-scrubber"))
.at_loc(Location::caller())
.key(key.to_string())
.style_profile(StyleProfile::Solid)
.focusable()
.text(value)
.text_align(TextAlign::Center)
.text_role(TextRole::Label)
.text_color(tokens::FOREGROUND)
.fill(tokens::INPUT)
.stroke(tokens::BORDER)
.default_radius(tokens::RADIUS_MD)
.default_width(Size::Fixed(MIN_WIDTH))
.default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
.default_padding(Sides::xy(tokens::SPACE_3, 0.0))
.cursor(Cursor::EwResize)
.cursor_pressed(Cursor::EwResize)
.consumes_touch_drag()
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
}
pub fn apply_event(
value: &mut String,
drag: &mut ScrubDrag,
key: &str,
opts: &ScrubberOpts,
event: &UiEvent,
) -> bool {
if event.route() != Some(key) {
return false;
}
match event.kind {
UiEventKind::PointerDown => {
if let Some((px, _)) = event.pointer {
drag.anchor_x = Some(px);
drag.initial = parse_or_default(value, opts);
}
false
}
UiEventKind::Drag => {
let Some(anchor) = drag.anchor_x else {
return false;
};
let Some((px, _)) = event.pointer else {
return false;
};
let sens = opts.sensitivity.max(f32::EPSILON as f64);
let scale = step_scale(event.modifiers);
let delta = ((px - anchor) as f64) / sens * opts.step * scale;
let next = clamp_opt(drag.initial + delta, opts.min, opts.max);
let formatted = format_numeric(next, opts.decimals);
if formatted != *value {
*value = formatted;
true
} else {
false
}
}
UiEventKind::PointerUp => {
drag.anchor_x = None;
false
}
UiEventKind::KeyDown => {
let Some(kp) = event.key_press.as_ref() else {
return false;
};
let dir = match kp.key {
UiKey::ArrowRight | UiKey::ArrowUp => 1,
UiKey::ArrowLeft | UiKey::ArrowDown => -1,
_ => return false,
};
let parsed = parse_or_default(value, opts);
let stepped = parsed + (dir as f64) * opts.step * step_scale(kp.modifiers);
let next = clamp_opt(stepped, opts.min, opts.max);
let formatted = format_numeric(next, opts.decimals);
if formatted != *value {
*value = formatted;
true
} else {
false
}
}
_ => false,
}
}
fn parse_or_default(value: &str, opts: &ScrubberOpts) -> f64 {
value
.parse::<f64>()
.ok()
.unwrap_or_else(|| opts.min.unwrap_or(0.0))
}
fn step_scale(mods: KeyModifiers) -> f64 {
if mods.shift {
10.0
} else if mods.alt {
0.1
} else {
1.0
}
}
fn clamp_opt(n: f64, min: Option<f64>, max: Option<f64>) -> f64 {
let n = if let Some(hi) = max { n.min(hi) } else { n };
if let Some(lo) = min { n.max(lo) } else { n }
}
fn format_numeric(n: f64, decimals: Option<u8>) -> String {
match decimals {
Some(d) => format!("{:.*}", d as usize, n),
None if n.fract() == 0.0 && n.is_finite() && n.abs() < 1e18 => {
format!("{}", n as i64)
}
None => format!("{n}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyModifiers, KeyPress, UiTarget};
use crate::tree::Rect;
fn pointer_event(key: &str, kind: UiEventKind, x: f32, mods: KeyModifiers) -> UiEvent {
UiEvent {
path: None,
key: Some(key.to_string()),
target: Some(UiTarget {
key: key.to_string(),
node_id: format!("/{key}"),
rect: Rect::new(0.0, 0.0, MIN_WIDTH, tokens::CONTROL_HEIGHT),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: Some((x, tokens::CONTROL_HEIGHT * 0.5)),
key_press: None,
text: None,
selection: None,
modifiers: mods,
click_count: 0,
pointer_kind: None,
kind,
}
}
fn key_event(key: &str, ui_key: UiKey, mods: KeyModifiers) -> UiEvent {
UiEvent {
path: None,
key: Some(key.to_string()),
target: Some(UiTarget {
key: key.to_string(),
node_id: format!("/{key}"),
rect: Rect::new(0.0, 0.0, MIN_WIDTH, tokens::CONTROL_HEIGHT),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: None,
key_press: Some(KeyPress {
key: ui_key,
modifiers: mods,
repeat: false,
}),
text: None,
selection: None,
modifiers: mods,
click_count: 0,
pointer_kind: None,
kind: UiEventKind::KeyDown,
}
}
#[test]
fn pointer_drag_increments_by_pixel_ratio() {
let mut value = String::from("10");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default().sensitivity(4.0).step(1.0);
let down = pointer_event(
"n",
UiEventKind::PointerDown,
100.0,
KeyModifiers::default(),
);
assert!(!apply_event(&mut value, &mut drag, "n", &opts, &down));
assert_eq!(drag.anchor_x, Some(100.0));
assert_eq!(drag.initial, 10.0);
let drag_ev = pointer_event("n", UiEventKind::Drag, 116.0, KeyModifiers::default());
assert!(apply_event(&mut value, &mut drag, "n", &opts, &drag_ev));
assert_eq!(value, "14");
}
#[test]
fn pointer_drag_left_decrements() {
let mut value = String::from("10");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default().sensitivity(4.0).step(1.0);
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event(
"n",
UiEventKind::PointerDown,
100.0,
KeyModifiers::default(),
),
);
assert!(apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, 88.0, KeyModifiers::default()),
));
assert_eq!(value, "7");
}
#[test]
fn drag_recomputes_from_anchor_not_previous_event() {
let mut value = String::from("0");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default().sensitivity(4.0).step(1.0);
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::PointerDown, 50.0, KeyModifiers::default()),
);
for _ in 0..5 {
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, 70.0, KeyModifiers::default()),
);
}
assert_eq!(value, "5", "anchor-relative drag must not accumulate");
}
#[test]
fn pointer_up_clears_anchor() {
let mut value = String::from("3");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default();
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::PointerDown, 10.0, KeyModifiers::default()),
);
assert!(drag.anchor_x.is_some());
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::PointerUp, 30.0, KeyModifiers::default()),
);
assert!(drag.anchor_x.is_none());
assert!(!apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, 60.0, KeyModifiers::default()),
));
assert_eq!(value, "3");
}
#[test]
fn shift_drag_scales_step_by_ten() {
let mut value = String::from("0");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default().sensitivity(4.0).step(1.0);
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::PointerDown, 0.0, KeyModifiers::default()),
);
let shift = KeyModifiers {
shift: true,
..KeyModifiers::default()
};
assert!(apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, 8.0, shift),
));
assert_eq!(value, "20");
}
#[test]
fn alt_drag_scales_step_by_one_tenth() {
let mut value = String::from("0");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default()
.sensitivity(4.0)
.step(1.0)
.decimals(1);
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::PointerDown, 0.0, KeyModifiers::default()),
);
let alt = KeyModifiers {
alt: true,
..KeyModifiers::default()
};
assert!(apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, 40.0, alt),
));
assert_eq!(value, "1.0");
}
#[test]
fn drag_clamps_to_min_and_max() {
let mut value = String::from("50");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default()
.sensitivity(1.0)
.step(1.0)
.min(0.0)
.max(100.0);
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::PointerDown, 0.0, KeyModifiers::default()),
);
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, 9999.0, KeyModifiers::default()),
);
assert_eq!(value, "100");
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, -9999.0, KeyModifiers::default()),
);
assert_eq!(value, "0");
}
#[test]
fn arrow_keys_step_when_focused() {
let mut value = String::from("3");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default().step(2.0);
assert!(apply_event(
&mut value,
&mut drag,
"n",
&opts,
&key_event("n", UiKey::ArrowRight, KeyModifiers::default()),
));
assert_eq!(value, "5");
assert!(apply_event(
&mut value,
&mut drag,
"n",
&opts,
&key_event("n", UiKey::ArrowDown, KeyModifiers::default()),
));
assert_eq!(value, "3");
}
#[test]
fn arrow_keys_honor_shift_and_alt() {
let mut value = String::from("0");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default().step(1.0);
let shift = KeyModifiers {
shift: true,
..KeyModifiers::default()
};
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&key_event("n", UiKey::ArrowUp, shift),
);
assert_eq!(value, "10");
value = "0".into();
let opts = ScrubberOpts::default().step(1.0).decimals(1);
let alt = KeyModifiers {
alt: true,
..KeyModifiers::default()
};
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&key_event("n", UiKey::ArrowUp, alt),
);
assert_eq!(value, "0.1");
}
#[test]
fn events_routed_elsewhere_are_ignored() {
let mut value = String::from("3");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default();
assert!(!apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event(
"other",
UiEventKind::PointerDown,
10.0,
KeyModifiers::default()
),
));
assert!(drag.anchor_x.is_none());
assert!(!apply_event(
&mut value,
&mut drag,
"n",
&opts,
&key_event("other", UiKey::ArrowUp, KeyModifiers::default()),
));
assert_eq!(value, "3");
}
#[test]
fn unparseable_value_starts_drag_at_min_or_zero() {
let mut value = String::from("abc");
let mut drag = ScrubDrag::default();
let opts = ScrubberOpts::default().min(7.0).sensitivity(1.0);
apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::PointerDown, 0.0, KeyModifiers::default()),
);
assert_eq!(drag.initial, 7.0);
assert!(apply_event(
&mut value,
&mut drag,
"n",
&opts,
&pointer_event("n", UiEventKind::Drag, 3.0, KeyModifiers::default()),
));
assert_eq!(value, "10");
}
#[test]
fn build_widget_sets_key_and_is_focusable() {
let el = number_scrubber("42", "gain");
assert_eq!(el.key.as_deref(), Some("gain"));
assert!(el.focusable);
assert_eq!(el.cursor, Some(Cursor::EwResize));
assert_eq!(el.cursor_pressed, Some(Cursor::EwResize));
}
}