use super::value::{format_numeric_value, range_progress, snap_numeric_value};
use super::{
DisabledInput, InputField, InputValueChangedMessage, RangeFill, RangeState, RangeThumb,
RangeTrack, push_range_value_changed,
};
use bevy::picking::Pickable;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, ComputedUiRenderTargetInfo, UiGlobalTransform, UiScale, Val::Px};
pub(crate) const RANGE_TRACK_WIDTH: f32 = 240.0;
pub(crate) const RANGE_TRACK_HEIGHT: f32 = 32.0;
const RANGE_THUMB_VISUAL_NUDGE_Y: f32 = -1.0;
const RANGE_FILL_PADDING_Y: f32 = 3.0;
const RANGE_THUMB_PADDING: f32 = 4.0;
pub(crate) fn range_track_inset() -> f32 {
crate::style::regular_border_width()
}
fn range_fill_inset_y() -> f32 {
range_track_inset() + RANGE_FILL_PADDING_Y
}
pub(crate) fn range_thumb_size() -> f32 {
(RANGE_TRACK_HEIGHT - range_track_inset() * 2.0 - RANGE_THUMB_PADDING * 2.0).max(12.0)
}
fn centered_thumb_top(track_height: f32, thumb_size: f32) -> f32 {
((track_height - thumb_size) * 0.5 + RANGE_THUMB_VISUAL_NUDGE_Y).max(0.0)
}
fn slider_theme() -> &'static crate::style::UiThemeConfig {
crate::style::ui_theme()
}
pub(crate) fn spawn_range_track(world: &mut World, input: Entity) -> Entity {
let slider = &slider_theme().control.slider;
let mut node = Node {
min_width: Px(120.0),
max_width: Px(RANGE_TRACK_WIDTH),
height: Px(RANGE_TRACK_HEIGHT),
flex_grow: 1.0,
overflow: Overflow::clip(),
..default()
};
node.border = UiRect::all(Px(range_track_inset()));
node.border_radius = BorderRadius::MAX;
world
.spawn((
RangeTrack { input },
node,
BorderColor::all(slider.track.border.to_bevy()),
BackgroundColor(slider.track.background.to_bevy()),
Visibility::Visible,
))
.observe(range_track_press)
.observe(range_track_drag_start)
.observe(range_track_drag)
.observe(range_track_drag_end)
.id()
}
pub(crate) fn spawn_range_fill(world: &mut World) -> Entity {
let slider = &slider_theme().control.slider;
world
.spawn((
RangeFill,
Pickable::IGNORE,
{
let mut node = Node {
position_type: PositionType::Absolute,
left: Px(range_track_inset()),
top: Px(range_fill_inset_y()),
bottom: Px(range_fill_inset_y()),
width: Px(0.0),
..default()
};
node.border_radius = BorderRadius::MAX;
node
},
BackgroundColor(slider.fill_background.to_bevy()),
))
.id()
}
pub(crate) fn spawn_range_thumb(world: &mut World) -> Entity {
let slider = &slider_theme().control.slider;
let size = range_thumb_size();
world
.spawn((
RangeThumb,
Pickable::IGNORE,
{
let mut node = Node {
position_type: PositionType::Absolute,
left: Px(range_track_inset()),
top: Px(centered_thumb_top(RANGE_TRACK_HEIGHT, size)),
width: Px(size),
height: Px(size),
..default()
};
node.border_radius = BorderRadius::MAX;
node
},
BorderColor::all(slider.thumb_border.to_bevy()),
BackgroundColor(slider.thumb_background.to_bevy()),
))
.id()
}
pub(crate) fn sync_range_visuals(
fields: Query<(Entity, &InputField, &RangeState), Or<(Changed<InputField>, Added<InputField>)>>,
track_nodes: Query<&ComputedNode, With<RangeTrack>>,
thumb_nodes: Query<&ComputedNode, With<RangeThumb>>,
mut nodes: Query<&mut Node>,
) {
for (_entity, field, range_state) in &fields {
let Ok(track_node) = track_nodes.get(range_state.track) else {
continue;
};
let thumb_size = thumb_nodes
.get(range_state.thumb)
.map(computed_node_logical_width)
.unwrap_or_else(|_| range_thumb_size());
let thumb_height = thumb_nodes
.get(range_state.thumb)
.map(computed_node_logical_height)
.unwrap_or(thumb_size);
let min = field.min.unwrap_or(0.0);
let max = field.max.unwrap_or(1.0);
let value = field.numeric_value().unwrap_or(min);
let progress = range_progress(value, min, max);
let inset = range_track_inset();
let track_width = computed_node_logical_width(track_node);
let track_height = computed_node_logical_height(track_node);
let travel = (track_width - inset * 2.0 - thumb_size).max(1.0);
let fill_width = inset + thumb_size * 0.5 + travel * progress;
let thumb_left = inset + travel * progress;
let thumb_top = centered_thumb_top(track_height, thumb_height);
if let Ok(mut fill_node) = nodes.get_mut(range_state.fill) {
fill_node.width = Px(snap_slider_pixel(fill_width.max(0.0)));
}
if let Ok(mut thumb_node) = nodes.get_mut(range_state.thumb) {
thumb_node.left = Px(snap_slider_pixel(thumb_left.max(0.0)));
thumb_node.top = Px(snap_slider_pixel(thumb_top));
}
}
}
fn range_track_press(
mut event: On<Pointer<Press>>,
tracks: Query<(
&RangeTrack,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
)>,
mut fields: Query<(&mut InputField, Has<DisabledInput>)>,
mut range_states: Query<&mut RangeState>,
mut value_changed: MessageWriter<InputValueChangedMessage>,
thumbs: Query<&ComputedNode, With<RangeThumb>>,
ui_scale: Res<UiScale>,
) {
let Ok((track, node, node_target, transform)) = tracks.get(event.entity) else {
return;
};
event.propagate(false);
let Ok((mut field, disabled)) = fields.get_mut(track.input) else {
return;
};
if disabled {
return;
}
let mut range_state = range_states
.get_mut(track.input)
.expect("range state missing");
range_state.drag_start_value = field.numeric_value().unwrap_or(field.min.unwrap_or(0.0));
let thumb_size = thumbs
.get(range_state.thumb)
.map(computed_node_logical_width)
.unwrap_or_else(|_| range_thumb_size());
let value = pointer_position_to_range_value(
event.pointer_location.position,
node,
node_target.scale_factor(),
transform,
&field,
thumb_size,
&ui_scale,
);
commit_range_value(track.input, &mut field, value, &mut value_changed);
}
fn range_track_drag_start(
mut event: On<Pointer<DragStart>>,
tracks: Query<&RangeTrack>,
mut fields: Query<(&mut InputField, Has<DisabledInput>)>,
mut range_states: Query<&mut RangeState>,
) {
let Ok(track) = tracks.get(event.entity) else {
return;
};
event.propagate(false);
let Ok((field, disabled)) = fields.get_mut(track.input) else {
return;
};
if disabled {
return;
}
let mut range_state = range_states
.get_mut(track.input)
.expect("range state missing");
range_state.drag_start_value = field.numeric_value().unwrap_or(field.min.unwrap_or(0.0));
}
fn range_track_drag(
mut event: On<Pointer<Drag>>,
tracks: Query<(&RangeTrack, &ComputedNode)>,
mut fields: Query<(&mut InputField, Has<DisabledInput>)>,
range_states: Query<&RangeState>,
mut value_changed: MessageWriter<InputValueChangedMessage>,
thumbs: Query<&ComputedNode, With<RangeThumb>>,
ui_scale: Res<UiScale>,
) {
let Ok((track, node)) = tracks.get(event.entity) else {
return;
};
event.propagate(false);
let Ok((mut field, disabled)) = fields.get_mut(track.input) else {
return;
};
if disabled {
return;
}
let range_state = range_states.get(track.input).expect("range state missing");
let thumb_size = thumbs
.get(range_state.thumb)
.map(computed_node_logical_width)
.unwrap_or_else(|_| range_thumb_size());
let value = drag_distance_to_range_value(
&field,
range_state.drag_start_value,
computed_node_logical_width(node),
thumb_size,
event.distance.x,
&ui_scale,
);
commit_range_value(track.input, &mut field, value, &mut value_changed);
}
fn range_track_drag_end(mut event: On<Pointer<DragEnd>>) {
event.propagate(false);
}
fn commit_range_value(
entity: Entity,
field: &mut InputField,
value: f32,
value_changed: &mut MessageWriter<InputValueChangedMessage>,
) {
let snapped = snap_numeric_value(value, field.min, field.max, field.step);
let next_value = format_numeric_value(snapped, field.step);
if field.value() == next_value {
return;
}
field.set_value(next_value.clone());
push_range_value_changed(value_changed, entity, &field.name, next_value);
}
fn pointer_position_to_range_value(
pointer_position: Vec2,
node: &ComputedNode,
target_scale_factor: f32,
transform: &UiGlobalTransform,
field: &InputField,
thumb_size: f32,
ui_scale: &UiScale,
) -> f32 {
let Some(inverse_transform) = transform.try_inverse() else {
return field.numeric_value().unwrap_or(field.min.unwrap_or(0.0));
};
let local_pos =
inverse_transform.transform_point2(pointer_position * target_scale_factor / ui_scale.0);
let track_width = computed_node_logical_width(node);
let x_from_left = local_pos.x * node.inverse_scale_factor() + track_width * 0.5;
let adjusted_x = x_from_left - range_track_inset() - thumb_size * 0.5;
let travel = (track_width - range_track_inset() * 2.0 - thumb_size).max(1.0);
let min = field.min.unwrap_or(0.0);
let max = field.max.unwrap_or(1.0);
min + (adjusted_x / travel).clamp(0.0, 1.0) * (max - min)
}
fn computed_node_logical_width(node: &ComputedNode) -> f32 {
node.size().x * node.inverse_scale_factor()
}
fn computed_node_logical_height(node: &ComputedNode) -> f32 {
node.size().y * node.inverse_scale_factor()
}
fn drag_distance_to_range_value(
field: &InputField,
drag_start_value: f32,
track_width: f32,
thumb_size: f32,
drag_distance_x: f32,
ui_scale: &UiScale,
) -> f32 {
let travel = (track_width - range_track_inset() * 2.0 - thumb_size).max(1.0);
let min = field.min.unwrap_or(0.0);
let max = field.max.unwrap_or(1.0);
let span = max - min;
if span <= f32::EPSILON {
return min;
}
let logical_drag_distance = drag_distance_x / ui_scale.0;
(drag_start_value + (logical_drag_distance / travel) * span).clamp(min, max)
}
fn snap_slider_pixel(value: f32) -> f32 {
value.round()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::edit::TextEditState;
fn range_field(min: f32, max: f32, step: f32, value: &str) -> InputField {
InputField {
name: "range".to_string(),
input_type: super::super::InputType::Range,
checked: false,
placeholder: String::new(),
viewport_entity: None,
text_entity: None,
selection_entity: None,
caret_entity: None,
edit_state: TextEditState::with_text(value),
initial_value: value.to_string(),
initial_checked: false,
min: Some(min),
max: Some(max),
step: Some(step),
caret_blink_resume_at: 0.0,
preferred_caret_x: None,
undo_stack: crate::input::UndoStack::default(),
}
}
fn computed_node(logical_width: f32, scale: f32) -> ComputedNode {
ComputedNode {
size: Vec2::new(logical_width * scale, RANGE_TRACK_HEIGHT * scale),
inverse_scale_factor: scale.recip(),
..default()
}
}
fn pointer_for_logical_x(x_from_left: f32, logical_width: f32, scale: f32) -> Vec2 {
Vec2::new((x_from_left - logical_width * 0.5) * scale, 0.0)
}
fn value_at_logical_x(x_from_left: f32, scale: f32) -> f32 {
let logical_width = RANGE_TRACK_WIDTH;
let node = computed_node(logical_width, scale);
let field = range_field(0.0, 100.0, 10.0, "0");
pointer_position_to_range_value(
pointer_for_logical_x(x_from_left, logical_width, scale),
&node,
scale,
&UiGlobalTransform::default(),
&field,
range_thumb_size(),
&UiScale(scale),
)
}
#[test]
fn pointer_position_to_range_value_maps_midpoint_at_default_scale() {
assert_eq!(
value_at_logical_x(RANGE_TRACK_WIDTH * 0.5, 1.0).round(),
50.0
);
}
#[test]
fn pointer_position_to_range_value_keeps_logical_progress_under_ui_scale() {
let x_from_left = RANGE_TRACK_WIDTH * 0.7;
assert_eq!(
value_at_logical_x(x_from_left, 1.0).round(),
value_at_logical_x(x_from_left, 1.3).round()
);
}
#[test]
fn pointer_position_to_range_value_snaps_after_commit() {
let travel = RANGE_TRACK_WIDTH - range_track_inset() * 2.0 - range_thumb_size();
let x_from_left = range_track_inset() + range_thumb_size() * 0.5 + travel * 0.74;
let value = value_at_logical_x(x_from_left, 1.3);
assert_eq!(
format_numeric_value(
snap_numeric_value(value, Some(0.0), Some(100.0), Some(10.0)),
Some(10.0)
),
"70"
);
}
#[test]
fn centered_thumb_top_applies_visual_nudge() {
let top = centered_thumb_top(RANGE_TRACK_HEIGHT, range_thumb_size());
assert!(top < (RANGE_TRACK_HEIGHT - range_thumb_size()) * 0.5);
assert!(top >= 0.0);
}
#[test]
fn drag_distance_to_range_value_uses_total_drag_distance() {
let field = InputField {
..range_field(0.0, 100.0, 10.0, "40")
};
let track_width = RANGE_TRACK_WIDTH;
let thumb_size = range_thumb_size();
let travel = track_width - range_track_inset() * 2.0 - thumb_size;
let value = drag_distance_to_range_value(
&field,
40.0,
track_width,
thumb_size,
travel * 0.2,
&UiScale(1.0),
);
assert!((value - 60.0).abs() < 0.001);
}
}