Password-style character masking for editable text inputs - #25117
Open
Cyannide wants to merge 6 commits into
Open
Password-style character masking for editable text inputs#25117Cyannide wants to merge 6 commits into
Cyannide wants to merge 6 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onEditableTextwhile a mask is present, sovalue()always returns the entered text and reading it requires no knowledge of the mask. RemovingEditableTextremoves the value with it. The shadow slot is a pub field of an opaqueShadowValuetype, 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:Alternative considered: keeping the value on
CharacterMaskand exposing an honest read through aQueryDatawrapper (&EditableText+Option<&CharacterMask>). Rejected because the plainQuery<&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'sSplitString, which has no public constructor and can't carry the shadow.Cowis 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.
apply_pending_edits(the documented entry point);TextEdit::applyis unchanged and documented as bypassing the mask. Paste routes throughpoll_and_apply_paste— the one place clipboard text is visible, and the reason this lives inbevy_text.clear(), externalset_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.EditableTextFilter/max_charactersapply to the real characters.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.*— 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
Placeholderwith no integration code:value()is the entered text, so a hint keyed tovalue()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::valuereturnsCow<'_, str>instead ofSplitString(see migration guide).EditableText::apply_pending_editstakes a new final parametermask: Option<&CharacterMask>— passNonefor existing behavior.apply_text_edits' query changed accordingly.Follow-ups (declared, not solved)
char, not per grapheme cluster.•as the default glyph (asset regeneration).ImePurposeis not exposed bybevy_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 settingImePurpose::Passwordwhile a masked field has focus is the completing piece.Testing
ambiguity_detectionis 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 externalset_text, the IME-commit (mobile soft keyboard) path, and thevalue()contract set: honest-while-masked, shadow-presence-iff-masked, multibyte conceal/reveal round-trip, andclear().cargo run --example text_input— right input masked; the submit handler readsEditableText::value()with no mask in the query.Showcase