From 9e2495dac767c444f1967089d4a9497da0f70a17 Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Thu, 22 May 2025 19:03:19 -0400 Subject: [PATCH 1/2] Ensure TextEdit cursor is never out of bounds When rendering a TextArea we don't know if the saved cursor applies to the current galley since it's possible the app changed the TextBuffer (e.g. when submitting a chat input) So we now detect if the galley changed from the last known one and clamp the cursor to ensure it's not out of bounds. This fixes an issue where backspace can suddenly stop working Repro: - Render TextArea with long-ish text (say 20 chars) - Without losing focus, clear the text - Write something short (say 5 chars) - Result: backspace doesn't work because the cursor position is wrong --- .../egui/src/text_selection/text_cursor_state.rs | 8 ++++++++ crates/egui/src/widgets/text_edit/builder.rs | 14 ++++++++++++++ crates/egui/src/widgets/text_edit/state.rs | 6 +++++- crates/epaint/src/text/text_layout_types.rs | 4 ++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index 298d8abfbff..89fb880810b 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -39,6 +39,14 @@ impl TextCursorState { pub fn set_char_range(&mut self, ccursor_range: Option) { self.ccursor_range = ccursor_range; } + + /// Clamp the cursors to be in bounds of the galley. + pub fn ensure_in_bounds(&mut self, galley: &Galley) { + if let Some(range) = self.ccursor_range.as_mut() { + range.primary = galley.clamp_cursor(&range.primary); + range.secondary = galley.clamp_cursor(&range.secondary); + } + } } impl TextCursorState { diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index ca201edda02..58c57ee573a 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -549,6 +549,19 @@ impl TextEdit<'_> { }); let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default(); + // At this point we don't know if the saved cursor still applies to the current galley since + // it's possible the app changed the TextBuffer (e.g. when submitting a chat input) so here + // we detect if the galley changed from the last known one and clamp the cursor to the new one + if let Some(last_galley) = state.last_galley.as_ref() { + let changed = last_galley + .upgrade() + .map_or(true, |last_galley| last_galley != galley); + if changed { + state.cursor.ensure_in_bounds(&galley); + state.last_galley = Some(Arc::downgrade(&galley)); + } + } + // On touch screens (e.g. mobile in `eframe` web), should // dragging select text, or scroll the enclosing [`ScrollArea`] (if any)? // Since currently copying selected text in not supported on `eframe` web, @@ -799,6 +812,7 @@ impl TextEdit<'_> { ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_)))); } + state.last_galley = Some(Arc::downgrade(&galley)); state.clone().store(ui.ctx(), id); if response.changed() { diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index 0051ea8e7ef..de05eb86979 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, Weak}; use crate::mutex::Mutex; @@ -57,6 +57,10 @@ pub struct TextEditState { /// Used to pause the cursor animation when typing. #[cfg_attr(feature = "serde", serde(skip))] pub(crate) last_interaction_time: f64, + + /// The last galley that was used to render the text. When this changes, we need to ensure the cursor is still in bounds. + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) last_galley: Option>, } impl TextEditState { diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 6b786342675..f7e11911b73 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -1104,6 +1104,10 @@ impl Galley { } } + pub fn clamp_cursor(&self, cursor: &CCursor) -> CCursor { + self.cursor_from_layout(self.layout_from_cursor(*cursor)) + } + pub fn cursor_up_one_row( &self, cursor: &CCursor, From 091357515da2b8ea9fdf8d7543be3ceb710a8c7e Mon Sep 17 00:00:00 2001 From: Juan Campa Date: Fri, 23 May 2025 01:41:41 -0400 Subject: [PATCH 2/2] clippy --- crates/egui/src/widgets/text_edit/builder.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 58c57ee573a..8737a6a1a0c 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -553,10 +553,7 @@ impl TextEdit<'_> { // it's possible the app changed the TextBuffer (e.g. when submitting a chat input) so here // we detect if the galley changed from the last known one and clamp the cursor to the new one if let Some(last_galley) = state.last_galley.as_ref() { - let changed = last_galley - .upgrade() - .map_or(true, |last_galley| last_galley != galley); - if changed { + if last_galley.upgrade().as_ref() != Some(&galley) { state.cursor.ensure_in_bounds(&galley); state.last_galley = Some(Arc::downgrade(&galley)); }