diff --git a/Cargo.toml b/Cargo.toml index 7ce00e88ba4e6..f0931e95afb55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1009,6 +1009,17 @@ description = "Demonstrates a simple, unstyled text input widget" category = "UI (User Interface)" wasm = true +[[example]] +name = "multiline_text_input" +path = "examples/ui/text/multiline_text_input.rs" +doc-scrape-examples = true + +[package.metadata.example.multiline_text_input] +name = "Multiline Text Input" +description = "Demonstrates a single multiline EditableText widget" +category = "UI (User Interface)" +wasm = true + [[example]] name = "multiple_text_inputs" path = "examples/ui/text/multiple_text_inputs.rs" diff --git a/crates/bevy_text/src/text_editable.rs b/crates/bevy_text/src/text_editable.rs index 085fbbae18cc2..779226f89b602 100644 --- a/crates/bevy_text/src/text_editable.rs +++ b/crates/bevy_text/src/text_editable.rs @@ -125,6 +125,8 @@ pub struct EditableText { pub max_characters: Option, /// Sets the input’s height in number of visible lines. pub visible_lines: Option, + /// Allow new lines + pub allow_newlines: bool, } impl Default for EditableText { @@ -138,6 +140,7 @@ impl Default for EditableText { text_edited: false, max_characters: None, visible_lines: Some(1.), + allow_newlines: false, } } } diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index f22d248c7d3a4..b9777614a7506 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -251,7 +251,8 @@ fn build_text_interop(app: &mut App) { .in_set(UiSystems::Content) .ambiguous_with(widget::update_image_content_size_system) .ambiguous_with(widget::measure_text_system), - widget::editable_text_system + (widget::editable_text_system, widget::scroll_editable_text) + .chain() .in_set(UiSystems::PostLayout) .ambiguous_with(ui_stack_system) .ambiguous_with(widget::text_system) diff --git a/crates/bevy_ui/src/widget/text_editable.rs b/crates/bevy_ui/src/widget/text_editable.rs index fd511db37420d..2a867b98bddf6 100644 --- a/crates/bevy_ui/src/widget/text_editable.rs +++ b/crates/bevy_ui/src/widget/text_editable.rs @@ -6,6 +6,7 @@ use bevy_asset::Assets; use bevy_ecs::{ change_detection::DetectChanges, + component::Component, entity::Entity, system::{Local, Query, Res, ResMut}, world::Ref, @@ -16,14 +17,18 @@ use bevy_math::{Rect, Vec2}; use bevy_platform::hash::FixedHasher; use bevy_text::{ add_glyph_to_atlas, get_glyph_atlas_info, resolve_font_source, EditableText, Font, - FontAtlasKey, FontAtlasSet, FontCx, FontHinting, GlyphCacheKey, LayoutCx, LineHeight, - PositionedGlyph, RemSize, RunGeometry, ScaleCx, TextBrush, TextFont, TextLayoutInfo, + FontAtlasKey, FontAtlasSet, FontCx, FontHinting, GlyphCacheKey, LayoutCx, LineBreak, + LineHeight, PositionedGlyph, RemSize, RunGeometry, ScaleCx, TextBrush, TextFont, TextLayout, + TextLayoutInfo, }; use bevy_time::{Real, Time}; -use parley::{BoundingBox, PositionedLayoutItem}; +use parley::{BoundingBox, PositionedLayoutItem, StyleProperty}; use swash::FontRef; use taffy::MaybeMath; +#[derive(Component, Clone, Copy, PartialEq, Debug, Default)] +pub struct TextScroll(pub Vec2); + struct TextInputMeasure { height: f32, } @@ -108,6 +113,7 @@ pub fn editable_text_system( &mut EditableText, &mut TextLayoutInfo, Ref, + &TextLayout, )>, rem_size: Res, input_focus: Option>, @@ -125,6 +131,7 @@ pub fn editable_text_system( mut editable_text, mut info, computed_node, + text_layout, ) in input_field_query.iter_mut() { let Ok(font_family) = resolve_font_source(&text_font.font, fonts.as_ref()) else { @@ -133,17 +140,40 @@ pub fn editable_text_system( let family = font_family.into_owned(); let style_set = editable_text.editor.edit_styles(); - style_set.insert(parley::StyleProperty::LineHeight(line_height.eval())); - style_set.insert(parley::StyleProperty::FontFamily(family)); + style_set.insert(StyleProperty::LineHeight(line_height.eval())); + style_set.insert(StyleProperty::FontFamily(family)); 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)); - style_set.insert(parley::StyleProperty::Brush(TextBrush::new( + style_set.insert(StyleProperty::FontSize(font_size)); + style_set.insert(StyleProperty::Brush(TextBrush::new( 0, text_font.font_smoothing, ))); + match text_layout.linebreak { + LineBreak::AnyCharacter => { + style_set.insert(StyleProperty::WordBreak(parley::WordBreak::BreakAll)); + style_set.insert(StyleProperty::OverflowWrap(parley::OverflowWrap::Normal)); + style_set.insert(StyleProperty::TextWrapMode(parley::TextWrapMode::Wrap)); + } + LineBreak::WordOrCharacter => { + style_set.insert(StyleProperty::WordBreak(parley::WordBreak::Normal)); + style_set.insert(StyleProperty::OverflowWrap(parley::OverflowWrap::Anywhere)); + style_set.insert(StyleProperty::TextWrapMode(parley::TextWrapMode::Wrap)); + } + LineBreak::NoWrap => { + style_set.insert(StyleProperty::WordBreak(parley::WordBreak::Normal)); + style_set.insert(StyleProperty::OverflowWrap(parley::OverflowWrap::Normal)); + style_set.insert(StyleProperty::TextWrapMode(parley::TextWrapMode::NoWrap)); + } + LineBreak::WordBoundary => { + style_set.insert(StyleProperty::WordBreak(parley::WordBreak::Normal)); + style_set.insert(StyleProperty::OverflowWrap(parley::OverflowWrap::Normal)); + style_set.insert(StyleProperty::TextWrapMode(parley::TextWrapMode::Wrap)); + } + } + if target.is_changed() { editable_text.editor.set_scale(target.scale_factor()); } @@ -151,7 +181,7 @@ pub fn editable_text_system( if computed_node.is_changed() { editable_text .editor - .set_width(Some(computed_node.content_size().x)); + .set_width(Some(computed_node.content_box().width())); } let mut driver = editable_text @@ -304,3 +334,45 @@ fn bounding_box_to_rect(geom: BoundingBox) -> Rect { }, } } + +/// Scroll editable text to keep cursor in view after edits. +pub fn scroll_editable_text(mut query: Query<(&EditableText, &mut TextScroll, &ComputedNode)>) { + for (editable_text, mut scroll, node) in query.iter_mut() { + if !editable_text.text_edited { + continue; + } + + let view_size = node.content_box().size(); + if view_size.cmple(Vec2::ZERO).any() { + continue; + } + + let Some(cursor) = editable_text + .editor + .cursor_geometry(1.0) + .map(bounding_box_to_rect) + else { + continue; + }; + + let mut new_scroll = scroll.0; + + if cursor.min.x < new_scroll.x { + new_scroll.x = cursor.min.x; + } else if new_scroll.x + view_size.x < cursor.max.x { + new_scroll.x = cursor.max.x - view_size.x; + } + + if cursor.min.y < new_scroll.y { + new_scroll.y = cursor.min.y; + } else if new_scroll.y + view_size.y < cursor.max.y { + new_scroll.y = cursor.max.y - view_size.y; + } + + new_scroll = new_scroll.max(Vec2::ZERO); + + if scroll.0 != new_scroll { + scroll.0 = new_scroll; + } + } +} diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index 012f206629c8e..2d96b399ac706 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -25,7 +25,7 @@ use bevy_reflect::prelude::ReflectDefault; use bevy_reflect::Reflect; use bevy_shader::load_shader_library; use bevy_sprite_render::SpriteAssetEvents; -use bevy_ui::widget::{ImageNode, TextShadow, ViewportNode}; +use bevy_ui::widget::{ImageNode, TextScroll, TextShadow, ViewportNode}; use bevy_ui::{ BackgroundColor, BorderColor, CalculatedClip, ComputedNode, ComputedUiTargetCamera, Display, Node, OuterColor, Outline, ResolvedBorderRadius, UiGlobalTransform, @@ -905,6 +905,7 @@ pub fn extract_text_sections( &ComputedTextBlock, &TextColor, &TextLayoutInfo, + Option<&TextScroll>, )>, >, text_styles: Extract>, @@ -917,13 +918,14 @@ pub fn extract_text_sections( for ( entity, uinode, - transform, + global_transform, inherited_visibility, - clip, + maybe_clip, camera, computed_block, text_color, text_layout_info, + text_scroll, ) in &uinode_query { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) @@ -935,8 +937,21 @@ pub fn extract_text_sections( continue; }; - let transform = - Affine2::from(*transform) * Affine2::from_translation(uinode.content_box().min); + let transform = Affine2::from(*global_transform) + * Affine2::from_translation( + uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), + ); + + let clip = if text_scroll.is_some() { + let content_box = uinode.content_box(); + let text_clip = Rect::from_center_size( + global_transform.affine().translation + content_box.center(), + content_box.size(), + ); + Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) + } else { + maybe_clip.map(|clip| clip.clip) + }; let mut color = text_color.0.to_linear(); @@ -980,7 +995,7 @@ pub fn extract_text_sections( z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), image: atlas_info.texture, - clip: clip.map(|clip| clip.clip), + clip, extracted_camera_entity, item: ExtractedUiItem::Glyphs { range: start..end }, main_entity: entity.into(), @@ -1008,6 +1023,7 @@ pub fn extract_text_shadows( &TextLayoutInfo, &TextShadow, &ComputedTextBlock, + Option<&TextScroll>, )>, >, text_decoration_query: Extract, Has)>>, @@ -1020,13 +1036,14 @@ pub fn extract_text_shadows( for ( entity, uinode, - transform, + global_transform, target, inherited_visibility, - clip, + maybe_clip, text_layout_info, shadow, computed_block, + text_scroll, ) in &uinode_query { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) @@ -1038,11 +1055,23 @@ pub fn extract_text_shadows( continue; }; - let node_transform = Affine2::from(*transform) + let node_transform = Affine2::from(*global_transform) * Affine2::from_translation( - uinode.content_box().min + shadow.offset / uinode.inverse_scale_factor(), + uinode.content_box().min + shadow.offset / uinode.inverse_scale_factor() + - text_scroll.map_or(Vec2::ZERO, |s| s.0), ); + let clip = if text_scroll.is_some() { + let content_box = uinode.content_box(); + let text_clip = Rect::from_center_size( + global_transform.affine().translation + content_box.center(), + content_box.size(), + ); + Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) + } else { + maybe_clip.map(|clip| clip.clip) + }; + for ( i, PositionedGlyph { @@ -1068,7 +1097,7 @@ pub fn extract_text_shadows( z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), image: atlas_info.texture, - clip: clip.map(|clip| clip.clip), + clip, extracted_camera_entity, item: ExtractedUiItem::Glyphs { range: start..end }, main_entity: entity.into(), @@ -1096,7 +1125,7 @@ pub fn extract_text_shadows( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip: clip.map(|clip| clip.clip), + clip, image: AssetId::default(), extracted_camera_entity, transform: node_transform @@ -1122,7 +1151,7 @@ pub fn extract_text_shadows( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip: clip.map(|clip| clip.clip), + clip, image: AssetId::default(), extracted_camera_entity, transform: node_transform * Affine2::from_translation(run.underline_position()), @@ -1159,6 +1188,7 @@ pub fn extract_text_decorations( Option<&CalculatedClip>, &ComputedUiTargetCamera, &TextLayoutInfo, + Option<&TextScroll>, )>, >, text_background_colors_query: Extract< @@ -1178,9 +1208,10 @@ pub fn extract_text_decorations( computed_block, global_transform, inherited_visibility, - clip, + maybe_clip, camera, text_layout_info, + text_scroll, ) in &uinode_query { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) @@ -1192,8 +1223,21 @@ pub fn extract_text_decorations( continue; }; - let transform = - Affine2::from(global_transform) * Affine2::from_translation(uinode.content_box().min); + let transform = Affine2::from(global_transform) + * Affine2::from_translation( + uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), + ); + + let clip = if text_scroll.is_some() { + let content_box = uinode.content_box(); + let text_clip = Rect::from_center_size( + global_transform.affine().translation + content_box.center(), + content_box.size(), + ); + Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) + } else { + maybe_clip.map(|clip| clip.clip) + }; for run in text_layout_info.run_geometry.iter() { let Some(section_entity) = computed_block @@ -1217,7 +1261,7 @@ pub fn extract_text_decorations( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip: clip.map(|clip| clip.clip), + clip, image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(run.bounds.center()), @@ -1247,7 +1291,7 @@ pub fn extract_text_decorations( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip: clip.map(|clip| clip.clip), + clip, image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(run.strikethrough_position()), @@ -1277,7 +1321,7 @@ pub fn extract_text_decorations( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip: clip.map(|clip| clip.clip), + clip, image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(run.underline_position()), diff --git a/crates/bevy_ui_render/src/text.rs b/crates/bevy_ui_render/src/text.rs index a8d321afbf23b..d84275636dd98 100644 --- a/crates/bevy_ui_render/src/text.rs +++ b/crates/bevy_ui_render/src/text.rs @@ -7,7 +7,8 @@ use bevy_render::{sync_world::TemporaryRenderEntity, Extract}; use bevy_sprite::BorderRect; use bevy_text::{TextCursorStyle, TextLayoutInfo}; use bevy_ui::{ - CalculatedClip, ComputedNode, ComputedUiTargetCamera, ResolvedBorderRadius, UiGlobalTransform, + widget::TextScroll, CalculatedClip, ComputedNode, ComputedUiTargetCamera, ResolvedBorderRadius, + UiGlobalTransform, }; use crate::{ @@ -27,6 +28,7 @@ pub fn extract_text_cursor( &ComputedUiTargetCamera, &TextLayoutInfo, &TextCursorStyle, + Option<&TextScroll>, )>, >, camera_map: Extract, @@ -42,6 +44,7 @@ pub fn extract_text_cursor( target_camera, text_layout_info, cursor_style, + text_scroll, ) in text_node_query.iter() { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) @@ -53,8 +56,21 @@ pub fn extract_text_cursor( continue; }; - let transform = - Affine2::from(global_transform) * Affine2::from_translation(uinode.content_box().min); + let transform = Affine2::from(global_transform) + * Affine2::from_translation( + uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), + ); + + let clip = if text_scroll.is_some() { + let content_box = uinode.content_box(); + let text_clip = Rect::from_center_size( + global_transform.affine().translation + content_box.center(), + content_box.size(), + ); + Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) + } else { + maybe_clip.map(|clip| clip.clip) + }; if !text_layout_info.selection_rects.is_empty() && !cursor_style.selection_color.is_fully_transparent() @@ -65,7 +81,7 @@ pub fn extract_text_cursor( extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT_SELECTION, - clip: maybe_clip.map(|clip| clip.clip), + clip, image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(selection.center()), @@ -94,7 +110,7 @@ pub fn extract_text_cursor( extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT_CURSOR, - clip: maybe_clip.map(|clip| clip.clip), + clip, image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(cursor_rect.center()), diff --git a/crates/bevy_ui_widgets/src/editable_text.rs b/crates/bevy_ui_widgets/src/editable_text.rs index 3c122219b2164..6d4a87da91565 100644 --- a/crates/bevy_ui_widgets/src/editable_text.rs +++ b/crates/bevy_ui_widgets/src/editable_text.rs @@ -14,6 +14,7 @@ use bevy_input_focus::{FocusedInput, InputFocus}; use bevy_picking::events::{Drag, Pointer, Press}; use bevy_picking::pointer::PointerButton; use bevy_text::{EditableText, TextEdit}; +use bevy_ui::widget::TextScroll; use bevy_ui::{ widget::TextNodeFlags, ComputedNode, ComputedUiRenderTargetInfo, ContentSize, Node, UiGlobalTransform, UiScale, @@ -54,6 +55,8 @@ fn on_focused_keyboard_input( return; // Focused entity is not an EditableText, nothing to do }; + let allow_newlines = editable_text.allow_newlines; + // Bitflags representing states of modifier keys. // On macOS Option is mapped to `Key::Alt` by `bevy_input`. let mod_flags = (SUPER * u8::from(keys.pressed(Key::Super))) @@ -120,9 +123,12 @@ fn on_focused_keyboard_input( queue_edit(TextEdit::Insert(text.clone())); } } + (NONE, Key::Enter) => { + if allow_newlines { + queue_edit(TextEdit::Insert("\n".into())); + } + } _ => { - // Enter and Tab ignored for now. - // Enter needs extra logic for handling special cases and Parley doesn't support tabs yet. // Ignore and propagate to allow for tab navigation and submit actions. } } @@ -141,6 +147,7 @@ fn on_pointer_press( &ComputedNode, &ComputedUiRenderTargetInfo, &UiGlobalTransform, + &TextScroll, )>, keys: Res>, mut input_focus: ResMut, @@ -150,7 +157,8 @@ fn on_pointer_press( return; } - let Ok((mut editable_text, node, target, transform)) = text_input_query.get_mut(press.entity) + let Ok((mut editable_text, node, target, transform, text_scroll)) = + text_input_query.get_mut(press.entity) else { return; }; @@ -159,6 +167,7 @@ fn on_pointer_press( inverse .transform_point2(press.pointer_location.position * target.scale_factor() / ui_scale.0) - node.content_box().min + + text_scroll.0 }) else { return; }; @@ -187,6 +196,7 @@ fn on_pointer_drag( &ComputedNode, &ComputedUiRenderTargetInfo, &UiGlobalTransform, + &TextScroll, )>, ui_scale: Res, ) { @@ -194,7 +204,8 @@ fn on_pointer_drag( return; } - let Ok((mut editable_text, node, target, transform)) = text_input_query.get_mut(drag.entity) + let Ok((mut editable_text, node, target, transform, text_scroll)) = + text_input_query.get_mut(drag.entity) else { return; }; @@ -203,6 +214,7 @@ fn on_pointer_drag( inverse .transform_point2(drag.pointer_location.position * target.scale_factor() / ui_scale.0) - node.content_box().min + + text_scroll.0 }) else { return; }; @@ -234,6 +246,7 @@ impl Plugin for EditableTextInputPlugin { // because that would create a circular dependency between `bevy_text` and `bevy_ui`. app.register_required_components::() .register_required_components::() - .register_required_components::(); + .register_required_components::() + .register_required_components::(); } } diff --git a/examples/README.md b/examples/README.md index 18a16d2caaa4d..23cd619bfe709 100644 --- a/examples/README.md +++ b/examples/README.md @@ -606,6 +606,7 @@ Example | Description [Image Node](../examples/ui/images/image_node.rs) | Demonstrates how to create an image node [Image Node Resizing](../examples/ui/images/image_node_resizing.rs) | Demonstrates how to resize an image node [Letter Spacing](../examples/ui/text/letter_spacing.rs) | Demonstrates the letter spacing feature +[Multiline Text Input](../examples/ui/text/multiline_text_input.rs) | Demonstrates a single multiline EditableText widget [Multiple Text Inputs](../examples/ui/text/multiple_text_inputs.rs) | Demonstrates multiple text inputs [Overflow](../examples/ui/scroll_and_overflow/overflow.rs) | Simple example demonstrating overflow behavior [Overflow Clip Margin](../examples/ui/scroll_and_overflow/overflow_clip_margin.rs) | Simple example demonstrating the OverflowClipMargin style property diff --git a/examples/ui/text/multiline_text_input.rs b/examples/ui/text/multiline_text_input.rs new file mode 100644 index 0000000000000..7d89d228ad853 --- /dev/null +++ b/examples/ui/text/multiline_text_input.rs @@ -0,0 +1,54 @@ +//! Demonstrates a single, minimal multiline [`EditableText`] widget. + +use bevy::color::palettes::css::{DARK_SLATE_GRAY, YELLOW}; +use bevy::input_focus::AutoFocus; +use bevy::prelude::*; +use bevy::text::{EditableText, TextCursorStyle}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .run(); +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2d); + + commands + .spawn(Node { + width: percent(100.), + height: percent(100.), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..default() + }) + .with_children(|parent| { + parent.spawn(( + Node { + width: px(300.), + border: px(2.).all(), + padding: px(8.).all(), + ..default() + }, + EditableText { + visible_lines: Some(8.), + allow_newlines: true, + ..default() + }, + TextLayout { + linebreak: LineBreak::AnyCharacter, + ..default() + }, + TextFont { + font: asset_server.load("fonts/FiraMono-Medium.ttf").into(), + font_size: FontSize::Px(30.), + ..default() + }, + TextCursorStyle::default(), + BackgroundColor(DARK_SLATE_GRAY.into()), + BorderColor::all(YELLOW), + AutoFocus, + )); + }); +}