diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index 382f10a66e109..e0425df584287 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -73,13 +73,15 @@ // and `bevy_ui`, such as text layout and font management. use crate::{ - text_edit::{poll_and_apply_paste, TextEdit}, + scroll::TextViewport, + text_edit::{poll_and_apply_paste, reveal_cursor, TextEdit}, FontCx, FontHinting, LayoutCx, LineHeight, TextBrush, TextColor, TextFont, TextLayout, }; 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}; @@ -115,6 +117,8 @@ pub struct EditableText { /// These operations should generally be batched together to avoid redundant layout work. // The B: Brush generic here must match the brush used by `ComputedTextBlock` to ensure that the font system is compatible. pub editor: PlainEditor, + /// The bounds of the visible portion of the text layout. + pub viewport: TextViewport, /// Text edit actions that have been requested but not yet applied. /// /// These edits are processed in first-in, first-out order. @@ -128,6 +132,11 @@ pub struct EditableText { /// rather than draining further edits, so that everything after the paste stays correctly ordered *behind* it. // TODO: this may cause unexpected stalls if the clipboard read takes too long. We may want to add a timeout. pub pending_paste: Option, + /// Cursor reveal margins as fractions of the viewport size. + /// + /// Each component is applied to both edges of its axis. Values are clamped + /// to `0.0..=0.5`, and non-finite values are treated as zero. + pub cursor_margin: Vec2, /// Cursor width, relative to font size pub cursor_width: f32, /// Cursor blink period in seconds. @@ -151,6 +160,8 @@ impl Default for EditableText { Self { // Defaults selected to match `Text::default()` editor: PlainEditor::new(100.), + viewport: TextViewport::default(), + cursor_margin: Vec2::splat(0.2), pending_edits: Vec::new(), pending_paste: None, cursor_width: 0.2, @@ -217,6 +228,8 @@ impl EditableText { pending_edits, pending_paste, max_characters, + viewport, + cursor_margin, .. } = self; @@ -225,11 +238,15 @@ 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() { + let generation = driver.editor.generation(); + if !poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter) { + *pending_paste = Some(read); + return; + } + if generation != driver.editor.generation() { + reveal_cursor(&mut driver, viewport, *cursor_margin); + } } // Drain edits one at a time. A paste that resolves synchronously (always the case @@ -239,6 +256,7 @@ impl EditableText { while let Some(edit) = edits.next() { match edit { TextEdit::Paste => { + let generation = driver.editor.generation(); let mut read = clipboard.fetch_text(); if !poll_and_apply_paste(&mut read, &mut driver, *max_characters, &char_filter) { @@ -246,8 +264,18 @@ impl EditableText { pending_edits.extend(edits); return; } + if generation != driver.editor.generation() { + reveal_cursor(&mut driver, viewport, *cursor_margin); + } } - other => other.apply(&mut driver, clipboard, *max_characters, &char_filter), + other => other.apply( + &mut driver, + viewport, + *cursor_margin, + clipboard, + *max_characters, + &char_filter, + ), } } } diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index e6ebb42f97a76..a01f51f625887 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -42,6 +42,7 @@ mod font_loader; mod glyph; mod parley_context; mod pipeline; +mod scroll; mod text; mod text_access; mod text_edit; @@ -57,6 +58,7 @@ pub use font_loader::*; pub use glyph::*; pub use parley_context::*; pub use pipeline::*; +pub use scroll::*; pub use text::*; pub use text_access::*; pub use text_edit::*; diff --git a/crates/bevy_text/src/scroll.rs b/crates/bevy_text/src/scroll.rs new file mode 100644 index 0000000000000..1653f577e15a8 --- /dev/null +++ b/crates/bevy_text/src/scroll.rs @@ -0,0 +1,780 @@ +//! EditableText scrolling logic +//! +//! - [`TextViewport`] is a rectangle aligned to the text layout's axis representing +//! the user's view of the text layout. +//! - Coordinates are in text layout space, increasing right and downwards. +//! - If the text layout is smaller than the viewport on an axis, the viewport is +//! given an offset of zero on that the axis. +//! - An origin-size representation is used because the size is generally fixed. +//! This avoids floating point error accumulation that might happen with min-max coords. +//! +//! # Scrolling rules +//! +//! - The text viewport is controlled exclusively through `TextEdit`s. +//! - Displacement scrolling is continuous and unquantized. +//! - Scrolling to a point moves each axis by the minimum amount needed to make +//! that point visible. +//! - Scrolling by lines moves by the distance between visual-line starts. +//! Fractional amounts interpolate across the next line interval in the scroll +//! direction. This supports wrapped and variable-height lines without +//! snapping the viewport to line bounds. +//! +//! Text and keyboard edits reveal the caret, including edits that do not change +//! the editor state. Pointer-driven edits and explicit scroll edits do not. +//! Caret reveal follows these rules: +//! +//! - Normalized horizontal and vertical margins inset a caret reveal region from every +//! viewport edge. Caret movement within this region leaves the viewport unchanged. +//! - If the caret leaves the caret reveal region horizontally, the viewport scrolls +//! sideways by the smallest amount that brings it back inside, keeping one +//! `0`-advance of space visible from the caret position onward to leave room for the +//! next typed character. +//! - Vertically, the viewport scrolls to reveal Parley's caret rectangle, +//! which spans the whole visual line. The margin-aware offset is clamped +//! toward the next visual-line start in the scroll direction, so a caret +//! that moved up or down leaves the viewport aligned to a line in that direction. +//! - If the caret or its visual line is too large to fit inside the caret +//! reveal region on an axis, then the viewport is centered on it on that axis instead. +//! - The viewport never scrolls outside the layout bounds (and when the +//! viewport is larger than the layout on an axis, it does not scroll on +//! that axis at all), even when that leaves the caret closer to the +//! viewport edge than the margin requests. + +use bevy_math::Rect; +use bevy_math::Vec2; +use bevy_reflect::Reflect; + +use crate::LineBreak; + +/// The region of the editable text layout visible to the user. +/// +/// Scrolling changes the offset, size depends on the layout. +#[derive(Debug, Clone, Copy, Default, PartialEq, Reflect)] +pub struct TextViewport { + /// The top-left corner of the text viewport in text-layout coordinates. + pub offset: Vec2, + /// The size of the viewport in text-layout coordinates. + pub size: Vec2, +} + +impl TextViewport { + /// Returns the viewport as a `Rect`. + pub fn rect(&self) -> Rect { + Rect { + min: self.offset, + max: self.offset + self.size, + } + } + + /// Clamp the scroll offset to fit inside `max`. + pub fn clamp_inside(&mut self, max: Vec2) { + self.offset = self + .offset + .clamp(Vec2::ZERO, (max - self.size).max(Vec2::ZERO)); + } + + /// Scroll by a displacement + pub fn scroll_by(&mut self, displacement: Vec2, max: Vec2) { + self.offset = self.offset + displacement; + self.clamp_inside(max); + } + + /// Scroll to a position + pub fn scroll_to(&mut self, point: Vec2, max: Vec2) { + self.offset = Vec2::new( + scroll_to_axis(self.offset.x, self.size.x, point.x), + scroll_to_axis(self.offset.y, self.size.y, point.y), + ); + self.clamp_inside(max); + } + + /// Moves the viewport by the minimum amount needed to reveal the caret. + pub fn reveal_caret( + &mut self, + caret: Rect, + max: Vec2, + caret_margin: Vec2, + lines: impl IntoIterator, + ) { + let mut line_bounds = lines.into_iter().peekable(); + let caret_max = max.max(caret.max.max(Vec2::ZERO)); + + self.clamp_inside(caret_max); + let view = self.rect(); + let margin = caret_margin.clamp(Vec2::ZERO, Vec2::splat(0.5)) * self.size; + let caret_reveal_region = Rect { + min: view.min + margin, + max: view.max - margin, + }; + if caret_reveal_region.min.cmple(caret.min).all() + && caret_reveal_region.max.cmpge(caret.max).all() + { + return; + } + + self.offset.x = min_scroll_axis( + caret_reveal_region.min.x, + caret_reveal_region.max.x, + caret.min.x, + caret.max.x, + ) - margin.x; + let vertical_offset = min_scroll_axis( + caret_reveal_region.min.y, + caret_reveal_region.max.y, + caret.min.y, + caret.max.y, + ) - margin.y; + if line_bounds.peek().is_none() { + self.offset.y = vertical_offset; + } else if vertical_offset < self.offset.y { + self.offset.y = line_bounds + .filter(|line| line.min <= vertical_offset) + .last() + .map_or(0.0, |line| line.min); + } else if self.offset.y < vertical_offset { + self.offset.y = line_bounds + .find(|line| vertical_offset <= line.min) + .map_or((caret_max - self.size).max(Vec2::ZERO).y, |line| line.min); + } + + self.clamp_inside(caret_max); + } + + /// Scroll vertically by a number of lines. + pub fn scroll_by_lines(&mut self, scroll_lines: f32, content_size: Vec2, line_bounds: I) + where + I: IntoIterator, + I::IntoIter: Clone + DoubleEndedIterator + ExactSizeIterator, + { + let line_bounds = line_bounds.into_iter(); + + if line_bounds.clone().next().is_none() { + return; + } + + let content_size = Vec2::new(content_size.x, line_bounds.clone().next_back().unwrap().max); + + self.clamp_inside(content_size); + if scroll_lines == 0.0 { + return; + } + + let current_line = line_bounds + .clone() + .rposition(|line| line.min <= self.offset.y) + .unwrap_or(0); + let line_delta = scroll_lines.abs(); + let whole_lines = line_delta.floor() as usize; + let fraction = line_delta.fract(); + let current_line_bounds = line_bounds.clone().nth(current_line).unwrap(); + + if scroll_lines.is_sign_positive() { + if line_bounds.len() - 1 - current_line < whole_lines { + self.offset.y = (content_size - self.size).max(Vec2::ZERO).y; + return; + } + + let target_line = current_line + whole_lines; + let target_line_bounds = line_bounds.clone().nth(target_line).unwrap(); + self.offset.y += target_line_bounds.min - current_line_bounds.min + + fraction + * (line_bounds + .clone() + .nth(target_line + 1) + .map_or(target_line_bounds.max, |line| line.min) + - target_line_bounds.min); + } else { + if current_line < whole_lines { + self.offset.y = 0.0; + return; + } + + let target_line = current_line - whole_lines; + let target_line_bounds = line_bounds.clone().nth(target_line).unwrap(); + self.offset.y += target_line_bounds.min + - current_line_bounds.min + - fraction + * (if target_line == 0 { + target_line_bounds.max - target_line_bounds.min + } else { + target_line_bounds.min + - line_bounds.clone().nth(target_line - 1).unwrap().min + }); + } + self.clamp_inside(content_size); + } +} + +/// The vertical bounds of one visual line in text-layout coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Reflect)] +pub struct TextLineYBounds { + /// Top edge of the visual line. + pub min: f32, + /// Bottom edge of the visual line. + pub max: f32, +} + +impl TextLineYBounds { + /// Creates line bounds from a top and bottom edge. + pub const fn new(min: f32, max: f32) -> Self { + Self { min, max } + } + + /// Creates line bounds from a Parley `Line`. + pub fn from_line<'a, B: parley::Brush>(line: &parley::Line<'a, B>) -> Self { + Self { + min: line.metrics().block_min_coord, + max: line.metrics().block_max_coord, + } + } +} + +/// The horizontal extent an editable text viewport may scroll across. +pub fn scrollable_text_layout_width( + linebreak: LineBreak, + layout_width: f32, + viewport_width: f32, + caret: Option, +) -> f32 { + let scrollable_width = match linebreak { + LineBreak::NoWrap | LineBreak::WordBoundary => layout_width.max(viewport_width), + LineBreak::AnyCharacter | LineBreak::WordOrCharacter => viewport_width, + }; + + if linebreak == LineBreak::NoWrap || viewport_width < scrollable_width { + caret.map_or(scrollable_width, |caret| scrollable_width.max(caret.max.x)) + } else { + scrollable_width + } +} + +fn scroll_to_axis(view_min: f32, view_size: f32, point: f32) -> f32 { + if point < view_min { + point + } else if view_min + view_size < point { + point - view_size + } else { + view_min + } +} + +fn min_scroll_axis(view_min: f32, view_max: f32, target_min: f32, target_max: f32) -> f32 { + let view_size = view_max - view_min; + let target_size = target_max - target_min; + + if view_size < target_size { + target_min + (target_size - view_size) / 2. + } else if target_min < view_min { + target_min + } else if view_max < target_max { + target_max - view_size + } else { + view_min + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VARIABLE_LINE_BOUNDS: [TextLineYBounds; 5] = [ + TextLineYBounds::new(0.0, 10.0), + TextLineYBounds::new(10.0, 25.0), + TextLineYBounds::new(25.0, 45.0), + TextLineYBounds::new(45.0, 70.0), + TextLineYBounds::new(70.0, 100.0), + ]; + + fn make_lines(line_count: usize, line_height: f32) -> Vec { + (0..line_count) + .map(move |index| { + let min = index as f32 * line_height; + TextLineYBounds::new(min, min + line_height) + }) + .collect() + } + + #[test] + fn scroll_by_lines() { + let mut view = TextViewport::default(); + let lines = make_lines(10, 20.); + view.size = Vec2::new(100.0, 60.0); + view.scroll_by_lines(2.0, Vec2::new(100.0, 200.0), lines.iter().cloned()); + assert_eq!(view.offset, Vec2::new(0.0, 40.0)); + + view.scroll_by_lines(3.0, Vec2::new(100.0, 200.0), lines.iter().cloned()); + view.scroll_by_lines(-2.0, Vec2::new(100.0, 200.0), lines); + assert_eq!(view.offset, Vec2::new(0.0, 60.0)); + } + + #[test] + fn scroll_by_lines_preserves_partial_line_offset() { + let mut view = TextViewport::default(); + let lines = make_lines(10, 20.); + view.size = Vec2::new(100.0, 55.0); + + view.scroll_by(Vec2::new(0.0, 5.0), Vec2::new(100.0, 200.0)); + + view.scroll_by_lines(1.0, Vec2::new(100.0, 200.0), lines.iter().cloned()); + assert_eq!(view.offset, Vec2::new(0.0, 25.0)); + + view.scroll_by_lines(1.0, Vec2::new(100.0, 200.0), lines.iter().cloned()); + assert_eq!(view.offset, Vec2::new(0.0, 45.0)); + + view.scroll_to(Vec2::new(0.0, 45.0), Vec2::new(100.0, 200.0)); + + view.scroll_by_lines(-1.0, Vec2::new(100.0, 200.0), lines.iter().cloned()); + assert_eq!(view.offset, Vec2::new(0.0, 25.0)); + + view.scroll_by_lines(-1.0, Vec2::new(100.0, 200.0), lines); + assert_eq!(view.offset, Vec2::new(0.0, 5.0)); + } + + #[test] + fn scroll_by_fractional_lines() { + let mut view = TextViewport::default(); + let lines = make_lines(10, 20.); + view.size = Vec2::new(100.0, 50.0); + view.scroll_by(Vec2::new(0.0, 45.0), Vec2::new(100.0, 200.0)); + + view.scroll_by_lines(0.5, Vec2::new(100.0, 200.0), lines.iter().cloned()); + assert_eq!(view.offset, Vec2::new(0.0, 55.0)); + + view.scroll_by_lines(-0.25, Vec2::new(100.0, 200.0), lines); + assert_eq!(view.offset, Vec2::new(0.0, 50.0)); + } + + #[test] + fn text_view_zero_line_scroll_clamp() { + let mut view = TextViewport::default(); + let lines = make_lines(10, 20.); + view.size = Vec2::new(100.0, 60.0); + view.scroll_by_lines(10.0, Vec2::new(100.0, 200.0), lines.iter().cloned()); + view.size = Vec2::new(100.0, 180.0); + view.scroll_by_lines(0.0, Vec2::new(100.0, 200.0), lines); + + assert_eq!(view.offset, Vec2::new(0.0, 20.0)); + } + + #[test] + fn text_view_scroll_lines_clamps_large_deltas() { + let mut view = TextViewport::default(); + let lines = make_lines(10, 20.); + view.size = Vec2::new(100.0, 60.0); + view.scroll_by_lines(1000.0, Vec2::new(100.0, 200.0), lines.iter().cloned()); + assert_eq!(view.offset, Vec2::new(0.0, 140.0)); + + view.scroll_by_lines(-1000.0, Vec2::new(100.0, 200.0), lines); + assert_eq!(view.offset, Vec2::ZERO); + } + + #[test] + fn text_view_scroll_lines_keeps_offset_zero_when_content_fits() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 200.0); + view.scroll_by_lines(5.0, Vec2::new(100.0, 80.0), make_lines(4, 20.0)); + + assert_eq!(view.offset, Vec2::ZERO); + } + + #[test] + fn text_view_scrolls_by_variable_lines() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 40.0); + view.scroll_by(Vec2::new(0.0, 5.0), Vec2::new(100.0, 100.0)); + view.scroll_by_lines(3.0, Vec2::new(100.0, 100.0), VARIABLE_LINE_BOUNDS); + + assert_eq!(view.offset, Vec2::new(0.0, 50.0)); + + view.scroll_by_lines(-1.0, Vec2::new(100.0, 100.0), VARIABLE_LINE_BOUNDS); + assert_eq!(view.offset, Vec2::new(0.0, 30.0)); + } + + #[test] + fn text_view_scrolls_by_fractional_variable_lines() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 20.0); + view.scroll_by(Vec2::new(0.0, 5.0), Vec2::new(100.0, 100.0)); + + view.scroll_by_lines(1.5, Vec2::new(100.0, 100.0), VARIABLE_LINE_BOUNDS); + assert_eq!(view.offset, Vec2::new(0.0, 22.5)); + + view.scroll_by_lines(-0.5, Vec2::new(100.0, 100.0), VARIABLE_LINE_BOUNDS); + assert_eq!(view.offset, Vec2::new(0.0, 17.5)); + } + + #[test] + fn text_view_fractional_line_scroll_clamps_at_start() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 50.0); + let content_size = Vec2::new(100.0, 200.0); + + view.scroll_by(Vec2::new(0.0, 5.0), content_size); + view.scroll_by_lines(-0.5, content_size, make_lines(10, 20.)); + assert_eq!(view.offset, Vec2::ZERO); + } + + #[test] + fn text_view_scroll_by_clamping() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 50.0); + + view.scroll_by(Vec2::new(20.0, 30.0), Vec2::new(80.0, 40.0)); + assert_eq!(view.offset, Vec2::ZERO); + + view.scroll_by(Vec2::new(30.0, 40.0), Vec2::new(250.0, 180.0)); + assert_eq!(view.offset, Vec2::new(30.0, 40.0)); + + view.scroll_by(Vec2::new(1000.0, 1000.0), Vec2::new(180.0, 90.0)); + assert_eq!(view.offset, Vec2::new(80.0, 40.0)); + + view.scroll_by(Vec2::new(-1000.0, -1000.0), Vec2::new(180.0, 90.0)); + assert_eq!(view.offset, Vec2::ZERO); + } + + #[test] + fn text_view_scroll_to_moves_min_distance() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 50.0); + let content_size = Vec2::new(250.0, 180.0); + view.scroll_by(Vec2::new(30.0, 40.0), content_size); + + view.scroll_to(Vec2::new(80.0, 60.0), content_size); + assert_eq!(view.offset, Vec2::new(30.0, 40.0)); + + view.scroll_by(Vec2::new(20.0, 10.0), content_size); + + view.scroll_to(Vec2::new(40.0, 40.0), content_size); + assert_eq!(view.offset, Vec2::new(40.0, 40.0)); + + view.scroll_to(Vec2::new(160.0, 100.0), content_size); + assert_eq!(view.offset, Vec2::new(60.0, 50.0)); + + view.scroll_to(Vec2::new(60.0, 110.0), content_size); + assert_eq!(view.offset, Vec2::new(60.0, 60.0)); + } + + #[test] + fn text_view_scroll_to_clamps_to_content_bounds() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 50.0); + let content_size = Vec2::new(250.0, 180.0); + + view.scroll_to(Vec2::new(1000.0, 1000.0), content_size); + assert_eq!(view.offset, Vec2::new(150.0, 130.0)); + + view.scroll_to(Vec2::new(-1000.0, -1000.0), content_size); + assert_eq!(view.offset, Vec2::ZERO); + } + + #[test] + fn text_view_reveal_caret_keeps_visible_caret_in_view() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 55.0); + let content_size = Vec2::new(100.0, 200.0); + view.scroll_by(Vec2::new(0.0, 25.0), content_size); + + view.reveal_caret( + Rect::new(20.0, 40.0, 22.0, 60.0), + content_size, + Vec2::ZERO, + make_lines(10, 20.0), + ); + + assert_eq!(view.offset, Vec2::new(0.0, 25.0)); + } + + #[test] + fn text_view_reveal_caret_quantizes_vertical_scroll_to_lines() { + let mut view = TextViewport::default(); + let lines = make_lines(10, 20.); + view.size = Vec2::new(100.0, 55.0); + let content_size = Vec2::new(100.0, 200.0); + + view.reveal_caret( + Rect::new(20.0, 60.0, 22.0, 80.0), + content_size, + Vec2::ZERO, + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::new(0.0, 40.0)); + + view.scroll_by(Vec2::new(0.0, 100.0), content_size); + view.reveal_caret( + Rect::new(20.0, 20.0, 22.0, 40.0), + content_size, + Vec2::ZERO, + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::new(0.0, 20.0)); + + view.reveal_caret( + Rect::new(20.0, 180.0, 22.0, 200.0), + content_size, + Vec2::ZERO, + lines, + ); + assert_eq!(view.offset, Vec2::new(0.0, 145.0)); + } + + #[test] + fn text_view_reveal_caret_rounds_margin_scroll_in_direction() { + let mut view = TextViewport::default(); + let lines = make_lines(15, 20.); + view.size = Vec2::new(100.0, 90.0); + let content_size = Vec2::new(100.0, 300.0); + view.scroll_by(Vec2::new(0.0, 50.0), content_size); + + view.reveal_caret( + Rect::new(50.0, 45.0, 52.0, 55.0), + content_size, + Vec2::splat(0.2), + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::new(0.0, 20.0)); + + view.scroll_by(Vec2::new(0.0, 30.0), content_size); + view.reveal_caret( + Rect::new(50.0, 145.0, 52.0, 155.0), + content_size, + Vec2::splat(0.2), + lines, + ); + assert_eq!(view.offset, Vec2::new(0.0, 100.0)); + } + + #[test] + fn text_view_reveal_caret_rounds_to_variable_line_starts() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 50.0); + let content_size = Vec2::new(100.0, 135.0); + let lines = [ + TextLineYBounds::new(0.0, 10.0), + TextLineYBounds::new(10.0, 25.0), + TextLineYBounds::new(25.0, 45.0), + TextLineYBounds::new(45.0, 70.0), + TextLineYBounds::new(70.0, 100.0), + TextLineYBounds::new(100.0, 135.0), + ]; + view.scroll_by(Vec2::new(0.0, 45.0), content_size); + + view.reveal_caret( + Rect::new(50.0, 25.0, 52.0, 45.0), + content_size, + Vec2::splat(0.2), + lines, + ); + assert_eq!(view.offset, Vec2::new(0.0, 10.0)); + + view.scroll_by(Vec2::new(0.0, 35.0), content_size); + view.reveal_caret( + Rect::new(50.0, 70.0, 52.0, 100.0), + content_size, + Vec2::splat(0.2), + lines, + ); + assert_eq!(view.offset, Vec2::new(0.0, 70.0)); + } + + #[test] + fn text_view_reveal_caret_without_line_bounds() { + let mut view = TextViewport::default(); + view.size = Vec2::new(100.0, 50.0); + + view.reveal_caret( + Rect::new(50.0, 75.0, 52.0, 85.0), + Vec2::new(100.0, 200.0), + Vec2::splat(0.2), + [], + ); + + assert_eq!(view.offset, Vec2::new(0.0, 45.0)); + } + + #[test] + fn text_view_reveal_caret_scrolls_horizontally_and_clamps() { + let mut view = TextViewport::default(); + view.size = Vec2::new(50.0, 20.0); + + view.reveal_caret( + Rect::new(95.0, 0.0, 110.0, 20.0), + Vec2::new(100.0, 20.0), + Vec2::ZERO, + make_lines(1, 20.0), + ); + + assert_eq!(view.offset, Vec2::new(60.0, 0.0)); + } + + #[test] + fn text_view_reveal_caret_keeps_target_inside_margin_safe_region() { + let mut view = TextViewport::default(); + view.size = Vec2::splat(100.0); + let content_size = Vec2::splat(300.0); + view.scroll_by(Vec2::splat(50.0), content_size); + + view.reveal_caret( + Rect::new(80.0, 80.0, 82.0, 100.0), + content_size, + Vec2::splat(0.2), + make_lines(15, 20.0), + ); + + assert_eq!(view.offset, Vec2::splat(50.0)); + } + + #[test] + fn text_view_reveal_caret_scrolls_minimally_at_each_margin_edge() { + let mut view = TextViewport::default(); + let lines = make_lines(15, 20.); + view.size = Vec2::splat(100.0); + let content_size = Vec2::splat(300.0); + + view.scroll_by(Vec2::splat(50.0), content_size); + view.reveal_caret( + Rect::new(60.0, 80.0, 62.0, 100.0), + content_size, + Vec2::splat(0.2), + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::new(40.0, 50.0)); + + view.scroll_by(Vec2::new(10.0, 0.0), content_size); + view.reveal_caret( + Rect::new(140.0, 80.0, 142.0, 100.0), + content_size, + Vec2::splat(0.2), + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::new(62.0, 50.0)); + + view.reveal_caret( + Rect::new(100.0, 45.0, 102.0, 55.0), + content_size, + Vec2::splat(0.2), + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::new(62.0, 20.0)); + + view.scroll_by(Vec2::new(0.0, 30.0), content_size); + view.reveal_caret( + Rect::new(100.0, 145.0, 102.0, 155.0), + content_size, + Vec2::splat(0.2), + lines, + ); + assert_eq!(view.offset, Vec2::new(62.0, 80.0)); + } + + #[test] + fn text_view_reveal_caret_independent_margins() { + let mut view = TextViewport::default(); + let lines = make_lines(15, 20.); + view.size = Vec2::splat(100.0); + let content_size = Vec2::splat(300.0); + view.scroll_by(Vec2::splat(50.0), content_size); + + view.reveal_caret( + Rect::new(140.0, 75.0, 142.0, 95.0), + content_size, + Vec2::new(0.2, 0.0), + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::new(62.0, 50.0)); + + view.reveal_caret( + Rect::new(80.0, 120.0, 82.0, 140.0), + content_size, + Vec2::new(0.0, 0.2), + lines, + ); + assert_eq!(view.offset, Vec2::new(62.0, 60.0)); + } + + #[test] + fn text_view_reveal_caret_margin_clamped_at_layout_edges() { + let mut view = TextViewport::default(); + let lines = make_lines(10, 20.); + view.size = Vec2::splat(100.0); + let content_size = Vec2::splat(200.0); + + view.reveal_caret( + Rect::new(0.0, 0.0, 2.0, 20.0), + content_size, + Vec2::splat(0.2), + lines.iter().cloned(), + ); + assert_eq!(view.offset, Vec2::ZERO); + + view.scroll_by(Vec2::splat(100.0), content_size); + view.reveal_caret( + Rect::new(198.0, 180.0, 200.0, 200.0), + content_size, + Vec2::splat(0.2), + lines, + ); + assert_eq!(view.offset, Vec2::splat(100.0)); + } + + #[test] + fn character_wrapped_text_returns_viewport_width() { + for linebreak in [LineBreak::AnyCharacter, LineBreak::WordOrCharacter] { + assert_eq!( + scrollable_text_layout_width( + linebreak, + 102.0, + 100.0, + Some(Rect::new(95.0, 0.0, 110.0, 20.0)) + ), + 100.0 + ); + } + } + + #[test] + fn word_boundary_wrapping_preserves_overflow() { + assert_eq!( + scrollable_text_layout_width(LineBreak::WordBoundary, 150.0, 100.0, None,), + 150.0 + ); + } + + #[test] + fn overflowing_wrapped_text_includes_trailing_caret() { + assert_eq!( + scrollable_text_layout_width( + LineBreak::WordBoundary, + 102.0, + 100.0, + Some(Rect::new(95.0, 0.0, 110.0, 20.0)), + ), + 110.0 + ); + } + + #[test] + fn no_wrap_text_includes_trailing_caret() { + assert_eq!( + scrollable_text_layout_width( + LineBreak::NoWrap, + 102.0, + 100.0, + Some(Rect::new(95.0, 0.0, 110.0, 20.0)), + ), + 110.0 + ); + } + + #[test] + fn scrollable_text_word_boundary() { + assert_eq!( + scrollable_text_layout_width( + LineBreak::WordBoundary, + 150., + 100., + Some(Rect::new(150., 0., 160.0, 10.0)), + ), + 160. + ); + } +} diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 9e9f5defa0e7e..84c236a451f6b 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -1,9 +1,13 @@ +use core::num::NonZeroU16; + use bevy_clipboard::ClipboardRead; -use bevy_math::Vec2; +use bevy_math::{Rect, Vec2}; use bevy_reflect::Reflect; -use parley::PlainEditorDriver; +use parley::{PlainEditorDriver, StyleProperty}; use smol_str::SmolStr; +use swash::FontRef; +use crate::scroll::{TextLineYBounds, TextViewport}; use crate::TextBrush; /// A selection within IME preedit text, expressed as byte offsets from the start of the preedit. @@ -199,6 +203,14 @@ pub enum TextEdit { /// The committed text to insert at the cursor. value: SmolStr, }, + /// Scroll vertically by the given number of visual lines, increasing downwards. + /// + /// Fractional values scroll by the corresponding fraction of a visual line. + ScrollByLines(f32), + /// Scroll the minimum amount such that the given point is in view. + ScrollTo(Vec2), + /// Scroll by the given displacement + ScrollBy(Vec2), } impl TextEdit { @@ -210,7 +222,7 @@ impl TextEdit { } } - /// Apply the [`TextEdit`] to the text editor driver. + /// Apply the [`TextEdit`] to the text editor driver and viewport. /// /// Note that some edits, such as [`TextEdit::Paste`], may need to be deferred across frames due to asynchronous clipboard I/O. /// For proper handling of deferred edits, use [`EditableText::apply_pending_edits`](super::EditableText::apply_pending_edits) instead, @@ -218,6 +230,8 @@ impl TextEdit { pub fn apply<'a>( self, driver: &'a mut PlainEditorDriver, + viewport: &mut TextViewport, + cursor_margin: Vec2, clipboard: &mut bevy_clipboard::Clipboard, max_characters: Option, char_filter: impl Fn(char) -> bool, @@ -237,6 +251,7 @@ impl TextEdit { Err(e) => bevy_log::warn!("Failed to write selection to clipboard: {e:?}"), } } + reveal_cursor(driver, viewport, cursor_margin); } TextEdit::Paste => { // It's nice to be able to provide apply as a public method, but Paste is a little buggy. @@ -246,44 +261,137 @@ impl TextEdit { let mut read = clipboard.fetch_text(); poll_and_apply_paste(&mut read, driver, max_characters, char_filter); + reveal_cursor(driver, viewport, cursor_margin); } TextEdit::Insert(text) => { let _ = insert_filtered(driver, text.as_str(), max_characters, char_filter); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Backspace => { + driver.backdelete(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::BackspaceWord => { + driver.backdelete_word(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Delete => { + driver.delete(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::DeleteWord => { + driver.delete_word(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Left(false) => { + driver.move_left(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Right(false) => { + driver.move_right(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::WordLeft(false) => { + driver.move_word_left(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::WordRight(false) => { + driver.move_word_right(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Up(false) => { + driver.move_up(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Down(false) => { + driver.move_down(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::TextStart(false) => { + driver.move_to_text_start(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::TextEnd(false) => { + driver.move_to_text_end(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::HardLineStart(false) => { + driver.move_to_hard_line_start(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::HardLineEnd(false) => { + driver.move_to_hard_line_end(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::LineStart(false) => { + driver.move_to_line_start(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::LineEnd(false) => { + driver.move_to_line_end(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Left(true) => { + driver.select_left(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Right(true) => { + driver.select_right(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::WordLeft(true) => { + driver.select_word_left(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::WordRight(true) => { + driver.select_word_right(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Up(true) => { + driver.select_up(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::Down(true) => { + driver.select_down(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::TextStart(true) => { + driver.select_to_text_start(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::TextEnd(true) => { + driver.select_to_text_end(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::HardLineStart(true) => { + driver.select_to_hard_line_start(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::HardLineEnd(true) => { + driver.select_to_hard_line_end(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::LineStart(true) => { + driver.select_to_line_start(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::LineEnd(true) => { + driver.select_to_line_end(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::CollapseSelection => { + driver.collapse_selection(); + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::SelectAll => { + driver.select_all(); + reveal_cursor(driver, viewport, cursor_margin); } - TextEdit::Backspace => driver.backdelete(), - TextEdit::BackspaceWord => driver.backdelete_word(), - TextEdit::Delete => driver.delete(), - TextEdit::DeleteWord => driver.delete_word(), - TextEdit::Left(false) => driver.move_left(), - TextEdit::Right(false) => driver.move_right(), - TextEdit::WordLeft(false) => driver.move_word_left(), - TextEdit::WordRight(false) => driver.move_word_right(), - TextEdit::Up(false) => driver.move_up(), - TextEdit::Down(false) => driver.move_down(), - TextEdit::TextStart(false) => driver.move_to_text_start(), - TextEdit::TextEnd(false) => driver.move_to_text_end(), - TextEdit::HardLineStart(false) => driver.move_to_hard_line_start(), - TextEdit::HardLineEnd(false) => driver.move_to_hard_line_end(), - TextEdit::LineStart(false) => driver.move_to_line_start(), - TextEdit::LineEnd(false) => driver.move_to_line_end(), - TextEdit::Left(true) => driver.select_left(), - TextEdit::Right(true) => driver.select_right(), - TextEdit::WordLeft(true) => driver.select_word_left(), - TextEdit::WordRight(true) => driver.select_word_right(), - TextEdit::Up(true) => driver.select_up(), - TextEdit::Down(true) => driver.select_down(), - TextEdit::TextStart(true) => driver.select_to_text_start(), - TextEdit::TextEnd(true) => driver.select_to_text_end(), - TextEdit::HardLineStart(true) => driver.select_to_hard_line_start(), - TextEdit::HardLineEnd(true) => driver.select_to_hard_line_end(), - TextEdit::LineStart(true) => driver.select_to_line_start(), - TextEdit::LineEnd(true) => driver.select_to_line_end(), - TextEdit::CollapseSelection => driver.collapse_selection(), - TextEdit::SelectAll => driver.select_all(), TextEdit::SelectAllIfCollapsed => { if driver.editor.raw_selection().is_collapsed() { driver.select_all(); } + reveal_cursor(driver, viewport, cursor_margin); } TextEdit::MoveToPoint(point) => driver.move_to_point(point.x, point.y), TextEdit::SelectWordAtPoint(point) => driver.select_word_at_point(point.x, point.y), @@ -302,6 +410,7 @@ impl TextEdit { let cursor = cursor.map(|c| (c.anchor, c.focus)); driver.set_compose(&value, cursor); } + reveal_cursor(driver, viewport, cursor_margin); } TextEdit::ImeCommit { value: text } => { driver.clear_compose(); @@ -312,11 +421,104 @@ impl TextEdit { { driver.insert_or_replace_selection(text.as_str()); } + reveal_cursor(driver, viewport, cursor_margin); + } + TextEdit::ScrollByLines(n) => { + driver.refresh_layout(); + let content_size = scroll_content_size(driver); + let layout = driver.layout(); + viewport.scroll_by_lines( + n, + content_size, + layout.lines().map(|line| TextLineYBounds::from_line(&line)), + ); + } + TextEdit::ScrollTo(target) => { + driver.refresh_layout(); + viewport.scroll_to(target, scroll_content_size(driver)); + } + TextEdit::ScrollBy(displacement) => { + driver.refresh_layout(); + viewport.scroll_by(displacement, scroll_content_size(driver)); } } } } +fn scroll_content_size(driver: &mut PlainEditorDriver<'_, TextBrush>) -> Vec2 { + let cursor_max = cursor_reveal_rect(driver).map_or(Vec2::ZERO, |cursor| cursor.max); + let layout = driver.layout(); + Vec2::new(layout.full_width(), layout.height()).max(cursor_max) +} + +/// Returns the caret rectangle extended by one `0` advance. +pub fn cursor_reveal_rect(driver: &mut PlainEditorDriver<'_, TextBrush>) -> Option { + let cluster = driver + .editor + .raw_selection() + .focus() + .logical_clusters(driver.layout()) + .into_iter() + .rev() + .flatten() + .next(); + let cursor_advance = cluster + .map(|cluster| { + let run = cluster.run(); + let font = run.font(); + FontRef::from_index(font.data.as_ref(), font.index as usize) + .and_then(|font_ref| { + NonZeroU16::new(font_ref.charmap().map('0')).map(|glyph_id| { + font_ref + .glyph_metrics(run.normalized_coords()) + .scale(run.font_size()) + .advance_width(glyph_id.get()) + }) + }) + .unwrap_or(0.6 * run.font_size()) + }) + .unwrap_or_else(|| { + 0.6 * driver.editor.get_scale() + * driver + .editor + .get_styles() + .inner() + .values() + .find_map(|property| match property { + StyleProperty::FontSize(font_size) => Some(*font_size), + _ => None, + }) + .unwrap_or_default() + }); + + driver.editor.cursor_geometry(cursor_advance).map(|cursor| { + Rect::new( + cursor.x0 as f32, + cursor.y0 as f32, + cursor.x1 as f32, + cursor.y1 as f32, + ) + }) +} + +pub(crate) fn reveal_cursor( + driver: &mut PlainEditorDriver<'_, TextBrush>, + viewport: &mut TextViewport, + cursor_margin: Vec2, +) { + driver.refresh_layout(); + let Some(cursor) = cursor_reveal_rect(driver) else { + return; + }; + let layout = driver.layout(); + viewport.reveal_caret( + cursor, + Vec2::new(layout.full_width(), layout.height()), + cursor_margin, + layout.lines().map(|line| TextLineYBounds::from_line(&line)), + ); +} + /// Reason an [`insert_filtered`] call was rejected. /// /// The two branches matter to callers (paste warns on [`CharFilter`](Self::CharFilter) but @@ -387,3 +589,141 @@ pub(crate) fn poll_and_apply_paste( None => false, } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::borrow::Cow; + use parley::{FontContext, FontFamilyName, LayoutContext, PlainEditor}; + + fn fira_mono_editor() -> ( + PlainEditor, + FontContext, + LayoutContext, + ) { + let mut editor = PlainEditor::::new(20.); + editor + .edit_styles() + .insert(FontFamilyName::Named(Cow::Borrowed("Fira Mono")).into()); + + let mut font_context = FontContext::new(); + let font = crate::Font::from_bytes(include_bytes!("FiraMono-subset.ttf").to_vec()); + font_context.collection.register_fonts(font.data, None); + + (editor, font_context, LayoutContext::new()) + } + + fn apply_edit( + edit: TextEdit, + driver: &mut PlainEditorDriver<'_, TextBrush>, + viewport: &mut TextViewport, + ) { + edit.apply( + driver, + viewport, + Vec2::ZERO, + &mut bevy_clipboard::Clipboard::default(), + None, + |_| true, + ); + } + + #[test] + fn scroll_right_includes_caret_reveal() { + let (mut editor, mut font_context, mut layout_context) = fira_mono_editor(); + editor.set_text("A long line of text that can be scrolled horizontally."); + + let mut driver = editor.driver(&mut font_context, &mut layout_context); + let mut viewport = TextViewport { + size: Vec2::splat(40.), + ..Default::default() + }; + + apply_edit(TextEdit::TextEnd(false), &mut driver, &mut viewport); + let reveal_offset = viewport.offset.x; + + apply_edit( + TextEdit::ScrollBy(-1000. * Vec2::X), + &mut driver, + &mut viewport, + ); + assert_eq!(viewport.offset.x, 0.); + + apply_edit( + TextEdit::ScrollBy(1000. * Vec2::X), + &mut driver, + &mut viewport, + ); + assert_eq!(viewport.offset.x, reveal_offset); + } + + #[test] + fn cursor_navigation_reveals_cursor() { + let (mut editor, mut font_context, mut layout_context) = fira_mono_editor(); + editor.set_text("A long line of text that can be scrolled horizontally."); + + let mut driver = editor.driver(&mut font_context, &mut layout_context); + let mut viewport = TextViewport { + size: Vec2::splat(40.), + ..Default::default() + }; + + apply_edit( + TextEdit::ScrollBy(1000. * Vec2::X), + &mut driver, + &mut viewport, + ); + assert!(0. < viewport.offset.x); + + apply_edit(TextEdit::TextStart(false), &mut driver, &mut viewport); + assert_eq!(viewport.offset.x, 0.); + } + + #[test] + fn reveal_cursor_on_right() { + let (mut editor, mut font_context, mut layout_context) = fira_mono_editor(); + editor.set_text("A line of text"); + + let mut driver = editor.driver(&mut font_context, &mut layout_context); + driver.refresh_layout(); + let mut viewport = TextViewport { + size: Vec2::new(driver.layout().full_width(), driver.layout().height()), + ..Default::default() + }; + + apply_edit(TextEdit::TextEnd(false), &mut driver, &mut viewport); + + let caret_x = driver.editor.cursor_geometry(0.).unwrap().x0 as f32; + assert!(0. < viewport.offset.x); + assert!(caret_x < viewport.rect().max.x); + + viewport.clamp_inside(Vec2::new( + driver.layout().full_width(), + driver.layout().height(), + )); + assert_eq!(viewport.offset.x, 0.); + + reveal_cursor(&mut driver, &mut viewport, Vec2::ZERO); + assert!(0. < viewport.offset.x); + assert!(caret_x < viewport.rect().max.x); + } + + #[test] + fn empty_right_aligned_caret_is_scrolled_inside_view() { + let (mut editor, mut font_context, mut layout_context) = fira_mono_editor(); + editor.set_alignment(parley::Alignment::Right); + editor.set_width(Some(100.)); + + let mut driver = editor.driver(&mut font_context, &mut layout_context); + let mut viewport = TextViewport { + size: Vec2::new(100., 40.), + ..Default::default() + }; + + apply_edit(TextEdit::TextEnd(false), &mut driver, &mut viewport); + + let caret_x = driver.editor.cursor_geometry(0.).unwrap().x0 as f32; + assert!(0. < viewport.offset.x); + assert!(caret_x < viewport.rect().max.x); + } +} diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 966616a705506..28bc0b187cb8a 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -268,11 +268,10 @@ fn build_text_interop(app: &mut App) { .ambiguous_with(widget::update_image_content_size_system) .ambiguous_with(widget::measure_text_system) .ambiguous_with(bevy_sprite::update_text2d_layout), - ( - widget::update_editable_text_layout.before(bevy_asset::AssetEventSystems), - widget::scroll_editable_text, - ) - .chain() + widget::sync_editable_text_viewports + .after(UiSystems::Layout) + .before(EditableTextSystems), + widget::update_editable_text_layout .in_set(UiSystems::PostLayout) // This is unlikely to result in real conflicts, // as FocusChangeEvents only mutates internal state of InputFocus, @@ -281,6 +280,7 @@ fn build_text_interop(app: &mut App) { // as editable_text_system or related systems could generate focus changes // which should be processed ASAP. .before(bevy_input_focus::InputFocusSystems::FocusChangeEvents) + .before(bevy_asset::AssetEventSystems) .ambiguous_with(widget::text_system) .ambiguous_with(bevy_sprite::update_text2d_layout) .ambiguous_with(bevy_sprite::calculate_bounds_text2d), @@ -298,5 +298,10 @@ fn build_text_interop(app: &mut App) { ); // We cannot set this up in bevy_text as this would create a circular dependency between bevy_ui and bevy_text - app.configure_sets(PostUpdate, EditableTextSystems.in_set(UiSystems::Content)); + app.configure_sets( + PostUpdate, + EditableTextSystems + .after(UiSystems::Layout) + .before(UiSystems::PostLayout), + ); } diff --git a/crates/bevy_ui/src/widget/text_input_layout.rs b/crates/bevy_ui/src/widget/text_input_layout.rs index 6f5ca8e241457..acc59a34b5c8c 100644 --- a/crates/bevy_ui/src/widget/text_input_layout.rs +++ b/crates/bevy_ui/src/widget/text_input_layout.rs @@ -5,10 +5,8 @@ use crate::{ComputedNode, ComputedUiRenderTargetInfo, ContentSize, NodeMeasure}; use bevy_asset::Assets; use bevy_ecs::{ - change_detection::{DetectChanges, DetectChangesMut}, - component::Component, + change_detection::DetectChanges, entity::Entity, - reflect::ReflectComponent, system::{Local, Query, Res, ResMut}, world::Ref, }; @@ -16,23 +14,19 @@ use bevy_image::prelude::*; use bevy_input_focus::InputFocus; use bevy_math::{Rect, Vec2}; use bevy_platform::hash::FixedHasher; -use bevy_reflect::std_traits::ReflectDefault; -use bevy_reflect::Reflect; + 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, + add_glyph_to_atlas, cursor_reveal_rect, get_glyph_atlas_info, resolve_font_source, + scrollable_text_layout_width, EditableText, EditableTextGeneration, Font, FontAtlasKey, + FontAtlasSet, FontCx, FontHinting, FontSize, GlyphCacheKey, LayoutCx, LineBreak, LineHeight, + PositionedGlyph, RemSize, RunGeometry, ScaleCx, TextBrush, TextFont, TextLayout, + TextLayoutInfo, TextLineYBounds, }; use bevy_time::{Real, Time}; use parley::{BoundingBox, PositionedLayoutItem, StyleProperty}; use swash::FontRef; use taffy::MaybeMath; -#[derive(Component, Clone, Copy, PartialEq, Debug, Default, Reflect)] -#[reflect(Component, Default, Clone)] -pub struct TextScroll(pub Vec2); - struct TextInputMeasure { width: Option, height: Option, @@ -253,6 +247,17 @@ pub fn update_editable_text_styles( } } +/// Syncs each [`EditableText`]'s viewport size with their `ComputedNode`'s content size before text edits are applied. +pub fn sync_editable_text_viewports(mut query: Query<(&mut EditableText, &ComputedNode)>) { + for (mut editable_text, computed_node) in &mut query { + let size = computed_node.content_box().size(); + if editable_text.viewport.size != size { + editable_text.viewport.size = size; + editable_text.editor.set_width(Some(size.x)); + } + } +} + /// Refreshes the [`EditableText`]'s layout if stale and then writes it /// it to [`TextLayoutInfo`] for rendering and picking. /// Adds required glyphs to the texture atlas @@ -265,43 +270,43 @@ pub fn update_editable_text_layout( mut input_field_query: Query<( Entity, &TextFont, + &TextLayout, Ref, Ref, &mut EditableText, &mut TextLayoutInfo, - Ref, &mut EditableTextGeneration, )>, rem_size: Res, input_focus: Option>, mut cursor_timer: Local, + mut previous_focus: Local>, time: Res>, ) { *cursor_timer += time.delta(); + let current_focus = input_focus + .as_ref() + .and_then(|input_focus| input_focus.get()); + let focus_changed = *previous_focus != current_focus; for ( entity, text_font, + text_layout, hinting, target, mut editable_text, mut info, - computed_node, mut generation, ) in input_field_query.iter_mut() { let cursor_width = editable_text.cursor_width; let cursor_blink_period = editable_text.cursor_blink_period; + let cursor_margin = editable_text.cursor_margin; + let editable_text = &mut *editable_text; + let (editor, viewport) = (&mut editable_text.editor, &mut editable_text.viewport); - if computed_node.is_changed() { - editable_text - .editor - .set_width(Some(computed_node.content_box().width())); - } - - let mut driver = editable_text - .editor - .driver(font_cx.as_mut(), layout_cx.as_mut()); + let mut driver = editor.driver(font_cx.as_mut(), layout_cx.as_mut()); driver.refresh_layout(); @@ -462,10 +467,17 @@ pub fn update_editable_text_layout( } } - if let Some(input_focus) = input_focus.as_ref() - && Some(entity) == input_focus.get() - { - if input_focus.is_changed() || layout_changed || *cursor_timer >= cursor_blink_period { + let full_layout_size = { + let layout = driver.layout(); + Vec2::new(layout.full_width(), layout.height()) + }; + let is_focused = Some(entity) == current_focus; + let cursor_reveal = is_focused + .then(|| cursor_reveal_rect(&mut driver)) + .flatten(); + + if is_focused { + if focus_changed || layout_changed || *cursor_timer >= cursor_blink_period { *cursor_timer = Duration::ZERO; } @@ -483,7 +495,27 @@ pub fn update_editable_text_layout( .map(bounding_box_to_rect) .map(|rect| (false, rect)); } + + if focus_changed && let Some(cursor) = cursor_reveal { + let line_bounds = driver + .layout() + .lines() + .map(|line| TextLineYBounds::from_line(&line)); + viewport.reveal_caret(cursor, full_layout_size, cursor_margin, line_bounds); + } + let viewport_width = viewport.size.x; + viewport.clamp_inside(Vec2::new( + scrollable_text_layout_width( + text_layout.linebreak, + full_layout_size.x, + viewport_width, + cursor_reveal, + ), + full_layout_size.y, + )); } + + *previous_focus = current_focus; } fn bounding_box_to_rect(geom: BoundingBox) -> Rect { @@ -498,102 +530,3 @@ fn bounding_box_to_rect(geom: BoundingBox) -> Rect { }, } } - -/// Scroll editable text to keep cursor in view after edits. -pub fn scroll_editable_text( - input_focus: Option>, - mut previous_focus: Local>, - mut query: Query<( - Entity, - Ref, - Ref, - &mut TextScroll, - &ComputedNode, - &TextLayoutInfo, - )>, -) { - let current_focus = input_focus - .as_ref() - .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)) - { - continue; - } - - let view_size = node.content_box().size(); - if view_size.cmple(Vec2::ZERO).any() { - scroll.set_if_neq(TextScroll(Vec2::ZERO)); - continue; - } - - let Some(cursor) = info.cursor.map(|(_, rect)| rect) else { - continue; - }; - - let Some(layout) = editable_text.editor.try_layout() else { - continue; - }; - - let Some((line_min, line_max)) = find_visual_line_bounds(layout, cursor.center().y) else { - continue; - }; - - let max_scroll_x = (if input_focus - .as_ref() - .is_some_and(|input_focus| input_focus.get() == Some(entity)) - { - info.size.x.max(cursor.max.x) - } else { - info.size.x - } - view_size.x) - .max(0.); - - scroll.set_if_neq(TextScroll(Vec2 { - x: scroll_axis( - 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(), - })); - } - - *previous_focus = current_focus; -} - -fn find_visual_line_bounds( - layout: &parley::Layout, - y: f32, -) -> Option<(f32, f32)> { - let mut min = 0.0; - for line in layout.lines() { - let max = min + line.metrics().line_height; - if y < max { - return Some((min, max)); - } - min = max; - } - None -} - -fn scroll_axis(v_min: f32, v_max: f32, t_min: f32, t_max: f32) -> f32 { - let v_size = v_max - v_min; - let t_size = t_max - t_min; - if v_size < t_size { - t_min + (t_size - v_size) / 2. - } else if t_min < v_min { - t_min - } else if v_max < t_max { - t_max - v_size - } else { - v_min - } -} diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index a1efacc959275..20c2b7453c0c7 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -26,9 +26,7 @@ use bevy_reflect::Reflect; use bevy_render::camera::{extract_cameras, CameraMainPassTextureFormats}; use bevy_shader::load_shader_library; use bevy_sprite_render::SpriteAssetEvents; -use bevy_ui::widget::{ - ImageNode, ImageNodeSize, NodeImageMode, TextScroll, TextShadow, ViewportNode, -}; +use bevy_ui::widget::{ImageNode, ImageNodeSize, NodeImageMode, TextShadow, ViewportNode}; use bevy_ui::{ BackgroundColor, BorderColor, CalculatedClip, ComputedNode, ComputedStackIndex, ComputedUiTargetCamera, Display, Node, OuterColor, Outline, ResolvedBorderRadius, @@ -66,8 +64,8 @@ use gradient::GradientPlugin; use bevy_platform::collections::{HashMap, HashSet}; use bevy_text::{ - ComputedTextBlock, PositionedGlyph, Strikethrough, StrikethroughColor, TextBackgroundColor, - TextColor, TextCursorStyle, TextLayoutInfo, Underline, UnderlineColor, + ComputedTextBlock, EditableText, PositionedGlyph, Strikethrough, StrikethroughColor, + TextBackgroundColor, TextColor, TextCursorStyle, TextLayoutInfo, Underline, UnderlineColor, }; use bevy_transform::components::GlobalTransform; use box_shadow::BoxShadowPlugin; @@ -967,7 +965,7 @@ pub fn extract_text_sections( &ComputedTextBlock, &TextColor, &TextLayoutInfo, - Option<&TextScroll>, + Option<&EditableText>, Option<&TextCursorStyle>, )>, >, @@ -989,7 +987,7 @@ pub fn extract_text_sections( computed_block, text_color, text_layout_info, - text_scroll, + editable_text, cursor_style, ) in &uinode_query { @@ -1004,10 +1002,11 @@ pub fn extract_text_sections( let transform = Affine2::from(*global_transform) * Affine2::from_translation( - uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), + uinode.content_box().min + - editable_text.map_or(Vec2::ZERO, |text| text.viewport.offset), ); - let clip = if text_scroll.is_some() { + let clip = if editable_text.is_some() { let content_box = uinode.content_box(); let text_clip = Rect::from_center_size( global_transform.affine().translation + content_box.center(), @@ -1110,7 +1109,7 @@ pub fn extract_text_shadows( &TextLayoutInfo, &TextShadow, &ComputedTextBlock, - Option<&TextScroll>, + Option<&EditableText>, )>, >, text_decoration_query: Extract, Has)>>, @@ -1131,7 +1130,7 @@ pub fn extract_text_shadows( text_layout_info, shadow, computed_block, - text_scroll, + editable_text, ) in &uinode_query { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) @@ -1146,10 +1145,10 @@ pub fn extract_text_shadows( let node_transform = Affine2::from(*global_transform) * Affine2::from_translation( uinode.content_box().min + shadow.offset / uinode.inverse_scale_factor() - - text_scroll.map_or(Vec2::ZERO, |s| s.0), + - editable_text.map_or(Vec2::ZERO, |text| text.viewport.offset), ); - let clip = if text_scroll.is_some() { + let clip = if editable_text.is_some() { let content_box = uinode.content_box(); let text_clip = Rect::from_center_size( global_transform.affine().translation + content_box.center(), @@ -1277,7 +1276,7 @@ pub fn extract_text_decorations( Option<&CalculatedClip>, &ComputedUiTargetCamera, &TextLayoutInfo, - Option<&TextScroll>, + Option<&EditableText>, )>, >, text_background_colors_query: Extract< @@ -1301,7 +1300,7 @@ pub fn extract_text_decorations( maybe_clip, camera, text_layout_info, - text_scroll, + editable_text, ) in &uinode_query { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) @@ -1315,10 +1314,11 @@ pub fn extract_text_decorations( let transform = Affine2::from(global_transform) * Affine2::from_translation( - uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), + uinode.content_box().min + - editable_text.map_or(Vec2::ZERO, |text| text.viewport.offset), ); - let clip = if text_scroll.is_some() { + let clip = if editable_text.is_some() { let content_box = uinode.content_box(); let text_clip = Rect::from_center_size( global_transform.affine().translation + content_box.center(), diff --git a/crates/bevy_ui_render/src/text.rs b/crates/bevy_ui_render/src/text.rs index 50735ef30cb27..43dfdb024ca5c 100644 --- a/crates/bevy_ui_render/src/text.rs +++ b/crates/bevy_ui_render/src/text.rs @@ -8,8 +8,8 @@ use bevy_render::{sync_world::TemporaryRenderEntity, Extract}; use bevy_sprite::BorderRect; use bevy_text::{EditableText, TextColor, TextCursorStyle, TextLayoutInfo}; use bevy_ui::{ - widget::TextScroll, CalculatedClip, ComputedNode, ComputedStackIndex, ComputedUiTargetCamera, - ResolvedBorderRadius, UiGlobalTransform, + CalculatedClip, ComputedNode, ComputedStackIndex, ComputedUiTargetCamera, ResolvedBorderRadius, + UiGlobalTransform, }; use crate::{ @@ -30,7 +30,7 @@ pub fn extract_text_cursor( &ComputedUiTargetCamera, &TextLayoutInfo, &TextCursorStyle, - Option<&TextScroll>, + Option<&EditableText>, )>, >, camera_map: Extract, @@ -48,7 +48,7 @@ pub fn extract_text_cursor( target_camera, text_layout_info, cursor_style, - text_scroll, + editable_text, ) 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`) @@ -62,10 +62,11 @@ pub fn extract_text_cursor( let transform = Affine2::from(global_transform) * Affine2::from_translation( - uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), + uinode.content_box().min + - editable_text.map_or(Vec2::ZERO, |editor| editor.viewport.offset), ); - let clip = if text_scroll.is_some() { + let clip = if editable_text.is_some() { let content_box = uinode.content_box(); let text_clip = Rect::from_center_size( global_transform.affine().translation + content_box.center(), @@ -206,7 +207,7 @@ pub fn extract_preedit_underlines( Option<&CalculatedClip>, &ComputedUiTargetCamera, &ComputedStackIndex, - Option<&TextScroll>, + &EditableText, ), With, >, @@ -225,7 +226,7 @@ pub fn extract_preedit_underlines( maybe_clip, target_camera, stack_index, - text_scroll, + editable_text, ) in text_node_query.iter() { if !inherited_visibility.get() @@ -240,20 +241,13 @@ pub fn extract_preedit_underlines( }; let transform = Affine2::from(global_transform) - * Affine2::from_translation( - uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), - ); + * Affine2::from_translation(uinode.content_box().min - editable_text.viewport.offset); - 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 text_clip = Rect::from_center_size( + global_transform.affine().translation + uinode.content_box().center(), + uinode.content_box().size(), + ); + let clip = Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))); let color = text_color.0.to_linear(); diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index bcb9fb5b16525..242e5e32ba15f 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -15,11 +15,15 @@ use bevy_input_focus::{ FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, }; use bevy_math::Vec2; -use bevy_picking::events::{Drag, Pointer, Press, Release}; +use bevy_picking::events::{Drag, Pointer, PointerState, Press, Release}; use bevy_picking::pointer::PointerButton; use bevy_reflect::Reflect; -use bevy_text::{EditableText, PreeditCursor, TextEdit}; -use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll}; +use bevy_text::{ + scrollable_text_layout_width, EditableText, EditableTextSystems, PreeditCursor, TextEdit, + TextLayout, TextLayoutInfo, +}; +use bevy_time::{Real, Time}; +use bevy_ui::widget::{sync_editable_text_viewports, update_editable_text_layout}; use bevy_ui::UiSystems; use bevy_ui::{ widget::TextNodeFlags, ComputedNode, ComputedUiRenderTargetInfo, ContentSize, Node, @@ -46,6 +50,13 @@ const SHIFT_COMMAND: u8 = SHIFT | COMMAND; #[cfg(not(target_os = "macos"))] const SHIFT_ALT: u8 = SHIFT | ALT; +/// Autoscroll speed is proportional to the input size +const AUTOSCROLL_BASE_SPEED: f32 = 0.75; +const AUTOSCROLL_MAX_SPEED: f32 = 2.0; +/// Distance from the input to the point along an axis where `AUTOSCROLL_MAX_SPEED` is reached. +/// Proportional to the input size. +const AUTOSCROLL_RAMP_DISTANCE: f32 = 0.5; + /// 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 @@ -121,10 +132,16 @@ fn on_focused_keyboard_input( (WORD | SHIFT_WORD, Key::ArrowRight) => queue_edit(TextEdit::WordRight(shift_pressed)), (NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed)), (NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed)), + #[cfg(target_os = "macos")] (COMMAND | SHIFT_COMMAND, Key::ArrowUp) => queue_edit(TextEdit::TextStart(shift_pressed)), + #[cfg(target_os = "macos")] (COMMAND | SHIFT_COMMAND, Key::ArrowDown) => queue_edit(TextEdit::TextEnd(shift_pressed)), (NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed)), (NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed)), + #[cfg(not(target_os = "macos"))] + (CTRL, Key::ArrowUp) => queue_edit(TextEdit::ScrollByLines(-1.0)), + #[cfg(not(target_os = "macos"))] + (CTRL, Key::ArrowDown) => queue_edit(TextEdit::ScrollByLines(1.0)), (COMMAND | SHIFT_COMMAND, Key::Home) => queue_edit(TextEdit::TextStart(shift_pressed)), (COMMAND | SHIFT_COMMAND, Key::End) => queue_edit(TextEdit::TextEnd(shift_pressed)), (NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed)), @@ -161,7 +178,6 @@ fn on_pointer_press( &ComputedNode, &ComputedUiRenderTargetInfo, &UiGlobalTransform, - &TextScroll, )>, keys: Res>, mut input_focus: ResMut, @@ -171,22 +187,24 @@ fn on_pointer_press( return; } - let Ok((mut editable_text, node, target, transform, text_scroll)) = - text_input_query.get_mut(press.entity) + let Ok((mut editable_text, node, target, transform)) = text_input_query.get_mut(press.entity) else { return; }; + input_focus.set(press.entity, FocusCause::Pressed); + press.propagate(false); + if editable_text.is_composing() { // The IME is active; all input needs to be routed there, including pointer presses. return; } - - let Some(local_pos) = transform.try_inverse().map(|inverse| { - inverse - .transform_point2(press.pointer_location.position * target.scale_factor() / ui_scale.0) - - node.content_box().min - + text_scroll.0 + let Some(local_pos) = transform.try_inverse().and_then(|inverse| { + let local_pos = inverse + .transform_point2(press.pointer_location.position * target.scale_factor() / ui_scale.0); + node.content_box() + .contains(local_pos) + .then(|| local_pos - node.content_box().min + editable_text.viewport.offset) }) else { return; }; @@ -204,10 +222,6 @@ fn on_pointer_press( 2 => editable_text.queue_edit(TextEdit::SelectWordAtPoint(local_pos)), _ => editable_text.queue_edit(TextEdit::SelectAll), } - - input_focus.set(press.entity, FocusCause::Pressed); - - press.propagate(false); } /// System that processes pointer drag events into text edit actions for [`EditableText`] widgets. @@ -221,16 +235,19 @@ fn on_pointer_drag( &ComputedNode, &ComputedUiRenderTargetInfo, &UiGlobalTransform, - &TextScroll, )>, ui_scale: Res, + input_focus: Res, ) { if drag.button != PointerButton::Primary { return; } - let Ok((mut editable_text, node, target, transform, text_scroll)) = - text_input_query.get_mut(drag.entity) + if input_focus.get() != Some(drag.entity) { + return; + } + + let Ok((mut editable_text, node, target, transform)) = text_input_query.get_mut(drag.entity) else { return; }; @@ -240,22 +257,134 @@ fn on_pointer_drag( return; } - let Some(current_local_pos) = transform.try_inverse().map(|inverse| { + let Some(local_point) = 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 - .push(TextEdit::ExtendSelectionToPoint(current_local_pos)); + let clamped_local_point = node.content_box().clamp_point(local_point); + let current_offset = editable_text.viewport.offset; + editable_text.queue_edit(TextEdit::ExtendSelectionToPoint( + clamped_local_point - node.content_box().min + current_offset, + )); drag.propagate(false); } +fn text_input_autoscroll_system( + time: Res>, + pointer_state: Res, + input_focus: Res, + mut text_input_query: Query<( + &mut EditableText, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &TextLayoutInfo, + &TextLayout, + )>, + ui_scale: Res, +) { + let Some(entity) = input_focus.get() else { + return; + }; + let Some(pointer_position) = pointer_state + .pointer_buttons + .iter() + .filter(|((_, button), ..)| *button == PointerButton::Primary) + .find_map(|(_, state)| state.dragging.get(&entity).map(|drag| drag.latest_pos)) + else { + return; + }; + + let Ok((mut editable_text, node, target, transform, layout_info, text_layout)) = + text_input_query.get_mut(entity) + else { + return; + }; + + if editable_text.is_composing() + || editable_text + .pending_edits + .iter() + .any(|edit| matches!(edit, TextEdit::ImeSetCompose { value, .. } if !value.is_empty())) + { + return; + } + + if editable_text.viewport.size.cmple(Vec2::ZERO).any() { + return; + } + + let Some(local_point) = transform.try_inverse().map(|inverse| { + inverse.transform_point2(pointer_position * target.scale_factor() / ui_scale.0) + }) else { + return; + }; + + let clamped_local_point = node.content_box().clamp_point(local_point); + + // Signed per-axis distance of the pointer from the text viewport + let signed_distance = local_point - clamped_local_point; + if signed_distance == Vec2::ZERO { + return; + } + + // Distance to scroll on each axis + let scroll_delta = Vec2::new( + autoscroll_axis( + signed_distance.x, + editable_text.viewport.size.x, + time.delta_secs(), + ), + autoscroll_axis( + signed_distance.y, + editable_text.viewport.size.y, + time.delta_secs(), + ), + ); + + // Calculate the full text layout size, including space for the cursor. + let full_layout_size = Vec2::new( + scrollable_text_layout_width( + text_layout.linebreak, + layout_info.size.x, + editable_text.viewport.size.x, + layout_info.cursor.map(|(_, rect)| rect), + ), + layout_info.size.y, + ); + + let clamped_offset = (editable_text.viewport.offset + scroll_delta).clamp( + Vec2::ZERO, + (full_layout_size - editable_text.viewport.size).max(Vec2::ZERO), + ); + let clamped_scroll_delta = clamped_offset - editable_text.viewport.offset; + + if clamped_scroll_delta == Vec2::ZERO { + return; + } + + editable_text.queue_edit(TextEdit::ScrollBy(clamped_scroll_delta)); + + // Extend the selection using the post-scroll viewport offset. + editable_text.queue_edit(TextEdit::ExtendSelectionToPoint( + clamped_local_point - node.content_box().min + clamped_offset, + )); +} + +fn autoscroll_axis(overflow: f32, view_size: f32, time_delta: f32) -> f32 { + if overflow == 0. || view_size == 0. { + return 0.; + } + let ramp_distance = (overflow.abs() / (view_size * AUTOSCROLL_RAMP_DISTANCE)).min(1.0); + let speed = + AUTOSCROLL_BASE_SPEED + ramp_distance * (AUTOSCROLL_MAX_SPEED - AUTOSCROLL_BASE_SPEED); + overflow.signum() * view_size * speed * time_delta +} + /// System that processes [`Ime`] events into [`TextEdit`] actions for the focused [`EditableText`] widget. /// /// Preedit text (in-progress IME composition) is excluded from [`EditableText::value`]. @@ -321,7 +450,6 @@ fn update_ime_position( &ComputedNode, &UiGlobalTransform, &ComputedUiRenderTargetInfo, - &TextScroll, )>, // TODO: support multiple windows and track which one has focus mut windows: Query<&mut Window, With>, @@ -330,9 +458,7 @@ fn update_ime_position( let Some(focused) = input_focus.get() else { return; }; - let Ok((editable_text, node, transform, target, text_scroll)) = - editable_text_query.get(focused) - else { + let Ok((editable_text, node, transform, target)) = editable_text_query.get(focused) else { return; }; @@ -345,7 +471,7 @@ fn update_ime_position( // Use `y1` (bottom edge) so the OS-drawn candidate box sits below the current line // rather than overlapping it. let parley_local = Vec2::new(area.x0 as f32, area.y1 as f32); - let ui_local = parley_local + node.content_box().min - text_scroll.0; + let ui_local = parley_local + node.content_box().min - editable_text.viewport.offset; window.ime_position = transform.affine().transform_point2(ui_local) * ui_scale.0 / target.scale_factor(); } @@ -482,7 +608,7 @@ pub enum ImeSystems { /// if only editable text input is needed. /// /// Note that [`TextEdit`]s are applied during [`PostUpdate`] -/// in the [`EditableTextSystems`](bevy_text::EditableTextSystems) system set. +/// in the [`EditableTextSystems`] system set. pub struct EditableTextInputPlugin; impl Plugin for EditableTextInputPlugin { @@ -511,11 +637,16 @@ impl Plugin for EditableTextInputPlugin { .in_set(UiSystems::PostLayout) .before(AccessibilitySystems::Update) .after(update_editable_text_layout) - .after(scroll_editable_text) // FocusChangeEvents does not mutate the actual InputFocus; // this is a false positive that can be ignored .ambiguous_with(InputFocusSystems::FocusChangeEvents), ) + .add_systems( + PostUpdate, + text_input_autoscroll_system + .after(sync_editable_text_viewports) + .before(EditableTextSystems), + ) .add_systems( PostUpdate, apply_queued_select_all @@ -527,7 +658,224 @@ 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::(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_app::Update; + use bevy_math::Rect; + use bevy_picking::{events::DragEntry, pointer::PointerId}; + use core::time::Duration; + + #[test] + fn autoscroll_speed_is_zero_inside_then_ramps_and_caps() { + let visible_size = 100.0; + + assert_eq!(autoscroll_axis(0.0, visible_size, 1.0), 0.0); + assert_eq!(autoscroll_axis(25.0, visible_size, 1.0), 137.5); + assert_eq!(autoscroll_axis(-25.0, visible_size, 1.0), -137.5); + assert_eq!(autoscroll_axis(50.0, visible_size, 1.0), 200.0); + assert_eq!(autoscroll_axis(-100.0, visible_size, 1.0), -200.0); + } + + #[test] + fn autoscroll_displacement_is_frame_rate_independent() { + let overflow = 25.0; + let view_size = 100.0; + + let one_frame = autoscroll_axis(overflow, view_size, 1.0 / 30.0); + let two_frames = autoscroll_axis(overflow, view_size, 1.0 / 60.0) * 2.0; + + assert!((one_frame - two_frames).abs() < 1e-5); + } + + fn autoscroll_app(initial_drag_pos: Vec2) -> (App, Entity) { + let mut app = App::new(); + app.init_resource::>() + .init_resource::() + .init_resource::() + .add_systems(Update, text_input_autoscroll_system); + + let mut editable_text = EditableText::default(); + editable_text.viewport.size = Vec2::splat(100.0); + let entity = app + .world_mut() + .spawn(( + editable_text, + ComputedNode { + size: Vec2::splat(100.0), + ..Default::default() + }, + ComputedUiRenderTargetInfo::default(), + UiGlobalTransform::default(), + TextLayoutInfo { + size: Vec2::splat(300.0), + ..Default::default() + }, + )) + .id(); + + app.insert_resource(InputFocus::from_entity(entity)); + app.world_mut() + .resource_mut::() + .get_mut(PointerId::Mouse, PointerButton::Primary) + .dragging + .insert( + entity, + DragEntry { + start_pos: initial_drag_pos, + latest_pos: initial_drag_pos, + }, + ); + app.world_mut() + .resource_mut::>() + .advance_by(Duration::from_secs_f32(1.0 / 60.0)); + + (app, entity) + } + + #[test] + fn active_drag_autoscrolls_until_reentry() { + let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0)); + + app.update(); + + assert_eq!( + app.world() + .entity(entity) + .get::() + .unwrap() + .pending_edits + .len(), + 2 + ); + + app.world_mut() + .entity_mut(entity) + .get_mut::() + .unwrap() + .pending_edits + .clear(); + app.update(); + + assert_eq!( + app.world() + .entity(entity) + .get::() + .unwrap() + .pending_edits + .len(), + 2 + ); + + app.world_mut() + .entity_mut(entity) + .get_mut::() + .unwrap() + .pending_edits + .clear(); + app.world_mut() + .resource_mut::() + .get_mut(PointerId::Mouse, PointerButton::Primary) + .dragging + .get_mut(&entity) + .unwrap() + .latest_pos = Vec2::ZERO; + app.update(); + + assert!(app + .world() + .entity(entity) + .get::() + .unwrap() + .pending_edits + .is_empty()); + } + + #[test] + fn autoscroll_into_cursor_space() { + let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0)); + let mut entity_mut = app.world_mut().entity_mut(entity); + let mut editable_text = entity_mut.get_mut::().unwrap(); + // set up the viewport with the caret fitting exactly + editable_text.viewport.reveal_caret( + Rect::new(295.0, 0.0, 300.0, 20.0), + Vec2::new(300.0, 300.0), + Vec2::ZERO, + core::iter::empty(), + ); + assert_eq!(editable_text.viewport.offset.x, 200.0); + + // set layout caret outside of viewport + entity_mut.insert(TextLayout::no_wrap()); + entity_mut.get_mut::().unwrap().cursor = + Some((true, Rect::new(295.0, 0.0, 320.0, 20.0))); + + app.update(); + + // expect autoscroll will have queued a scroll right edit + assert!(matches!( + app.world().entity(entity).get::().unwrap().pending_edits.iter().next().unwrap(), + TextEdit::ScrollBy(delta) if 0. < delta.x + )); + } + + #[test] + fn autoscroll_stops_after_release_cancellation_or_focus_loss() { + let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0)); + app.update(); + + assert_eq!( + app.world() + .entity(entity) + .get::() + .unwrap() + .pending_edits + .len(), + 2 + ); + + app.world_mut() + .entity_mut(entity) + .get_mut::() + .unwrap() + .pending_edits + .clear(); + + app.insert_resource(InputFocus::default()); + app.update(); + let editable_text = app.world().entity(entity).get::().unwrap(); + assert!(editable_text.pending_edits.is_empty()); + } + + #[test] + fn ime_composition_stops_autoscroll() { + let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0)); + app.world_mut() + .entity_mut(entity) + .get_mut::() + .unwrap() + .queue_edit(TextEdit::ImeSetCompose { + value: "compose".into(), + cursor: Some(PreeditCursor { + anchor: 0, + focus: 7, + }), + }); + + app.update(); + + assert_eq!( + app.world() + .entity(entity) + .get::() + .unwrap() + .pending_edits + .len(), + 1 + ); } } diff --git a/examples/testbed/ui.rs b/examples/testbed/ui.rs index 9b1c1e276c822..fb618d985028d 100644 --- a/examples/testbed/ui.rs +++ b/examples/testbed/ui.rs @@ -2036,75 +2036,487 @@ mod editable_text { use bevy::color::palettes::css::YELLOW; use bevy::prelude::*; use bevy::text::EditableText; + use bevy::text::TextCursorStyle; use bevy::text::TextEdit; - use bevy::ui::widget::TextScroll; + + const DUMMY_TEXT: &str = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten"; + const LOREM_TEXT: &str = concat!( + "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. ", + "Aenean commodo ligula eget dolor. Aenean massa. ", + "Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. ", + "Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. ", + "Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. ", + "In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. ", + "Nullam dictum felis eu pede mollis pretium. Integer tincidunt. ", + "Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. ", + "Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. ", + "Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. ", + "Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. ", + "Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi.", + " Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, ", + "sem quam semper libero, sit amet adipiscing sem neque sed ipsum. ", + "Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. ", + "Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. ", + "Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. ", + "Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. ", + "Sed consequat, leo eget bibendum sodales, augue velit cursus nunc," + ); pub fn setup(mut commands: Commands) { commands.spawn((Camera2d, DespawnOnExit(super::Scene::EditableText))); commands.spawn(( Node { - flex_direction: FlexDirection::Column, - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, + flex_wrap: FlexWrap::Wrap, + align_items: AlignItems::Start, + margin: px(10.).all(), width: vw(100), height: vh(100), - row_gap: px(25.), + row_gap: px(10), + column_gap: px(20), ..default() }, DespawnOnExit(super::Scene::EditableText), children![ ( - EditableText { - pending_edits: vec![TextEdit::Insert("Single line EditableText".into())], + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), ..default() }, + children![ + Text::new("Single line"), + ( + EditableText { + pending_edits: vec![TextEdit::Insert( + "Single line EditableText".into(), + )], + ..default() + }, + TextLayout::no_wrap(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + TextCursorStyle::default(), + Node { + width: px(200.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + Text::new("Initial end"), + ( + EditableText::new(LOREM_TEXT), + TextLayout::no_wrap(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + TextCursorStyle::default(), + Node { + width: px(200.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + Text::new("Insert end"), + ( + EditableText { + pending_edits: vec![TextEdit::Insert(LOREM_TEXT.into())], + ..default() + }, + TextLayout::no_wrap(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + TextCursorStyle::default(), + Node { + width: px(200.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + Text::new("Select line start"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(LOREM_TEXT.into()), + TextEdit::LineStart(true), + ], + ..default() + }, + TextLayout::no_wrap(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + TextCursorStyle::default(), + Node { + width: px(200.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( Node { - width: px(200.), - border: px(2).all(), + flex_direction: FlexDirection::Column, + row_gap: px(10), ..default() }, - BorderColor::all(YELLOW), + children![ + Text::new("Wrapped start"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(LOREM_TEXT.into()), + TextEdit::TextStart(false), + ], + visible_lines: Some(8.), + ..default() + }, + Node { + width: px(200.), + border: px(2).all(), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], ), ( - EditableText { - pending_edits: vec![ - TextEdit::Insert( - "1. Multiline EditableText\n2.\n3.\n4.\n5.\n6.\n7.\n8.\n9.\n10." - .into() - ), - TextEdit::TextStart(false), - ], - visible_lines: Some(8.), + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), ..default() }, - TextScroll::default(), + children![ + Text::new("Wrapped selection"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(LOREM_TEXT.into()), + TextEdit::TextStart(false), + TextEdit::Down(false), + TextEdit::TextEnd(true), + ], + visible_lines: Some(8.), + ..default() + }, + TextCursorStyle::default(), + Node { + width: px(200.), + border: px(2).all(), + ..default() + }, + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( Node { - width: px(350.), - border: px(2).all(), + flex_direction: FlexDirection::Column, + row_gap: px(10), ..default() }, - BorderColor::all(YELLOW), + children![ + Text::new("Clamp top"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollByLines(-10.0), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], ), ( - EditableText { - pending_edits: vec![ - TextEdit::Insert( - "1. Multiline EditableText\n2.\n3.\n4.\n5.\n6.\n7.\n8.\n9.\n10." - .into() - ), - TextEdit::TextEnd(true), - ], - visible_lines: Some(8.), + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), + ..default() + }, + children![ + Text::new("Home, Scroll 1"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollTo(Vec2::ZERO), + TextEdit::ScrollByLines(1.0), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), + ..default() + }, + children![ + Text::new("Home, Scroll 2"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollTo(Vec2::ZERO), + TextEdit::ScrollByLines(2.0), + ], + visible_lines: Some(5.5), + ..default() + }, + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + TextCursorStyle::default(), + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), ..default() }, - TextScroll::default(), + children![ + Text::new("Clamp bottom"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollByLines(-1000.0), + TextEdit::ScrollByLines(1000.0), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( Node { - width: px(350.), - border: px(2).all(), + flex_direction: FlexDirection::Column, + row_gap: px(10), ..default() }, - BorderColor::all(YELLOW), + children![ + Text::new("Bottom -1"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollByLines(-1000.0), + TextEdit::ScrollByLines(1000.0), + TextEdit::ScrollByLines(-1.0), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], ), + ( + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), + ..default() + }, + children![ + Text::new("Top +3"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollByLines(-1000.0), + TextEdit::ScrollByLines(3.0), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), + ..default() + }, + children![ + Text::new("Select down 3"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::TextStart(false), + TextEdit::Down(false), + TextEdit::Down(true), + TextEdit::Down(true), + TextEdit::Down(true), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), + ..default() + }, + children![ + Text::new("End, Scroll 1"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollByLines(1.0), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ), + ( + Node { + flex_direction: FlexDirection::Column, + row_gap: px(10), + ..default() + }, + children![ + Text::new("End, Scroll -0.5"), + ( + EditableText { + pending_edits: vec![ + TextEdit::Insert(DUMMY_TEXT.into()), + TextEdit::ScrollByLines(-0.5), + ], + visible_lines: Some(5.5), + ..default() + }, + TextCursorStyle::default(), + TextFont { + font_size: FontSize::Px(10.), + ..default() + }, + Node { + width: px(100.), + border: px(2).all(), + ..default() + }, + BorderColor::all(YELLOW), + ), + ], + ) ], )); }