Skip to content

Password-style character masking for editable text inputs - #25117

Open
Cyannide wants to merge 2 commits into
bevyengine:mainfrom
Cyannide:text_input_character_mask
Open

Password-style character masking for editable text inputs#25117
Cyannide wants to merge 2 commits into
bevyengine:mainfrom
Cyannide:text_input_character_mask

Conversation

@Cyannide

@Cyannide Cyannide commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Objective

Password fields. EditableText's own docs list "Password-style character masking" as planned-but-unimplemented and invite contribution...sooo this implements it.

Solution

Where the value lives: the mask is presentation; EditableText::value() is the contract. The entered text sits in a shadow slot on EditableText while a mask is present, so value() always returns the entered text and reading it requires no knowledge of the mask. Removing EditableText removes the value with it. The shadow slot is a pub field of an opaque ShadowValue type, so struct-literal construction (..default()) keeps working while the contents stay managed by the mask. The example's submit handler shows the consumer shape; one query, one call, no mask in sight:

// `EditableText::value()` always returns the entered text; a
// `CharacterMask` affects display only.
text_output.0 = format!("{:}: {:}", name, text_input.value());

Alternative considered: keeping the value on CharacterMask and exposing an honest read through a QueryData wrapper (&EditableText + Option<&CharacterMask>). Rejected because the plain Query<&EditableText> + .value() path would keep returning mask glyphs, leaving the honest API as opt-in knowledge.

Parley quirks: SplitString has no public constructor and can't carry the shadow value, which is why this PR now sits on #25532.

