Skip to content

Minimal unstyled Text Input with Parley#23282

Open
Zeophlite wants to merge 36 commits intobevyengine:mainfrom
Zeophlite:parley-text-input2
Open

Minimal unstyled Text Input with Parley#23282
Zeophlite wants to merge 36 commits intobevyengine:mainfrom
Zeophlite:parley-text-input2

Conversation

@Zeophlite
Copy link
Contributor

@Zeophlite Zeophlite commented Mar 9, 2026

Objective

Solution

Relevant schedules are:

  • PreUpdate, bevy_ui_widgets::process_text_inputs
  • PostUpdate, bevy_text::apply_text_edits
  • PostUpdate, bevy_ui::editable_text_system

And in render_app:

  • ExtractSchedule, bevy_ui_render::extract_text_editable
  • ExtractSchedule, bevy_ui_render::extract_text_cursor

Testing

  • cargo run --example editable_text

Showcase

text-input-wip4

@IQuick143 IQuick143 added A-Text Rendering and layout for characters D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes A-UI Graphical user interfaces, styles, layouts, and widgets labels Mar 9, 2026
@github-project-automation github-project-automation bot moved this to Needs SME Triage in UI Mar 9, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

The generated examples/README.md is out of sync with the example metadata in Cargo.toml or the example readme template. Please run cargo run -p build-templated-pages -- update examples to update it, and commit the file change.

CI

docfixs

docs
@Zeophlite Zeophlite force-pushed the parley-text-input2 branch from 9a88110 to 8e06897 Compare March 9, 2026 17:27
@Zeophlite Zeophlite force-pushed the parley-text-input2 branch from 8567f0a to 0a4c802 Compare March 9, 2026 18:14
@alice-i-cecile alice-i-cecile added 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 Mar 9, 2026
Copy link
Member

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

Choose a reason for hiding this comment

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

Getting close! I've spotted a few issues during review, but I'm really pleased with the progress here.

IMO one more round of fixes and we should be good to merge.

@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 Mar 16, 2026
@Zeophlite Zeophlite 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 Mar 17, 2026
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
@alice-i-cecile alice-i-cecile added this to the 0.19 milestone Mar 17, 2026
@@ -0,0 +1,118 @@
use bevy_asset::AssetId;
Copy link
Contributor

@ickshonpe ickshonpe Mar 13, 2026

Choose a reason for hiding this comment

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

Feels excessive to add a module just for cursor rendering. If we do this, it would make more sense to rename it to text and move all text extraction into it.

let mut scaler = scale_cx
.builder(font_ref)
.size(font_size)
.hint(true)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
.hint(true)
.hint(matches!(hinting, FontHinting::Enabled))

And made a quick PR to change should_hint to pub: #23397

let logical_viewport_size = target.logical_size();
let font_size = text_font.font_size.eval(logical_viewport_size, rem_size.0);
style_set.insert(parley::StyleProperty::FontSize(font_size));

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
style_set.insert(parley::StyleProperty::Brush(TextBrush::new(
0,
text_font.font_smoothing,
)));

/// Text edit actions that have been requested but not yet applied.
///
/// These edits are processed in first-in, first-out order.
pub pending_edits: VecDeque<TextEdit>,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the edits buffer should probably be managed by the widget implementation, otherwise there might be synchronisation issues with non-editing actions like submit and insert/overwrite mode switching.

/// Text edit actions that have been requested but not yet applied.
///
/// These edits are processed in first-in, first-out order.
pub pending_edits: VecDeque<TextEdit>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason for using a VecDeque? If all the edits are always applied, it can just be drained.

Comment on lines +157 to +165
// Take the `pending_edits` out of the struct so we can apply them without mutable aliasing issues.
// We do not need to put the `pending_edits` back into the struct,
// as all edits should be consumed by this method, leaving `pending_edits` empty at the end.
let mut pending_edits = core::mem::take(&mut self.pending_edits);
let mut driver = self.editor_mut().driver(font_context, layout_context);

while let Some(edit) = pending_edits.pop_front() {
driver = apply_edit(edit, driver);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Take the `pending_edits` out of the struct so we can apply them without mutable aliasing issues.
// We do not need to put the `pending_edits` back into the struct,
// as all edits should be consumed by this method, leaving `pending_edits` empty at the end.
let mut pending_edits = core::mem::take(&mut self.pending_edits);
let mut driver = self.editor_mut().driver(font_context, layout_context);
while let Some(edit) = pending_edits.pop_front() {
driver = apply_edit(edit, driver);
}
let Self {
editor,
pending_edits,
..
} = self;
let mut driver = editor.driver(font_context, layout_context);
for edit in pending_edits.drain(..) {
driver = apply_edit(edit, driver);
}

Comment on lines +110 to +111
/// Cursor width, relative to font size
pub cursor_width: f32,
Copy link
Contributor

@ickshonpe ickshonpe Mar 17, 2026

Choose a reason for hiding this comment

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

Cursor width options should be in a separate CursorStyle/Shape/Size component I think.

font_size_bits: font_size.to_bits(),
variations_hash: FixedHasher.hash_one(coords),
hinting: *hinting,
font_smoothing: brush.font_smoothing,
Copy link
Contributor

Choose a reason for hiding this comment

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

Brush needs to be set, otherwise this always uses the default smoothing setting.

pub const TEXT: f32 = 0.06;
pub const TEXT_CURSOR: f32 = 0.065;
pub const TEXT_STRIKETHROUGH: f32 = 0.07;
pub const TEXT_SELECTION: f32 = 0.08;
Copy link
Contributor

Choose a reason for hiding this comment

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

Text selection rects should be drawn behind the text and cursor.

Suggested change
pub const TEXT_SELECTION: f32 = 0.08;
pub const TEXT_SELECTION: f32 = 0.055;

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I see you set it transparent atm, it should be behind the cursor at least though.

.add(RadioGroupPlugin)
.add(ScrollbarPlugin)
.add(SliderPlugin)
.add(EditableTextInputPlugin)
Copy link
Contributor

Choose a reason for hiding this comment

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

Adding UiWidgetsPlugin will panic if you don't add InputDispatchPlugin as process_text_inputs uses Res<InputFocus>.

}
}

