use std::panic::Location;
use crate::cursor::Cursor;
use crate::event::{UiEvent, UiEventKind, UiKey};
use crate::layout::LayoutCtx;
use crate::metrics::MetricsRole;
use crate::tokens;
use crate::tree::*;
pub const TRACK_HEIGHT: f32 = 10.0;
pub const THUMB_SIZE: f32 = 14.0;
pub const DEFAULT_HEIGHT: f32 = 18.0;
#[track_caller]
pub fn slider(value: f32, fill_color: Color) -> El {
let value = value.clamp(0.0, 1.0);
let layout = move |ctx: LayoutCtx| {
let rect = ctx.container;
let usable = (rect.w - THUMB_SIZE).max(1.0);
let track_x = rect.x + THUMB_SIZE * 0.5;
let track_y = rect.y + (rect.h - TRACK_HEIGHT) * 0.5;
let thumb_x = rect.x + value * usable;
let thumb_y = rect.y + (rect.h - THUMB_SIZE) * 0.5;
vec![
Rect::new(track_x, track_y, usable, TRACK_HEIGHT),
Rect::new(track_x, track_y, value * usable, TRACK_HEIGHT),
Rect::new(thumb_x, thumb_y, THUMB_SIZE, THUMB_SIZE),
]
};
stack([
El::new(Kind::Custom("slider-track"))
.height(Size::Fixed(TRACK_HEIGHT))
.width(Size::Fill(1.0))
.fill(tokens::MUTED)
.radius(tokens::RADIUS_PILL),
El::new(Kind::Custom("slider-fill"))
.height(Size::Fixed(TRACK_HEIGHT))
.width(Size::Fill(1.0))
.fill(fill_color)
.radius(tokens::RADIUS_PILL),
El::new(Kind::Custom("slider-thumb"))
.width(Size::Fixed(THUMB_SIZE))
.height(Size::Fixed(THUMB_SIZE))
.fill(tokens::FOREGROUND)
.stroke(tokens::BORDER)
.radius(tokens::RADIUS_PILL)
.state_follows_interactive_ancestor(),
])
.at_loc(Location::caller())
.metrics_role(MetricsRole::Slider)
.focusable()
.cursor(Cursor::Grab)
.cursor_pressed(Cursor::Grabbing)
.layout(layout)
.default_height(Size::Fixed(DEFAULT_HEIGHT))
.paint_overflow(Sides::all(tokens::RING_WIDTH))
.hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
.width(Size::Fill(1.0))
}
pub fn normalized_from_event(rect: Rect, x: f32) -> f32 {
let usable = (rect.w - THUMB_SIZE).max(1.0);
let local = x - rect.x - THUMB_SIZE * 0.5;
(local / usable).clamp(0.0, 1.0)
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum SliderAction {
Step(f32),
Set(f32),
}
pub fn classify_event(
event: &UiEvent,
key: &str,
step: f32,
page_step: f32,
) -> Option<SliderAction> {
if event.kind != UiEventKind::KeyDown || event.route() != Some(key) {
return None;
}
let press = event.key_press.as_ref()?;
Some(match press.key {
UiKey::ArrowUp | UiKey::ArrowRight => SliderAction::Step(step),
UiKey::ArrowDown | UiKey::ArrowLeft => SliderAction::Step(-step),
UiKey::PageUp => SliderAction::Step(page_step),
UiKey::PageDown => SliderAction::Step(-page_step),
UiKey::Home => SliderAction::Set(0.0),
UiKey::End => SliderAction::Set(1.0),
_ => return None,
})
}
pub fn apply_event(value: &mut f32, event: &UiEvent, key: &str, step: f32, page_step: f32) -> bool {
let Some(action) = classify_event(event, key, step, page_step) else {
return false;
};
let prev = *value;
let next = match action {
SliderAction::Step(d) => *value + d,
SliderAction::Set(v) => v,
};
*value = next.clamp(0.0, 1.0);
*value != prev
}
pub fn apply_input(value: &mut f32, event: &UiEvent, key: &str, step: f32, page_step: f32) -> bool {
let pointer_kind = matches!(
event.kind,
UiEventKind::Click | UiEventKind::PointerDown | UiEventKind::Drag,
);
if pointer_kind
&& event.route() == Some(key)
&& let (Some(rect), Some(x)) = (event.target_rect(), event.pointer_x())
{
let prev = *value;
*value = normalized_from_event(rect, x);
return *value != prev;
}
apply_event(value, event, key, step, page_step)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{KeyModifiers, KeyPress, UiTarget};
fn key_event(key: &str, ui_key: UiKey) -> 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, 100.0, 20.0),
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: None,
key_press: Some(KeyPress {
key: ui_key,
modifiers: KeyModifiers::default(),
repeat: false,
}),
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind: UiEventKind::KeyDown,
}
}
#[test]
fn apply_event_steps_and_clamps() {
let mut value = 0.5;
assert!(apply_event(
&mut value,
&key_event("vol", UiKey::ArrowUp),
"vol",
0.1,
0.25
));
assert!((value - 0.6).abs() < 1e-6);
assert!(apply_event(
&mut value,
&key_event("vol", UiKey::ArrowDown),
"vol",
0.1,
0.25
));
assert!((value - 0.5).abs() < 1e-6);
assert!(apply_event(
&mut value,
&key_event("vol", UiKey::PageUp),
"vol",
0.1,
0.25
));
assert!((value - 0.75).abs() < 1e-6);
assert!(apply_event(
&mut value,
&key_event("vol", UiKey::Home),
"vol",
0.1,
0.25
));
assert_eq!(value, 0.0);
assert!(apply_event(
&mut value,
&key_event("vol", UiKey::End),
"vol",
0.1,
0.25
));
assert_eq!(value, 1.0);
assert!(!apply_event(
&mut value,
&key_event("vol", UiKey::ArrowUp),
"vol",
0.1,
0.25
));
assert_eq!(value, 1.0);
}
#[test]
fn apply_event_ignores_unrouted_or_unrelated_keys() {
let mut value = 0.5;
assert!(!apply_event(
&mut value,
&key_event("other", UiKey::ArrowUp),
"vol",
0.1,
0.25
));
assert_eq!(value, 0.5);
assert!(!apply_event(
&mut value,
&key_event("vol", UiKey::Tab),
"vol",
0.1,
0.25
));
assert_eq!(value, 0.5);
}
#[test]
fn classify_left_right_mirrors_up_down() {
assert_eq!(
classify_event(&key_event("k", UiKey::ArrowRight), "k", 0.1, 0.25),
Some(SliderAction::Step(0.1)),
);
assert_eq!(
classify_event(&key_event("k", UiKey::ArrowLeft), "k", 0.1, 0.25),
Some(SliderAction::Step(-0.1)),
);
}
#[test]
fn thumb_borrows_state_envelopes_from_focusable_container() {
let s = slider(0.5, tokens::PRIMARY);
assert!(s.focusable, "container is the focusable / hit target");
let thumb = s
.children
.iter()
.find(|c| matches!(&c.kind, Kind::Custom(name) if *name == "slider-thumb"))
.expect("thumb child");
assert!(
thumb.state_follows_interactive_ancestor,
"thumb borrows hover / press from the slider container",
);
for c in &s.children {
if let Kind::Custom(name) = &c.kind
&& (*name == "slider-track" || *name == "slider-fill")
{
assert!(!c.state_follows_interactive_ancestor);
}
}
}
#[test]
fn slider_declares_grab_at_rest_and_grabbing_while_pressed() {
let s = slider(0.5, tokens::PRIMARY);
assert_eq!(s.cursor, Some(Cursor::Grab));
assert_eq!(s.cursor_pressed, Some(Cursor::Grabbing));
}
#[test]
fn normalized_tracks_thumb_center() {
let rect = Rect::new(10.0, 20.0, 220.0, DEFAULT_HEIGHT);
let left = rect.x + THUMB_SIZE * 0.5;
let usable = rect.w - THUMB_SIZE;
assert_eq!(normalized_from_event(rect, left), 0.0);
assert!((normalized_from_event(rect, left + usable * 0.5) - 0.5).abs() < 1e-6);
assert_eq!(normalized_from_event(rect, left + usable), 1.0);
assert_eq!(normalized_from_event(rect, rect.x - 30.0), 0.0);
assert_eq!(normalized_from_event(rect, rect.x + rect.w + 30.0), 1.0);
}
fn pointer_event(key: &str, kind: UiEventKind, rect: Rect, x: f32) -> UiEvent {
UiEvent {
path: None,
key: Some(key.to_string()),
target: Some(UiTarget {
key: key.to_string(),
node_id: format!("/{key}"),
rect,
tooltip: None,
scroll_offset_y: 0.0,
}),
pointer: Some((x, rect.y + rect.h * 0.5)),
key_press: None,
text: None,
selection: None,
modifiers: KeyModifiers::default(),
click_count: 0,
kind,
}
}
#[test]
fn apply_input_handles_pointer_drag() {
let rect = Rect::new(10.0, 20.0, 220.0, DEFAULT_HEIGHT);
let usable = rect.w - THUMB_SIZE;
let mid_x = rect.x + THUMB_SIZE * 0.5 + usable * 0.5;
let mut value = 0.0;
assert!(apply_input(
&mut value,
&pointer_event("vol", UiEventKind::Drag, rect, mid_x),
"vol",
0.1,
0.25
));
assert!((value - 0.5).abs() < 1e-6);
let right = rect.x + THUMB_SIZE * 0.5 + usable;
assert!(apply_input(
&mut value,
&pointer_event("vol", UiEventKind::Click, rect, right),
"vol",
0.1,
0.25
));
assert_eq!(value, 1.0);
assert!(!apply_input(
&mut value,
&pointer_event("vol", UiEventKind::Click, rect, right),
"vol",
0.1,
0.25
));
}
#[test]
fn apply_input_falls_through_to_keyboard() {
let mut value = 0.5;
assert!(apply_input(
&mut value,
&key_event("vol", UiKey::ArrowUp),
"vol",
0.1,
0.25
));
assert!((value - 0.6).abs() < 1e-6);
}
#[test]
fn apply_input_ignores_pointer_events_for_other_keys() {
let rect = Rect::new(0.0, 0.0, 200.0, DEFAULT_HEIGHT);
let mut value = 0.5;
assert!(!apply_input(
&mut value,
&pointer_event("other", UiEventKind::Drag, rect, 100.0),
"vol",
0.1,
0.25
));
assert_eq!(value, 0.5);
}
}