diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index d5cc2b905773a..af164a3410871 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, @@ -225,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 @@ -240,14 +248,21 @@ 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; } } - other => other.apply(&mut driver, clipboard, *max_characters, &char_filter), + other => other.apply( + &mut driver, + clipboard, + *max_characters, + &char_filter, + needs_scroll, + ), } } } @@ -297,13 +312,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 +331,7 @@ pub fn apply_text_edits( Some(EditableTextFilter(Some(filter))) => filter.as_ref(), _ => &|_| true, }, + &mut needs_scroll.0, ); } @@ -331,3 +348,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 b601328344fbe..026b3448140db 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}; @@ -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() @@ -494,8 +503,9 @@ pub fn scroll_editable_text( Ref, Ref, &mut TextScroll, - &ComputedNode, + Ref, &TextLayoutInfo, + &mut EditableTextNeedsScroll, )>, ) { let current_focus = input_focus @@ -503,11 +513,18 @@ 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() { - if !(editable_text.is_changed() - || generation.is_changed() - || focus_changed && (Some(entity) == *previous_focus || Some(entity) == current_focus)) - { + for (entity, editable_text, generation, mut scroll, node, info, mut needs_scroll) in + query.iter_mut() + { + 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; } @@ -525,7 +542,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; }; @@ -538,36 +555,54 @@ 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.); + + let y = scroll_axis_with_inset( + editable_text.scroll_inset.y.clamp(0., 0.49) * view_size.y, + 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( + 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: y.floor(), })); + needs_scroll.0 = false; } - *previous_focus = current_focus; } 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 { @@ -583,3 +618,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/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 diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 0174191b29074..81f80692b3f40 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, 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 @@ -285,22 +311,140 @@ fn on_pointer_drag( return; } - let Some(current_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; }; + let content_box = node.content_box(); editable_text .pending_edits - .push(TextEdit::ExtendSelectionToPoint(current_local_pos)); + .push(TextEdit::ExtendSelectionToPoint( + 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 y_remainder: Local>, + mut text_input_query: Query<( + &mut EditableText, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &TextFont, + &LineHeight, + &TextLayoutInfo, + &mut TextScroll, + )>, + rem_size: Res, +) { + let previous_y_remainder = y_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, + } * target.scale_factor(); + 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 y_rem = match previous_y_remainder { + Some((previous_entity, y_rem)) if previous_entity == entity => y_rem, + _ => 0., + }; + + 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 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; + } + + 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`]. @@ -530,6 +674,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) @@ -559,6 +704,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