Skip to content

Placeholder text for editable text inputs - #25110

Open
Cyannide wants to merge 10 commits into
bevyengine:mainfrom
Cyannide:text_input_placeholder
Open

Placeholder text for editable text inputs#25110
Cyannide wants to merge 10 commits into
bevyengine:mainfrom
Cyannide:text_input_placeholder

Conversation

@Cyannide

@Cyannide Cyannide commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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 EditableText currently has to hand-roll an overlay
and keep its visibility in sync with the buffer and focus state.

Solution

Placeholder { text, mode } plus an optional PlaceholderColor,
living in bevy_ui_widgets alongside SelectAllOnFocus and following
the same pattern: widget-layer components composing with
EditableText through public APIs.

update_placeholders ambiguities resolved with three targeted
public sets.

  • Modes: WhileEmpty (web default — the caret renders over the
    hint) and UntilFocused (hidden on focus gain, back on
    blur-while-empty).
  • Color derives from the field's TextColor at reduced alpha so it
    tracks any theme with zero configuration; PlaceholderColor
    overrides.
  • The hint never enters the editor's buffer: 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() + UiGlobalTransform via the same pipeline and
schedule slot as update_ime_position. 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 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.
  2. TextBrush carries no color (colors resolve per section index
    at render extraction): injecting muted placeholder glyphs in
    update_editable_text_layout would need a reserved section index
    plus render-extraction changes across three crates, for no
    user-visible difference. Hence an ordinary Text entity riding the
    existing pipeline.
  3. Overflow clips children, not a node's own text: hence the
    outer-clip-node / inner-text split, so long hints truncate at the
    field's bounds like a real input.

The overlay spawns hidden behind a positioned gate — Node writes
take 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 above
default-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's
    existing 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 a
    placeholder; module docs corrected in passing (the example was never
    "unstyled").

Showcase

The text_input example, left field empty and hinted:

image

Cyannide added 4 commits July 21, 2026 14:49
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.
@kfc35 kfc35 added C-Feature A new feature, making something new possible A-Text Rendering and layout for characters D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes S-Needs-Review Needs reviewer attention (from anyone!) to move forward A-UI Graphical user interfaces, styles, layouts, and widgets labels Jul 22, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in UI Jul 22, 2026
@alice-i-cecile
alice-i-cecile requested a review from kfc35 July 22, 2026 03:46
@alice-i-cecile alice-i-cecile added M-Release-Note Work that should be called out in the blog due to impact X-Uncontroversial This work is generally agreed upon labels Jul 22, 2026
// systems (bevy_gizmos, bevy_sprite) are not
// dependencies of this crate, so they cannot be named
// individually.
.ambiguous_with_all(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread examples/ui/text/text_input.rs Outdated
@@ -1,8 +1,11 @@
//! Demonstrates a simple, unstyled [`EditableText`] widget.
//! Demonstrates the [`EditableText`] widget: two minimally styled text inputs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread examples/README.md Outdated
[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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much worse: too long. Revert.

assert!(!matches_edit_shortcut(&event, "c", KeyCode::KeyC));
}

// -----------------------------------------------------------------------

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: we don't use these type of separators in Bevy. Cut.

@alice-i-cecile alice-i-cecile left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this feature a lot and the implementation seems broadly good, but it needs cleanup for style.

@alice-i-cecile alice-i-cecile added S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Jul 22, 2026
@Cyannide

Copy link
Copy Markdown
Contributor Author

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.

Cyannide added 3 commits July 22, 2026 02:44
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.
@Cyannide

Copy link
Copy Markdown
Contributor Author

Addressed all four points:

  • Example docs, README line, and the matching Cargo.toml metadata reverted to the original wording; one sentence added near the bottom of the module docs for the placeholder. Test separators cut.
  • ambiguous_with_all replaced with targeted resolution: cross-crate pairs moved to IgnoreAmbiguitiesPlugin (via a new public PlaceholderSystems set, plus a TransformGizmoMeshSystems set in bevy_gizmos_render so that pair is nameable without exposing its system fn).
  • One finding on the Without suggestion: it can't resolve the accessibility trio — their text access flows through TextUiReader, which is unfiltered (it reads arbitrary UI children), so the disjointness isn't expressible in filters. Added a public UiAccessibilitySystems set in bevy_ui and declared a targeted ambiguous_with from ui_widgets instead; any future PostLayout text-writer will hit the same three pairs and now has a set to resolve against.
  • ambiguity_detection passes (PostUpdate: 0).

@ickshonpe ickshonpe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Cyannide

Cyannide commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

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 TextColor swap works, no per-glyph color needed.

The costs show up away from rendering, in the edit pipeline and in value():

value() reports the hint while active. Every consumer needs to know the flag — form reads, validation, a11y value reporting. This is the pre-HTML5 value-swapping placeholder, and "submitted the hint as data" was its signature bug class. The overlay keeps value() honest by construction: the hint never enters the buffer.

Clear-before-apply has to enumerate interactions, not just insertions. SelectAllOnFocus would select and highlight the hint; Copy copies it; ArrowRight/Home/End walk the caret through phantom text (and clear-first on a cursor move empties the field, which re-triggers injection); IME preedit composes in-buffer against the hint. Each is a placeholder_active special case, and the interception point is apply_text_edits - so this becomes bevy_text surgery, where the current implementation is pure ui_widgets presentation with zero edit-pipeline surface.

Phantom change detection. Inject/clear are real edits: autoscroll, IME positioning, a11y updates, and any Changed<EditableText> watcher react to placeholder lifecycle, and UntilFocused mode turns every focus transition into buffer mutations.

Composition with the character-mask PR. The two currently compose with zero integration code because value()-emptiness == real-emptiness drives visibility. An in-buffer hint inside a masked field either gets concealed into mask glyphs by the reconcile invariant (hint unreadable) or needs mask-aware special-casing on both sides. (The mask PR's latest push moves the value onto EditableText for exactly this reason — value() is the entered text there now, so the two compose by construction.)

On the UiTransform point: the sync exists because the overlay can't be a child of the field — taffy runs measure functions on leaf nodes only, so any child kills EditableText's ContentSize intrinsic sizing (constraint 1 in the description; the example exposed it). Root-level means no layout relationship to the field, hence the manual placement from content_box + UiGlobalTransform, same recipe as update_ime_position. If there's a way to place a non-child at the field's content box through layout alone I'd happily delete that system.

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 TextEdit variant has to consider it. Happy to prototype the single-buffer version for a side-by-side if you'd find that useful.

@Cyannide
Cyannide requested a review from ickshonpe July 23, 2026 07:00
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.
@Cyannide

Cyannide commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

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 InheritedVisibility, ordered after VisibilityPropagate. In reference to the earlier discussion: each field property the label mirrors (position, now visibility) is a sync the single-buffer version would get for free. Still a smaller cost than moving placeholder-awareness into the edit pipeline. The fix is five hunks plus a regression test.

@JaySpruce JaySpruce added S-Needs-Review Needs reviewer attention (from anyone!) to move forward and removed S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged labels Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Text Rendering and layout for characters A-UI Graphical user interfaces, styles, layouts, and widgets C-Feature A new feature, making something new possible D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes 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 X-Uncontroversial This work is generally agreed upon

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

5 participants