Why a twin representation: caret position, selection geometry, and click-to-position all come from the editor's own parley layout, IOW what is drawn must be what is laid out, or mask glyphs and real characters (different advances) desync every caret coordinate. One glyph per character preserves index parity in both char and byte
space, so cursor/selection operations pass through untouched and content operations mirror by char range.

  • Interception at apply_pending_edits (the documented entry point); TextEdit::apply is unchanged and documented as bypassing the mask. Paste routes through poll_and_apply_paste, the one place clipboard text is visible, and the reason this lives in bevy_text.
  • Reconcile invariant, enforced each frame: the editor must hold exactly the mask string for the real value; any deviation (spawn ordering, clear(), external set_text) is adopted and re-concealed. This makes the component-hook lifecycle safe in any insertion order, and the hooks give show/hide password for free: add conceals, remove reveals.
  • Password-field norms: Copy no-ops, Cut is a selection-gated delete with the clipboard untouched. IME stays enabled; on mobile the soft keyboard delivers text as IME commits, which route through the masked insert; but composition preedit is suppressed (it renders in-buffer and would display the raw text), so IME entry is commit-per-key, matching platform secure-entry behavior. EditableTextFilter/max_characters apply to the real characters.
  • Word ops treat the whole value as one word, explicitly (mask glyphs are punctuation under UAX #29, so driver word segmentation is unreliable over them, and word ops shouldn't reflect the real text's word structure anyway).
  • Default glyph is *, Bevy's embedded default font is an ASCII subset and renders as tofu out of the box; one line opts into with a real font.

Composes with #25110's Placeholder with no integration code:value() is the entered text, so a hint keyed to value() emptiness just works, a hinted password field is two components on one entity. (Both PRs touch the text_input example...so there will be conflict resolution required by one or the other)

Migration

EditableText::value returns Cow<'_, str> instead of SplitString (see #25532).
EditableText::apply_pending_edits takes a new final parameter mask: Option<&CharacterMask> (pass None for existing behavior). apply_text_edits' query changed accordingly.

Follow-ups (declared, not solved)

  • One glyph per char, not per grapheme cluster.
  • Adding U+2022 to the embedded default-font subset would allow as the default glyph (asset regeneration).
  • Accessibility: masked fields should expose a password role and never the real value.
  • ImePurpose is not exposed by bevy_window; until it is, mobile masked fields receive the standard soft keyboard rather than the platform's secure/password keyboard (predictions active). Exposing it and setting ImePurpose::Password while a masked field has focus is the completing piece.

Testing

  • Zero new systems, everything lives in the existing apply path and component hooks; ambiguity_detection is unaffected.
  • cargo test -p bevy_text, 17 new headless tests against real parley layout: conceal-on-add, per-op mirroring, type-over-selection, selection-gated Cut, clipboard untouched under Copy/Cut, filter-on-real-chars, explicit word-op semantics, reveal-on-remove, reconcile after external set_text, the IME-commit (mobile soft keyboard) path, and the value() contract set: honest-while-masked, shadow-presence-iff-masked, multibyte conceal/reveal round-trip, and clear().
  • cargo run --example text_input, right input masked; the submit handler reads EditableText::value() with no mask in the query.
  • Bevy 0.19 backport as part of a production application on WASM and desktop

LLM Usage Disclosure:

  • The code has been LLM reviewed and some suggestions have been manually implemented after review
  • Most of the test harness is LLM generated from data and bug discoveries produced by in-house QA testers from a production application

Showcase

image image

@alice-i-cecile alice-i-cecile added C-Feature A new feature, making something new possible A-UI Graphical user interfaces, styles, layouts, and widgets M-Release-Note Work that should be called out in the blog due to impact S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Jul 22, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in UI Jul 22, 2026
@Cyannide

Copy link
Copy Markdown
Contributor Author

Data point: this has been in production for about a month now (applied to 0.19, wasm + native desktop) in an app with daily testers. No issues beyond what's already noted in the thread -- placeholder overlay tracks the field correctly across resizes and focus changes / masking hasn't produced any cursor or IME surprises.

@kfc35
kfc35 self-requested a review August 13, 2026 18:19
@Cyannide

Copy link
Copy Markdown
Contributor Author

merged main and resolved conflicts

@kfc35 kfc35 added the M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide label Aug 14, 2026
@viridia

viridia commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

The value -> Cow migration seems useful in its own right; it should probably be a separate PR. I know I have had a lot of frustration parsing and comparing edit buffers (particularly in FeathersNumberInput).

@Cyannide

Copy link
Copy Markdown
Contributor Author

The value -> Cow migration seems useful in its own right; it should probably be a separate PR. I know I have had a lot of frustration parsing and comparing edit buffers (particularly in FeathersNumberInput).

Yeah, it kinda came about as a workaround to simplify the edit buffer interactions.

I'd be happy to split the value -> Cow into its own PR so the migration guide will be specific for the actual breaking change...and it'll make this mask PR actually quite simple. Plus, FeathersNumberInput can be converted to the new API in the same PR 😉 along with the examples that do it the hard way.

Previously value() returned a parley SplitString, which required every
consumer to reassemble the segments by hand for just about any use case.
With Cow<str> (usually borrowed) most existing code continues working
unchanged using deref, IME being an owned exception because of the preedit
split during composition.

FeathersNumberInput and the multiline/multiple-input examples converted
to the new API with manual reassembly now one liners.
@Cyannide
Cyannide force-pushed the text_input_character_mask branch from 19072e5 to 1ca4e7e Compare August 24, 2026 04:26
Rebased onto the value() -> Cow split (bevyengine#25532)

The display buffer holds mask glyphs while the entered text lives in
EditableText::shadow_value, and value() reads through the shadow into
the entered text so the mask only affects the display. Paste routes
through masked_insert so the clipboard string never exists in the
visible buffer.

value() migration guide now part of the Cow return PR, the
apply_pending_edits() guide still here.
@Cyannide
Cyannide force-pushed the text_input_character_mask branch from 1ca4e7e to a5c3730 Compare August 24, 2026 04:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-UI Graphical user interfaces, styles, layouts, and widgets C-Feature A new feature, making something new possible M-Migration-Guide A breaking change to Bevy's public API that needs to be noted in a migration guide M-Release-Note Work that should be called out in the blog due to impact S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

4 participants