Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,17 @@ description = "Demonstrates a simple, unstyled text input widget"
category = "UI (User Interface)"
wasm = true

[[example]]
name = "editable_text_filter"
path = "examples/ui/text/editable_text_filter.rs"
doc-scrape-examples = true

[package.metadata.example.editable_text_filter]
name = "Editable Text Filter"
description = "Demonstrates an 8-character hex input using EditableTextFilter"
category = "UI (User Interface)"
wasm = true

[[example]]
name = "multiline_text_input"
path = "examples/ui/text/multiline_text_input.rs"
Expand Down
7 changes: 7 additions & 0 deletions crates/bevy_text/src/text_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ impl TextEdit {
driver: &'a mut PlainEditorDriver<TextBrush>,
clipboard_text: &mut String,
max_characters: Option<usize>,
char_filter: impl Fn(char) -> bool,
) {
match self {
TextEdit::Copy => {
Expand All @@ -157,6 +158,9 @@ impl TextEdit {
}
}
TextEdit::Paste => {
if !clipboard_text.chars().all(char_filter) {
return;
}
if let Some(max) = max_characters {
let select_len = driver.editor.selected_text().map(str::len).unwrap_or(0);
if max
Expand All @@ -168,6 +172,9 @@ impl TextEdit {
driver.insert_or_replace_selection(clipboard_text.as_str());
}
TextEdit::Insert(text) => {
if !text.chars().all(char_filter) {
return;
}
if let Some(max) = max_characters {
let select_len = driver.editor.selected_text().map(str::len).unwrap_or(0);
if max < driver.editor.text().chars().count() - select_len + text.len() {
Expand Down
25 changes: 22 additions & 3 deletions crates/bevy_text/src/text_editable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ use crate::{
text_edit::TextEdit, FontCx, FontHinting, LayoutCx, LineHeight, TextBrush, TextColor, TextFont,
TextLayout,
};
use alloc::sync::Arc;
use bevy_ecs::prelude::*;
use core::time::Duration;
use parley::{FontContext, LayoutContext, PlainEditor, SplitString};
Expand Down Expand Up @@ -177,6 +178,7 @@ impl EditableText {
font_context: &mut FontContext,
layout_context: &mut LayoutContext<TextBrush>,
clipboard_text: &mut String,
char_filter: impl Fn(char) -> bool,
) {
let Self {
editor,
Expand All @@ -188,7 +190,7 @@ impl EditableText {
let mut driver = editor.driver(font_context, layout_context);

for edit in pending_edits.drain(..) {
edit.apply(&mut driver, clipboard_text, *max_characters);
edit.apply(&mut driver, clipboard_text, *max_characters, &char_filter);
}
}

Expand All @@ -205,22 +207,39 @@ impl EditableText {
}
}

/// Sets a per-character filter for this text input. Insert and paste edits are ignored if the filter rejects any character.
///
/// The filter does not apply to characters already within the `EditableText`'s text buffer.
#[derive(Component, Clone, Default)]
pub struct EditableTextFilter(Option<Arc<dyn Fn(char) -> bool + Send + Sync + 'static>>);

impl EditableTextFilter {
/// Create a new `EditableTextFilter` from the given filter function.
pub fn new(filter: impl Fn(char) -> bool + Send + Sync + 'static) -> Self {
Self(Some(Arc::new(filter)))
}
}

/// Applies pending text edit actions to all [`EditableText`] widgets.
pub fn apply_text_edits(
mut query: Query<(Entity, &mut EditableText)>,
mut query: Query<(Entity, &mut EditableText, Option<&EditableTextFilter>)>,
mut font_context: ResMut<FontCx>,
mut layout_context: ResMut<LayoutCx>,
mut clipboard_text: ResMut<Clipboard>,
mut commands: Commands,
) {
for (entity, mut editable_text) in query.iter_mut() {
for (entity, mut editable_text, filter) in query.iter_mut() {
editable_text.text_edited = !editable_text.pending_edits.is_empty();

if editable_text.text_edited {
editable_text.apply_pending_edits(
&mut font_context.0,
&mut layout_context.0,
&mut clipboard_text.0,
match filter {
Some(EditableTextFilter(Some(filter))) => filter.as_ref(),
_ => &|_| true,
Copy link
Copy Markdown
Contributor

@Zeophlite Zeophlite Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer this argument to be an Option (not a strong feeling)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it, but it just seemed simpler this way, instead of having to unwrap.

},
);

commands.trigger(TextEditChange { entity });
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ Example | Description
[Display and Visibility](../examples/ui/layout/display_and_visibility.rs) | Demonstrates how Display and Visibility work in the UI.
[Drag to Scroll](../examples/ui/scroll_and_overflow/drag_to_scroll.rs) | This example tests scale factor, dragging and scrolling
[Editable Text](../examples/ui/text/editable_text.rs) | Demonstrates a simple, unstyled text input widget
[Editable Text Filter](../examples/ui/text/editable_text_filter.rs) | Demonstrates an 8-character hex input using EditableTextFilter
[Feathers Widgets](../examples/ui/widgets/feathers.rs) | Gallery of Feathers Widgets
[Flex Layout](../examples/ui/layout/flex_layout.rs) | Demonstrates how the AlignItems and JustifyContent properties can be composed to layout nodes and position text
[Font Atlas Debug](../examples/ui/text/font_atlas_debug.rs) | Illustrates how FontAtlases are populated (used to optimize text rendering internally)
Expand Down
46 changes: 46 additions & 0 deletions examples/ui/text/editable_text_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Demonstrates a minimal [`EditableTextFilter`] with an 8-character hex input.

use bevy::color::palettes::css::{DARK_SLATE_GRAY, YELLOW};
use bevy::input_focus::AutoFocus;
use bevy::prelude::*;
use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle};

fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}

fn setup(mut commands: Commands) {
commands.spawn(Camera2d);

commands
.spawn(Node {
width: percent(100.),
height: percent(100.),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
})
.with_children(|parent| {
parent.spawn((
Node {
width: px(240.),
border: px(2.).all(),
padding: px(8.).all(),
..default()
},
EditableText {
max_characters: Some(8),
..default()
},
TextCursorStyle::default(),
EditableTextFilter::new(|c| c.is_ascii_hexdigit()),
TextFont::from_font_size(32.),
BackgroundColor(DARK_SLATE_GRAY.into()),
BorderColor::all(YELLOW),
AutoFocus,
));
});
}