Placeholder text for editable text inputs - #25110
Conversation
Placeholder { text, mode } + optional PlaceholderColor, alongside
SelectAllOnFocus and following the same pattern: widget-layer
components composing with EditableText through public APIs. Modes:
WhileEmpty (web default -- caret renders over the hint) and
UntilFocused. Color derives from the field's TextColor at
PLACEHOLDER_ALPHA unless overridden. The hint NEVER enters the
editor's buffer, so value(), IME, selection, and submit handling all
observe the true empty contents.
ARCHITECTURE -- a root-level overlay, not a child of the field, and
not injected glyphs. Three constraints forced this shape:
1. taffy only calls measure functions on LEAF nodes: giving an
EditableText any child silently disables its ContentSize-based
intrinsic sizing (the field collapses unless explicitly sized).
This bites ANY child, not just placeholders; documented on
PlaceholderLabel.
2. TextBrush carries no color (colors resolve per section index at
render extraction): native placeholder glyphs in
update_editable_text_layout would need a reserved section plus
render-extraction changes across three crates, for no
user-visible difference.
3. Overflow clips CHILDREN, not a node's own text: the overlay is an
outer clip node (position/size/visibility/GlobalZIndex) with an
inner text child, so long hints truncate at the field's bounds.
The overlay is position-synced from the field's content_box() +
UiGlobalTransform using the same pipeline as update_ime_position, in
the same schedule slot (PostLayout, after update_editable_text_layout,
ambiguous_with FocusChangeEvents -- same false positive). Spawned
hidden with a `positioned` gate: Node writes take effect at the next
frame's layout, so the label only reveals after placement from real
layout data and can never render at a stale origin. GlobalZIndex(1)
keeps the hint above default-Z roots; overlays that must cover a
hinted field (popups, modals) should sit higher -- documented, not
solved. Orphaned labels (field despawned) are cleaned up by the sync
system; label styling follows the field, written on change only. Both
label queries are excluded from the field query via Without so the
access sets are provably disjoint (B0001).
Tested headlessly in the file's existing style (minimal apps, systems
registered directly): spawn contract, the leaf-field invariant (label
never parented to the field), the positioned-gate two-tick reveal,
buffer and focus visibility lifecycle, both teardown directions
(Placeholder removed; field despawned -> orphan reaped), and color
derivation/override.
text_input example: placeholder added to the left field; module docs
corrected (the example was never "unstyled", and styling directly on
the field entity is the demonstrated pattern).
Known scope: UI text inputs only; no accessibility hint attribute yet
-- follow-up.
Six PostUpdate pairs flagged: gizmo meshes and text2d layout (Visibility / TextFont / TextColor), ui clipping (Node), and the three accessibility change-detectors (Text/TextFont/TextColor). All are entity-disjoint by construction -- update_placeholders writes those components ONLY on the label entities it spawns and owns -- which component-level analysis cannot see. Two of the conflicting systems (bevy_gizmos, bevy_sprite) are not dependencies of this crate, so they cannot be excluded individually; ambiguous_with_all with a comment stating the ownership invariant replaces the pair-by-pair approach (subsuming the previous FocusChangeEvents annotation). before(AccessibilitySystems::Update) added as a true ordering, not an appeasement: matches update_ime_position, and accessibility sees fresh label text same-frame if placeholder labels ever become a11y-relevant. Not moved to Update: that would demote hide-on-first-keystroke to one-frame-late (edits apply in EditableTextSystems, PostUpdate), a real polish regression traded for schedule silence.
Implemented by this PR -- in bevy_ui_widgets rather than bevy_text, which the list's placement note permits: placeholder is display-only (an overlay label riding the ordinary text pipeline) and needs no integration with the editing systems that anchor EditableText's logic in bevy_text. Listed in the supported features following the SelectAllOnFocus precedent for ui_widgets components.
| // systems (bevy_gizmos, bevy_sprite) are not | ||
| // dependencies of this crate, so they cannot be named | ||
| // individually. | ||
| .ambiguous_with_all(), |
There was a problem hiding this comment.
Hmm. I don't like this, but it is annoying to fix. There's an area in DefaultPlugins to resolve ambiguities already; you can add to it there. You can resolve the component-disjoint errors by adding Without filters to your system.
| @@ -1,8 +1,11 @@ | |||
| //! Demonstrates a simple, unstyled [`EditableText`] widget. | |||
| //! Demonstrates the [`EditableText`] widget: two minimally styled text inputs | |||
There was a problem hiding this comment.
These docs are worse. Replace them with the old version, and then add a single sentence paragraph below near the bottom that mentions the placeholder text.
| [Text Background Colors](../examples/ui/text/text_background_colors.rs) | Demonstrates text background colors | ||
| [Text Debug](../examples/ui/text/text_debug.rs) | An example for debugging text layout | ||
| [Text Input](../examples/ui/text/text_input.rs) | Demonstrates a simple, unstyled text input widget | ||
| [Text Input](../examples/ui/text/text_input.rs) | Demonstrates the [`EditableText`] widget: two minimally styled text inputs with tab navigation, Enter-to-submit, and a [`Placeholder`] hint on the left input. |
There was a problem hiding this comment.
Much worse: too long. Revert.
| assert!(!matches_edit_shortcut(&event, "c", KeyCode::KeyC)); | ||
| } | ||
|
|
||
| // ----------------------------------------------------------------------- |
There was a problem hiding this comment.
Style: we don't use these type of separators in Bevy. Cut.
alice-i-cecile
left a comment
There was a problem hiding this comment.
I like this feature a lot and the implementation seems broadly good, but it needs cleanup for style.
|
Awesome, thanks for the feedback. I'll go through and get the style cleaned up and figure out a better way to deal with the ambiguities. |
Per review, the blanket is gone; each pair is resolved by the mechanism that actually fits it: - Cross-crate pairs (transform-gizmo Visibility, text2d TextFont/TextColor, ui clipping Node) are declared in DefaultPlugins' IgnoreAmbiguitiesPlugin, following the in-file ImeSystems x update_text2d_layout precedent. update_placeholders is addressed via a new public PlaceholderSystems set; the transform gizmo's mesh update gains a public TransformGizmoMeshSystems set for the same reason (its system fn stays private). - The bevy_ui accessibility trio cannot be resolved with Without filters as suggested: their Text/TextFont/TextColor access flows through TextUiReader, which is UNFILTERED (it reads arbitrary UI children of visited entities), so the disjointness is not expressible in query filters. It holds in practice -- placeholder labels are root-level overlays, never UI children of a Button/ImageNode/Label subtree -- and is declared as a targeted ambiguous_with against a new public UiAccessibilitySystems set in bevy_ui (button_changed / image_changed / label_changed). - The original FocusChangeEvents false-positive annotation returns as the targeted pair it was. before(AccessibilitySystems::Update) is kept as a true ordering, matching update_ime_position. ambiguity_detection passes: PostUpdate 0.
Per review: text_input's module docs, its examples/README.md line, and the Cargo.toml metadata description return to the original one-liner; one sentence added near the bottom of the module docs mentioning the Placeholder component on the left input. Test-section banner separators removed.
|
Addressed all four points:
|
There was a problem hiding this comment.
The two entity method can probably be implemented without using UiTransform, I've only skimmed through the PR though, so might be wrong.
I think though there is a simpler approach. Loosely, just have one text buffer, add a new PlaceHolder(String, Color) component. When the component is present and the EditableText is empty, queue TextEdit(<placeholder string>), TextEdit::TextStart(false) edits and set a placeholder_active flag (or something). If you recieve another edit while placeholder_active is set, clear the text input's buffer first before applying the edit. We'd need to toggle the color as well somewhere. I think something like that should work.
Thanks, my initial thinking was the single-buffer version also, since it genuinely kills the overlay's three ugliest parts (position sync, the positioned-gate, z-order), and you're right that color is solvable there: with the hint as the sole buffer content, a whole-entity The costs show up away from rendering, in the edit pipeline and in
Clear-before-apply has to enumerate interactions, not just insertions. Phantom change detection. Inject/clear are real edits: autoscroll, IME positioning, a11y updates, and any Composition with the character-mask PR. The two currently compose with zero integration code because On the Net: the overlay's complexity is real but isolated to presentation and covered by the tests; the single-buffer version moves placeholder-awareness into the edit pipeline, where every current and future |
The label is a root-level overlay and inherits nothing from the field; hiding the field (or any ancestor) left the hint floating. Gate the reveal on the field's InheritedVisibility, ordered after VisibilityPropagate so the gate reads this frame's propagation. Display::None needs no handling -- the outer clip node collapses with the zero-sized content box. The test harness models VisibilityPropagate's output explicitly: InheritedVisibility defaults to HIDDEN and only propagation makes it true, and the minimal app does not run propagation. Found integrating downstream.
|
Downstream integration surfaced an issue: the overlay didn't honor the field's effective visibility (root-level entity, inherits nothing — hiding an ancestor panel left the hint floating). Fixed: the reveal now gates on the field's |
Objective
Text inputs have no way to show hint text — the near-universal UI
affordance of a muted prompt ("Search…", "email") in an empty field.
Every consumer of
EditableTextcurrently has to hand-roll an overlayand keep its visibility in sync with the buffer and focus state.
Solution
Placeholder { text, mode }plus an optionalPlaceholderColor,living in
bevy_ui_widgetsalongsideSelectAllOnFocusand followingthe same pattern: widget-layer components composing with
EditableTextthrough public APIs.update_placeholdersambiguities resolved with three targetedpublic sets.
WhileEmpty(web default — the caret renders over thehint) and
UntilFocused(hidden on focus gain, back onblur-while-empty).
TextColorat reduced alpha so ittracks any theme with zero configuration;
PlaceholderColoroverrides.
value(), IME,selection, and submit handling all observe the true empty contents,
and select-all over a hinted field selects nothing.
Architecture
The hint renders as an internal root-level overlay entity (an outer
clip node with an inner text child), position-synced from the field's
content_box()+UiGlobalTransformvia the same pipeline andschedule slot as
update_ime_position. Three constraints forced thisshape:
EditableTextany child silently disables itsContentSize-basedintrinsic sizing — the field collapses unless explicitly sized.
(This bites any child of a text input, not just placeholders;
it's now documented on the internal label component.) Hence a
root-level overlay rather than a child.
TextBrushcarries no color (colors resolve per section indexat render extraction): injecting muted placeholder glyphs in
update_editable_text_layoutwould need a reserved section indexplus render-extraction changes across three crates, for no
user-visible difference. Hence an ordinary
Textentity riding theexisting pipeline.
Overflowclips children, not a node's own text: hence theouter-clip-node / inner-text split, so long hints truncate at the
field's bounds like a real input.
The overlay spawns hidden behind a
positionedgate —Nodewritestake effect at the next frame's layout, so it only reveals after
placement from real layout data and can never render at a stale
origin.
GlobalZIndex(1)keeps the hint deterministically abovedefault-Z roots; overlays that must cover a hinted field (popups,
modals) should sit higher — documented rather than solved here.
Orphaned labels are cleaned up by the sync system; label styling
follows the field, written on change only.
Known scope
UI text inputs only; no accessibility hint attribute yet (follow-up).
Testing
cargo test -p bevy_ui_widgets— 9 new headless tests in the file'sexisting style: spawn contract, the leaf-field invariant (the label
is never parented to the field), the positioned-gate two-tick
reveal, buffer and focus visibility lifecycle, both teardown
directions (component removed; field despawned → orphan reaped), and
color derivation/override.
cargo run --example text_input— the left input now demonstrates aplaceholder; module docs corrected in passing (the example was never
"unstyled").
Showcase
The
text_inputexample, left field empty and hinted: