bevy_ui_widgets/
slider.rs

1use core::ops::RangeInclusive;
2
3use accesskit::{Orientation, Role};
4use bevy_a11y::AccessibilityNode;
5use bevy_app::{App, Plugin};
6use bevy_ecs::event::EntityEvent;
7use bevy_ecs::hierarchy::Children;
8use bevy_ecs::lifecycle::Insert;
9use bevy_ecs::query::Has;
10use bevy_ecs::system::Res;
11use bevy_ecs::world::DeferredWorld;
12use bevy_ecs::{
13    component::Component,
14    observer::On,
15    query::With,
16    reflect::ReflectComponent,
17    system::{Commands, Query},
18};
19use bevy_input::keyboard::{KeyCode, KeyboardInput};
20use bevy_input::ButtonState;
21use bevy_input_focus::FocusedInput;
22use bevy_log::warn_once;
23use bevy_math::ops;
24use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press};
25use bevy_reflect::{prelude::ReflectDefault, Reflect};
26use bevy_ui::{
27    ComputedNode, ComputedUiRenderTargetInfo, InteractionDisabled, UiGlobalTransform, UiScale,
28};
29
30use crate::ValueChange;
31use bevy_ecs::entity::Entity;
32
33/// Defines how the slider should behave when you click on the track (not the thumb).
34#[derive(Debug, Default, PartialEq, Clone, Copy, Reflect)]
35#[reflect(Clone, PartialEq, Default)]
36pub enum TrackClick {
37    /// Clicking on the track lets you drag to edit the value, just like clicking on the thumb.
38    #[default]
39    Drag,
40    /// Clicking on the track increments or decrements the slider by [`SliderStep`].
41    Step,
42    /// Clicking on the track snaps the value to the clicked position.
43    Snap,
44}
45
46/// A headless slider widget, which can be used to build custom sliders. Sliders have a value
47/// (represented by the [`SliderValue`] component) and a range (represented by [`SliderRange`]). An
48/// optional step size can be specified via [`SliderStep`], and you can control the rounding
49/// during dragging with [`SliderPrecision`].
50///
51/// You can also control the slider remotely by triggering a [`SetSliderValue`] event on it. This
52/// can be useful in a console environment for controlling the value gamepad inputs.
53///
54/// The presence of the `on_change` property controls whether the slider uses internal or external
55/// state management. If the `on_change` property is `None`, then the slider updates its own state
56/// automatically. Otherwise, the `on_change` property contains the id of a one-shot system which is
57/// passed the new slider value. In this case, the slider value is not modified, it is the
58/// responsibility of the callback to trigger whatever data-binding mechanism is used to update the
59/// slider's value.
60///
61/// Typically a slider will contain entities representing the "track" and "thumb" elements. The core
62/// slider makes no assumptions about the hierarchical structure of these elements, but expects that
63/// the thumb will be marked with a [`SliderThumb`] component.
64///
65/// The core slider does not modify the visible position of the thumb: that is the responsibility of
66/// the stylist. This can be done either in percent or pixel units as desired. To prevent overhang
67/// at the ends of the slider, the positioning should take into account the thumb width, by reducing
68/// the amount of travel. So for example, in a slider 100px wide, with a thumb that is 10px, the
69/// amount of travel is 90px. The core slider's calculations for clicking and dragging assume this
70/// is the case, and will reduce the travel by the measured size of the thumb entity, which allows
71/// the movement of the thumb to be perfectly synchronized with the movement of the mouse.
72///
73/// In cases where overhang is desired for artistic reasons, the thumb may have additional
74/// decorative child elements, absolutely positioned, which don't affect the size measurement.
75#[derive(Component, Debug, Default)]
76#[require(
77    AccessibilityNode(accesskit::Node::new(Role::Slider)),
78    CoreSliderDragState,
79    SliderValue,
80    SliderRange,
81    SliderStep
82)]
83pub struct Slider {
84    /// Set the track-clicking behavior for this slider.
85    pub track_click: TrackClick,
86    // TODO: Think about whether we want a "vertical" option.
87}
88
89/// Marker component that identifies which descendant element is the slider thumb.
90#[derive(Component, Debug, Default)]
91pub struct SliderThumb;
92
93/// A component which stores the current value of the slider.
94#[derive(Component, Debug, Default, PartialEq, Clone, Copy)]
95#[component(immutable)]
96pub struct SliderValue(pub f32);
97
98/// A component which represents the allowed range of the slider value. Defaults to 0.0..=1.0.
99#[derive(Component, Debug, PartialEq, Clone, Copy)]
100#[component(immutable)]
101pub struct SliderRange {
102    /// The beginning of the allowed range for the slider value.
103    start: f32,
104    /// The end of the allowed range for the slider value.
105    end: f32,
106}
107
108impl SliderRange {
109    /// Creates a new slider range with the given start and end values.
110    pub fn new(start: f32, end: f32) -> Self {
111        if end < start {
112            warn_once!(
113                "Expected SliderRange::start ({}) <= SliderRange::end ({})",
114                start,
115                end
116            );
117        }
118        Self { start, end }
119    }
120
121    /// Creates a new slider range from a Rust range.
122    pub fn from_range(range: RangeInclusive<f32>) -> Self {
123        let (start, end) = range.into_inner();
124        Self { start, end }
125    }
126
127    /// Returns the minimum allowed value for this slider.
128    pub fn start(&self) -> f32 {
129        self.start
130    }
131
132    /// Return a new instance of a `SliderRange` with a new start position.
133    pub fn with_start(&self, start: f32) -> Self {
134        Self::new(start, self.end)
135    }
136
137    /// Returns the maximum allowed value for this slider.
138    pub fn end(&self) -> f32 {
139        self.end
140    }
141
142    /// Return a new instance of a `SliderRange` with a new end position.
143    pub fn with_end(&self, end: f32) -> Self {
144        Self::new(self.start, end)
145    }
146
147    /// Returns the full span of the range (max - min).
148    pub fn span(&self) -> f32 {
149        self.end - self.start
150    }
151
152    /// Returns the center value of the range.
153    pub fn center(&self) -> f32 {
154        (self.start + self.end) / 2.0
155    }
156
157    /// Constrain a value between the minimum and maximum allowed values for this slider.
158    pub fn clamp(&self, value: f32) -> f32 {
159        value.clamp(self.start, self.end)
160    }
161
162    /// Compute the position of the thumb on the slider, as a value between 0 and 1, taking
163    /// into account the proportion of the value between the minimum and maximum limits.
164    pub fn thumb_position(&self, value: f32) -> f32 {
165        if self.end > self.start {
166            (value - self.start) / (self.end - self.start)
167        } else {
168            0.5
169        }
170    }
171}
172
173impl Default for SliderRange {
174    fn default() -> Self {
175        Self {
176            start: 0.0,
177            end: 1.0,
178        }
179    }
180}
181
182/// Defines the amount by which to increment or decrement the slider value when using keyboard
183/// shortcuts. Defaults to 1.0.
184#[derive(Component, Debug, PartialEq, Clone)]
185#[component(immutable)]
186#[derive(Reflect)]
187#[reflect(Component)]
188pub struct SliderStep(pub f32);
189
190impl Default for SliderStep {
191    fn default() -> Self {
192        Self(1.0)
193    }
194}
195
196/// A component which controls the rounding of the slider value during dragging.
197///
198/// Stepping is not affected, although presumably the step size will be an integer multiple of the
199/// rounding factor. This also doesn't prevent the slider value from being set to non-rounded values
200/// by other means, such as manually entering digits via a numeric input field.
201///
202/// The value in this component represents the number of decimal places of desired precision, so a
203/// value of 2 would round to the nearest 1/100th. A value of -3 would round to the nearest
204/// thousand.
205#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
206#[reflect(Component, Default)]
207pub struct SliderPrecision(pub i32);
208
209impl SliderPrecision {
210    fn round(&self, value: f32) -> f32 {
211        let factor = ops::powf(10.0_f32, self.0 as f32);
212        (value * factor).round() / factor
213    }
214}
215
216/// Component used to manage the state of a slider during dragging.
217#[derive(Component, Default, Reflect)]
218#[reflect(Component)]
219pub struct CoreSliderDragState {
220    /// Whether the slider is currently being dragged.
221    pub dragging: bool,
222
223    /// The value of the slider when dragging started.
224    offset: f32,
225}
226
227pub(crate) fn slider_on_pointer_down(
228    mut press: On<Pointer<Press>>,
229    q_slider: Query<(
230        &Slider,
231        &SliderValue,
232        &SliderRange,
233        &SliderStep,
234        Option<&SliderPrecision>,
235        &ComputedNode,
236        &ComputedUiRenderTargetInfo,
237        &UiGlobalTransform,
238        Has<InteractionDisabled>,
239    )>,
240    q_thumb: Query<&ComputedNode, With<SliderThumb>>,
241    q_children: Query<&Children>,
242    mut commands: Commands,
243    ui_scale: Res<UiScale>,
244) {
245    if q_thumb.contains(press.entity) {
246        // Thumb click, stop propagation to prevent track click.
247        press.propagate(false);
248    } else if let Ok((
249        slider,
250        value,
251        range,
252        step,
253        precision,
254        node,
255        node_target,
256        transform,
257        disabled,
258    )) = q_slider.get(press.entity)
259    {
260        // Track click
261        press.propagate(false);
262
263        if disabled {
264            return;
265        }
266
267        // Find thumb size by searching descendants for the first entity with SliderThumb
268        let thumb_size = q_children
269            .iter_descendants(press.entity)
270            .find_map(|child_id| q_thumb.get(child_id).ok().map(|thumb| thumb.size().x))
271            .unwrap_or(0.0);
272
273        // Detect track click.
274        let local_pos = transform.try_inverse().unwrap().transform_point2(
275            press.pointer_location.position * node_target.scale_factor() / ui_scale.0,
276        );
277        let track_width = node.size().x - thumb_size;
278        // Avoid division by zero
279        let click_val = if track_width > 0. {
280            local_pos.x * range.span() / track_width + range.center()
281        } else {
282            0.
283        };
284
285        // Compute new value from click position
286        let new_value = range.clamp(match slider.track_click {
287            TrackClick::Drag => {
288                return;
289            }
290            TrackClick::Step => {
291                if click_val < value.0 {
292                    value.0 - step.0
293                } else {
294                    value.0 + step.0
295                }
296            }
297            TrackClick::Snap => precision
298                .map(|prec| prec.round(click_val))
299                .unwrap_or(click_val),
300        });
301
302        commands.trigger(ValueChange {
303            source: press.entity,
304            value: new_value,
305        });
306    }
307}
308
309pub(crate) fn slider_on_drag_start(
310    mut drag_start: On<Pointer<DragStart>>,
311    mut q_slider: Query<
312        (
313            &SliderValue,
314            &mut CoreSliderDragState,
315            Has<InteractionDisabled>,
316        ),
317        With<Slider>,
318    >,
319) {
320    if let Ok((value, mut drag, disabled)) = q_slider.get_mut(drag_start.entity) {
321        drag_start.propagate(false);
322        if !disabled {
323            drag.dragging = true;
324            drag.offset = value.0;
325        }
326    }
327}
328
329pub(crate) fn slider_on_drag(
330    mut event: On<Pointer<Drag>>,
331    mut q_slider: Query<
332        (
333            &ComputedNode,
334            &SliderRange,
335            Option<&SliderPrecision>,
336            &UiGlobalTransform,
337            &mut CoreSliderDragState,
338            Has<InteractionDisabled>,
339        ),
340        With<Slider>,
341    >,
342    q_thumb: Query<&ComputedNode, With<SliderThumb>>,
343    q_children: Query<&Children>,
344    mut commands: Commands,
345    ui_scale: Res<UiScale>,
346) {
347    if let Ok((node, range, precision, transform, drag, disabled)) = q_slider.get_mut(event.entity)
348    {
349        event.propagate(false);
350        if drag.dragging && !disabled {
351            let mut distance = event.distance / ui_scale.0;
352            distance.y *= -1.;
353            let distance = transform.transform_vector2(distance);
354            // Find thumb size by searching descendants for the first entity with SliderThumb
355            let thumb_size = q_children
356                .iter_descendants(event.entity)
357                .find_map(|child_id| q_thumb.get(child_id).ok().map(|thumb| thumb.size().x))
358                .unwrap_or(0.0);
359            let slider_width = ((node.size().x - thumb_size) * node.inverse_scale_factor).max(1.0);
360            let span = range.span();
361            let new_value = if span > 0. {
362                drag.offset + (distance.x * span) / slider_width
363            } else {
364                range.start() + span * 0.5
365            };
366            let rounded_value = range.clamp(
367                precision
368                    .map(|prec| prec.round(new_value))
369                    .unwrap_or(new_value),
370            );
371
372            commands.trigger(ValueChange {
373                source: event.entity,
374                value: rounded_value,
375            });
376        }
377    }
378}
379
380pub(crate) fn slider_on_drag_end(
381    mut drag_end: On<Pointer<DragEnd>>,
382    mut q_slider: Query<(&Slider, &mut CoreSliderDragState)>,
383) {
384    if let Ok((_slider, mut drag)) = q_slider.get_mut(drag_end.entity) {
385        drag_end.propagate(false);
386        if drag.dragging {
387            drag.dragging = false;
388        }
389    }
390}
391
392fn slider_on_key_input(
393    mut focused_input: On<FocusedInput<KeyboardInput>>,
394    q_slider: Query<
395        (
396            &SliderValue,
397            &SliderRange,
398            &SliderStep,
399            Has<InteractionDisabled>,
400        ),
401        With<Slider>,
402    >,
403    mut commands: Commands,
404) {
405    if let Ok((value, range, step, disabled)) = q_slider.get(focused_input.focused_entity) {
406        let input_event = &focused_input.input;
407        if !disabled && input_event.state == ButtonState::Pressed {
408            let new_value = match input_event.key_code {
409                KeyCode::ArrowLeft => range.clamp(value.0 - step.0),
410                KeyCode::ArrowRight => range.clamp(value.0 + step.0),
411                KeyCode::Home => range.start(),
412                KeyCode::End => range.end(),
413                _ => {
414                    return;
415                }
416            };
417            focused_input.propagate(false);
418            commands.trigger(ValueChange {
419                source: focused_input.focused_entity,
420                value: new_value,
421            });
422        }
423    }
424}
425
426pub(crate) fn slider_on_insert(insert: On<Insert, Slider>, mut world: DeferredWorld) {
427    let mut entity = world.entity_mut(insert.entity);
428    if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
429        accessibility.set_orientation(Orientation::Horizontal);
430    }
431}
432
433pub(crate) fn slider_on_insert_value(insert: On<Insert, SliderValue>, mut world: DeferredWorld) {
434    let mut entity = world.entity_mut(insert.entity);
435    let value = entity.get::<SliderValue>().unwrap().0;
436    if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
437        accessibility.set_numeric_value(value.into());
438    }
439}
440
441pub(crate) fn slider_on_insert_range(insert: On<Insert, SliderRange>, mut world: DeferredWorld) {
442    let mut entity = world.entity_mut(insert.entity);
443    let range = *entity.get::<SliderRange>().unwrap();
444    if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
445        accessibility.set_min_numeric_value(range.start().into());
446        accessibility.set_max_numeric_value(range.end().into());
447    }
448}
449
450pub(crate) fn slider_on_insert_step(insert: On<Insert, SliderStep>, mut world: DeferredWorld) {
451    let mut entity = world.entity_mut(insert.entity);
452    let step = entity.get::<SliderStep>().unwrap().0;
453    if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
454        accessibility.set_numeric_value_step(step.into());
455    }
456}
457
458/// An [`EntityEvent`] that can be triggered on a slider to modify its value (using the `on_change` callback).
459/// This can be used to control the slider via gamepad buttons or other inputs. The value will be
460/// clamped when the event is processed.
461///
462/// # Example:
463///
464/// ```
465/// # use bevy_ecs::system::Commands;
466/// # use bevy_ui_widgets::{Slider, SliderRange, SliderValue, SetSliderValue, SliderValueChange};
467/// fn setup(mut commands: Commands) {
468///     // Create a slider
469///     let entity = commands.spawn((
470///         Slider::default(),
471///         SliderValue(0.5),
472///         SliderRange::new(0.0, 1.0),
473///     )).id();
474///
475///     // Set to an absolute value
476///     commands.trigger(SetSliderValue {
477///         entity,
478///         change: SliderValueChange::Absolute(0.75),
479///     });
480///
481///     // Adjust relatively
482///     commands.trigger(SetSliderValue {
483///         entity,
484///         change: SliderValueChange::Relative(-0.25),
485///     });
486/// }
487/// ```
488#[derive(EntityEvent, Clone)]
489pub struct SetSliderValue {
490    /// The slider entity to change.
491    pub entity: Entity,
492    /// The change to apply to the slider entity.
493    pub change: SliderValueChange,
494}
495
496/// The type of slider value change to apply in [`SetSliderValue`].
497#[derive(Clone)]
498pub enum SliderValueChange {
499    /// Set the slider value to a specific value.
500    Absolute(f32),
501    /// Add a delta to the slider value.
502    Relative(f32),
503    /// Add a delta to the slider value, multiplied by the step size.
504    RelativeStep(f32),
505}
506
507fn slider_on_set_value(
508    set_slider_value: On<SetSliderValue>,
509    q_slider: Query<(&SliderValue, &SliderRange, Option<&SliderStep>), With<Slider>>,
510    mut commands: Commands,
511) {
512    if let Ok((value, range, step)) = q_slider.get(set_slider_value.entity) {
513        let new_value = match set_slider_value.change {
514            SliderValueChange::Absolute(new_value) => range.clamp(new_value),
515            SliderValueChange::Relative(delta) => range.clamp(value.0 + delta),
516            SliderValueChange::RelativeStep(delta) => {
517                range.clamp(value.0 + delta * step.map(|s| s.0).unwrap_or_default())
518            }
519        };
520        commands.trigger(ValueChange {
521            source: set_slider_value.entity,
522            value: new_value,
523        });
524    }
525}
526
527/// Observer function which updates the slider value in response to a [`ValueChange`] event.
528/// This can be used to make the slider automatically update its own state when dragged,
529/// as opposed to managing the slider state externally.
530pub fn slider_self_update(value_change: On<ValueChange<f32>>, mut commands: Commands) {
531    commands
532        .entity(value_change.source)
533        .insert(SliderValue(value_change.value));
534}
535
536/// Plugin that adds the observers for the [`Slider`] widget.
537pub struct SliderPlugin;
538
539impl Plugin for SliderPlugin {
540    fn build(&self, app: &mut App) {
541        app.add_observer(slider_on_pointer_down)
542            .add_observer(slider_on_drag_start)
543            .add_observer(slider_on_drag_end)
544            .add_observer(slider_on_drag)
545            .add_observer(slider_on_key_input)
546            .add_observer(slider_on_insert)
547            .add_observer(slider_on_insert_value)
548            .add_observer(slider_on_insert_range)
549            .add_observer(slider_on_insert_step)
550            .add_observer(slider_on_set_value);
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn test_slider_precision_rounding() {
560        // Test positive precision values (decimal places)
561        let precision_2dp = SliderPrecision(2);
562        assert_eq!(precision_2dp.round(1.234567), 1.23);
563        assert_eq!(precision_2dp.round(1.235), 1.24);
564
565        // Test zero precision (rounds to integers)
566        let precision_0dp = SliderPrecision(0);
567        assert_eq!(precision_0dp.round(1.4), 1.0);
568
569        // Test negative precision (rounds to tens, hundreds, etc.)
570        let precision_neg1 = SliderPrecision(-1);
571        assert_eq!(precision_neg1.round(14.0), 10.0);
572    }
573}