Skip to content

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

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

Password-style character masking for editable text inputs#25117
Cyannide wants to merge 6 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 — 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 — the obvious call — would keep returning mask glyphs, leaving the honest API as opt-in knowledge.

Cow<str>: value() previously returned parley's SplitString, which has no public constructor and can't carry the shadow. Cow is borrowed in the common case, owned only during IME composition, and comparisons/.to_string() at existing call sites work unchanged (migration guide included).

Why a twin representation: caret position, selection geometry, and click-to-position all come from the editor's own parley layout — 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). Matches browsers.
  • 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; whichever lands second takes a one-line conflict.)

Migration

EditableText::value returns Cow<'_, str> instead of SplitString (see migration guide). 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.

Showcase

image

Cyannide added 2 commits July 22, 2026 04:50
CharacterMask { value, glyph } beside EditableText: the editor's
buffer holds one mask glyph per character while the real text
accumulates in the component. This twin representation is forced by
geometry: caret position, selection, and click-to-position all come
from the editor's own parley layout, so what is DRAWN must be what is
LAID OUT -- glyph substitution at render time would desync every
caret x-coordinate. One glyph per character preserves index parity in
both char and byte space (every char of an all-mask string is exactly
glyph.len_utf8() bytes), so cursor and selection operations pass
through untouched and content operations mirror by char range.

Masking intercepts 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's new mask parameter -- the
one place the clipboard string is visible, and the reason this feature
lives in bevy_text rather than ui_widgets.

INVARIANT, enforced every frame in apply_text_edits: the editor must
contain exactly the mask string for the current real value. Any
deviation means the editor was set from outside the masked path
(bundle-ordering races at spawn, clear(), external set_text, tests)
and its content is adopted as the new real value and re-concealed.
This one rule makes the component-hook lifecycle safe in any insertion
order, and the hooks give reveal-toggle for free: adding the component
conceals current content, removing it writes the real value back --
show/hide password is insert/remove.

Standard password-field behavior: Copy is a no-op and Cut degrades to
a selection-gated delete with the clipboard untouched (real text never
leaks; Cut must NOT be merged with Delete despite similar bodies --
Cut no-ops on a collapsed cursor, Delete does not). IME stays ENABLED:
on mobile the soft keyboard IS the IME, and commits are the primary
touch input path -- routed through the masked insert so keyboard text
can never bypass the mask. Composition preedit is suppressed (it
renders in-buffer and would display the raw text), degrading IME entry
to commit-per-key, which matches platform secure-entry behavior.
EditableTextFilter and max_characters apply to the REAL characters --
the glyph write deliberately bypasses the filter, since a digit filter
would reject the mask glyph itself.

Word operations (BackspaceWord/DeleteWord) treat the whole value as
one word, implemented explicitly rather than via the driver: mask
glyphs are punctuation under UAX bevyengine#29, so parley's word segmentation
may split them per-glyph -- and word ops must never reflect the real
text's word structure regardless. Matches browser password fields.

Default glyph is '*', not U+2022: Bevy's embedded default font is a
minimal ASCII subset and the bullet renders as tofu out of the box.
Fields with real fonts opt into '\u{2022}' with one line.

Because the editor holds only glyphs, EditableText::value() returns
the mask string on masked fields; read the real text via
CharacterMask::value(). Known limitation: one glyph per char, not per
grapheme cluster.

Migration: EditableText::apply_pending_edits takes a new final
parameter `mask: Option<&mut CharacterMask>` -- pass None to preserve
existing behavior. apply_text_edits' query changed accordingly.

Zero new systems (everything lives in the existing apply path and
component hooks); ambiguity_detection is unaffected. Thirteen headless
tests against real parley layout (FiraMono subset registered per the
existing text_edit harness): conceal-on-add of pre-filled text, typing
and per-op deletion mirroring, type-over-selection, selection-gated
Cut, clipboard-untouched under Copy and Cut, filter-on-real-chars (a
digit filter accepts '5' where the glyph would fail), explicit word-op
semantics both directions, reveal-on-remove, reconcile after an
external set_text, and the IME-commit (mobile soft keyboard) path.

Docs: masking moves from the planned to the supported list in
editing.rs. text_input example: the right input is masked; its output
reads CharacterMask::value() with a comment teaching the value()
distinction.
@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 added 4 commits July 23, 2026 00:24
CharacterMask owned the real string while masked, which made the value's
location depend on component presence: consumers had to know about the
mask to read the right source, and removing EditableText while masked
stranded the entered text on the mask component.

The mask is presentation; EditableText::value() is the contract. The
entered text now lives in a shadow slot on EditableText whenever a
presentation layer substitutes the buffer content. value() reads through
it, so it always returns the entered text, masked or not, and
CharacterMask shrinks to pure configuration (the glyph). The value lives
and dies with the text: removing EditableText removes it. The example's
submit handler drops its mask-aware branch entirely. The shadow slot is
a pub field of an opaque ShadowValue type so struct-literal construction
(..default()) keeps working.

value() returns Cow<str> instead of parley's SplitString, which has no
public constructor and cannot carry the shadow. Borrowed in the common
case; owned only while an IME composition splits the editor text.
Comparisons against &str and to_string() keep working (migration guide
included). Release note added.
Two patterns: Cow<str> has no PartialEq<&String>, so comparisons drop
the borrow (feathers number input); and code that iterated SplitString
segments collapses to a single push_str (both text input examples).
Every fix is shorter than what it replaces -- the segment-walking
boilerplate existed only because SplitString leaked into consumers.
Migration guide covers both patterns.
Srgba::hex takes AsRef<str>, which Cow satisfies directly — the owned
copy dated from the SplitString return. The shown() helper's &App
signature made the tests' &mut passing unnecessarily mutable.
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-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.

2 participants