|
| 1 | +use bevy_app::{Plugin, PreUpdate}; |
| 2 | +use bevy_ecs::{ |
| 3 | + change_detection::{DetectChanges, DetectChangesMut}, |
| 4 | + component::Component, |
| 5 | + entity::Entity, |
| 6 | + hierarchy::ChildOf, |
| 7 | + lifecycle::RemovedComponents, |
| 8 | + query::{Added, Has, With}, |
| 9 | + schedule::IntoScheduleConfigs, |
| 10 | + system::{Commands, Query, Res}, |
| 11 | +}; |
| 12 | +use bevy_input_focus::{tab_navigation::TabIndex, InputFocus, InputFocusVisible}; |
| 13 | +use bevy_picking::PickingSystems; |
| 14 | +use bevy_scene::prelude::*; |
| 15 | +use bevy_text::{EditableText, FontSize, FontWeight, LineBreak, TextCursorStyle, TextLayout}; |
| 16 | +use bevy_ui::{ |
| 17 | + px, AlignItems, BorderColor, BorderRadius, Display, InteractionDisabled, JustifyContent, Node, |
| 18 | + UiRect, Val, |
| 19 | +}; |
| 20 | + |
| 21 | +use crate::{ |
| 22 | + constants::{fonts, size}, |
| 23 | + cursor::EntityCursor, |
| 24 | + font_styles::InheritableFont, |
| 25 | + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor, ThemedText, UiTheme}, |
| 26 | + tokens, |
| 27 | +}; |
| 28 | + |
| 29 | +/// Marker to indicate a text input widget with feathers styling. |
| 30 | +#[derive(Component, Default, Clone)] |
| 31 | +struct FeathersTextInputContainer; |
| 32 | + |
| 33 | +/// Marker to indicate the inner part of the text input widget. |
| 34 | +#[derive(Component, Default, Clone)] |
| 35 | +struct FeathersTextInput; |
| 36 | + |
| 37 | +/// Parameters for the text input template, passed to [`text_input`] function. |
| 38 | +pub struct TextInputProps { |
| 39 | + /// Max characters |
| 40 | + pub max_characters: Option<usize>, |
| 41 | +} |
| 42 | + |
| 43 | +/// Decorative frame around a text input widget. This is a separate entity to allow icons |
| 44 | +/// (such as "search" or "clear") to be inserted adjacent to the input. |
| 45 | +pub fn text_input_container() -> impl Scene { |
| 46 | + bsn! { |
| 47 | + Node { |
| 48 | + height: size::ROW_HEIGHT, |
| 49 | + display: Display::Flex, |
| 50 | + justify_content: JustifyContent::Center, |
| 51 | + align_items: AlignItems::Center, |
| 52 | + padding: UiRect::axes(Val::Px(4.0), Val::Px(0.)), |
| 53 | + border: UiRect::all(Val::Px(2.0)), |
| 54 | + flex_grow: 1.0, |
| 55 | + border_radius: {BorderRadius::all(px(4.0))}, |
| 56 | + column_gap: px(4), |
| 57 | + } |
| 58 | + FeathersTextInputContainer |
| 59 | + ThemeBorderColor(tokens::TEXT_INPUT_BG) |
| 60 | + ThemeBackgroundColor(tokens::TEXT_INPUT_BG) |
| 61 | + ThemeFontColor(tokens::TEXT_INPUT_TEXT) |
| 62 | + InheritableFont { |
| 63 | + font: fonts::REGULAR, |
| 64 | + font_size: FontSize::Px(13.0), |
| 65 | + weight: FontWeight::NORMAL, |
| 66 | + } |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +/// Scene function to spawn a text input. For proper styling, this should be enclosed by a |
| 71 | +/// `text_input_container`. |
| 72 | +/// |
| 73 | +/// ```ignore |
| 74 | +/// :text_input_container |
| 75 | +/// Children [ |
| 76 | +/// text_input(props) |
| 77 | +/// ] |
| 78 | +/// ``` |
| 79 | +/// |
| 80 | +/// # Arguments |
| 81 | +/// * `props` - construction properties for the text input. |
| 82 | +pub fn text_input(props: TextInputProps) -> impl Scene { |
| 83 | + bsn! { |
| 84 | + Node { |
| 85 | + flex_grow: 1.0, |
| 86 | + } |
| 87 | + FeathersTextInput |
| 88 | + EditableText { |
| 89 | + cursor_width: 0.3, |
| 90 | + max_characters: {props.max_characters}, |
| 91 | + } |
| 92 | + TextLayout { |
| 93 | + linebreak: LineBreak::NoWrap, |
| 94 | + } |
| 95 | + TabIndex(0) |
| 96 | + ThemedText |
| 97 | + EntityCursor::System(bevy_window::SystemCursorIcon::Text) |
| 98 | + TextCursorStyle::default() |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +fn update_text_cursor_color( |
| 103 | + mut q_text_input: Query<&mut TextCursorStyle, With<FeathersTextInput>>, |
| 104 | + theme: Res<UiTheme>, |
| 105 | +) { |
| 106 | + if theme.is_changed() { |
| 107 | + for mut cursor_style in q_text_input.iter_mut() { |
| 108 | + cursor_style.color = theme.color(&tokens::TEXT_INPUT_CURSOR); |
| 109 | + cursor_style.selection_color = theme.color(&tokens::TEXT_INPUT_SELECTION); |
| 110 | + } |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +fn update_text_input_styles( |
| 115 | + q_inputs: Query< |
| 116 | + (Entity, Has<InteractionDisabled>, &ThemeFontColor), |
| 117 | + (With<FeathersTextInput>, Added<InteractionDisabled>), |
| 118 | + >, |
| 119 | + mut commands: Commands, |
| 120 | +) { |
| 121 | + for (input_ent, disabled, font_color) in q_inputs.iter() { |
| 122 | + set_text_input_styles(input_ent, disabled, font_color, &mut commands); |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +fn update_text_input_styles_remove( |
| 127 | + q_inputs: Query<(Entity, Has<InteractionDisabled>, &ThemeFontColor), With<FeathersTextInput>>, |
| 128 | + mut removed_disabled: RemovedComponents<InteractionDisabled>, |
| 129 | + mut commands: Commands, |
| 130 | +) { |
| 131 | + removed_disabled.read().for_each(|ent| { |
| 132 | + if let Ok((input_ent, disabled, font_color)) = q_inputs.get(ent) { |
| 133 | + set_text_input_styles(input_ent, disabled, font_color, &mut commands); |
| 134 | + } |
| 135 | + }); |
| 136 | +} |
| 137 | + |
| 138 | +fn update_text_input_focus( |
| 139 | + q_inputs: Query<(), With<FeathersTextInput>>, |
| 140 | + q_input_containers: Query<(Entity, &mut BorderColor), With<FeathersTextInputContainer>>, |
| 141 | + parents: Query<&ChildOf>, |
| 142 | + focus: Res<InputFocus>, |
| 143 | + focus_visible: Res<InputFocusVisible>, |
| 144 | + theme: Res<UiTheme>, |
| 145 | +) { |
| 146 | + // We're not using FocusIndicator here because (a) the focus ring is inset rather than |
| 147 | + // an outline, and (b) we want to detect focus on a descendant rather than an ancestor. |
| 148 | + if focus.is_changed() { |
| 149 | + let focus_parent = focus.0.and_then(|focus_ent| { |
| 150 | + if focus_visible.0 && q_inputs.contains(focus_ent) { |
| 151 | + parents |
| 152 | + .iter_ancestors(focus_ent) |
| 153 | + .find(|ent| q_input_containers.contains(*ent)) |
| 154 | + } else { |
| 155 | + None |
| 156 | + } |
| 157 | + }); |
| 158 | + |
| 159 | + for (container, mut border_color) in q_input_containers { |
| 160 | + let new_border_color = if Some(container) == focus_parent { |
| 161 | + theme.color(&tokens::FOCUS_RING) |
| 162 | + } else { |
| 163 | + theme.color(&tokens::TEXT_INPUT_BG) |
| 164 | + }; |
| 165 | + |
| 166 | + border_color.set_if_neq(BorderColor::all(new_border_color)); |
| 167 | + } |
| 168 | + } |
| 169 | +} |
| 170 | + |
| 171 | +fn set_text_input_styles( |
| 172 | + input_ent: Entity, |
| 173 | + disabled: bool, |
| 174 | + font_color: &ThemeFontColor, |
| 175 | + commands: &mut Commands, |
| 176 | +) { |
| 177 | + let font_color_token = match disabled { |
| 178 | + true => tokens::TEXT_INPUT_TEXT_DISABLED, |
| 179 | + false => tokens::TEXT_INPUT_TEXT, |
| 180 | + }; |
| 181 | + |
| 182 | + let cursor_shape = match disabled { |
| 183 | + true => bevy_window::SystemCursorIcon::NotAllowed, |
| 184 | + false => bevy_window::SystemCursorIcon::Text, |
| 185 | + }; |
| 186 | + |
| 187 | + // Change font color |
| 188 | + if font_color.0 != font_color_token { |
| 189 | + commands |
| 190 | + .entity(input_ent) |
| 191 | + .insert(ThemeFontColor(font_color_token)); |
| 192 | + } |
| 193 | + |
| 194 | + // Change cursor shape |
| 195 | + commands |
| 196 | + .entity(input_ent) |
| 197 | + .insert(EntityCursor::System(cursor_shape)); |
| 198 | +} |
| 199 | + |
| 200 | +/// Plugin which registers the systems for updating the text input styles. |
| 201 | +pub struct TextInputPlugin; |
| 202 | + |
| 203 | +impl Plugin for TextInputPlugin { |
| 204 | + fn build(&self, app: &mut bevy_app::App) { |
| 205 | + app.add_systems( |
| 206 | + PreUpdate, |
| 207 | + ( |
| 208 | + update_text_cursor_color, |
| 209 | + update_text_input_styles, |
| 210 | + update_text_input_styles_remove, |
| 211 | + update_text_input_focus, |
| 212 | + ) |
| 213 | + .in_set(PickingSystems::Last), |
| 214 | + ); |
| 215 | + } |
| 216 | +} |
0 commit comments