use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role, Theme};
use denise_render::Canvas;
use crate::widget::{Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::style::{focus_ring, interactive_pair};
#[derive(Clone, Debug)]
pub struct Slider<M> {
min: f32,
max: f32,
value: f32,
step: Option<f32>,
dragging: bool,
message: Option<fn(f32) -> M>,
role: Role,
}
impl<M> Slider<M> {
pub fn new(min: f32, max: f32, value: f32, message: fn(f32) -> M) -> Self {
let (min, max) = order(min, max);
Self {
min,
max,
value: clamp(min, max, value),
step: None,
dragging: false,
message: Some(message),
role: Role::Primary,
}
}
pub fn inert(min: f32, max: f32, value: f32) -> Self {
let (min, max) = order(min, max);
Self {
min,
max,
value: clamp(min, max, value),
step: None,
dragging: false,
message: None,
role: Role::Primary,
}
}
pub fn with_step(mut self, step: f32) -> Self {
self.step = (step.is_finite() && step > 0.0).then_some(step);
self.value = self.settle(self.value);
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
#[inline]
pub const fn value(&self) -> f32 {
self.value
}
#[inline]
pub const fn range(&self) -> (f32, f32) {
(self.min, self.max)
}
pub fn set_value(&mut self, value: f32) {
self.value = self.settle(value);
}
pub fn update(&mut self, value: f32) -> bool {
let value = self.settle(value);
let changed = value != self.value;
self.value = value;
changed
}
pub fn set_range(&mut self, min: f32, max: f32) {
let (min, max) = order(min, max);
self.min = min;
self.max = max;
self.value = self.settle(self.value);
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
#[inline]
pub const fn dragging(&self) -> bool {
self.dragging
}
#[inline]
fn span(&self) -> f32 {
self.max - self.min
}
fn settle(&self, value: f32) -> f32 {
let value = clamp(self.min, self.max, value);
let Some(step) = self.step else {
return value;
};
let steps = ((value - self.min) / step + 0.5) as i32;
clamp(self.min, self.max, self.min + steps as f32 * step)
}
fn fraction(&self) -> f32 {
let span = self.span();
if span <= 0.0 {
return 0.0;
}
((self.value - self.min) / span).clamp(0.0, 1.0)
}
fn small_step(&self) -> f32 {
self.step.unwrap_or_else(|| self.span() / 100.0)
}
fn large_step(&self) -> f32 {
self.small_step() * 10.0
}
fn commit(&mut self, value: f32, ctx: &mut EventCtx<'_, M>) -> Handled {
let value = self.settle(value);
if value != self.value {
self.value = value;
if let Some(message) = self.message {
ctx.emit(message(value));
}
}
Handled::Yes
}
}
#[inline]
fn order(a: f32, b: f32) -> (f32, f32) {
if b < a { (b, a) } else { (a, b) }
}
#[inline]
fn clamp(min: f32, max: f32, value: f32) -> f32 {
if value.is_nan() {
min
} else {
value.clamp(min, max)
}
}
fn knob_size(bounds: Rect, theme: &Theme) -> i32 {
theme
.metrics
.size_selector
.min(bounds.height)
.min(bounds.width)
.max(1)
}
fn travel(bounds: Rect, diameter: i32) -> (i32, i32) {
let radius = diameter / 2;
let left = bounds.x + radius;
let right = bounds.right() - diameter + radius;
(left, right.max(left))
}
fn fraction_at(bounds: Rect, diameter: i32, x: i32) -> f32 {
let (left, right) = travel(bounds, diameter);
if right <= left {
return 0.0;
}
((x - left) as f32 / (right - left) as f32).clamp(0.0, 1.0)
}
impl<M: 'static> Widget<M> for Slider<M> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let diameter = knob_size(bounds, ctx.theme);
let (left, right) = travel(bounds, diameter);
let centre = left + (((right - left) as f32) * self.fraction()) as i32;
let state = ctx.state.set(VisualState::PRESSED, self.dragging);
let thickness = (diameter / 4).max(2);
let track = Rect::new(
bounds.x,
bounds.y + (bounds.height - thickness) / 2,
bounds.width,
thickness,
);
let radius = thickness / 2;
let (unfilled, _) = interactive_pair(ctx.theme, Role::Base300, state);
canvas.fill_rounded_rect(track, radius, unfilled);
let (fill, rim) = interactive_pair(ctx.theme, self.role, state);
let filled_to = centre - track.x;
if filled_to > 0 {
let filled = Rect::new(track.x, track.y, filled_to, track.height);
canvas.fill_rounded_rect(filled, radius.min(filled_to / 2), fill);
}
let knob = Rect::new(
centre - diameter / 2,
bounds.y + (bounds.height - diameter) / 2,
diameter,
diameter,
);
canvas.fill_rounded_rect(knob, diameter / 2, fill);
canvas.stroke_rounded_rect(knob, diameter / 2, ctx.theme.metrics.border, rim);
if state.contains(VisualState::FOCUSED) {
focus_ring(
ctx.theme,
bounds,
ctx.theme.radius(denise::Radius::Field),
canvas,
);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
let bounds = ctx.bounds;
let diameter = knob_size(bounds, ctx.theme);
let (min, span) = (self.min, self.span());
let at = move |point: Point| min + fraction_at(bounds, diameter, point.x) * span;
match event {
Event::Input(InputEvent::PointerButton {
state: ElementState::Down,
position,
..
})
| Event::Input(InputEvent::TouchDown { position, .. }) => {
if !bounds.contains(*position) {
return Handled::No;
}
self.dragging = true;
let value = at(*position);
self.commit(value, ctx)
}
Event::Input(InputEvent::PointerMoved { position })
| Event::Input(InputEvent::TouchMoved { position, .. }) => {
if !self.dragging {
return Handled::No;
}
let value = at(*position);
self.commit(value, ctx)
}
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
..
})
| Event::Input(InputEvent::TouchUp { .. }) => {
if !self.dragging {
return Handled::No;
}
self.dragging = false;
Handled::Yes
}
Event::Input(InputEvent::Key {
code,
state: ElementState::Down,
..
}) if ctx.state.contains(VisualState::FOCUSED) => {
let value = match code {
KeyCode::ArrowLeft | KeyCode::ArrowDown => self.value - self.small_step(),
KeyCode::ArrowRight | KeyCode::ArrowUp => self.value + self.small_step(),
KeyCode::PageDown => self.value - self.large_step(),
KeyCode::PageUp => self.value + self.large_step(),
KeyCode::Home => self.min,
KeyCode::End => self.max,
_ => return Handled::No,
};
self.commit(value, ctx)
}
_ => Handled::No,
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::theme;
fn slider() -> Slider<f32> {
Slider::new(0.0, 100.0, 50.0, |value| value)
}
#[test]
fn a_reversed_range_is_put_the_right_way_round() {
let slider: Slider<f32> = Slider::new(30.0, 16.0, 21.0, |v| v);
assert_eq!(slider.range(), (16.0, 30.0));
assert_eq!(slider.value(), 21.0);
}
#[test]
fn an_empty_range_pins_the_value_and_does_not_divide_by_zero() {
let mut slider: Slider<f32> = Slider::new(5.0, 5.0, 5.0, |v| v);
assert_eq!(slider.fraction(), 0.0);
slider.set_value(99.0);
assert_eq!(slider.value(), 5.0);
assert!(slider.small_step().is_finite() || slider.small_step() == 0.0);
}
#[test]
fn a_value_that_is_not_a_number_lands_at_the_low_end() {
let done = core::hint::black_box(0.0f32);
let total = core::hint::black_box(0.0f32);
let mut slider = slider();
slider.set_value(done / total);
assert_eq!(slider.value(), 0.0);
assert_eq!(slider.fraction(), 0.0);
}
#[test]
fn values_outside_the_range_are_clamped() {
let mut slider = slider();
slider.set_value(1e9);
assert_eq!(slider.value(), 100.0);
slider.set_value(-1e9);
assert_eq!(slider.value(), 0.0);
slider.set_value(f32::NEG_INFINITY);
assert_eq!(slider.value(), 0.0);
}
#[test]
fn a_step_snaps_to_multiples_from_the_low_end() {
let mut slider: Slider<f32> = Slider::new(16.0, 30.0, 21.0, |v| v).with_step(0.5);
slider.set_value(21.3);
assert_eq!(slider.value(), 21.5);
slider.set_value(21.2);
assert_eq!(slider.value(), 21.0);
slider.set_value(29.9);
assert!(slider.value() <= 30.0);
slider.set_value(16.1);
assert!(slider.value() >= 16.0);
}
#[test]
fn a_nonsense_step_is_ignored() {
for step in [0.0, -1.0, f32::NAN, f32::INFINITY] {
let slider: Slider<f32> = Slider::new(0.0, 10.0, 3.3, |v| v).with_step(step);
assert_eq!(slider.value(), 3.3, "step {step} should have been ignored");
}
}
#[test]
fn the_knob_stays_inside_the_rectangle_across_the_whole_travel() {
for width in [1, 2, 21, 200, 1920] {
let bounds = Rect::new(7, 3, width, 40);
let diameter = knob_size(bounds, &theme::DARK);
let (left, right) = travel(bounds, diameter);
assert!(right >= left, "width {width}: travel is inverted");
for centre in [left, (left + right) / 2, right] {
let knob = Rect::new(centre - diameter / 2, bounds.y, diameter, diameter);
assert!(
knob.x >= bounds.x && knob.right() <= bounds.right(),
"width {width}: knob at {centre} escaped {bounds:?}"
);
}
}
}
#[test]
fn a_pointer_past_either_end_clamps_rather_than_wrapping() {
let bounds = Rect::new(10, 0, 200, 40);
let diameter = knob_size(bounds, &theme::DARK);
assert_eq!(fraction_at(bounds, diameter, -100_000), 0.0);
assert_eq!(fraction_at(bounds, diameter, 100_000), 1.0);
assert_eq!(fraction_at(bounds, diameter, bounds.x - 1), 0.0);
assert_eq!(fraction_at(bounds, diameter, bounds.right() + 1), 1.0);
let mut previous = -1.0;
for x in bounds.x..bounds.right() {
let fraction = fraction_at(bounds, diameter, x);
assert!(fraction >= previous, "went backwards at x {x}");
previous = fraction;
}
}
#[test]
fn a_rectangle_with_no_travel_answers_zero() {
let bounds = Rect::new(0, 0, 4, 40);
let diameter = knob_size(bounds, &theme::DARK);
assert_eq!(fraction_at(bounds, diameter, 2), 0.0);
assert_eq!(fraction_at(bounds, diameter, 1000), 0.0);
}
#[test]
fn the_keyboard_steps_are_a_hundredth_and_a_tenth_of_the_range() {
let slider = slider();
assert_eq!(slider.small_step(), 1.0);
assert_eq!(slider.large_step(), 10.0);
let stepped: Slider<f32> = Slider::new(0.0, 10.0, 0.0, |v| v).with_step(0.25);
assert_eq!(stepped.small_step(), 0.25);
assert_eq!(stepped.large_step(), 2.5);
}
#[test]
fn the_knob_rim_is_visible_against_the_knob_in_every_theme_and_state() {
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for state in [
VisualState::NONE,
VisualState::HOVERED,
VisualState::PRESSED,
VisualState::DISABLED,
VisualState::FOCUSED,
] {
let (fill, rim) = interactive_pair(&theme, Role::Primary, state);
let ratio = contrast_x100(fill, rim);
assert!(
ratio >= AA_LARGE,
"{} {state:?}: rim against knob is {ratio}, floor is {AA_LARGE}",
theme.name
);
}
}
}
}