SliderPrecision component (#20032)

This PR adds a `SliderPrecision` component that lets you control the
rounding when dragging a slider.

Part of #19236
This commit is contained in:
Talin 2025-07-09 13:06:58 -07:00 committed by GitHub
parent c65ef19b7c
commit f2d25355c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 75 additions and 12 deletions

View File

@ -19,6 +19,7 @@ use bevy_input::keyboard::{KeyCode, KeyboardInput};
use bevy_input::ButtonState; use bevy_input::ButtonState;
use bevy_input_focus::FocusedInput; use bevy_input_focus::FocusedInput;
use bevy_log::warn_once; use bevy_log::warn_once;
use bevy_math::ops;
use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press}; use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press};
use bevy_ui::{ComputedNode, ComputedNodeTarget, InteractionDisabled, UiGlobalTransform, UiScale}; use bevy_ui::{ComputedNode, ComputedNodeTarget, InteractionDisabled, UiGlobalTransform, UiScale};
@ -38,7 +39,8 @@ pub enum TrackClick {
/// A headless slider widget, which can be used to build custom sliders. Sliders have a value /// A headless slider widget, which can be used to build custom sliders. Sliders have a value
/// (represented by the [`SliderValue`] component) and a range (represented by [`SliderRange`]). An /// (represented by the [`SliderValue`] component) and a range (represented by [`SliderRange`]). An
/// optional step size can be specified via [`SliderStep`]. /// optional step size can be specified via [`SliderStep`], and you can control the rounding
/// during dragging with [`SliderPrecision`].
/// ///
/// You can also control the slider remotely by triggering a [`SetSliderValue`] event on it. This /// You can also control the slider remotely by triggering a [`SetSliderValue`] event on it. This
/// can be useful in a console environment for controlling the value gamepad inputs. /// can be useful in a console environment for controlling the value gamepad inputs.
@ -187,6 +189,25 @@ impl Default for SliderStep {
} }
} }
/// A component which controls the rounding of the slider value during dragging.
///
/// Stepping is not affected, although presumably the step size will be an integer multiple of the
/// rounding factor. This also doesn't prevent the slider value from being set to non-rounded values
/// by other means, such as manually entering digits via a numeric input field.
///
/// The value in this component represents the number of decimal places of desired precision, so a
/// value of 2 would round to the nearest 1/100th. A value of -3 would round to the nearest
/// thousand.
#[derive(Component, Debug, Default, Clone, Copy)]
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
}
}
/// Component used to manage the state of a slider during dragging. /// Component used to manage the state of a slider during dragging.
#[derive(Component, Default)] #[derive(Component, Default)]
pub struct CoreSliderDragState { pub struct CoreSliderDragState {
@ -204,6 +225,7 @@ pub(crate) fn slider_on_pointer_down(
&SliderValue, &SliderValue,
&SliderRange, &SliderRange,
&SliderStep, &SliderStep,
Option<&SliderPrecision>,
&ComputedNode, &ComputedNode,
&ComputedNodeTarget, &ComputedNodeTarget,
&UiGlobalTransform, &UiGlobalTransform,
@ -217,8 +239,17 @@ pub(crate) fn slider_on_pointer_down(
if q_thumb.contains(trigger.target()) { if q_thumb.contains(trigger.target()) {
// Thumb click, stop propagation to prevent track click. // Thumb click, stop propagation to prevent track click.
trigger.propagate(false); trigger.propagate(false);
} else if let Ok((slider, value, range, step, node, node_target, transform, disabled)) = } else if let Ok((
q_slider.get(trigger.target()) slider,
value,
range,
step,
precision,
node,
node_target,
transform,
disabled,
)) = q_slider.get(trigger.target())
{ {
// Track click // Track click
trigger.propagate(false); trigger.propagate(false);
@ -257,7 +288,9 @@ pub(crate) fn slider_on_pointer_down(
value.0 + step.0 value.0 + step.0
} }
} }
TrackClick::Snap => click_val, TrackClick::Snap => precision
.map(|prec| prec.round(click_val))
.unwrap_or(click_val),
}); });
if matches!(slider.on_change, Callback::Ignore) { if matches!(slider.on_change, Callback::Ignore) {
@ -296,6 +329,7 @@ pub(crate) fn slider_on_drag(
&ComputedNode, &ComputedNode,
&CoreSlider, &CoreSlider,
&SliderRange, &SliderRange,
Option<&SliderPrecision>,
&UiGlobalTransform, &UiGlobalTransform,
&mut CoreSliderDragState, &mut CoreSliderDragState,
Has<InteractionDisabled>, Has<InteractionDisabled>,
@ -305,7 +339,8 @@ pub(crate) fn slider_on_drag(
mut commands: Commands, mut commands: Commands,
ui_scale: Res<UiScale>, ui_scale: Res<UiScale>,
) { ) {
if let Ok((node, slider, range, transform, drag, disabled)) = q_slider.get_mut(trigger.target()) if let Ok((node, slider, range, precision, transform, drag, disabled)) =
q_slider.get_mut(trigger.target())
{ {
trigger.propagate(false); trigger.propagate(false);
if drag.dragging && !disabled { if drag.dragging && !disabled {
@ -320,17 +355,22 @@ pub(crate) fn slider_on_drag(
let slider_width = ((node.size().x - thumb_size) * node.inverse_scale_factor).max(1.0); let slider_width = ((node.size().x - thumb_size) * node.inverse_scale_factor).max(1.0);
let span = range.span(); let span = range.span();
let new_value = if span > 0. { let new_value = if span > 0. {
range.clamp(drag.offset + (distance.x * span) / slider_width) drag.offset + (distance.x * span) / slider_width
} else { } else {
range.start() + span * 0.5 range.start() + span * 0.5
}; };
let rounded_value = range.clamp(
precision
.map(|prec| prec.round(new_value))
.unwrap_or(new_value),
);
if matches!(slider.on_change, Callback::Ignore) { if matches!(slider.on_change, Callback::Ignore) {
commands commands
.entity(trigger.target()) .entity(trigger.target())
.insert(SliderValue(new_value)); .insert(SliderValue(rounded_value));
} else { } else {
commands.notify_with(&slider.on_change, new_value); commands.notify_with(&slider.on_change, rounded_value);
} }
} }
} }
@ -491,3 +531,24 @@ impl Plugin for CoreSliderPlugin {
.add_observer(slider_on_set_value); .add_observer(slider_on_set_value);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slider_precision_rounding() {
// Test positive precision values (decimal places)
let precision_2dp = SliderPrecision(2);
assert_eq!(precision_2dp.round(1.234567), 1.23);
assert_eq!(precision_2dp.round(1.235), 1.24);
// Test zero precision (rounds to integers)
let precision_0dp = SliderPrecision(0);
assert_eq!(precision_0dp.round(1.4), 1.0);
// Test negative precision (rounds to tens, hundreds, etc.)
let precision_neg1 = SliderPrecision(-1);
assert_eq!(precision_neg1.round(14.0), 10.0);
}
}

View File

@ -33,7 +33,7 @@ pub use core_scrollbar::{
}; };
pub use core_slider::{ pub use core_slider::{
CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue, CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue,
SliderRange, SliderStep, SliderValue, TrackClick, SliderPrecision, SliderRange, SliderStep, SliderValue, TrackClick,
}; };
/// A plugin group that registers the observers for all of the core widgets. If you don't want to /// A plugin group that registers the observers for all of the core widgets. If you don't want to

View File

@ -1,7 +1,9 @@
//! This example shows off the various Bevy Feathers widgets. //! This example shows off the various Bevy Feathers widgets.
use bevy::{ use bevy::{
core_widgets::{Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugins, SliderStep}, core_widgets::{
Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugins, SliderPrecision, SliderStep,
},
feathers::{ feathers::{
controls::{ controls::{
button, checkbox, radio, slider, toggle_switch, ButtonProps, ButtonVariant, button, checkbox, radio, slider, toggle_switch, ButtonProps, ButtonVariant,
@ -259,7 +261,7 @@ fn demo_root(commands: &mut Commands) -> impl Bundle {
value: 20.0, value: 20.0,
..default() ..default()
}, },
SliderStep(10.) (SliderStep(10.), SliderPrecision(2)),
), ),
] ]
),], ),],

View File

@ -1,7 +1,7 @@
--- ---
title: Headless Widgets title: Headless Widgets
authors: ["@viridia", "@ickshonpe", "@alice-i-cecile"] authors: ["@viridia", "@ickshonpe", "@alice-i-cecile"]
pull_requests: [19366, 19584, 19665, 19778, 19803, 20036] pull_requests: [19366, 19584, 19665, 19778, 19803, 20032, 20036]
--- ---
Bevy's `Button` and `Interaction` components have been around for a long time. Unfortunately Bevy's `Button` and `Interaction` components have been around for a long time. Unfortunately