Skip to content

Commit 8f251ff

Browse files
viridiakfc35
andauthored
Feathers text input widget (#23645)
# Objective Feathers text input widget Part of #19236 ## Solution Build a wrapper around `EditableText` that has feathers styling and themes. ## Testing WiP --------- Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
1 parent cf2d04c commit 8f251ff

6 files changed

Lines changed: 297 additions & 7 deletions

File tree

crates/bevy_feathers/src/controls/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod color_slider;
88
mod color_swatch;
99
mod radio;
1010
mod slider;
11+
mod text_input;
1112
mod toggle_switch;
1213
mod virtual_keyboard;
1314

@@ -23,6 +24,7 @@ pub use color_swatch::{
2324
};
2425
pub use radio::{radio, radio_bundle, RadioPlugin};
2526
pub use slider::{slider, slider_bundle, SliderPlugin, SliderProps};
27+
pub use text_input::{text_input, text_input_container, TextInputPlugin, TextInputProps};
2628
pub use toggle_switch::{toggle_switch, toggle_switch_bundle, ToggleSwitchPlugin};
2729
pub use virtual_keyboard::{virtual_keyboard, virtual_keyboard_bundle, VirtualKeyPressed};
2830

@@ -46,6 +48,7 @@ impl Plugin for ControlsPlugin {
4648
ColorSwatchPlugin,
4749
RadioPlugin,
4850
SliderPlugin,
51+
TextInputPlugin,
4952
ToggleSwitchPlugin,
5053
));
5154
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
}

crates/bevy_feathers/src/dark_theme.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ pub fn create_dark_theme() -> ThemeProps {
9797
palette::LIGHT_GRAY_2.with_alpha(0.3),
9898
),
9999
(tokens::COLOR_PLANE_BG, palette::GRAY_1),
100+
// Text Input
101+
(tokens::TEXT_INPUT_BG, palette::GRAY_1),
102+
(tokens::TEXT_INPUT_TEXT, palette::LIGHT_GRAY_1),
103+
(
104+
tokens::TEXT_INPUT_TEXT_DISABLED,
105+
palette::WHITE.with_alpha(0.5),
106+
),
107+
(tokens::TEXT_INPUT_CURSOR, palette::ACCENT),
108+
(tokens::TEXT_INPUT_SELECTION, palette::GRAY_2),
100109
]),
101110
}
102111
}

crates/bevy_feathers/src/tokens.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,17 @@ pub const SWITCH_SLIDE_DISABLED: ThemeToken =
142142

143143
/// Color plane frame background
144144
pub const COLOR_PLANE_BG: ThemeToken = ThemeToken::new_static("feathers.colorplane.bg");
145+
146+
// Text Input
147+
148+
/// Background for text input
149+
pub const TEXT_INPUT_BG: ThemeToken = ThemeToken::new_static("feathers.textinput.bg");
150+
/// Text color for text input
151+
pub const TEXT_INPUT_TEXT: ThemeToken = ThemeToken::new_static("feathers.textinput.text");
152+
/// Text color for text input (disabled)
153+
pub const TEXT_INPUT_TEXT_DISABLED: ThemeToken =
154+
ThemeToken::new_static("feathers.textinput.text.disabled");
155+
/// Cursor color for text input
156+
pub const TEXT_INPUT_CURSOR: ThemeToken = ThemeToken::new_static("feathers.textinput.cursor");
157+
/// Selection color for text input
158+
pub const TEXT_INPUT_SELECTION: ThemeToken = ThemeToken::new_static("feathers.textinput.selection");

crates/bevy_text/src/text_editable.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ pub struct Clipboard(pub String);
9494
/// which manages both the text content and the cursor position,
9595
/// and provides methods for applying text edits and cursor movements correctly
9696
/// according to Unicode rules.
97-
#[derive(Component)]
97+
#[derive(Component, Clone)]
9898
#[require(TextLayout, TextFont, TextColor, LineHeight, FontHinting)]
9999
pub struct EditableText {
100100
/// A [`parley::PlainEditor`], tracking both the text content and cursor position.
@@ -232,6 +232,6 @@ pub fn apply_text_edits(
232232
///
233233
/// As [`TextEdit`] includes cursor motions, this will be emitted even if [`EditableText::value`] is unchanged.
234234
#[derive(EntityEvent)]
235-
struct TextEditChange {
235+
pub struct TextEditChange {
236236
entity: Entity,
237237
}

0 commit comments

Comments
 (0)