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