Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
119362a
Added `scroll_inset` field to `EditableText`.
ickshonpe May 12, 2026
454334e
`on_pointer_drag` queues `MoveToPoint` as well as `ExtendSelectionToP…
ickshonpe May 12, 2026
2154aad
Removed unused import.
ickshonpe May 12, 2026
fa303e7
Merge branch 'editabletext-drag-selection-fix' into text-input-scroll…
ickshonpe May 12, 2026
a633abd
Added `NeedsScroll` component tracking if scrolling needs to be recal…
ickshonpe May 12, 2026
3b9f893
Don't scroll on `ExtendSelectionToPoint`
ickshonpe May 12, 2026
3281913
Set needs scroll if pointer dragged outside of editor.
ickshonpe May 12, 2026
f4bdad7
Fixed content box drag check
ickshonpe May 12, 2026
bf0ada7
MoveToPoint needs scroll
ickshonpe May 12, 2026
0ae93a7
clean up
ickshonpe May 12, 2026
a01c105
Fixed texts by denormalizing inset values.
ickshonpe May 12, 2026
c7e5858
cargo fmt --all
ickshonpe May 12, 2026
104d930
renamed variables for clarity
ickshonpe May 12, 2026
2a8bfe9
Merge branch 'main' into text-input-scroll-with-inset
ickshonpe May 13, 2026
54a3a34
added bevy_time dep
ickshonpe May 13, 2026
3500d2d
Added drag_scroll_text_inputs system
ickshonpe May 13, 2026
f43b28e
scale line by scale factor
ickshonpe May 13, 2026
b25f034
set horizontal scroll 0 when inside horizontal bounds
ickshonpe May 13, 2026
2abcc63
Set needs scroll on deferred paste edits
ickshonpe May 14, 2026
b550778
update scroll if ComputedNode is changed
ickshonpe May 14, 2026
6f78e91
Sometimes gaps occur between vertically adjacent text selection rects…
ickshonpe May 15, 2026
03f3664
Merge branch 'main' into text-input-scroll-with-inset
ickshonpe May 16, 2026
23757dd
Merge branch 'selection-rects-gap-fix' into text-input-scroll-with-inset
ickshonpe May 16, 2026
21cc903
floor drag scroll offset
ickshonpe May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions crates/bevy_text/src/editing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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.
Expand Down Expand Up @@ -144,6 +146,8 @@ pub struct EditableText {
pub visible_width: Option<f32>,
/// 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 {
Expand All @@ -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),
}
}
}
Expand Down Expand Up @@ -211,6 +216,7 @@ impl EditableText {
layout_context: &mut LayoutContext<TextBrush>,
clipboard: &mut bevy_clipboard::Clipboard,
char_filter: impl Fn(char) -> bool,
needs_scroll: &mut bool,
) {
let Self {
editor,
Expand All @@ -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
Expand All @@ -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,
),
}
}
}
Expand Down Expand Up @@ -297,13 +312,14 @@ pub fn apply_text_edits(
&mut EditableText,
Option<&EditableTextFilter>,
&EditableTextGeneration,
&mut EditableTextNeedsScroll,
)>,
mut font_context: ResMut<FontCx>,
mut layout_context: ResMut<LayoutCx>,
mut clipboard: ResMut<bevy_clipboard::Clipboard>,
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() {
Expand All @@ -315,6 +331,7 @@ pub fn apply_text_edits(
Some(EditableTextFilter(Some(filter))) => filter.as_ref(),
_ => &|_| true,
},
&mut needs_scroll.0,
);
}

Expand All @@ -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);
40 changes: 40 additions & 0 deletions crates/bevy_text/src/text_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,10 @@ impl TextEdit {
clipboard: &mut bevy_clipboard::Clipboard,
max_characters: Option<usize>,
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()
Expand Down Expand Up @@ -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.
Expand Down
152 changes: 135 additions & 17 deletions crates/bevy_ui/src/widget/text_input_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -494,20 +503,28 @@ pub fn scroll_editable_text(
Ref<EditableText>,
Ref<EditableTextGeneration>,
&mut TextScroll,
&ComputedNode,
Ref<ComputedNode>,
&TextLayoutInfo,
&mut EditableTextNeedsScroll,
)>,
) {
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))
{
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;
}

Expand All @@ -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;
};

Expand All @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of 0.49 here and on line 568 seems a little magical. Why not 0.5 exactly? Would line 627 then catch the original intended case via e.g.
let new_v_min = if v_size - 2. * inset <= t_size { ?

@ickshonpe ickshonpe May 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of 0.49 here and on line 568 seems a little magical. Why not 0.5 exactly? Would line 627 then catch the original intended case via e.g. let new_v_min = if v_size - 2. * inset <= t_size { ?

0.5 is probably fine, clamping it below 0.49 felt safer though. It's just a defensive measure to preemptively head off the inevitable rounding bugs that pop up everywhere.

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<B: parley::Brush>(
layout: &parley::Layout<B>,
y: f32,
) -> Option<(f32, f32)> {
) -> (f32, Option<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));
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 {
Expand All @@ -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.
);
}
}
Loading
Loading