From 8354dbcd74a35ba7dc9b8577ff21f1053541928e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Mon, 12 Oct 2026 23:41:01 +1100 Subject: [PATCH 1/3] Keep a numeric field caret when focus arrives while the pointer is still down. Select-all ran on that late focus frame and replaced the drag the press had started. --- crates/ui-egui/src/tests_numfields.rs | 17 +++++++++++++++++ crates/ui-egui/src/widgets.rs | 17 ++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs index 98d8d4b4e..d18af4ad5 100644 --- a/crates/ui-egui/src/tests_numfields.rs +++ b/crates/ui-egui/src/tests_numfields.rs @@ -398,6 +398,23 @@ fn a_new_numeric_click_selects_all_on_release_without_moving_its_press_caret() { } #[test] +/// Focus that arrives while the button is still down, without a press event on that frame, +/// keeps the caret until release. +#[test] +fn focus_a_frame_after_the_press_does_not_select_all_until_release() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + let pressed = f.selection(); + f.frame(vec![Event::PointerMoved(at)], &draw); + assert_eq!(f.selection(), pressed, "holding the button does not select the whole field"); + assert!(pressed.is_some_and(|(a, b)| a == b)); + f.frame(vec![pointer_button(at, false)], &draw); + assert_eq!(f.selection(), Some((0, field_text(0).len()))); +} + fn keyboard_focus_keeps_selecting_the_entire_numeric_field() { for mode in 0..4 { let mut f = Field::new(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e03c4a968..929f3fad2 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -314,11 +314,19 @@ fn select_all(ui: &Ui, id: egui::Id, text: &str) { /// double-click. A pointer press keeps its caret so dragging selects from the pressed position. fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { let pending = resp.id.with("select-all-on-focus-click"); - let (pressed, released) = - ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary))); + let (pressed, released, down) = ui.input(|i| { + ( + i.pointer.button_pressed(egui::PointerButton::Primary), + i.pointer.button_released(egui::PointerButton::Primary), + i.pointer.button_down(egui::PointerButton::Primary), + ) + }); + // Focus can arrive the frame after the press, when `button_pressed` is already false + // and the button is still down. Defer select-all until release so a drag keeps its caret. + let defer = pressed || (down && resp.hovered()); if resp.gained_focus() { ui.data_mut(|d| { - if pressed { + if defer { d.insert_temp(pending, true); } else { d.remove_temp::(pending); @@ -329,6 +337,9 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { if resp.lost_focus() { ui.data_mut(|d| d.remove_temp::(pending)); } + if resp.gained_focus() && defer { + return; + } if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus) { return; } From 173c88d2a1c44e16e9fa9b7c91b98567250d3308 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 02:07:50 +1100 Subject: [PATCH 2/3] Rework #1326: a short drag the toolkit counts as a click keeps its caret. The late-focus case in the original change has no reproducible path (#1339 already covers focus on the press frame), so the button-down defer is dropped. The real issue is the clicked_focus branch: a very short drag inside one character produces no text selection, egui still reports a click, and select-all on release replaced the drag. The press position is now remembered and a release more than three points away keeps the caret; a plain tap still selects all. Also fixes the test placement bug: the new test sat between the #[test] attribute and keyboard_focus_keeps_selecting_the_entire_numeric_field, so that test no longer ran. The regression test fails on the fork-point widgets.rs and passes with this change. --- crates/ui-egui/src/tests_numfields.rs | 35 ++++++++++++++++++------- crates/ui-egui/src/widgets.rs | 37 +++++++++++++++------------ 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs index d18af4ad5..6358958e4 100644 --- a/crates/ui-egui/src/tests_numfields.rs +++ b/crates/ui-egui/src/tests_numfields.rs @@ -397,24 +397,41 @@ fn a_new_numeric_click_selects_all_on_release_without_moving_its_press_caret() { assert_eq!(f.num(vec![enter()]), Some(7.0)); } +/// #1326: a short drag that the toolkit still counts as a click must keep its caret on +/// release instead of selecting the whole value. #[test] -/// Focus that arrives while the button is still down, without a press event on that frame, -/// keeps the caret until release. -#[test] -fn focus_a_frame_after_the_press_does_not_select_all_until_release() { +fn a_short_drag_released_as_a_click_keeps_its_caret() { let mut f = Field::new(); let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); f.frame(vec![], &draw); let at = f.text_point(field_text(0), 2, 7.0); f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); let pressed = f.selection(); - f.frame(vec![Event::PointerMoved(at)], &draw); - assert_eq!(f.selection(), pressed, "holding the button does not select the whole field"); - assert!(pressed.is_some_and(|(a, b)| a == b)); - f.frame(vec![pointer_button(at, false)], &draw); - assert_eq!(f.selection(), Some((0, field_text(0).len()))); + assert!(pressed.is_some_and(|(a, b)| a == b), "the press places a caret"); + // Moved a few points: inside one character, so egui reports a click, not a text selection. + let nudge = egui::vec2(3.4, 0.0); + f.frame(vec![Event::PointerMoved(at + nudge)], &draw); + f.frame(vec![pointer_button(at + nudge, false)], &draw); + assert_eq!(f.selection(), pressed, "the short drag keeps its caret instead of selecting all"); + assert_ne!(f.selection(), Some((0, field_text(0).chars().count())), "a clicked short drag must not select all (#1326)"); + f.frame(vec![Event::Text("7".into())], &draw); + assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret"); } +/// A tap (no movement) on a fresh numeric field still selects all on release. +#[test] +fn a_tap_without_movement_still_selects_all_on_release() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + f.frame(vec![], &draw); + f.frame(vec![pointer_button(at, false)], &draw); + assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a plain tap selects all"); +} + +#[test] fn keyboard_focus_keeps_selecting_the_entire_numeric_field() { for mode in 0..4 { let mut f = Field::new(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 929f3fad2..38a0b4f09 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -311,36 +311,41 @@ fn select_all(ui: &Ui, id: egui::Id, text: &str) { } /// Numeric fields select the whole text on keyboard focus, a completed focus click or a -/// double-click. A pointer press keeps its caret so dragging selects from the pressed position. +/// double-click. A pointer press keeps its caret so dragging selects from the pressed position, +/// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap +/// releases into select-all. fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { let pending = resp.id.with("select-all-on-focus-click"); - let (pressed, released, down) = ui.input(|i| { - ( - i.pointer.button_pressed(egui::PointerButton::Primary), - i.pointer.button_released(egui::PointerButton::Primary), - i.pointer.button_down(egui::PointerButton::Primary), - ) + let press_pos = resp.id.with("select-all-on-focus-press"); + let (pressed, released, at_press) = ui.input(|i| { + (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.pointer.interact_pos()) }); - // Focus can arrive the frame after the press, when `button_pressed` is already false - // and the button is still down. Defer select-all until release so a drag keeps its caret. - let defer = pressed || (down && resp.hovered()); if resp.gained_focus() { ui.data_mut(|d| { - if defer { + if pressed { d.insert_temp(pending, true); + if let Some(p) = at_press { + d.insert_temp(press_pos, p); + } } else { d.remove_temp::(pending); + d.remove_temp::(press_pos); } }); } let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); + // A press released a few points away is a short drag, not a tap. egui still reports a + // click, and a release inside one character leaves no text selection, so select-all on + // release would replace the drag the user just made (#1326). + let dragged = clicked_focus + && ui.data_mut(|d| d.remove_temp::(press_pos)).is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); if resp.lost_focus() { - ui.data_mut(|d| d.remove_temp::(pending)); + ui.data_mut(|d| { + d.remove_temp::(pending); + d.remove_temp::(press_pos); + }); } - if resp.gained_focus() && defer { - return; - } - if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus) { + if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus && !dragged) { return; } if let Some(st) = egui::TextEdit::load_state(ui.ctx(), resp.id) { From 0c11ff656cabd8e453fc036117487d3e932004f1 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 02:08:06 +1100 Subject: [PATCH 3/3] Format the reworked select_all_on_focus. --- crates/ui-egui/src/widgets.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 38a0b4f09..c84ec2e81 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -338,7 +338,9 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { // click, and a release inside one character leaves no text selection, so select-all on // release would replace the drag the user just made (#1326). let dragged = clicked_focus - && ui.data_mut(|d| d.remove_temp::(press_pos)).is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); + && ui + .data_mut(|d| d.remove_temp::(press_pos)) + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); if resp.lost_focus() { ui.data_mut(|d| { d.remove_temp::(pending);