pub fn extract_text_editable(
Copy link
Contributor

@ickshonpe ickshonpe Mar 17, 2026

Choose a reason for hiding this comment

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

The EditableText entity has all the necessary components so the text is already rendered by extract_text_system, this system just queues the glyphs to be drawn a second time.

) in text_node_query.iter()
{
// Skip if not visible
// Note that `uinode.is_empty()` may be empty when there is no text, but we still want to display the cursor
Copy link
Contributor

Choose a reason for hiding this comment

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

There should be a uinode.is_empty() check here. In general the size of the node shouldn't depend on the size of the text layout.

Comment on lines +95 to +97
content_size.set(NodeMeasure::Fixed(FixedMeasure {
size: info.size * target.scale_factor(),
}));
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't add a measure func, the EditableText node should just be set to fill its parent. That is is unless it's using a fixed height based on the number of lines and/or a fixed width based on a max number of glyphs.

Suggested change
content_size.set(NodeMeasure::Fixed(FixedMeasure {
size: info.size * target.scale_factor(),
}));

info.glyphs.clear();
info.run_geometry.clear();

for line in layout.lines() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should just call TextPipeline::update_text_layout_info here.

Comment on lines +143 to +144
width: px(200),
height: px(100),
Copy link
Contributor

@ickshonpe ickshonpe Mar 17, 2026

Choose a reason for hiding this comment

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

Set the size on the EditableText node itself, then don't have to bother with the measure func.

Suggested change
width: px(200),
height: px(100),

.id();

let edit = commands
.spawn((
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
.spawn((
.spawn((
Node {
width: px(200),
height: px(100),
..Default::default()
},

/// and provides methods for applying text edits and cursor movements correctly
/// according to Unicode rules.
#[derive(Component)]
#[require(TextLayout, TextFont, TextColor, LineHeight, FontHinting)]
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like EditableText should just be newtype around PlainEditor and not have any requirements. Maybe no helper functions even.

IMO the basic design should instead use a primary TextInput (or whatever) component that newtypes a string and require's everything. Then the widget's update system keeps the TextInput component synchronised with the PlainEditor's text buffer, for seamless change detection. So users can modify the TextInput's String to update the PlainEditors text buffer and they can use change detection on TextInput to watch for any changes due to edits on the PlainEditor. We can just clone the strings, it doesn't matter as only one input will be active at a time and we aren't building a text editor for giant text files.

///
/// Note that this does not immediately apply the edits; they are queued up in [`EditableText::pending_edits`],
/// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system.
pub fn process_text_inputs(
Copy link
Contributor

@ickshonpe ickshonpe Mar 17, 2026

Choose a reason for hiding this comment

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

This should be an observer watching for On<FocusedInput<KeyboardInput> I guess. It's more idiomatic and an observer can propagate unhandled keystrokes if needed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed: any keystrokes that are not handled by the text input widget should be allowed to propagate. This lets things like "Ctrl-W to close window" work.

Comment on lines +124 to +126
app.register_required_components::<EditableText, Node>()
.register_required_components::<EditableText, TextNodeFlags>()
.register_required_components::<EditableText, ContentSize>();
Copy link
Contributor

Choose a reason for hiding this comment

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

The primary component should be in this module, then there's no need to bother with this.

.ambiguous_with(bevy_sprite::update_text2d_layout)
.ambiguous_with(bevy_sprite::calculate_bounds_text2d),
widget::editable_text_system
.in_set(UiSystems::PostLayout)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is incorrect atm, measure funcs should be updated before UiSystems::Layout. Will need to be split up into two systems as it is for UI text, one in UiSystems::Content and one in UiSystems::PostLayout.

Comment on lines +95 to +97
content_size.set(NodeMeasure::Fixed(FixedMeasure {
size: info.size * target.scale_factor(),
}));
Copy link
Contributor

@ickshonpe ickshonpe Mar 17, 2026

Choose a reason for hiding this comment

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

Another problem here is that most edits are going to change the size of the text layout, so the measure func will be updated which will cause an unnecessary relayout of the node.

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 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-Blessed Has a large architectural impact or tradeoffs, but the design has been endorsed by decision makers

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

6 participants