Skip to content

Commit 5eed63e

Browse files
authored
Per character filter for TextEdits (#23704)
# Objective Add an option to reject `TextEdit`s based on a per character filter. ## Solution * New component: `EditableTextFilter`. Can be used to set a per character filter for an `EditableText` entity. * `TextEdit::insert` and `TextEdit::Paste` edits are ignored unless all their characters pass the filter. The filter does not apply to characters already within the `EditableText`'s text buffer Initially I thought it would be better to implement the filter at the widget level, then a key press that results in a rejected edit could be propagated, but the clipboard isn't available to the keyboard observer function so paste edits would still need to be filtered when the input buffer is applied. I made a branch adding a `TextEditRejected` entity event as an alternative (that would also notify when an edit failed due to the `max_characters` limit being exceeded), but left it out of this PR to keep this one focused on just the filtering. ## Testing New example: ``` cargo run --example editable_text_filter ```
1 parent ba9d7a3 commit 5eed63e

5 files changed

Lines changed: 87 additions & 3 deletions

File tree

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,17 @@ description = "Demonstrates a simple, unstyled text input widget"
10121012
category = "UI (User Interface)"
10131013
wasm = true
10141014

1015+
[[example]]
1016+
name = "editable_text_filter"
1017+
path = "examples/ui/text/editable_text_filter.rs"
1018+
doc-scrape-examples = true
1019+
1020+
[package.metadata.example.editable_text_filter]
1021+
name = "Editable Text Filter"
1022+
description = "Demonstrates an 8-character hex input using EditableTextFilter"
1023+
category = "UI (User Interface)"
1024+
wasm = true
1025+
10151026
[[example]]
10161027
name = "multiline_text_input"
10171028
path = "examples/ui/text/multiline_text_input.rs"

crates/bevy_text/src/text_edit.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ impl TextEdit {
141141
driver: &'a mut PlainEditorDriver<TextBrush>,
142142
clipboard_text: &mut String,
143143
max_characters: Option<usize>,
144+
char_filter: impl Fn(char) -> bool,
144145
) {
145146
match self {
146147
TextEdit::Copy => {
@@ -157,6 +158,9 @@ impl TextEdit {
157158
}
158159
}
159160
TextEdit::Paste => {
161+
if !clipboard_text.chars().all(char_filter) {
162+
return;
163+
}
160164
if let Some(max) = max_characters {
161165
let select_len = driver.editor.selected_text().map(str::len).unwrap_or(0);
162166
if max
@@ -168,6 +172,9 @@ impl TextEdit {
168172
driver.insert_or_replace_selection(clipboard_text.as_str());
169173
}
170174
TextEdit::Insert(text) => {
175+
if !text.chars().all(char_filter) {
176+
return;
177+
}
171178
if let Some(max) = max_characters {
172179
let select_len = driver.editor.selected_text().map(str::len).unwrap_or(0);
173180
if max < driver.editor.text().chars().count() - select_len + text.len() {

crates/bevy_text/src/text_editable.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ use crate::{
7373
text_edit::TextEdit, FontCx, FontHinting, LayoutCx, LineHeight, TextBrush, TextColor, TextFont,
7474
TextLayout,
7575
};
76+
use alloc::sync::Arc;
7677
use bevy_ecs::prelude::*;
7778
use core::time::Duration;
7879
use parley::{FontContext, LayoutContext, PlainEditor, SplitString};
@@ -186,6 +187,7 @@ impl EditableText {
186187
font_context: &mut FontContext,
187188
layout_context: &mut LayoutContext<TextBrush>,
188189
clipboard_text: &mut String,
190+
char_filter: impl Fn(char) -> bool,
189191
) {
190192
let Self {
191193
editor,
@@ -197,7 +199,7 @@ impl EditableText {
197199
let mut driver = editor.driver(font_context, layout_context);
198200

199201
for edit in pending_edits.drain(..) {
200-
edit.apply(&mut driver, clipboard_text, *max_characters);
202+
edit.apply(&mut driver, clipboard_text, *max_characters, &char_filter);
201203
}
202204
}
203205

@@ -214,22 +216,39 @@ impl EditableText {
214216
}
215217
}
216218

219+
/// Sets a per-character filter for this text input. Insert and paste edits are ignored if the filter rejects any character.
220+
///
221+
/// The filter does not apply to characters already within the `EditableText`'s text buffer.
222+
#[derive(Component, Clone, Default)]
223+
pub struct EditableTextFilter(Option<Arc<dyn Fn(char) -> bool + Send + Sync + 'static>>);
224+
225+
impl EditableTextFilter {
226+
/// Create a new `EditableTextFilter` from the given filter function.
227+
pub fn new(filter: impl Fn(char) -> bool + Send + Sync + 'static) -> Self {
228+
Self(Some(Arc::new(filter)))
229+
}
230+
}
231+
217232
/// Applies pending text edit actions to all [`EditableText`] widgets.
218233
pub fn apply_text_edits(
219-
mut query: Query<(Entity, &mut EditableText)>,
234+
mut query: Query<(Entity, &mut EditableText, Option<&EditableTextFilter>)>,
220235
mut font_context: ResMut<FontCx>,
221236
mut layout_context: ResMut<LayoutCx>,
222237
mut clipboard_text: ResMut<Clipboard>,
223238
mut commands: Commands,
224239
) {
225-
for (entity, mut editable_text) in query.iter_mut() {
240+
for (entity, mut editable_text, filter) in query.iter_mut() {
226241
editable_text.text_edited = !editable_text.pending_edits.is_empty();
227242

228243
if editable_text.text_edited {
229244
editable_text.apply_pending_edits(
230245
&mut font_context.0,
231246
&mut layout_context.0,
232247
&mut clipboard_text.0,
248+
match filter {
249+
Some(EditableTextFilter(Some(filter))) => filter.as_ref(),
250+
_ => &|_| true,
251+
},
233252
);
234253

235254
commands.trigger(TextEditChange { entity });

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ Example | Description
599599
[Display and Visibility](../examples/ui/layout/display_and_visibility.rs) | Demonstrates how Display and Visibility work in the UI.
600600
[Drag to Scroll](../examples/ui/scroll_and_overflow/drag_to_scroll.rs) | This example tests scale factor, dragging and scrolling
601601
[Editable Text](../examples/ui/text/editable_text.rs) | Demonstrates a simple, unstyled text input widget
602+
[Editable Text Filter](../examples/ui/text/editable_text_filter.rs) | Demonstrates an 8-character hex input using EditableTextFilter
602603
[Feathers Widgets](../examples/ui/widgets/feathers.rs) | Gallery of Feathers Widgets
603604
[Flex Layout](../examples/ui/layout/flex_layout.rs) | Demonstrates how the AlignItems and JustifyContent properties can be composed to layout nodes and position text
604605
[Font Atlas Debug](../examples/ui/text/font_atlas_debug.rs) | Illustrates how FontAtlases are populated (used to optimize text rendering internally)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//! Demonstrates a minimal [`EditableTextFilter`] with an 8-character hex input.
2+
3+
use bevy::color::palettes::css::{DARK_SLATE_GRAY, YELLOW};
4+
use bevy::input_focus::AutoFocus;
5+
use bevy::prelude::*;
6+
use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle};
7+
8+
fn main() {
9+
App::new()
10+
.add_plugins(DefaultPlugins)
11+
.add_systems(Startup, setup)
12+
.run();
13+
}
14+
15+
fn setup(mut commands: Commands) {
16+
commands.spawn(Camera2d);
17+
18+
commands
19+
.spawn(Node {
20+
width: percent(100.),
21+
height: percent(100.),
22+
justify_content: JustifyContent::Center,
23+
align_items: AlignItems::Center,
24+
..default()
25+
})
26+
.with_children(|parent| {
27+
parent.spawn((
28+
Node {
29+
width: px(240.),
30+
border: px(2.).all(),
31+
padding: px(8.).all(),
32+
..default()
33+
},
34+
EditableText {
35+
max_characters: Some(8),
36+
..default()
37+
},
38+
TextCursorStyle::default(),
39+
EditableTextFilter::new(|c| c.is_ascii_hexdigit()),
40+
TextFont::from_font_size(32.),
41+
BackgroundColor(DARK_SLATE_GRAY.into()),
42+
BorderColor::all(YELLOW),
43+
AutoFocus,
44+
));
45+
});
46+
}

0 commit comments

Comments
 (0)