use accesskit::{Orientation, Role};
use bevy_a11y::AccessibilityNode;
use bevy_app::{App, Plugin};
use bevy_ecs::event::EntityEvent;
use bevy_ecs::hierarchy::Children;
use bevy_ecs::lifecycle::Insert;
use bevy_ecs::query::Has;
use bevy_ecs::system::{Res, ResMut};
use bevy_ecs::world::DeferredWorld;
use bevy_ecs::{
component::Component,
observer::On,
query::With,
reflect::{ReflectComponent, ReflectEvent},
system::{Commands, Query},
};
use bevy_input::keyboard::{KeyCode, KeyboardInput};
use bevy_input::ButtonState;
use bevy_input_focus::FocusedInput;
use bevy_log::warn_once;
use bevy_math::ops;
use bevy_picking::events::{
PointerCancel, PointerDrag, PointerDragEnd, PointerDragStart, PointerPress, PointerRelease,
};
use bevy_picking::hover::PointerCaptureMap;
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_ui::{
ComputedNode, ComputedUiRenderTargetInfo, InteractionDisabled, Pressed, UiGlobalTransform,
UiScale,
};
use core::ops::RangeInclusive;
use crate::ValueChange;
use bevy_ecs::entity::Entity;
#[derive(Debug, Default, PartialEq, Clone, Copy, Reflect)]
#[reflect(Clone, PartialEq, Default)]
pub enum SliderOrientation {
#[default]
Auto,
Horizontal,
Vertical,
}
impl SliderOrientation {
pub fn is_vertical(self, node: &ComputedNode) -> bool {
match self {
SliderOrientation::Auto => node.size().y > node.size().x,
SliderOrientation::Horizontal => false,
SliderOrientation::Vertical => true,
}
}
}
#[derive(Debug, Default, PartialEq, Clone, Copy, Reflect)]
#[reflect(Clone, PartialEq, Default)]
pub enum TrackClick {
#[default]
Drag,
Step,
Snap,
}
#[derive(Component, Debug, Default, Clone)]
#[require(
AccessibilityNode(accesskit::Node::new(Role::Slider)),
SliderDragState,
SliderValue,
SliderRange,
SliderStep
)]
#[derive(Reflect)]
#[reflect(Component)]
pub struct Slider {
pub track_click: TrackClick,
pub orientation: SliderOrientation,
}
#[derive(Component, Debug, Default, Clone, Reflect)]
#[reflect(Component)]
pub struct SliderThumb;
#[derive(Component, Debug, Default, PartialEq, Clone, Copy)]
#[component(immutable)]
#[derive(Reflect)]
#[reflect(Component)]
pub struct SliderValue(pub f32);
#[derive(Component, Debug, PartialEq, Clone, Copy)]
#[component(immutable)]
#[derive(Reflect)]
#[reflect(Component)]
pub struct SliderRange {
start: f32,
end: f32,
}
impl SliderRange {
pub fn new(start: f32, end: f32) -> Self {
if end < start {
warn_once!(
"Expected SliderRange::start ({}) <= SliderRange::end ({})",
start,
end
);
}
Self { start, end }
}
pub fn from_range(range: RangeInclusive<f32>) -> Self {
let (start, end) = range.into_inner();
Self { start, end }
}
pub fn start(&self) -> f32 {
self.start
}
pub fn with_start(&self, start: f32) -> Self {
Self::new(start, self.end)
}
pub fn end(&self) -> f32 {
self.end
}
pub fn with_end(&self, end: f32) -> Self {
Self::new(self.start, end)
}
pub fn span(&self) -> f32 {
self.end - self.start
}
pub fn center(&self) -> f32 {
(self.start + self.end) / 2.0
}
pub fn clamp(&self, value: f32) -> f32 {
value.clamp(self.start, self.end)
}
pub fn thumb_position(&self, value: f32) -> f32 {
if self.end > self.start {
(value - self.start) / (self.end - self.start)
} else {
0.5
}
}
}
impl Default for SliderRange {
fn default() -> Self {
Self {
start: 0.0,
end: 1.0,
}
}
}
#[derive(Component, Debug, PartialEq, Clone)]
#[component(immutable)]
#[derive(Reflect)]
#[reflect(Component)]
pub struct SliderStep(pub f32);
impl Default for SliderStep {
fn default() -> Self {
Self(1.0)
}
}
#[derive(Component, Debug, Default, Clone, Copy, Reflect)]
#[reflect(Component, Default)]
pub struct SliderPrecision(pub i32);
impl SliderPrecision {
fn round(&self, value: f32) -> f32 {
let factor = ops::powf(10.0_f32, self.0 as f32);
(value * factor).round() / factor
}
}
#[derive(Component, Default, Reflect)]
#[reflect(Component)]
pub struct SliderDragState {
pub dragging: bool,
offset: f32,
}
pub(crate) fn slider_on_pointer_down(
mut press: On<PointerPress>,
q_slider: Query<(
Entity,
&Slider,
&SliderValue,
&SliderRange,
&SliderStep,
Option<&SliderPrecision>,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
Has<InteractionDisabled>,
)>,
q_thumb: Query<&ComputedNode, With<SliderThumb>>,
q_children: Query<&Children>,
mut commands: Commands,
ui_scale: Res<UiScale>,
) {
if q_thumb.contains(press.entity) {
press.propagate(false);
} else if let Ok((
slider_ent,
slider,
value,
range,
step,
precision,
node,
node_target,
transform,
disabled,
)) = q_slider.get(press.entity)
{
press.propagate(false);
if disabled {
return;
}
commands.entity(slider_ent).insert(Pressed);
let is_vertical = slider.orientation.is_vertical(node);
let thumb_size = q_children
.iter_descendants(press.entity)
.find_map(|child_id| {
q_thumb.get(child_id).ok().map(|thumb| {
if is_vertical {
thumb.size().y
} else {
thumb.size().x
}
})
})
.unwrap_or(0.0);
let Some(normalized_pos) = node.normalize_point(
*transform,
press.pointer.position * node_target.scale_factor() / ui_scale.0,
) else {
return;
};
let track_size = if is_vertical {
node.size().y - thumb_size
} else {
node.size().x - thumb_size
};
let click_val = if track_size > 0. {
if is_vertical {
let y_from_bottom = (0.5 - normalized_pos.y) * node.size().y;
let adjusted_y = y_from_bottom - thumb_size / 2.0;
adjusted_y * range.span() / track_size + range.start()
} else {
let x_from_left = (normalized_pos.x + 0.5) * node.size().x;
let adjusted_x = x_from_left - thumb_size / 2.0;
adjusted_x * range.span() / track_size + range.start()
}
} else {
range.center()
};
let new_value = range.clamp(match slider.track_click {
TrackClick::Drag => {
return;
}
TrackClick::Step => {
if click_val < value.0 {
value.0 - step.0
} else {
value.0 + step.0
}
}
TrackClick::Snap => precision
.map(|prec| prec.round(click_val))
.unwrap_or(click_val),
});
commands.trigger(ValueChange {
source: press.entity,
value: new_value,
is_final: false,
});
}
}
pub(crate) fn slider_on_drag_start(
mut drag_start: On<PointerDragStart>,
mut q_slider: Query<
(&SliderValue, &mut SliderDragState, Has<InteractionDisabled>),
With<Slider>,
>,
mut capture_map: ResMut<PointerCaptureMap>,
) {
if let Ok((value, mut drag, disabled)) = q_slider.get_mut(drag_start.entity) {
drag_start.propagate(false);
if !disabled {
drag.dragging = true;
drag.offset = value.0;
capture_map.capture(
drag_start.pointer.id,
drag_start.entity,
drag_start.hit.clone(),
);
}
}
}
pub(crate) fn slider_on_drag(
mut event: On<PointerDrag>,
q_slider: Query<
(
&Slider,
&ComputedNode,
&SliderRange,
Option<&SliderPrecision>,
&UiGlobalTransform,
&SliderDragState,
Has<InteractionDisabled>,
),
With<Slider>,
>,
q_thumb: Query<&ComputedNode, With<SliderThumb>>,
q_children: Query<&Children>,
mut commands: Commands,
ui_scale: Res<UiScale>,
) {
if let Ok((slider, node, range, precision, transform, drag, disabled)) =
q_slider.get(event.entity)
{
event.propagate(false);
if drag.dragging && !disabled {
emit_slider_drag_value_change(
&mut commands,
event.entity,
slider,
node,
range,
precision,
transform,
drag,
&q_thumb,
&q_children,
&ui_scale,
event.distance,
false,
);
}
}
}
pub(crate) fn slider_on_drag_end(
mut drag_end: On<PointerDragEnd>,
mut capture_map: ResMut<PointerCaptureMap>,
mut q_slider: Query<
(
Entity,
&Slider,
&ComputedNode,
&SliderRange,
Option<&SliderPrecision>,
&UiGlobalTransform,
&mut SliderDragState,
Has<InteractionDisabled>,
),
With<Slider>,
>,
q_thumb: Query<&ComputedNode, With<SliderThumb>>,
q_children: Query<&Children>,
mut commands: Commands,
ui_scale: Res<UiScale>,
) {
if let Ok((slider_ent, slider, node, range, precision, transform, mut drag, disabled)) =
q_slider.get_mut(drag_end.entity)
{
drag_end.propagate(false);
if drag.dragging {
if !disabled {
emit_slider_drag_value_change(
&mut commands,
drag_end.entity,
slider,
node,
range,
precision,
transform,
&drag,
&q_thumb,
&q_children,
&ui_scale,
drag_end.distance,
true,
);
}
commands.entity(slider_ent).remove::<Pressed>();
drag.dragging = false;
capture_map.release(drag_end.pointer.id);
}
}
}
fn emit_slider_drag_value_change(
commands: &mut Commands,
entity: Entity,
slider: &Slider,
node: &ComputedNode,
range: &SliderRange,
precision: Option<&SliderPrecision>,
transform: &UiGlobalTransform,
drag: &SliderDragState,
q_thumb: &Query<&ComputedNode, With<SliderThumb>>,
q_children: &Query<&Children>,
ui_scale: &UiScale,
distance: bevy_math::Vec2,
is_final: bool,
) {
let is_vertical = slider.orientation.is_vertical(node);
let mut distance = distance / ui_scale.0;
distance.y *= -1.;
let distance = transform.transform_vector2(distance);
let thumb_size = q_children
.iter_descendants(entity)
.find_map(|child_id| {
q_thumb.get(child_id).ok().map(|thumb| {
if is_vertical {
thumb.size().y
} else {
thumb.size().x
}
})
})
.unwrap_or(0.0);
let slider_size = if is_vertical {
((node.size().y - thumb_size) * node.inverse_scale_factor).max(1.0)
} else {
((node.size().x - thumb_size) * node.inverse_scale_factor).max(1.0)
};
let drag_distance = if is_vertical { distance.y } else { distance.x };
let span = range.span();
let new_value = if span > 0. {
drag.offset + (drag_distance * span) / slider_size
} else {
range.start() + span * 0.5
};
let rounded_value = range.clamp(
precision
.map(|prec| prec.round(new_value))
.unwrap_or(new_value),
);
commands.trigger(ValueChange {
source: entity,
value: rounded_value,
is_final,
});
}
fn slider_on_pointer_up(
mut release: On<PointerRelease>,
mut q_slider: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<Slider>>,
mut commands: Commands,
) {
if let Ok((slider, disabled, pressed)) = q_slider.get_mut(release.entity) {
release.propagate(false);
if !disabled && pressed {
commands.entity(slider).remove::<Pressed>();
}
}
}
fn slider_on_pointer_cancel(
mut release: On<PointerCancel>,
mut q_slider: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<Slider>>,
mut commands: Commands,
) {
if let Ok((slider, disabled, pressed)) = q_slider.get_mut(release.entity) {
release.propagate(false);
if !disabled && pressed {
commands.entity(slider).remove::<Pressed>();
}
}
}
fn slider_on_key_input(
mut focused_input: On<FocusedInput<KeyboardInput>>,
q_slider: Query<
(
&SliderValue,
&SliderRange,
&SliderStep,
Has<InteractionDisabled>,
),
With<Slider>,
>,
mut commands: Commands,
) {
if let Ok((value, range, step, disabled)) = q_slider.get(focused_input.focused_entity) {
let input_event = &focused_input.input;
if !disabled && input_event.state == ButtonState::Pressed {
let new_value = match input_event.key_code {
KeyCode::ArrowLeft => range.clamp(value.0 - step.0),
KeyCode::ArrowRight => range.clamp(value.0 + step.0),
KeyCode::Home => range.start(),
KeyCode::End => range.end(),
_ => {
return;
}
};
focused_input.propagate(false);
commands.trigger(ValueChange {
source: focused_input.focused_entity,
value: new_value,
is_final: true,
});
}
}
}
pub(crate) fn slider_on_insert(insert: On<Insert<Slider>>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(insert.entity);
let orientation = entity
.get::<Slider>()
.map(|s| s.orientation)
.unwrap_or_default();
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
let a11y_orientation = match orientation {
SliderOrientation::Vertical => Orientation::Vertical,
_ => Orientation::Horizontal,
};
accessibility.set_orientation(a11y_orientation);
}
}
pub(crate) fn slider_on_insert_value(insert: On<Insert<SliderValue>>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(insert.entity);
let value = entity.get::<SliderValue>().unwrap().0;
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_numeric_value(value.into());
}
}
pub(crate) fn slider_on_insert_range(insert: On<Insert<SliderRange>>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(insert.entity);
let range = *entity.get::<SliderRange>().unwrap();
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_min_numeric_value(range.start().into());
accessibility.set_max_numeric_value(range.end().into());
}
}
pub(crate) fn slider_on_insert_step(insert: On<Insert<SliderStep>>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(insert.entity);
let step = entity.get::<SliderStep>().unwrap().0;
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_numeric_value_step(step.into());
}
}
#[derive(EntityEvent, Clone, Reflect)]
#[reflect(Event)]
pub struct SetSliderValue {
pub entity: Entity,
pub change: SliderValueChange,
}
#[derive(Clone, Reflect)]
pub enum SliderValueChange {
Absolute(f32),
Relative(f32),
RelativeStep(f32),
}
fn slider_on_set_value(
set_slider_value: On<SetSliderValue>,
q_slider: Query<(&SliderValue, &SliderRange, Option<&SliderStep>), With<Slider>>,
mut commands: Commands,
) {
if let Ok((value, range, step)) = q_slider.get(set_slider_value.entity) {
let new_value = match set_slider_value.change {
SliderValueChange::Absolute(new_value) => range.clamp(new_value),
SliderValueChange::Relative(delta) => range.clamp(value.0 + delta),
SliderValueChange::RelativeStep(delta) => {
range.clamp(value.0 + delta * step.map(|s| s.0).unwrap_or_default())
}
};
if new_value != value.0 {
commands.trigger(ValueChange {
source: set_slider_value.entity,
value: new_value,
is_final: true,
});
}
}
}
pub fn slider_self_update(value_change: On<ValueChange<f32>>, mut commands: Commands) {
commands
.entity(value_change.source)
.insert(SliderValue(value_change.value));
}
pub struct SliderPlugin;
impl Plugin for SliderPlugin {
fn build(&self, app: &mut App) {
app.add_observer(slider_on_pointer_down)
.add_observer(slider_on_pointer_up)
.add_observer(slider_on_pointer_cancel)
.add_observer(slider_on_drag_start)
.add_observer(slider_on_drag_end)
.add_observer(slider_on_drag)
.add_observer(slider_on_key_input)
.add_observer(slider_on_insert)
.add_observer(slider_on_insert_value)
.add_observer(slider_on_insert_range)
.add_observer(slider_on_insert_step)
.add_observer(slider_on_set_value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_camera::NormalizedRenderTarget;
use bevy_ecs::hierarchy::ChildOf;
use bevy_input::keyboard::Key;
use bevy_input::InputPlugin;
use bevy_input_focus::{
tab_navigation::{TabIndex, TabNavigationPlugin},
FocusCause, InputDispatchPlugin, InputFocus, InputFocusPlugin,
};
use bevy_math::Vec2;
use bevy_picking::{
backend::HitData,
events::Pointer,
pointer::{Location, PointerButton, PointerId},
};
use bevy_window::{PrimaryWindow, Window};
fn slider_app() -> (App, Entity) {
let mut app = App::new();
app.add_plugins((
InputPlugin,
InputFocusPlugin,
InputDispatchPlugin,
TabNavigationPlugin,
SliderPlugin,
));
app.add_observer(slider_self_update);
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
app.update();
(app, window)
}
fn spawn_focused_slider(app: &mut App, window: Entity) -> Entity {
let slider = app
.world_mut()
.spawn((
Slider::default(),
SliderValue(50.0),
SliderRange::new(0.0, 100.0),
SliderStep(10.0),
TabIndex(0),
ChildOf(window),
))
.id();
app.world_mut()
.resource_mut::<InputFocus>()
.set(slider, FocusCause::Navigated);
app.update();
slider
}
#[test]
fn slider_captures_pointer_on_drag_start() {
let (mut app, window) = slider_app();
let slider = spawn_focused_slider(&mut app, window);
let camera = app.world_mut().spawn_empty().id();
app.init_resource::<PointerCaptureMap>();
let hit = HitData {
camera,
depth: 0.0,
position: None,
normal: None,
extra: None,
};
let pointer = Pointer::new(
PointerId::Mouse,
Location {
target: NormalizedRenderTarget::None {
width: 0,
height: 0,
},
position: Vec2::ZERO,
},
);
app.world_mut().trigger(PointerDragStart {
entity: slider,
pointer,
button: PointerButton::Primary,
hit,
});
let capture_map = app.world().resource::<PointerCaptureMap>();
let (captured_entity, _) = capture_map
.get(&PointerId::Mouse)
.expect("slider should capture the pointer on drag start");
assert_eq!(captured_entity, slider);
}
fn press_key(app: &mut App, key_code: KeyCode, logical_key: Key, window: Entity) {
app.world_mut().write_message(KeyboardInput {
key_code,
logical_key,
state: ButtonState::Pressed,
text: None,
repeat: false,
window,
});
app.update();
}
fn slider_value(app: &App, slider: Entity) -> f32 {
app.world().entity(slider).get::<SliderValue>().unwrap().0
}
#[test]
fn arrow_keys_change_focused_slider_value() {
let (mut app, window) = slider_app();
let slider = spawn_focused_slider(&mut app, window);
press_key(&mut app, KeyCode::ArrowRight, Key::ArrowRight, window);
assert_eq!(
slider_value(&app, slider),
60.0,
"Right arrow adds one step"
);
press_key(&mut app, KeyCode::ArrowLeft, Key::ArrowLeft, window);
press_key(&mut app, KeyCode::ArrowLeft, Key::ArrowLeft, window);
assert_eq!(
slider_value(&app, slider),
40.0,
"Left arrow subtracts one step each press"
);
}
#[test]
fn home_end_keys_jump_slider_to_extremes() {
let (mut app, window) = slider_app();
let slider = spawn_focused_slider(&mut app, window);
press_key(&mut app, KeyCode::Home, Key::Home, window);
assert_eq!(slider_value(&app, slider), 0.0, "Home jumps to range start");
press_key(&mut app, KeyCode::End, Key::End, window);
assert_eq!(slider_value(&app, slider), 100.0, "End jumps to range end");
}
#[test]
fn arrow_keys_clamp_at_slider_bounds() {
let (mut app, window) = slider_app();
let slider = spawn_focused_slider(&mut app, window);
press_key(&mut app, KeyCode::End, Key::End, window); press_key(&mut app, KeyCode::ArrowRight, Key::ArrowRight, window); assert_eq!(slider_value(&app, slider), 100.0, "cannot exceed range end");
}
#[test]
fn arrow_keys_do_nothing_without_focus() {
let (mut app, window) = slider_app();
let slider = spawn_focused_slider(&mut app, window);
app.world_mut().resource_mut::<InputFocus>().clear();
press_key(&mut app, KeyCode::ArrowRight, Key::ArrowRight, window);
assert_eq!(
slider_value(&app, slider),
50.0,
"an unfocused slider must not respond to arrow keys"
);
}
#[test]
fn disabled_slider_ignores_arrow_keys() {
let (mut app, window) = slider_app();
let slider = app
.world_mut()
.spawn((
Slider::default(),
SliderValue(50.0),
SliderRange::new(0.0, 100.0),
SliderStep(10.0),
InteractionDisabled,
TabIndex(0),
ChildOf(window),
))
.id();
app.world_mut()
.resource_mut::<InputFocus>()
.set(slider, FocusCause::Navigated);
app.update();
press_key(&mut app, KeyCode::ArrowRight, Key::ArrowRight, window);
assert_eq!(
slider_value(&app, slider),
50.0,
"a disabled slider must not respond to arrow keys"
);
}
#[test]
fn test_slider_precision_rounding() {
let precision_2dp = SliderPrecision(2);
assert_eq!(precision_2dp.round(1.234567), 1.23);
assert_eq!(precision_2dp.round(1.235), 1.24);
let precision_0dp = SliderPrecision(0);
assert_eq!(precision_0dp.round(1.4), 1.0);
let precision_neg1 = SliderPrecision(-1);
assert_eq!(precision_neg1.round(14.0), 10.0);
}
}