diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index d5cc2b905773a..d160e8298fdc0 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}; @@ -101,7 +102,8 @@ use parley::{FontContext, LayoutContext, PlainEditor, SplitString}; TextColor, LineHeight, FontHinting, - EditableTextGeneration + EditableTextGeneration, + EditableTextNeedsScroll )] pub struct EditableText { /// A [`parley::PlainEditor`], tracking both the text content and cursor position. @@ -144,6 +146,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 +163,7 @@ impl Default for EditableText { visible_lines: Some(1.), visible_width: None, allow_newlines: false, + scroll_inset: Vec2::new(0.1, 0.25), } } } @@ -211,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, @@ -247,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, + ), } } } @@ -297,13 +309,14 @@ pub fn apply_text_edits( &mut EditableText, Option<&EditableTextFilter>, &EditableTextGeneration, + &mut EditableTextNeedsScroll, )>, 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() { @@ -315,6 +328,7 @@ pub fn apply_text_edits( Some(EditableTextFilter(Some(filter))) => filter.as_ref(), _ => &|_| true, }, + &mut needs_scroll.0, ); } @@ -331,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 EditableTextNeedsScroll(pub bool); diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 9e9f5defa0e7e..a91432a0ce553 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -221,7 +221,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 +318,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 + | TextEdit::SelectAllIfCollapsed + | TextEdit::SelectAll + | TextEdit::SelectWordAtPoint(_) + | TextEdit::SelectLineAtPoint(_) + | TextEdit::SelectedHardLineAtPoint(_) + | TextEdit::MoveToPoint(_) + | 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::ImeSetCompose { .. } + | 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 da53e23a45e96..cd4a89c7552ca 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, 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,6 +498,7 @@ pub fn scroll_editable_text( &mut TextScroll, &ComputedNode, &TextLayoutInfo, + &mut EditableTextNeedsScroll, )>, ) { 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; } @@ -540,20 +544,32 @@ 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(), })); + needs_scroll.0 = false; } - *previous_focus = current_focus; } @@ -585,3 +601,86 @@ 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 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 < inner_min { + t_min - inset + } else if inner_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(25., 0., 100., 0., 100., 0., 50.), 0.); + assert_eq!(scroll_axis_with_inset(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(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(25., 0., 100., 0., 100., 90., 95.), + 20. + ); + } + + #[test] + fn test_scroll_axis_with_inset_saturates() { + assert_eq!( + scroll_axis_with_inset(25., 0., 100., 10., 110., 10., 20.), + 0. + ); + assert_eq!( + scroll_axis_with_inset(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(25., 0., 100., 0., 100., 20., 90.), + 0. + ); + assert_eq!( + scroll_axis_with_inset(25., 0., 100., 0., 100., 80., 150.), + 50. + ); + } +} diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index fd7a2e6df181c..a16352e7beb03 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,23 +286,19 @@ 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(pointer_pos) = transform.try_inverse().map(|inverse| { + inverse + .transform_point2(drag.pointer_location.position * target.scale_factor() / ui_scale.0) }) else { return; }; - editable_text.pending_edits.extend([ - TextEdit::MoveToPoint(drag_start_local_pos), - TextEdit::ExtendSelectionToPoint(current_local_pos), - ]); + needs_scroll.0 = needs_scroll.0 || !node.content_box().contains(pointer_pos); + editable_text + .pending_edits + .push(TextEdit::ExtendSelectionToPoint( + pointer_pos - node.content_box().min + text_scroll.0, + )); drag.propagate(false); }