use blinc_animation::{get_scheduler, AnimationContext, SpringConfig};
use blinc_core::events::event_types;
use blinc_core::{BlincContext, BlincContextState, Color, State};
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::{CursorStyle, RenderProps};
use blinc_layout::motion::motion;
use blinc_layout::prelude::*;
use blinc_layout::stateful::{stateful_with_key, NoState, StateTransitions};
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_macros::BlincComponent;
use blinc_theme::{ColorToken, RadiusToken, ThemeState};
use std::sync::{Arc, Mutex};
use super::label::{label, LabelSize};
use blinc_layout::InstanceKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SliderThumbState {
#[default]
Idle,
Hovered,
Pressed,
Dragging,
}
impl StateTransitions for SliderThumbState {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
(SliderThumbState::Idle, event_types::POINTER_ENTER) => Some(SliderThumbState::Hovered),
(SliderThumbState::Hovered, event_types::POINTER_LEAVE) => Some(SliderThumbState::Idle),
(SliderThumbState::Hovered, event_types::POINTER_DOWN) => {
Some(SliderThumbState::Pressed)
}
(SliderThumbState::Pressed, event_types::POINTER_UP) => Some(SliderThumbState::Hovered),
(SliderThumbState::Pressed, event_types::POINTER_LEAVE) => Some(SliderThumbState::Idle),
(SliderThumbState::Pressed, event_types::DRAG) => Some(SliderThumbState::Dragging),
(SliderThumbState::Dragging, event_types::DRAG) => None, (SliderThumbState::Dragging, event_types::DRAG_END) => Some(SliderThumbState::Idle),
(SliderThumbState::Dragging, event_types::POINTER_LEAVE) => None,
(SliderThumbState::Dragging, event_types::POINTER_ENTER) => None,
(SliderThumbState::Dragging, event_types::POINTER_UP) => Some(SliderThumbState::Idle),
_ => None,
}
}
}
#[derive(BlincComponent)]
struct SliderState {
#[animation]
thumb_offset: f32,
drag_start_x: f32,
drag_start_offset: f32,
is_dragging: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SliderSize {
Small,
#[default]
Medium,
Large,
}
impl SliderSize {
fn track_height(&self) -> f32 {
match self {
SliderSize::Small => 4.0,
SliderSize::Medium => 6.0,
SliderSize::Large => 8.0,
}
}
fn thumb_size(&self) -> f32 {
match self {
SliderSize::Small => 14.0,
SliderSize::Medium => 18.0,
SliderSize::Large => 22.0,
}
}
}
pub struct Slider {
inner: Div,
}
impl Slider {
#[track_caller]
pub fn new(value_state: &State<f32>) -> Self {
Self::with_config(
InstanceKey::new("slider"),
SliderConfig::new(value_state.clone()),
)
}
fn with_config(key: InstanceKey, config: SliderConfig) -> Self {
let theme = ThemeState::get();
let track_height = config.size.track_height();
let thumb_size = config.size.thumb_size();
let radius = theme.radius(RadiusToken::Full);
let track_bg = config
.track_color
.unwrap_or_else(|| theme.color(ColorToken::SurfaceElevated));
let thumb_bg = config
.thumb_color
.unwrap_or_else(|| theme.color(ColorToken::Border).with_alpha(1.0));
let fill_bg = config
.fill_color
.unwrap_or_else(|| theme.color(ColorToken::Primary));
let _border_hover = theme.color(ColorToken::BorderHover);
let disabled = config.disabled;
let min = config.min;
let max = config.max;
let step = config.step;
let width: Option<f32> = config.width;
let track_width = config.width.unwrap_or(300.0);
let initial_value = config.value_state.get();
let initial_norm = ((initial_value - min) / (max - min)).clamp(0.0, 1.0);
let initial_offset = initial_norm * (track_width - thumb_size);
let instance_key = key.get().to_string();
let ctx = BlincContextState::get();
let scheduler = get_scheduler();
let thumb_offset = Arc::new(Mutex::new(AnimatedValue::new(
scheduler,
initial_offset,
SpringConfig::snappy(),
)));
let drag_start_x = ctx.use_state_keyed(&format!("{}_drag_start_x", instance_key), || 0.0);
let drag_start_offset =
ctx.use_state_keyed(&format!("{}_drag_start_offset", instance_key), || 0.0);
let is_dragging = ctx.use_state_keyed(&format!("{}_is_dragging", instance_key), || false);
let thumb_offset_for_click = thumb_offset.clone();
let round_to_step = move |value: f32| -> f32 {
if let Some(s) = step {
if s > 0.0 {
let steps = ((value - min) / s).round();
(min + steps * s).clamp(min, max)
} else {
value.clamp(min, max)
}
} else {
value.clamp(min, max)
}
};
let round_to_step_click = round_to_step;
let round_to_step_drag = round_to_step;
let value_state_for_click = config.value_state.clone();
let value_state_for_drag = config.value_state.clone();
let on_change_for_click = config.on_change.clone();
let on_change_for_drag = config.on_change.clone();
let thumb_offset_for_fill = thumb_offset.clone();
let thumb_offset_for_drag = thumb_offset.clone();
let thumb_offset_for_down = thumb_offset.clone();
let drag_start_x_for_down = drag_start_x.clone();
let drag_start_offset_for_down = drag_start_offset.clone();
let drag_start_x_for_drag = drag_start_x.clone();
let drag_start_offset_for_drag = drag_start_offset.clone();
let is_dragging_for_click = is_dragging.clone();
let is_dragging_for_drag = is_dragging.clone();
let is_dragging_for_drag_end = is_dragging.clone();
let is_dragging_for_thumb = is_dragging.clone();
let is_dragging_for_leave = is_dragging.clone();
let thumb_border_dragging = theme.color(ColorToken::Primary);
let thumb_key = format!("{}_thumb", instance_key);
let thumb = stateful_with_key::<NoState>(&thumb_key)
.deps([is_dragging.signal_id()])
.on_state(move |_ctx| {
let dragging = is_dragging_for_thumb.get();
let mut thumb_div = div()
.class("cn-slider-thumb")
.w(thumb_size)
.h(thumb_size)
.rounded(thumb_size / 2.0)
.border(2.0, theme.color(ColorToken::Border))
.bg(thumb_bg)
.shadow_sm();
if dragging {
thumb_div = thumb_div.border(2.0, thumb_border_dragging).shadow_md();
}
thumb_div
});
let fill_bar = div()
.class("cn-slider-fill")
.w(track_width)
.h(track_height)
.rounded(radius)
.bg(fill_bg);
let fill_left = thumb_size / 2.0 - track_width;
let fill_positioned = div().absolute().left(fill_left).top(0.0).child(fill_bar);
let animated_fill = motion()
.translate_x(thumb_offset_for_fill.clone())
.child(fill_positioned);
let track_fill = div()
.absolute()
.left(0.0)
.top((thumb_size - track_height) / 2.0)
.w(track_width)
.h(track_height)
.overflow_clip()
.rounded(radius)
.relative() .child(animated_fill);
let track_visual = div()
.class("cn-slider-track")
.absolute()
.left(0.0)
.right(0.0)
.top((thumb_size - track_height) / 2.0) .h(track_height)
.rounded(radius)
.bg(track_bg)
.cursor_pointer()
.on_click(move |event| {
if disabled {
return;
}
if is_dragging_for_click.get() {
is_dragging_for_click.set(false);
return;
}
let track_w = event.bounds_width;
if track_w > 0.0 {
let x = event.local_x;
let norm = (x / track_w).clamp(0.0, 1.0);
let raw = min + norm * (max - min);
let new_val = round_to_step_click(raw);
value_state_for_click.set(new_val);
let x_offset = norm * (track_w - thumb_size);
thumb_offset_for_click.lock().unwrap().set_target(x_offset);
if let Some(ref cb) = on_change_for_click {
cb(new_val);
}
}
});
let thumb_wrapper = div()
.absolute()
.left(0.0)
.top(0.0)
.child(motion().translate_x(thumb_offset).child(thumb));
let mut slider_container = div()
.relative() .h(thumb_size)
.overflow_visible() .cursor(CursorStyle::Grab)
.child(track_visual)
.child(track_fill)
.child(thumb_wrapper)
.on_mouse_down(move |event| {
if disabled {
return;
}
drag_start_x_for_down.set(event.mouse_x);
let current = thumb_offset_for_down.lock().unwrap().get();
drag_start_offset_for_down.set(current);
})
.on_drag(move |event| {
if disabled {
return;
}
is_dragging_for_drag.set(true);
let start_x = drag_start_x_for_drag.get();
let delta_x = event.mouse_x - start_x;
let start_offset = drag_start_offset_for_drag.get();
let max_offset = track_width - thumb_size;
let new_offset = (start_offset + delta_x).clamp(0.0, max_offset);
thumb_offset_for_drag
.lock()
.unwrap()
.set_immediate(new_offset);
let norm = new_offset / max_offset;
let raw = min + norm * (max - min);
let new_val = round_to_step_drag(raw);
value_state_for_drag.set(new_val);
if let Some(ref cb) = on_change_for_drag {
cb(new_val);
}
})
.on_drag_end(move |_event| {
let _ = is_dragging_for_drag_end.get(); })
.on_hover_leave(move |_event| {
is_dragging_for_leave.set(false);
});
if let Some(w) = width {
slider_container = slider_container.w(w);
} else {
slider_container = slider_container.w_full();
}
if disabled {
slider_container = slider_container.opacity(0.5);
}
let inner = if config.label.is_some() || config.show_value {
let spacing = theme.spacing_value(blinc_theme::SpacingToken::Space2);
let mut outer = div().h_fit().flex_col().gap_px(spacing);
if let Some(w) = width {
outer = outer.w(w);
} else {
outer = outer.w_full();
}
if config.label.is_some() || config.show_value {
let mut header = div().flex_row().justify_between().items_center();
if let Some(ref label_text) = config.label {
let mut lbl = label(label_text).size(LabelSize::Medium);
if disabled {
lbl = lbl.disabled(true);
}
header = header.child(lbl);
}
if config.show_value {
let value_color = if disabled {
theme.color(ColorToken::TextTertiary)
} else {
theme.color(ColorToken::TextSecondary)
};
let value_state_for_display = config.value_state.clone();
let step_for_display = config.step;
let value_display_key = format!("{}_value_display", instance_key);
let value_display = stateful_with_key::<NoState>(&value_display_key)
.deps([config.value_state.signal_id()])
.on_state(move |_ctx| {
let current_value = value_state_for_display.get();
let value_text =
if step_for_display.is_some() && step_for_display.unwrap() >= 1.0 {
format!("{:.0}", current_value)
} else {
format!("{:.2}", current_value)
};
div().child(text(&value_text).size(14.0).color(value_color))
});
header = header.child(value_display);
}
outer = outer.child(header);
}
outer = outer.child(slider_container);
outer
} else {
div().h_fit().child(slider_container)
};
Self {
inner: div().child(inner),
}
}
pub fn class(mut self, name: impl Into<String>) -> Self {
self.inner = self.inner.class(name);
self
}
pub fn id(mut self, id: &str) -> Self {
self.inner = self.inner.id(id);
self
}
}
impl ElementBuilder for Slider {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.inner.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.inner.element_type_id()
}
fn element_classes(&self) -> &[String] {
self.inner.element_classes()
}
}
#[derive(Clone)]
struct SliderConfig {
value_state: State<f32>,
min: f32,
max: f32,
step: Option<f32>,
size: SliderSize,
label: Option<String>,
show_value: bool,
disabled: bool,
width: Option<f32>,
track_color: Option<Color>,
fill_color: Option<Color>,
thumb_color: Option<Color>,
on_change: Option<Arc<dyn Fn(f32) + Send + Sync>>,
}
impl SliderConfig {
fn new(value_state: State<f32>) -> Self {
Self {
value_state,
min: 0.0,
max: 1.0,
step: None,
size: SliderSize::default(),
label: None,
show_value: false,
disabled: false,
width: None,
track_color: None,
fill_color: None,
thumb_color: None,
on_change: None,
}
}
}
pub struct SliderBuilder {
key: InstanceKey,
config: SliderConfig,
}
impl SliderBuilder {
#[track_caller]
pub fn new(value_state: &State<f32>) -> Self {
Self {
key: InstanceKey::new("slider"),
config: SliderConfig::new(value_state.clone()),
}
}
pub fn with_key(key: impl Into<String>, value_state: &State<f32>) -> Self {
Self {
key: InstanceKey::explicit(key),
config: SliderConfig::new(value_state.clone()),
}
}
pub fn min(mut self, min: f32) -> Self {
self.config.min = min;
self
}
pub fn max(mut self, max: f32) -> Self {
self.config.max = max;
self
}
pub fn step(mut self, step: f32) -> Self {
self.config.step = Some(step);
self
}
pub fn size(mut self, size: SliderSize) -> Self {
self.config.size = size;
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.config.label = Some(label.into());
self
}
pub fn show_value(mut self) -> Self {
self.config.show_value = true;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.config.disabled = disabled;
self
}
pub fn w(mut self, width: f32) -> Self {
self.config.width = Some(width);
self
}
pub fn track_color(mut self, color: impl Into<Color>) -> Self {
self.config.track_color = Some(color.into());
self
}
pub fn fill_color(mut self, color: impl Into<Color>) -> Self {
self.config.fill_color = Some(color.into());
self
}
pub fn thumb_color(mut self, color: impl Into<Color>) -> Self {
self.config.thumb_color = Some(color.into());
self
}
pub fn on_change<F>(mut self, callback: F) -> Self
where
F: Fn(f32) + Send + Sync + 'static,
{
self.config.on_change = Some(Arc::new(callback));
self
}
pub fn build_final(self) -> Slider {
Slider::with_config(self.key, self.config)
}
}
#[track_caller]
pub fn slider(state: &State<f32>) -> SliderBuilder {
SliderBuilder::new(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slider_sizes() {
assert_eq!(SliderSize::Small.track_height(), 4.0);
assert_eq!(SliderSize::Medium.track_height(), 6.0);
assert_eq!(SliderSize::Large.track_height(), 8.0);
}
#[test]
fn test_slider_thumb_sizes() {
assert_eq!(SliderSize::Small.thumb_size(), 14.0);
assert_eq!(SliderSize::Medium.thumb_size(), 18.0);
assert_eq!(SliderSize::Large.thumb_size(), 22.0);
}
}