From 119362a269b1a4a04b96531d28f8e88b37c9b478 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 13:03:05 +0100 Subject: [PATCH 01/20] Added `scroll_inset` field to `EditableText`. `scroll_editable_text` scrolling calculations changed to support the inset. --- crates/bevy_text/src/editing.rs | 4 + .../bevy_ui/src/widget/text_input_layout.rs | 107 +++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index d5cc2b905773a..8927b10cf3b57 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -80,6 +80,7 @@ use alloc::sync::Arc; use bevy_clipboard::ClipboardRead; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::prelude::*; +use bevy_math::Vec2; use core::time::Duration; use parley::{FontContext, LayoutContext, PlainEditor, SplitString}; @@ -144,6 +145,8 @@ pub struct EditableText { pub visible_width: Option, /// Allow new lines pub allow_newlines: bool, + /// Fraction of the visible input size to keep between the cursor and the view edge when scrolling. + pub scroll_inset: Vec2, } impl Default for EditableText { @@ -159,6 +162,7 @@ impl Default for EditableText { visible_lines: Some(1.), visible_width: None, allow_newlines: false, + scroll_inset: Vec2::new(0.1, 0.25), } } } diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index da53e23a45e96..a3c73cdba7733 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -540,17 +540,29 @@ pub fn scroll_editable_text( info.size.x } - view_size.x) .max(0.); + let max_scroll_y = (info.size.y - view_size.y).max(0.); scroll.set_if_neq(TextScroll(Vec2 { - x: scroll_axis( + x: scroll_axis_with_inset( + editable_text.scroll_inset.x.clamp(0., 0.49) * view_size.x, + 0., + max_scroll_x, scroll.0.x, scroll.0.x + view_size.x, cursor.min.x, cursor.max.x, ) - .clamp(0., max_scroll_x) .floor(), - y: scroll_axis(scroll.0.y, scroll.0.y + view_size.y, line_min, line_max).floor(), + y: scroll_axis_with_inset( + editable_text.scroll_inset.y.clamp(0., 0.49) * view_size.x, + 0., + max_scroll_y, + scroll.0.y, + scroll.0.y + view_size.y, + line_min, + line_max, + ) + .floor(), })); } @@ -585,3 +597,92 @@ fn scroll_axis(v_min: f32, v_max: f32, t_min: f32, t_max: f32) -> f32 { v_min } } + +fn scroll_axis_with_inset( + inset: f32, + scroll_min: f32, + scroll_max: f32, + v_min: f32, + v_max: f32, + t_min: f32, + t_max: f32, +) -> f32 { + let v_size = v_max - v_min; + let u_min = v_min + inset; + let u_max = v_max - inset; + let t_size = t_max - t_min; + + let new_v_min = if v_size - 2. * inset < t_size { + scroll_axis(v_min, v_max, t_min, t_max) + } else if t_min < u_min { + t_min - inset + } else if u_max < t_max { + t_max - v_size + inset + } else { + v_min + }; + + new_v_min.clamp(scroll_min, scroll_max) +} + +#[cfg(test)] +mod test { + use super::{scroll_axis, scroll_axis_with_inset}; + + #[test] + fn test_scroll_axis() { + assert_eq!(scroll_axis(0., 100., 0., 10.), 0.); + } + + #[test] + fn test_scroll_axis_with_inset() { + assert_eq!( + scroll_axis_with_inset(0.25, 0., 100., 0., 100., 0., 50.), + 0. + ); + assert_eq!( + scroll_axis_with_inset(0.25, 0., 0., 0., 100., 50., 100.), + 0. + ); + } + + #[test] + fn test_scroll_axis_with_inset_moves_to_inner_min() { + assert_eq!( + scroll_axis_with_inset(0.25, 0., 100., 50., 150., 60., 65.), + 35. + ); + } + + #[test] + fn test_scroll_axis_with_inset_moves_to_inner_max() { + assert_eq!( + scroll_axis_with_inset(0.25, 0., 100., 0., 100., 90., 95.), + 20. + ); + } + + #[test] + fn test_scroll_axis_with_inset_saturates() { + assert_eq!( + scroll_axis_with_inset(0.25, 0., 100., 10., 110., 10., 20.), + 0. + ); + assert_eq!( + scroll_axis_with_inset(0.25, 0., 100., 80., 180., 175., 180.), + 100. + ); + } + + #[test] + fn test_scroll_axis_with_inset_uses_full_view_when_target_larger_than_inner() { + assert_eq!( + scroll_axis_with_inset(0.25, 0., 100., 0., 100., 20., 90.), + 0. + ); + assert_eq!( + scroll_axis_with_inset(0.25, 0., 100., 0., 100., 80., 150.), + 50. + ); + } +} From 454334ec9c26b1b9099fdfa70170631b97422c3b Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 13:33:32 +0100 Subject: [PATCH 02/20] `on_pointer_drag` queues `MoveToPoint` as well as `ExtendSelectionToPoint` when dragging. So each time the mouse moves during a drag, the current selection is cleared and a new selection is created. As long as the local drag start position is constant this is seamless, but if you scroll the text input view, the start of the drag is now at a different position relative to the text layout, and the start of the selection range changes. Solution: Don't queue the `MoveToPoint` edit. --- crates/bevy_ui_widgets/src/text_input.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index fd7a2e6df181c..b65f7bc511bf1 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -15,7 +15,7 @@ use bevy_input_focus::{ FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, }; use bevy_math::Vec2; -use bevy_picking::events::{Click, Drag, Pointer, Press, Release}; +use bevy_picking::events::{Click, Drag, DragStart, Pointer, Press, Release}; use bevy_picking::pointer::PointerButton; use bevy_text::{EditableText, PreeditCursor, TextEdit}; use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll}; @@ -285,23 +285,18 @@ fn on_pointer_drag( return; } - let Some((drag_start_local_pos, current_local_pos)) = transform.try_inverse().map(|inverse| { - let transform_pos = |pointer_pos| { - inverse.transform_point2(pointer_pos * target.scale_factor() / ui_scale.0) - - node.content_box().min - + text_scroll.0 - }; - let current_pos = drag.pointer_location.position; - let drag_start_pos = current_pos - drag.distance; - (transform_pos(drag_start_pos), transform_pos(current_pos)) + let Some(current_local_pos) = transform.try_inverse().map(|inverse| { + inverse + .transform_point2(drag.pointer_location.position * target.scale_factor() / ui_scale.0) + - node.content_box().min + + text_scroll.0 }) else { return; }; - editable_text.pending_edits.extend([ - TextEdit::MoveToPoint(drag_start_local_pos), - TextEdit::ExtendSelectionToPoint(current_local_pos), - ]); + editable_text + .pending_edits + .push(TextEdit::ExtendSelectionToPoint(current_local_pos)); drag.propagate(false); } From 2154aad65ed791699a76c63cff4efd6515796396 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 14:16:11 +0100 Subject: [PATCH 03/20] Removed unused import. --- crates/bevy_ui_widgets/src/text_input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index b65f7bc511bf1..0174191b29074 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -15,7 +15,7 @@ use bevy_input_focus::{ FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, }; use bevy_math::Vec2; -use bevy_picking::events::{Click, Drag, DragStart, Pointer, Press, Release}; +use bevy_picking::events::{Click, Drag, Pointer, Press, Release}; use bevy_picking::pointer::PointerButton; use bevy_text::{EditableText, PreeditCursor, TextEdit}; use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll}; From a633abde6690325ef19f98254d69fc344eb95f8a Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 18:09:25 +0100 Subject: [PATCH 04/20] Added `NeedsScroll` component tracking if scrolling needs to be recalculated --- crates/bevy_text/src/editing.rs | 20 +++++++-- crates/bevy_text/src/text_edit.rs | 43 +++++++++++++++++++ .../bevy_ui/src/widget/text_input_layout.rs | 12 ++++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index 8927b10cf3b57..9979a9e492ed9 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -102,7 +102,8 @@ use parley::{FontContext, LayoutContext, PlainEditor, SplitString}; TextColor, LineHeight, FontHinting, - EditableTextGeneration + EditableTextGeneration, + NeedsScroll )] pub struct EditableText { /// A [`parley::PlainEditor`], tracking both the text content and cursor position. @@ -215,6 +216,7 @@ impl EditableText { layout_context: &mut LayoutContext, clipboard: &mut bevy_clipboard::Clipboard, char_filter: impl Fn(char) -> bool, + needs_scroll: &mut bool, ) { let Self { editor, @@ -251,7 +253,13 @@ impl EditableText { return; } } - other => other.apply(&mut driver, clipboard, *max_characters, &char_filter), + other => other.apply( + &mut driver, + clipboard, + *max_characters, + &char_filter, + needs_scroll, + ), } } } @@ -301,13 +309,14 @@ pub fn apply_text_edits( &mut EditableText, Option<&EditableTextFilter>, &EditableTextGeneration, + &mut NeedsScroll, )>, mut font_context: ResMut, mut layout_context: ResMut, mut clipboard: ResMut, mut commands: Commands, ) { - for (entity, mut editable_text, filter, generation) in query.iter_mut() { + for (entity, mut editable_text, filter, generation, mut needs_scroll) in query.iter_mut() { // `pending_paste` can hold a cross-frame paste even when no new edits are queued, // so check for either before doing work. if !editable_text.pending_edits.is_empty() || editable_text.pending_paste.is_some() { @@ -319,6 +328,7 @@ pub fn apply_text_edits( Some(EditableTextFilter(Some(filter))) => filter.as_ref(), _ => &|_| true, }, + &mut needs_scroll.0, ); } @@ -335,3 +345,7 @@ pub fn apply_text_edits( pub struct TextEditChange { entity: Entity, } + +/// If true, scrolling needs to be updated. +#[derive(Component, Copy, Clone, Debug, PartialEq, Default)] +pub struct NeedsScroll(pub bool); diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 9e9f5defa0e7e..a06f47a21c557 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -1,4 +1,7 @@ use bevy_clipboard::ClipboardRead; +use bevy_derive::Deref; +use bevy_derive::DerefMut; +use bevy_ecs::component::Component; use bevy_math::Vec2; use bevy_reflect::Reflect; use parley::PlainEditorDriver; @@ -221,7 +224,10 @@ impl TextEdit { clipboard: &mut bevy_clipboard::Clipboard, max_characters: Option, char_filter: impl Fn(char) -> bool, + needs_scroll: &mut bool, ) { + *needs_scroll = *needs_scroll || self.needs_scroll(); + match self { TextEdit::Copy => { if let Some(text) = driver.editor.selected_text() @@ -315,6 +321,43 @@ impl TextEdit { } } } + + /// True if the text editor view should scroll after the given edit. + pub fn needs_scroll(&self) -> bool { + match self { + TextEdit::Copy => false, + TextEdit::Cut => true, + TextEdit::Paste => true, + TextEdit::Insert(_) => true, + TextEdit::Backspace => true, + TextEdit::BackspaceWord => true, + TextEdit::Delete => true, + TextEdit::DeleteWord => true, + TextEdit::Left(_) => true, + TextEdit::Right(_) => true, + TextEdit::WordLeft(_) => true, + TextEdit::WordRight(_) => true, + TextEdit::Up(_) => true, + TextEdit::Down(_) => true, + TextEdit::TextStart(_) => true, + TextEdit::TextEnd(_) => true, + TextEdit::HardLineStart(_) => true, + TextEdit::HardLineEnd(_) => true, + TextEdit::LineStart(_) => true, + TextEdit::LineEnd(_) => true, + TextEdit::CollapseSelection => true, + TextEdit::SelectAll => false, + TextEdit::SelectAllIfCollapsed => false, + TextEdit::MoveToPoint(_) => true, + TextEdit::SelectWordAtPoint(_) => false, + TextEdit::SelectLineAtPoint(_) => false, + TextEdit::SelectedHardLineAtPoint(_) => false, + TextEdit::ExtendSelectionToPoint(_) => true, + TextEdit::ShiftClickExtension(_) => false, + TextEdit::ImeSetCompose { .. } => true, + TextEdit::ImeCommit { .. } => true, + } + } } /// Reason an [`insert_filtered`] call was rejected. diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index a3c73cdba7733..6e3a6ee44e836 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -18,8 +18,8 @@ use bevy_platform::hash::FixedHasher; use bevy_text::{ add_glyph_to_atlas, get_glyph_atlas_info, resolve_font_source, EditableText, EditableTextGeneration, Font, FontAtlasKey, FontAtlasSet, FontCx, FontHinting, FontSize, - GlyphCacheKey, LayoutCx, LineBreak, LineHeight, PositionedGlyph, RemSize, RunGeometry, ScaleCx, - TextBrush, TextFont, TextLayout, TextLayoutInfo, + GlyphCacheKey, LayoutCx, LineBreak, LineHeight, NeedsScroll, PositionedGlyph, RemSize, + RunGeometry, ScaleCx, TextBrush, TextFont, TextLayout, TextLayoutInfo, }; use bevy_time::{Real, Time}; use parley::{BoundingBox, PositionedLayoutItem, StyleProperty}; @@ -498,6 +498,7 @@ pub fn scroll_editable_text( &mut TextScroll, &ComputedNode, &TextLayoutInfo, + &mut NeedsScroll, )>, ) { let current_focus = input_focus @@ -505,10 +506,13 @@ pub fn scroll_editable_text( .and_then(|input_focus| input_focus.get()); let focus_changed = *previous_focus != current_focus; - for (entity, editable_text, generation, mut scroll, node, info) in query.iter_mut() { + for (entity, editable_text, generation, mut scroll, node, info, mut needs_scroll) in + query.iter_mut() + { if !(editable_text.is_changed() || generation.is_changed() || focus_changed && (Some(entity) == *previous_focus || Some(entity) == current_focus)) + || !needs_scroll.0 { continue; } @@ -564,8 +568,8 @@ pub fn scroll_editable_text( ) .floor(), })); + needs_scroll.0 = false; } - *previous_focus = current_focus; } From 3b9f89389a0ca01104805f542f1323b393450fd4 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 18:10:58 +0100 Subject: [PATCH 05/20] Don't scroll on `ExtendSelectionToPoint` --- crates/bevy_text/src/text_edit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index a06f47a21c557..353f9bdc41ff8 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -352,7 +352,7 @@ impl TextEdit { TextEdit::SelectWordAtPoint(_) => false, TextEdit::SelectLineAtPoint(_) => false, TextEdit::SelectedHardLineAtPoint(_) => false, - TextEdit::ExtendSelectionToPoint(_) => true, + TextEdit::ExtendSelectionToPoint(_) => false, TextEdit::ShiftClickExtension(_) => false, TextEdit::ImeSetCompose { .. } => true, TextEdit::ImeCommit { .. } => true, From 32819139dbd6d167099b6e541feb2002fea8670b Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 18:22:09 +0100 Subject: [PATCH 06/20] Set needs scroll if pointer dragged outside of editor. --- crates/bevy_text/src/editing.rs | 6 +++--- crates/bevy_ui/src/widget/text_input_layout.rs | 8 ++++---- crates/bevy_ui_widgets/src/text_input.rs | 11 +++++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index 9979a9e492ed9..d160e8298fdc0 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -103,7 +103,7 @@ use parley::{FontContext, LayoutContext, PlainEditor, SplitString}; LineHeight, FontHinting, EditableTextGeneration, - NeedsScroll + EditableTextNeedsScroll )] pub struct EditableText { /// A [`parley::PlainEditor`], tracking both the text content and cursor position. @@ -309,7 +309,7 @@ pub fn apply_text_edits( &mut EditableText, Option<&EditableTextFilter>, &EditableTextGeneration, - &mut NeedsScroll, + &mut EditableTextNeedsScroll, )>, mut font_context: ResMut, mut layout_context: ResMut, @@ -348,4 +348,4 @@ pub struct TextEditChange { /// If true, scrolling needs to be updated. #[derive(Component, Copy, Clone, Debug, PartialEq, Default)] -pub struct NeedsScroll(pub bool); +pub struct EditableTextNeedsScroll(pub bool); diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index 6e3a6ee44e836..a058877c1dcfa 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -17,9 +17,9 @@ 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, - EditableTextGeneration, Font, FontAtlasKey, FontAtlasSet, FontCx, FontHinting, FontSize, - GlyphCacheKey, LayoutCx, LineBreak, LineHeight, NeedsScroll, PositionedGlyph, RemSize, - RunGeometry, ScaleCx, TextBrush, TextFont, TextLayout, TextLayoutInfo, + EditableTextGeneration, EditableTextNeedsScroll, Font, FontAtlasKey, FontAtlasSet, FontCx, + FontHinting, FontSize, GlyphCacheKey, LayoutCx, LineBreak, LineHeight, PositionedGlyph, + RemSize, RunGeometry, ScaleCx, TextBrush, TextFont, TextLayout, TextLayoutInfo, }; use bevy_time::{Real, Time}; use parley::{BoundingBox, PositionedLayoutItem, StyleProperty}; @@ -498,7 +498,7 @@ pub fn scroll_editable_text( &mut TextScroll, &ComputedNode, &TextLayoutInfo, - &mut NeedsScroll, + &mut EditableTextNeedsScroll, )>, ) { let current_focus = input_focus diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 0174191b29074..220736e8d2266 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -17,7 +17,7 @@ use bevy_input_focus::{ use bevy_math::Vec2; use bevy_picking::events::{Click, Drag, Pointer, Press, Release}; use bevy_picking::pointer::PointerButton; -use bevy_text::{EditableText, PreeditCursor, TextEdit}; +use bevy_text::{EditableText, EditableTextNeedsScroll, PreeditCursor, TextEdit}; use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll}; use bevy_ui::UiSystems; use bevy_ui::{ @@ -267,6 +267,7 @@ fn on_pointer_drag( &ComputedUiRenderTargetInfo, &UiGlobalTransform, &TextScroll, + &mut EditableTextNeedsScroll, )>, ui_scale: Res, ) { @@ -274,7 +275,7 @@ fn on_pointer_drag( return; } - let Ok((mut editable_text, node, target, transform, text_scroll)) = + let Ok((mut editable_text, node, target, transform, text_scroll, mut needs_scroll)) = text_input_query.get_mut(drag.entity) else { return; @@ -285,7 +286,7 @@ fn on_pointer_drag( return; } - let Some(current_local_pos) = transform.try_inverse().map(|inverse| { + let Some(local_pos) = transform.try_inverse().map(|inverse| { inverse .transform_point2(drag.pointer_location.position * target.scale_factor() / ui_scale.0) - node.content_box().min @@ -294,9 +295,11 @@ fn on_pointer_drag( return; }; + needs_scroll.0 = needs_scroll.0 || node.content_box().contains(local_pos); + editable_text .pending_edits - .push(TextEdit::ExtendSelectionToPoint(current_local_pos)); + .push(TextEdit::ExtendSelectionToPoint(local_pos)); drag.propagate(false); } From f4bdad73e4760bc85d86ce0fca448c72dcb3c7cb Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 18:41:31 +0100 Subject: [PATCH 07/20] Fixed content box drag check --- crates/bevy_text/src/text_edit.rs | 62 ++++++++++++------------ crates/bevy_ui_widgets/src/text_input.rs | 12 ++--- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 353f9bdc41ff8..26fe2ea914b98 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -325,37 +325,37 @@ impl TextEdit { /// True if the text editor view should scroll after the given edit. pub fn needs_scroll(&self) -> bool { match self { - TextEdit::Copy => false, - TextEdit::Cut => true, - TextEdit::Paste => true, - TextEdit::Insert(_) => true, - TextEdit::Backspace => true, - TextEdit::BackspaceWord => true, - TextEdit::Delete => true, - TextEdit::DeleteWord => true, - TextEdit::Left(_) => true, - TextEdit::Right(_) => true, - TextEdit::WordLeft(_) => true, - TextEdit::WordRight(_) => true, - TextEdit::Up(_) => true, - TextEdit::Down(_) => true, - TextEdit::TextStart(_) => true, - TextEdit::TextEnd(_) => true, - TextEdit::HardLineStart(_) => true, - TextEdit::HardLineEnd(_) => true, - TextEdit::LineStart(_) => true, - TextEdit::LineEnd(_) => true, - TextEdit::CollapseSelection => true, - TextEdit::SelectAll => false, - TextEdit::SelectAllIfCollapsed => false, - TextEdit::MoveToPoint(_) => true, - TextEdit::SelectWordAtPoint(_) => false, - TextEdit::SelectLineAtPoint(_) => false, - TextEdit::SelectedHardLineAtPoint(_) => false, - TextEdit::ExtendSelectionToPoint(_) => false, - TextEdit::ShiftClickExtension(_) => false, - TextEdit::ImeSetCompose { .. } => true, - TextEdit::ImeCommit { .. } => true, + TextEdit::Copy + | TextEdit::SelectAllIfCollapsed + | TextEdit::SelectAll + | TextEdit::SelectWordAtPoint(_) + | TextEdit::SelectLineAtPoint(_) + | TextEdit::SelectedHardLineAtPoint(_) + | TextEdit::ExtendSelectionToPoint(_) + | TextEdit::ShiftClickExtension(_) => false, + TextEdit::Cut + | TextEdit::Paste + | TextEdit::Insert(_) + | TextEdit::Backspace + | TextEdit::BackspaceWord + | TextEdit::Delete + | TextEdit::DeleteWord + | TextEdit::Left(_) + | TextEdit::Right(_) + | TextEdit::WordLeft(_) + | TextEdit::WordRight(_) + | TextEdit::Up(_) + | TextEdit::Down(_) + | TextEdit::TextStart(_) + | TextEdit::TextEnd(_) + | TextEdit::HardLineStart(_) + | TextEdit::HardLineEnd(_) + | TextEdit::LineStart(_) + | TextEdit::LineEnd(_) + | TextEdit::CollapseSelection + | TextEdit::MoveToPoint(_) + | TextEdit::ImeSetCompose { .. } + | TextEdit::ImeCommit { .. } => true, } } } diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 220736e8d2266..73d56f7b29721 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -286,20 +286,20 @@ fn on_pointer_drag( return; } - let Some(local_pos) = transform.try_inverse().map(|inverse| { + let Some(pointer_pos) = transform.try_inverse().map(|inverse| { inverse .transform_point2(drag.pointer_location.position * target.scale_factor() / ui_scale.0) - - node.content_box().min - + text_scroll.0 }) else { return; }; - needs_scroll.0 = needs_scroll.0 || node.content_box().contains(local_pos); - + needs_scroll.0 = needs_scroll.0 || !node.content_box().contains(pointer_pos); + println!("drag needs scroll = {}", needs_scroll.0); editable_text .pending_edits - .push(TextEdit::ExtendSelectionToPoint(local_pos)); + .push(TextEdit::ExtendSelectionToPoint( + pointer_pos - node.content_box().min + text_scroll.0, + )); drag.propagate(false); } From bf0ada7c1ccc6850741561a43022a1199b89d96b Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 18:47:00 +0100 Subject: [PATCH 08/20] MoveToPoint needs scroll --- crates/bevy_text/src/text_edit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 26fe2ea914b98..609140df9a574 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -331,6 +331,7 @@ impl TextEdit { | TextEdit::SelectWordAtPoint(_) | TextEdit::SelectLineAtPoint(_) | TextEdit::SelectedHardLineAtPoint(_) + | TextEdit::MoveToPoint(_) | TextEdit::ExtendSelectionToPoint(_) | TextEdit::ShiftClickExtension(_) => false, TextEdit::Cut @@ -353,7 +354,6 @@ impl TextEdit { | TextEdit::LineStart(_) | TextEdit::LineEnd(_) | TextEdit::CollapseSelection - | TextEdit::MoveToPoint(_) | TextEdit::ImeSetCompose { .. } | TextEdit::ImeCommit { .. } => true, } From 0ae93a77e6dc995110bdeaca7929921cde44f0b1 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 19:11:21 +0100 Subject: [PATCH 09/20] clean up --- crates/bevy_text/src/text_edit.rs | 3 --- crates/bevy_ui_widgets/src/text_input.rs | 1 - 2 files changed, 4 deletions(-) diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 609140df9a574..a91432a0ce553 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -1,7 +1,4 @@ use bevy_clipboard::ClipboardRead; -use bevy_derive::Deref; -use bevy_derive::DerefMut; -use bevy_ecs::component::Component; use bevy_math::Vec2; use bevy_reflect::Reflect; use parley::PlainEditorDriver; diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 73d56f7b29721..a16352e7beb03 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -294,7 +294,6 @@ fn on_pointer_drag( }; needs_scroll.0 = needs_scroll.0 || !node.content_box().contains(pointer_pos); - println!("drag needs scroll = {}", needs_scroll.0); editable_text .pending_edits .push(TextEdit::ExtendSelectionToPoint( From a01c10540f2b3fef5d0dbbad7508c734d1dcd4b1 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 19:29:17 +0100 Subject: [PATCH 10/20] Fixed texts by denormalizing inset values. --- crates/bevy_ui/src/widget/text_input_layout.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index a058877c1dcfa..a2ced3cf46b0d 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -641,11 +641,11 @@ mod test { #[test] fn test_scroll_axis_with_inset() { assert_eq!( - scroll_axis_with_inset(0.25, 0., 100., 0., 100., 0., 50.), + scroll_axis_with_inset(25., 0., 100., 0., 100., 0., 50.), 0. ); assert_eq!( - scroll_axis_with_inset(0.25, 0., 0., 0., 100., 50., 100.), + scroll_axis_with_inset(25., 0., 0., 0., 100., 50., 100.), 0. ); } @@ -653,7 +653,7 @@ mod test { #[test] fn test_scroll_axis_with_inset_moves_to_inner_min() { assert_eq!( - scroll_axis_with_inset(0.25, 0., 100., 50., 150., 60., 65.), + scroll_axis_with_inset(25., 0., 100., 50., 150., 60., 65.), 35. ); } @@ -661,7 +661,7 @@ mod test { #[test] fn test_scroll_axis_with_inset_moves_to_inner_max() { assert_eq!( - scroll_axis_with_inset(0.25, 0., 100., 0., 100., 90., 95.), + scroll_axis_with_inset(25., 0., 100., 0., 100., 90., 95.), 20. ); } @@ -669,11 +669,11 @@ mod test { #[test] fn test_scroll_axis_with_inset_saturates() { assert_eq!( - scroll_axis_with_inset(0.25, 0., 100., 10., 110., 10., 20.), + scroll_axis_with_inset(25., 0., 100., 10., 110., 10., 20.), 0. ); assert_eq!( - scroll_axis_with_inset(0.25, 0., 100., 80., 180., 175., 180.), + scroll_axis_with_inset(25., 0., 100., 80., 180., 175., 180.), 100. ); } @@ -681,11 +681,11 @@ mod test { #[test] fn test_scroll_axis_with_inset_uses_full_view_when_target_larger_than_inner() { assert_eq!( - scroll_axis_with_inset(0.25, 0., 100., 0., 100., 20., 90.), + scroll_axis_with_inset(25., 0., 100., 0., 100., 20., 90.), 0. ); assert_eq!( - scroll_axis_with_inset(0.25, 0., 100., 0., 100., 80., 150.), + scroll_axis_with_inset(25., 0., 100., 0., 100., 80., 150.), 50. ); } From c7e58588946447db1f07e334f78d0d4778c8d55f Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 19:33:52 +0100 Subject: [PATCH 11/20] cargo fmt --all --- crates/bevy_ui/src/widget/text_input_layout.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index a2ced3cf46b0d..80d9b9c020fef 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -640,14 +640,8 @@ mod test { #[test] fn test_scroll_axis_with_inset() { - assert_eq!( - scroll_axis_with_inset(25., 0., 100., 0., 100., 0., 50.), - 0. - ); - assert_eq!( - scroll_axis_with_inset(25., 0., 0., 0., 100., 50., 100.), - 0. - ); + assert_eq!(scroll_axis_with_inset(25., 0., 100., 0., 100., 0., 50.), 0.); + assert_eq!(scroll_axis_with_inset(25., 0., 0., 0., 100., 50., 100.), 0.); } #[test] From 104d930ca7ebddab09214c17dcd024c107b2ac9e Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 12 May 2026 21:44:38 +0100 Subject: [PATCH 12/20] renamed variables for clarity --- crates/bevy_ui/src/widget/text_input_layout.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index 80d9b9c020fef..cd4a89c7552ca 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -612,15 +612,15 @@ fn scroll_axis_with_inset( t_max: f32, ) -> f32 { let v_size = v_max - v_min; - let u_min = v_min + inset; - let u_max = v_max - inset; + let inner_min = v_min + inset; + let inner_max = v_max - inset; let t_size = t_max - t_min; let new_v_min = if v_size - 2. * inset < t_size { scroll_axis(v_min, v_max, t_min, t_max) - } else if t_min < u_min { + } else if t_min < inner_min { t_min - inset - } else if u_max < t_max { + } else if inner_max < t_max { t_max - v_size + inset } else { v_min From 54a3a34f6dc84a572e58655e52db391bb1a80785 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 13 May 2026 22:00:33 +0100 Subject: [PATCH 13/20] added bevy_time dep --- crates/bevy_ui_widgets/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bevy_ui_widgets/Cargo.toml b/crates/bevy_ui_widgets/Cargo.toml index d9180cce87b0b..31cfc385dd563 100644 --- a/crates/bevy_ui_widgets/Cargo.toml +++ b/crates/bevy_ui_widgets/Cargo.toml @@ -22,6 +22,7 @@ bevy_picking = { path = "../bevy_picking", version = "0.19.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.19.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.19.0-dev" } bevy_text = { path = "../bevy_text", version = "0.19.0-dev" } +bevy_time = { path = "../bevy_time", version = "0.19.0-dev" } bevy_window = { path = "../bevy_window", version = "0.19.0-dev" } # other From 3500d2df8e691a87fa3ca0833d816a0059f41cda Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 13 May 2026 22:41:28 +0100 Subject: [PATCH 14/20] Added drag_scroll_text_inputs system --- .../bevy_ui/src/widget/text_input_layout.rs | 34 ++-- crates/bevy_ui_widgets/src/text_input.rs | 158 +++++++++++++++++- 2 files changed, 172 insertions(+), 20 deletions(-) diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index 1505a76949fc2..17dd9843d03ce 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -529,7 +529,7 @@ pub fn scroll_editable_text( continue; }; - let Some((line_min, line_max)) = find_visual_line_bounds(layout, cursor.center().y) else { + let (line_min, Some(line_max)) = find_visual_line_bounds(layout, cursor.center().y) else { continue; }; @@ -544,6 +544,21 @@ pub fn scroll_editable_text( .max(0.); let max_scroll_y = (info.size.y - view_size.y).max(0.); + let y = scroll_axis_with_inset( + editable_text.scroll_inset.y.clamp(0., 0.49) * view_size.x, + 0., + max_scroll_y, + scroll.0.y, + scroll.0.y + view_size.y, + line_min, + line_max, + ); + let y = if y >= max_scroll_y { + max_scroll_y + } else { + find_visual_line_bounds(layout, y).0 + }; + scroll.set_if_neq(TextScroll(Vec2 { x: scroll_axis_with_inset( editable_text.scroll_inset.x.clamp(0., 0.49) * view_size.x, @@ -555,16 +570,7 @@ pub fn scroll_editable_text( cursor.max.x, ) .floor(), - y: scroll_axis_with_inset( - editable_text.scroll_inset.y.clamp(0., 0.49) * view_size.x, - 0., - max_scroll_y, - scroll.0.y, - scroll.0.y + view_size.y, - line_min, - line_max, - ) - .floor(), + y: y.floor(), })); needs_scroll.0 = false; } @@ -574,16 +580,16 @@ pub fn scroll_editable_text( fn find_visual_line_bounds( layout: &parley::Layout, y: f32, -) -> Option<(f32, f32)> { +) -> (f32, Option) { let mut min = 0.0; for line in layout.lines() { let max = min + line.metrics().line_height; if y < max { - return Some((min, max)); + return (min, Some(max)); } min = max; } - None + (min, None) } fn scroll_axis(v_min: f32, v_max: f32, t_min: f32, t_max: f32) -> f32 { diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index a16352e7beb03..b3b43c329ed78 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -15,9 +15,12 @@ use bevy_input_focus::{ FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, }; use bevy_math::Vec2; -use bevy_picking::events::{Click, Drag, Pointer, Press, Release}; +use bevy_picking::events::{Click, Drag, Pointer, PointerState, Press, Release}; use bevy_picking::pointer::PointerButton; -use bevy_text::{EditableText, EditableTextNeedsScroll, PreeditCursor, TextEdit}; +use bevy_text::{ + EditableText, LineHeight, PreeditCursor, RemSize, TextEdit, TextFont, TextLayoutInfo, +}; +use bevy_time::{Real, Time}; use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll}; use bevy_ui::UiSystems; use bevy_ui::{ @@ -45,6 +48,29 @@ const SHIFT_COMMAND: u8 = SHIFT | COMMAND; #[cfg(not(target_os = "macos"))] const SHIFT_ALT: u8 = SHIFT | ALT; +/// Controls the text input scroll speed when dragging outside the input bounds. +/// +/// Values are relative to the text's line height. +#[derive(Resource)] +pub struct TextInputScrollSpeed { + /// Scroll speed when the pointer is just outside the input, in lines per second. + pub base_lines_per_second: f32, + /// Maximum scroll speed, in lines per second. + pub max_lines_per_second: f32, + /// Distance from the input where maximum scroll speed is reached, in lines. + pub max_speed_distance_lines: f32, +} + +impl Default for TextInputScrollSpeed { + fn default() -> Self { + Self { + base_lines_per_second: 4., + max_lines_per_second: 30., + max_speed_distance_lines: 4., + } + } +} + /// System that processes keyboard input events into text edit actions for focused [`EditableText`] widgets. /// /// See [`EditableText`] for more details on the standard mapping from keyboard events to text edit actions @@ -267,7 +293,6 @@ fn on_pointer_drag( &ComputedUiRenderTargetInfo, &UiGlobalTransform, &TextScroll, - &mut EditableTextNeedsScroll, )>, ui_scale: Res, ) { @@ -275,7 +300,7 @@ fn on_pointer_drag( return; } - let Ok((mut editable_text, node, target, transform, text_scroll, mut needs_scroll)) = + let Ok((mut editable_text, node, target, transform, text_scroll)) = text_input_query.get_mut(drag.entity) else { return; @@ -293,16 +318,130 @@ fn on_pointer_drag( return; }; - needs_scroll.0 = needs_scroll.0 || !node.content_box().contains(pointer_pos); + let content_box = node.content_box(); editable_text .pending_edits .push(TextEdit::ExtendSelectionToPoint( - pointer_pos - node.content_box().min + text_scroll.0, + pointer_pos.clamp(content_box.min, content_box.max) - content_box.min + text_scroll.0, )); drag.propagate(false); } +fn drag_scroll_text_inputs( + time: Res>, + scroll_speed: Res, + input_focus: Res, + pointer_state: Res, + ui_scale: Res, + mut vertical_scroll_remainder: Local>, + mut text_input_query: Query<( + &mut EditableText, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &TextFont, + &LineHeight, + &TextLayoutInfo, + &mut TextScroll, + )>, + rem_size: Res, +) { + let previous_remainder = vertical_scroll_remainder.take(); + + let Some(entity) = input_focus.get() else { + return; + }; + + let Ok(( + mut editable_text, + node, + target, + transform, + text_font, + line_height, + info, + mut text_scroll, + )) = text_input_query.get_mut(entity) + else { + return; + }; + + if editable_text.is_composing() { + return; + } + + let content_box = node.content_box(); + let view_size = content_box.size(); + if view_size.cmple(Vec2::ZERO).any() { + return; + } + + let Some(point) = pointer_state + .pointer_buttons + .iter() + .find_map(|((_, button), state)| { + if *button == PointerButton::Primary { + state.dragging.get(&entity).map(|drag| drag.latest_pos) + } else { + None + } + }) + .and_then(|position| { + transform.try_inverse().map(|inverse| { + inverse.transform_point2(position * target.scale_factor() / ui_scale.0) + }) + }) + else { + return; + }; + + let clamped_point = point.clamp(content_box.min, content_box.max); + let signed_distance = point - clamped_point; + + if signed_distance == Vec2::ZERO { + return; + } + + let font_size = text_font.font_size.eval(target.logical_size(), rem_size.0); + let line_height = match *line_height { + LineHeight::Px(px) => px, + LineHeight::RelativeToFont(scale) => scale * font_size, + }; + if line_height <= 0. { + return; + } + + let ramp = (signed_distance.abs() / (line_height * scroll_speed.max_speed_distance_lines)) + .clamp(Vec2::ZERO, Vec2::ONE); + + let velocity = line_height + * (scroll_speed.base_lines_per_second + + (scroll_speed.max_lines_per_second - scroll_speed.base_lines_per_second) * ramp); + + let mut scroll_delta = signed_distance.signum() * time.delta_secs() * velocity; + if scroll_delta.y != 0. { + let mut vertical_remainder = match previous_remainder { + Some((remainder_entity, remainder)) if remainder_entity == entity => remainder, + _ => 0., + }; + + let accumulated_y = vertical_remainder + scroll_delta.y; + scroll_delta.y = (accumulated_y / line_height).trunc() * line_height; + vertical_remainder = accumulated_y - scroll_delta.y; + *vertical_scroll_remainder = Some((entity, vertical_remainder)); + } + + let new_scroll = + (text_scroll.0 + scroll_delta).clamp(Vec2::ZERO, (info.size - view_size).max(Vec2::ZERO)); + + if text_scroll.set_if_neq(TextScroll(new_scroll)) { + editable_text.queue_edit(TextEdit::ExtendSelectionToPoint( + clamped_point - content_box.min + new_scroll, + )); + } +} + /// System that processes [`Ime`] events into [`TextEdit`] actions for the focused [`EditableText`] widget. /// /// Preedit text (in-progress IME composition) is excluded from [`EditableText::value`]. @@ -532,6 +671,7 @@ pub struct EditableTextInputPlugin; impl Plugin for EditableTextInputPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .add_observer(on_focused_keyboard_input) .add_observer(on_pointer_drag) .add_observer(on_pointer_press) @@ -561,6 +701,12 @@ impl Plugin for EditableTextInputPlugin { // this is a false positive that can be ignored .ambiguous_with(InputFocusSystems::FocusChangeEvents), ) + .add_systems( + PostUpdate, + drag_scroll_text_inputs + .in_set(UiSystems::Content) + .before(bevy_text::EditableTextSystems), + ) .add_systems( PostUpdate, apply_queued_select_all From f43b28e9e6ef0025b99d56cb510106dc2d6d245f Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 13 May 2026 22:55:59 +0100 Subject: [PATCH 15/20] scale line by scale factor --- crates/bevy_ui_widgets/src/text_input.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index b3b43c329ed78..ff5aa2f6e3c9a 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -334,7 +334,7 @@ fn drag_scroll_text_inputs( input_focus: Res, pointer_state: Res, ui_scale: Res, - mut vertical_scroll_remainder: Local>, + mut y_remainder: Local>, mut text_input_query: Query<( &mut EditableText, &ComputedNode, @@ -347,7 +347,7 @@ fn drag_scroll_text_inputs( )>, rem_size: Res, ) { - let previous_remainder = vertical_scroll_remainder.take(); + let previous_y_remainder = y_remainder.take(); let Some(entity) = input_focus.get() else { return; @@ -407,7 +407,7 @@ fn drag_scroll_text_inputs( let line_height = match *line_height { LineHeight::Px(px) => px, LineHeight::RelativeToFont(scale) => scale * font_size, - }; + } * target.scale_factor(); if line_height <= 0. { return; } @@ -421,15 +421,14 @@ fn drag_scroll_text_inputs( let mut scroll_delta = signed_distance.signum() * time.delta_secs() * velocity; if scroll_delta.y != 0. { - let mut vertical_remainder = match previous_remainder { - Some((remainder_entity, remainder)) if remainder_entity == entity => remainder, + let y_rem = match previous_y_remainder { + Some((previous_entity, y_rem)) if previous_entity == entity => y_rem, _ => 0., }; - let accumulated_y = vertical_remainder + scroll_delta.y; - scroll_delta.y = (accumulated_y / line_height).trunc() * line_height; - vertical_remainder = accumulated_y - scroll_delta.y; - *vertical_scroll_remainder = Some((entity, vertical_remainder)); + let acc = y_rem + scroll_delta.y; + scroll_delta.y = (acc / line_height).trunc() * line_height; + *y_remainder = Some((entity, acc - scroll_delta.y)); } let new_scroll = From b25f034a5fc6ba86b2d645a33149620df422bb8e Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 13 May 2026 23:47:53 +0100 Subject: [PATCH 16/20] set horizontal scroll 0 when inside horizontal bounds --- crates/bevy_ui_widgets/src/text_input.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index ff5aa2f6e3c9a..0317801921fcb 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -431,8 +431,11 @@ fn drag_scroll_text_inputs( *y_remainder = Some((entity, acc - scroll_delta.y)); } - let new_scroll = + let mut new_scroll = (text_scroll.0 + scroll_delta).clamp(Vec2::ZERO, (info.size - view_size).max(Vec2::ZERO)); + if signed_distance.x == 0. { + new_scroll.x = text_scroll.0.x; + } if text_scroll.set_if_neq(TextScroll(new_scroll)) { editable_text.queue_edit(TextEdit::ExtendSelectionToPoint( From 2abcc6396f09908b7ebae4c77798c4bd79881198 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Thu, 14 May 2026 11:17:41 +0100 Subject: [PATCH 17/20] Set needs scroll on deferred paste edits --- crates/bevy_text/src/editing.rs | 17 ++++++++++------- crates/bevy_ui/src/widget/text_input_layout.rs | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index d160e8298fdc0..af164a3410871 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -231,11 +231,13 @@ impl EditableText { // First: resolve any paste carried over from a previous frame. If it's still // pending, hold the remaining edits (untouched in `pending_edits`) for next frame // so ordering relative to the paste is preserved. - if let Some(mut read) = pending_paste.take() - && !poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter) - { - *pending_paste = Some(read); - return; + if let Some(mut read) = pending_paste.take() { + if poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter) { + *needs_scroll = true; + } else { + *pending_paste = Some(read); + return; + } } // Drain edits one at a time. A paste that resolves synchronously (always the case @@ -246,8 +248,9 @@ impl EditableText { match edit { TextEdit::Paste => { let mut read = clipboard.fetch_text(); - if !poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter) - { + if poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter) { + *needs_scroll = true; + } else { *pending_paste = Some(read); pending_edits.extend(edits); return; diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index 17dd9843d03ce..68f467d6742a4 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -545,7 +545,7 @@ pub fn scroll_editable_text( let max_scroll_y = (info.size.y - view_size.y).max(0.); let y = scroll_axis_with_inset( - editable_text.scroll_inset.y.clamp(0., 0.49) * view_size.x, + editable_text.scroll_inset.y.clamp(0., 0.49) * view_size.y, 0., max_scroll_y, scroll.0.y, From b5507786e7b336757d619fddc276610e10f6e6c3 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Thu, 14 May 2026 12:01:06 +0100 Subject: [PATCH 18/20] update scroll if ComputedNode is changed --- crates/bevy_ui/src/widget/text_input_layout.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index 68f467d6742a4..e2922f03f79c9 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -494,7 +494,7 @@ pub fn scroll_editable_text( Ref, Ref, &mut TextScroll, - &ComputedNode, + Ref, &TextLayoutInfo, &mut EditableTextNeedsScroll, )>, @@ -507,11 +507,15 @@ pub fn scroll_editable_text( for (entity, editable_text, generation, mut scroll, node, info, mut needs_scroll) in query.iter_mut() { - if !(editable_text.is_changed() - || generation.is_changed() - || focus_changed && (Some(entity) == *previous_focus || Some(entity) == current_focus)) - || !needs_scroll.0 - { + let is_focused = Some(entity) == current_focus; + let is_previous_focus = Some(entity) == *previous_focus; + let cursor_moved = needs_scroll.0 + && (editable_text.is_changed() + || generation.is_changed() + || focus_changed && (is_previous_focus || is_focused)); + let view_changed = is_focused && node.is_changed(); + + if !(cursor_moved || view_changed) { continue; } From 6f78e91bde79026e28182ee29cf845b91921fbb0 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 15 May 2026 12:01:53 +0100 Subject: [PATCH 19/20] Sometimes gaps occur between vertically adjacent text selection rects depending on the scaling. After collecting the rects from Parley, if there is a gap, close it by extending the bottom edge downwards. --- crates/bevy_ui/src/widget/text_input_layout.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index b601328344fbe..215a92f47d399 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -446,6 +446,15 @@ pub fn update_editable_text_layout( .iter() .map(|&b| bounding_box_to_rect(b.0)) .collect(); + + for i in 0..info.selection_rects.len().saturating_sub(1) { + let [a, b] = &mut info.selection_rects[i..i + 2] else { + unreachable!(); + }; + if a.max.y < b.min.y { + a.max.y = b.min.y; + } + } } if let Some(input_focus) = input_focus.as_ref() From 21cc903ef55b9bc2a0f8fe20d4e525bb6baf0a55 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 16 May 2026 12:55:32 +0100 Subject: [PATCH 20/20] floor drag scroll offset --- crates/bevy_ui_widgets/src/text_input.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 0317801921fcb..81f80692b3f40 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -433,6 +433,7 @@ fn drag_scroll_text_inputs( let mut new_scroll = (text_scroll.0 + scroll_delta).clamp(Vec2::ZERO, (info.size - view_size).max(Vec2::ZERO)); + new_scroll.y = new_scroll.y.floor(); if signed_distance.x == 0. { new_scroll.x = text_scroll.0.x; }