diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 1cbe0cec88029..6de9952ea9b1f 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -8,6 +8,7 @@ mod color_slider; mod color_swatch; mod radio; mod slider; +mod text_input; mod toggle_switch; mod virtual_keyboard; @@ -23,6 +24,7 @@ pub use color_swatch::{ }; pub use radio::{radio, radio_bundle, RadioPlugin}; pub use slider::{slider, slider_bundle, SliderPlugin, SliderProps}; +pub use text_input::{text_input, text_input_container, TextInputPlugin, TextInputProps}; pub use toggle_switch::{toggle_switch, toggle_switch_bundle, ToggleSwitchPlugin}; pub use virtual_keyboard::{virtual_keyboard, virtual_keyboard_bundle, VirtualKeyPressed}; @@ -46,6 +48,7 @@ impl Plugin for ControlsPlugin { ColorSwatchPlugin, RadioPlugin, SliderPlugin, + TextInputPlugin, ToggleSwitchPlugin, )); } diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs new file mode 100644 index 0000000000000..dbbb0cd2e205b --- /dev/null +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -0,0 +1,216 @@ +use bevy_app::{Plugin, PreUpdate}; +use bevy_ecs::{ + change_detection::{DetectChanges, DetectChangesMut}, + component::Component, + entity::Entity, + hierarchy::ChildOf, + lifecycle::RemovedComponents, + query::{Added, Has, With}, + schedule::IntoScheduleConfigs, + system::{Commands, Query, Res}, +}; +use bevy_input_focus::{tab_navigation::TabIndex, InputFocus, InputFocusVisible}; +use bevy_picking::PickingSystems; +use bevy_scene::prelude::*; +use bevy_text::{EditableText, FontSize, FontWeight, LineBreak, TextCursorStyle, TextLayout}; +use bevy_ui::{ + px, AlignItems, BorderColor, BorderRadius, Display, InteractionDisabled, JustifyContent, Node, + UiRect, Val, +}; + +use crate::{ + constants::{fonts, size}, + cursor::EntityCursor, + font_styles::InheritableFont, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor, ThemedText, UiTheme}, + tokens, +}; + +/// Marker to indicate a text input widget with feathers styling. +#[derive(Component, Default, Clone)] +struct FeathersTextInputContainer; + +/// Marker to indicate the inner part of the text input widget. +#[derive(Component, Default, Clone)] +struct FeathersTextInput; + +/// Parameters for the text input template, passed to [`text_input`] function. +pub struct TextInputProps { + /// Max characters + pub max_characters: Option, +} + +/// Decorative frame around a text input widget. This is a separate entity to allow icons +/// (such as "search" or "clear") to be inserted adjacent to the input. +pub fn text_input_container() -> impl Scene { + bsn! { + Node { + height: size::ROW_HEIGHT, + display: Display::Flex, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(4.0), Val::Px(0.)), + border: UiRect::all(Val::Px(2.0)), + flex_grow: 1.0, + border_radius: {BorderRadius::all(px(4.0))}, + column_gap: px(4), + } + FeathersTextInputContainer + ThemeBorderColor(tokens::TEXT_INPUT_BG) + ThemeBackgroundColor(tokens::TEXT_INPUT_BG) + ThemeFontColor(tokens::TEXT_INPUT_TEXT) + InheritableFont { + font: fonts::REGULAR, + font_size: FontSize::Px(13.0), + weight: FontWeight::NORMAL, + } + } +} + +/// Scene function to spawn a text input. For proper styling, this should be enclosed by a +/// `text_input_container`. +/// +/// ```ignore +/// :text_input_container +/// Children [ +/// text_input(props) +/// ] +/// ``` +/// +/// # Arguments +/// * `props` - construction properties for the text input. +pub fn text_input(props: TextInputProps) -> impl Scene { + bsn! { + Node { + flex_grow: 1.0, + } + FeathersTextInput + EditableText { + cursor_width: 0.3, + max_characters: {props.max_characters}, + } + TextLayout { + linebreak: LineBreak::NoWrap, + } + TabIndex(0) + ThemedText + EntityCursor::System(bevy_window::SystemCursorIcon::Text) + TextCursorStyle::default() + } +} + +fn update_text_cursor_color( + mut q_text_input: Query<&mut TextCursorStyle, With>, + theme: Res, +) { + if theme.is_changed() { + for mut cursor_style in q_text_input.iter_mut() { + cursor_style.color = theme.color(&tokens::TEXT_INPUT_CURSOR); + cursor_style.selection_color = theme.color(&tokens::TEXT_INPUT_SELECTION); + } + } +} + +fn update_text_input_styles( + q_inputs: Query< + (Entity, Has, &ThemeFontColor), + (With, Added), + >, + mut commands: Commands, +) { + for (input_ent, disabled, font_color) in q_inputs.iter() { + set_text_input_styles(input_ent, disabled, font_color, &mut commands); + } +} + +fn update_text_input_styles_remove( + q_inputs: Query<(Entity, Has, &ThemeFontColor), With>, + mut removed_disabled: RemovedComponents, + mut commands: Commands, +) { + removed_disabled.read().for_each(|ent| { + if let Ok((input_ent, disabled, font_color)) = q_inputs.get(ent) { + set_text_input_styles(input_ent, disabled, font_color, &mut commands); + } + }); +} + +fn update_text_input_focus( + q_inputs: Query<(), With>, + q_input_containers: Query<(Entity, &mut BorderColor), With>, + parents: Query<&ChildOf>, + focus: Res, + focus_visible: Res, + theme: Res, +) { + // We're not using FocusIndicator here because (a) the focus ring is inset rather than + // an outline, and (b) we want to detect focus on a descendant rather than an ancestor. + if focus.is_changed() { + let focus_parent = focus.0.and_then(|focus_ent| { + if focus_visible.0 && q_inputs.contains(focus_ent) { + parents + .iter_ancestors(focus_ent) + .find(|ent| q_input_containers.contains(*ent)) + } else { + None + } + }); + + for (container, mut border_color) in q_input_containers { + let new_border_color = if Some(container) == focus_parent { + theme.color(&tokens::FOCUS_RING) + } else { + theme.color(&tokens::TEXT_INPUT_BG) + }; + + border_color.set_if_neq(BorderColor::all(new_border_color)); + } + } +} + +fn set_text_input_styles( + input_ent: Entity, + disabled: bool, + font_color: &ThemeFontColor, + commands: &mut Commands, +) { + let font_color_token = match disabled { + true => tokens::TEXT_INPUT_TEXT_DISABLED, + false => tokens::TEXT_INPUT_TEXT, + }; + + let cursor_shape = match disabled { + true => bevy_window::SystemCursorIcon::NotAllowed, + false => bevy_window::SystemCursorIcon::Text, + }; + + // Change font color + if font_color.0 != font_color_token { + commands + .entity(input_ent) + .insert(ThemeFontColor(font_color_token)); + } + + // Change cursor shape + commands + .entity(input_ent) + .insert(EntityCursor::System(cursor_shape)); +} + +/// Plugin which registers the systems for updating the text input styles. +pub struct TextInputPlugin; + +impl Plugin for TextInputPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems( + PreUpdate, + ( + update_text_cursor_color, + update_text_input_styles, + update_text_input_styles_remove, + update_text_input_focus, + ) + .in_set(PickingSystems::Last), + ); + } +} diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index 3933d8cdd39df..f5db276eeca4c 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -97,6 +97,15 @@ pub fn create_dark_theme() -> ThemeProps { palette::LIGHT_GRAY_2.with_alpha(0.3), ), (tokens::COLOR_PLANE_BG, palette::GRAY_1), + // Text Input + (tokens::TEXT_INPUT_BG, palette::GRAY_1), + (tokens::TEXT_INPUT_TEXT, palette::LIGHT_GRAY_1), + ( + tokens::TEXT_INPUT_TEXT_DISABLED, + palette::WHITE.with_alpha(0.5), + ), + (tokens::TEXT_INPUT_CURSOR, palette::ACCENT), + (tokens::TEXT_INPUT_SELECTION, palette::GRAY_2), ]), } } diff --git a/crates/bevy_feathers/src/tokens.rs b/crates/bevy_feathers/src/tokens.rs index 55084d10aa72c..4b3959c091f32 100644 --- a/crates/bevy_feathers/src/tokens.rs +++ b/crates/bevy_feathers/src/tokens.rs @@ -142,3 +142,17 @@ pub const SWITCH_SLIDE_DISABLED: ThemeToken = /// Color plane frame background pub const COLOR_PLANE_BG: ThemeToken = ThemeToken::new_static("feathers.colorplane.bg"); + +// Text Input + +/// Background for text input +pub const TEXT_INPUT_BG: ThemeToken = ThemeToken::new_static("feathers.textinput.bg"); +/// Text color for text input +pub const TEXT_INPUT_TEXT: ThemeToken = ThemeToken::new_static("feathers.textinput.text"); +/// Text color for text input (disabled) +pub const TEXT_INPUT_TEXT_DISABLED: ThemeToken = + ThemeToken::new_static("feathers.textinput.text.disabled"); +/// Cursor color for text input +pub const TEXT_INPUT_CURSOR: ThemeToken = ThemeToken::new_static("feathers.textinput.cursor"); +/// Selection color for text input +pub const TEXT_INPUT_SELECTION: ThemeToken = ThemeToken::new_static("feathers.textinput.selection"); diff --git a/crates/bevy_text/src/text_editable.rs b/crates/bevy_text/src/text_editable.rs index d214b268fdc1c..baefbce0e17b9 100644 --- a/crates/bevy_text/src/text_editable.rs +++ b/crates/bevy_text/src/text_editable.rs @@ -94,7 +94,7 @@ pub struct Clipboard(pub String); /// which manages both the text content and the cursor position, /// and provides methods for applying text edits and cursor movements correctly /// according to Unicode rules. -#[derive(Component)] +#[derive(Component, Clone)] #[require(TextLayout, TextFont, TextColor, LineHeight, FontHinting)] pub struct EditableText { /// A [`parley::PlainEditor`], tracking both the text content and cursor position. @@ -232,6 +232,6 @@ pub fn apply_text_edits( /// /// As [`TextEdit`] includes cursor motions, this will be emitted even if [`EditableText::value`] is unchanged. #[derive(EntityEvent)] -struct TextEditChange { +pub struct TextEditChange { entity: Entity, } diff --git a/examples/ui/widgets/feathers.rs b/examples/ui/widgets/feathers.rs index 0efd432f17535..b2c800f75ed17 100644 --- a/examples/ui/widgets/feathers.rs +++ b/examples/ui/widgets/feathers.rs @@ -4,10 +4,10 @@ use bevy::{ color::palettes, feathers::{ controls::{ - button, checkbox, color_plane, color_slider, color_swatch, radio, slider, - toggle_switch, ButtonProps, ButtonVariant, ColorChannel, ColorPlane, ColorPlaneValue, - ColorSlider, ColorSliderProps, ColorSwatch, ColorSwatchValue, SliderBaseColor, - SliderProps, + button, checkbox, color_plane, color_slider, color_swatch, radio, slider, text_input, + text_input_container, toggle_switch, ButtonProps, ButtonVariant, ColorChannel, + ColorPlane, ColorPlaneValue, ColorSlider, ColorSliderProps, ColorSwatch, + ColorSwatchValue, SliderBaseColor, SliderProps, TextInputProps, }, cursor::{EntityCursor, OverrideCursor}, dark_theme::create_dark_theme, @@ -15,8 +15,10 @@ use bevy::{ theme::{ThemeBackgroundColor, ThemedText, UiTheme}, tokens, FeathersPlugins, }, - input_focus::tab_navigation::TabGroup, + input_focus::{tab_navigation::TabGroup, AutoFocus, InputFocus}, prelude::*, + scene::prelude::Scene, + text::{EditableText, TextEdit, TextEditChange}, ui::{Checked, InteractionDisabled}, ui_widgets::{ checkbox_self_update, slider_self_update, Activate, RadioButton, RadioGroup, @@ -39,6 +41,9 @@ enum SwatchType { Hsl, } +#[derive(Component, Clone, Copy, Default)] +struct HexColorInput; + #[derive(Component, Clone, Copy, Default)] struct DemoDisabledButton; @@ -99,6 +104,7 @@ fn demo_root() -> impl Scene { on(|_activate: On| { info!("Normal button clicked!"); }) + AutoFocus Children [ (Text::new("Normal") ThemedText) ] ), ( @@ -272,9 +278,30 @@ fn demo_root() -> impl Scene { display: Display::Flex, flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, + column_gap: px(4.0), } Children [ Text("Srgba"), + // Spacer + Node { + flex_grow: 1.0, + }, + // Text input + ( + :text_input_container + Node { + flex_grow: 1.0, + } + Children [ + ( + text_input(TextInputProps { + max_characters: Some(9), + }) + HexColorInput + on(handle_hex_color_change) + ) + ] + ) (color_swatch() SwatchType::Rgb), ] ), @@ -369,7 +396,9 @@ fn update_colors( mut sliders: Query<(Entity, &ColorSlider, &mut SliderBaseColor)>, mut swatches: Query<(&mut ColorSwatchValue, &SwatchType), With>, mut color_planes: Query<&mut ColorPlaneValue, With>, + q_text_input: Single<(Entity, &mut EditableText), With>, mut commands: Commands, + focus: Res, ) { if colors.is_changed() { for (slider_ent, slider, mut base) in sliders.iter_mut() { @@ -431,5 +460,24 @@ fn update_colors( plane_value.0.y = colors.rgb_color.blue; plane_value.0.z = colors.rgb_color.green; } + + // Only update the hex input field when it's not focused, otherwise it interferes + // with typing. + let (input_ent, mut editable_text) = q_text_input.into_inner(); + if Some(input_ent) != focus.0 { + editable_text.queue_edit(TextEdit::SelectAll); + editable_text.queue_edit(TextEdit::Insert(colors.rgb_color.to_hex().into())); + } + } +} + +fn handle_hex_color_change( + _change: On, + q_text_input: Single<&EditableText, With>, + mut colors: ResMut, +) { + let editable_text = *q_text_input; + if let Ok(color) = Srgba::hex(editable_text.value().to_string()) { + colors.rgb_color = color; } }