diff --git a/Cargo.toml b/Cargo.toml index 0ea80afabedf2..dcee35d1a7d27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index fddf0dea689dd..3df334edbf7f1 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -141,6 +141,7 @@ impl TextEdit { driver: &'a mut PlainEditorDriver, clipboard_text: &mut String, max_characters: Option, + char_filter: impl Fn(char) -> bool, ) { match self { TextEdit::Copy => { @@ -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 @@ -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() { diff --git a/crates/bevy_text/src/text_editable.rs b/crates/bevy_text/src/text_editable.rs index d214b268fdc1c..add0026d58fb1 100644 --- a/crates/bevy_text/src/text_editable.rs +++ b/crates/bevy_text/src/text_editable.rs @@ -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}; @@ -177,6 +178,7 @@ impl EditableText { font_context: &mut FontContext, layout_context: &mut LayoutContext, clipboard_text: &mut String, + char_filter: impl Fn(char) -> bool, ) { let Self { editor, @@ -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); } } @@ -205,15 +207,28 @@ 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 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, mut layout_context: ResMut, mut clipboard_text: ResMut, 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 { @@ -221,6 +236,10 @@ pub fn apply_text_edits( &mut font_context.0, &mut layout_context.0, &mut clipboard_text.0, + match filter { + Some(EditableTextFilter(Some(filter))) => filter.as_ref(), + _ => &|_| true, + }, ); commands.trigger(TextEditChange { entity }); diff --git a/examples/README.md b/examples/README.md index ab5155abe303a..fec5263c03ee1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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) diff --git a/examples/ui/text/editable_text_filter.rs b/examples/ui/text/editable_text_filter.rs new file mode 100644 index 0000000000000..5eb1f74519467 --- /dev/null +++ b/examples/ui/text/editable_text_filter.rs @@ -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, + )); + }); +}