Path: blob/main/crates/bevy_feathers/src/controls/number_input.rs
30636 views
use bevy_app::PropagateOver;1use bevy_ecs::{2component::Component,3entity::Entity,4event::EntityEvent,5hierarchy::{ChildOf, Children},6observer::On,7query::With,8relationship::Relationship,9system::{Commands, Query, Res},10};11use bevy_input::keyboard::{KeyCode, KeyboardInput};12use bevy_input_focus::{FocusLost, FocusedInput, InputFocus};13use bevy_log::warn;14use bevy_scene::prelude::*;15use bevy_text::{16EditableText, EditableTextFilter, FontSourceTemplate, FontWeight, TextEdit, TextEditChange,17TextFont,18};19use bevy_ui::{px, widget::Text, AlignItems, AlignSelf, Display, JustifyContent, Node, UiRect};20use bevy_ui_widgets::{SelectAllOnFocus, ValueChange};2122use crate::{23constants::{fonts, size},24controls::{FeathersTextInput, FeathersTextInputContainer},25theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeTextColor, ThemeToken},26tokens,27};2829/// Widget that permits text entry of floating-point numbers. This widget implements two-way30/// synchronization:31/// * when the widget has focus, it emits values (via a [`ValueChange<T>`]) event as the user types.32/// The type of ``T`` will be ``f32``, ``f64``, ``i32``, or ``i64`` depending on the33/// ``number_format`` parameter.34/// * when the widget does not have focus, it listens for [`UpdateNumberInput`] events, and replaces35/// the contents of the text buffer based on the value in that event.36///37/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersNumberInputProps`].38///39/// To avoid excessive updating, you should only update the number value when there is an actual40/// change, that is, when the new value is different from the current value.41///42/// In most cases, the actual source of truth for the numeric value will be external, that is,43/// some property in an app-specific data structure. It's the responsibility of the app to44/// synchronize this value with the [`FeathersNumberInput`] widget in both directions:45/// * When a [`ValueChange`] event is received, update the app-specific property.46/// * When the app-specific property changes - either in response to a [`ValueChange`] event, or47/// because of some other action, trigger an [`UpdateNumberInput`] entity event to update the48/// displayed value.49// TODO: Add text_input field validation when it becomes available.50#[derive(SceneComponent, Default, Clone)]51#[scene(FeathersNumberInputProps)]52pub struct FeathersNumberInput;5354/// Props used to construct a [`FeathersNumberInput`] scene.55pub struct FeathersNumberInputProps {56/// The "sigil" is a colored strip along the left edge of the input, which is used to57/// distinguish between different axes. The default is transparent (no sigil).58pub sigil_color: ThemeToken,59/// A caption to be placed on the left side of the input, next to the colored stripe.60/// Usually one of "X", "Y" or "Z".61pub label_text: Option<&'static str>,62/// Indicate what size numbers we are editing.63pub number_format: NumberFormat,64}6566impl Default for FeathersNumberInputProps {67fn default() -> Self {68Self {69sigil_color: tokens::TEXT_INPUT_BG,70label_text: None,71number_format: NumberFormat::F32,72}73}74}7576impl FeathersNumberInput {77fn scene(props: FeathersNumberInputProps) -> impl Scene {78bsn! {79:@FeathersTextInputContainer80ThemeBorderColor({props.sigil_color})81FeathersNumberInput82template_value(props.number_format)83on(number_input_on_update)84Children [85{86match props.label_text {87Some(text) => Box::new(bsn_list!(88Node {89display: Display::Flex,90align_items: AlignItems::Center,91align_self: AlignSelf::Stretch,92justify_content: JustifyContent::Center,93padding: UiRect::axes(px(6), px(0)),94}95ThemeBackgroundColor(tokens::TEXT_INPUT_LABEL_BG)96Children [97Text(text)98TextFont {99font: FontSourceTemplate::Handle(fonts::REGULAR),100font_size: size::COMPACT_FONT,101weight: FontWeight::NORMAL,102}103PropagateOver<TextFont>104ThemeTextColor(tokens::TEXT_INPUT_TEXT)105]106)) as Box<dyn SceneList>,107None => Box::new(bsn_list!()) as Box<dyn SceneList>108}109}110@FeathersTextInput {111@max_characters: 20usize,112}113SelectAllOnFocus114on(number_input_on_text_change)115on(number_input_on_enter_key)116on(number_input_on_focus_loss)117EditableTextFilter::new(|c| {118c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')119}),120]121}122}123}124125/// Used to indicate what format of numbers we are editing. This primarily affects the type126/// of [`ValueChange`] event that is emitted.127#[derive(Component, Default, Clone, Copy)]128pub enum NumberFormat {129/// A 32-bit float130#[default]131F32,132/// A 64-bit float133F64,134/// A 32-bit integer135I32,136/// A 64-bit integer137I64,138}139140/// Represents numbers in different formats.141#[derive(Debug, PartialEq, Clone, Copy)]142pub enum NumberInputValue {143/// An f32 value144F32(f32),145/// An f64 value146F64(f64),147/// An i32 value148I32(i32),149/// An i64 value150I64(i64),151}152153impl core::fmt::Display for NumberInputValue {154fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {155match self {156NumberInputValue::F32(v) => write!(f, "{}", v),157NumberInputValue::F64(v) => write!(f, "{}", v),158NumberInputValue::I32(v) => write!(f, "{}", v),159NumberInputValue::I64(v) => write!(f, "{}", v),160}161}162}163164/// Event which can be sent to the number input widget to update the displayed value.165#[derive(Clone, EntityEvent)]166pub struct UpdateNumberInput {167/// Target widget168pub entity: Entity,169170/// Value to change to171pub value: NumberInputValue,172}173174fn number_input_on_text_change(175change: On<TextEditChange>,176q_parent: Query<&ChildOf>,177q_number_input: Query<&NumberFormat, With<FeathersNumberInput>>,178q_text_input: Query<&EditableText>,179mut commands: Commands,180) {181let Ok(parent) = q_parent.get(change.event_target()) else {182return;183};184185let Ok(number_format) = q_number_input.get(parent.get()) else {186return;187};188189let Ok(editable_text) = q_text_input.get(change.event_target()) else {190return;191};192193let text_value = editable_text.value().to_string();194emit_value_change(text_value, *number_format, parent.0, &mut commands, false);195}196197fn number_input_on_update(198update: On<UpdateNumberInput>,199q_children: Query<&Children>,200q_number_input: Query<(), With<FeathersNumberInput>>,201mut q_text_input: Query<&mut EditableText>,202focus: Res<InputFocus>,203) {204if !q_number_input.contains(update.event_target()) {205return;206};207208let Ok(children) = q_children.get(update.event_target()) else {209return;210};211212for child_id in children.iter() {213if focus.get() != Some(*child_id)214&& let Ok(mut editable_text) = q_text_input.get_mut(*child_id)215{216let new_digits = update.value.to_string();217let old_digits = editable_text.value().to_string();218if old_digits != new_digits {219editable_text.queue_edit(TextEdit::SelectAll);220editable_text.queue_edit(TextEdit::Insert(new_digits.into()));221}222break;223}224}225}226227fn number_input_on_enter_key(228key_input: On<FocusedInput<KeyboardInput>>,229q_parent: Query<&ChildOf>,230q_number_input: Query<&NumberFormat, With<FeathersNumberInput>>,231q_text_input: Query<&EditableText>,232mut commands: Commands,233) {234if key_input.input.key_code != KeyCode::Enter {235return;236}237238let Ok(parent) = q_parent.get(key_input.event_target()) else {239return;240};241242let Ok(number_format) = q_number_input.get(parent.get()) else {243return;244};245246let Ok(editable_text) = q_text_input.get(key_input.event_target()) else {247return;248};249250let text_value = editable_text.value().to_string();251emit_value_change(text_value, *number_format, parent.0, &mut commands, true);252}253254fn number_input_on_focus_loss(255focus_lost: On<FocusLost>,256q_parent: Query<&ChildOf>,257q_number_input: Query<&NumberFormat, With<FeathersNumberInput>>,258mut q_text_input: Query<&mut EditableText>,259mut commands: Commands,260) {261let editable_text_id = focus_lost.event_target();262263let Ok(parent) = q_parent.get(editable_text_id) else {264return;265};266267let Ok(number_format) = q_number_input.get(parent.get()) else {268return;269};270271let Ok(editable_text) = q_text_input.get_mut(editable_text_id) else {272return;273};274275let text_value = editable_text.value().to_string();276emit_value_change(text_value, *number_format, parent.0, &mut commands, true);277}278279fn emit_value_change(280text_value: String,281format: NumberFormat,282source: Entity,283commands: &mut Commands,284is_final: bool,285) {286let text_value = text_value.trim();287if text_value.is_empty() {288return;289}290291match format {292NumberFormat::F32 => {293match text_value.parse::<f32>() {294Ok(new_value) => {295commands.trigger(ValueChange {296source,297value: new_value,298is_final,299});300}301Err(_) => {302// TODO: Emit a validation error once these are defined303warn!("Invalid floating-point number in text edit");304}305}306}307NumberFormat::F64 => {308match text_value.parse::<f64>() {309Ok(new_value) => {310commands.trigger(ValueChange {311source,312value: new_value,313is_final,314});315}316Err(_) => {317// TODO: Emit a validation error once these are defined318warn!("Invalid floating-point number in text edit");319}320}321}322NumberFormat::I32 => {323match text_value.parse::<i32>() {324Ok(new_value) => {325commands.trigger(ValueChange {326source,327value: new_value,328is_final,329});330}331Err(_) => {332// TODO: Emit a validation error once these are defined333warn!("Invalid integer number in text edit");334}335}336}337NumberFormat::I64 => {338match text_value.parse::<i64>() {339Ok(new_value) => {340commands.trigger(ValueChange {341source,342value: new_value,343is_final,344});345}346Err(_) => {347// TODO: Emit a validation error once these are defined348warn!("Invalid integer number in text edit");349}350}351}352}353}354355356