From 17b1bdeeb34b8d66477be3b43fc6ea9ea6c93b58 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 13:04:30 -0400 Subject: [PATCH] LR-BEHAV-TEXTFIELD: Return and Esc with the field's menu open Return with the menu open left the edit hanging (the field gave up the focus, taken for a click in the menu) and it ended later as Away: a name field that submits on Return never submitted. It now ends the edit as Return and closes the menu. Esc with the menu open, or with the focus in the menu, cancelled the whole edit; it now closes only the menu, as native menus do, and the field takes the focus back. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/text_field.rs | 61 +++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index 4db2a7dc0..9f9921e1c 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -140,6 +140,7 @@ impl<'a> TextField<'a> { // only the host can read the clipboard: it answers with a paste event Action::Paste => ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestPaste), Action::SelectAll => select_all(ui.ctx(), id, self.text), + Action::Refocus => {} } } let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; @@ -199,9 +200,13 @@ impl<'a> TextField<'a> { .is_some(); let mut ending = None; + let close_menu = |ui: &Ui| egui::Popup::close_id(ui.ctx(), egui::Popup::default_response_id(&response)); // egui reports a lost focus on two frames: only an edit under way ends if response.lost_focus() && memo.before.is_some() { - if escape { + if escape && menu_was_open { + // Esc closes only the menu, as in native menus: the field takes the focus back + memo.action = Some(Action::Refocus); + } else if escape { if let Some(before) = memo.before.take() && *self.text != before { @@ -209,15 +214,19 @@ impl<'a> TextField<'a> { response.mark_changed(); } ending = Some(Ending::Escape); + } else if enter && !self.multiline { + ending = Some(Ending::Return); + close_menu(ui); } else if menu_was_open || memo.menu_open || memo.action.is_some() { // the click went to the menu: the edit goes on once the menu is done with memo.away = true; } else { ending = Some(Ending::Away); } - if enter && !self.multiline && ending == Some(Ending::Away) { - ending = Some(Ending::Return); - } + } else if memo.away && escape && memo.action.is_none() { + // Esc with the focus in the menu (Tab took it there) closes only the menu + memo.action = Some(Action::Refocus); + memo.away = false; } else if memo.away && !memo.menu_open && memo.action.is_none() && !response.has_focus() { // the menu closed without a choice: the click outside it left the field ending = Some(Ending::Away); @@ -253,6 +262,8 @@ enum Action { Copy, Paste, SelectAll, + /// Only take the focus back (Esc closed the menu). + Refocus, } fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) { @@ -493,6 +504,48 @@ mod tests { assert_eq!(rig.endings, vec![Ending::Return]); } + /// Return with the menu open ends the edit as Return does, and closes the menu. + #[test] + fn return_with_the_menu_open_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Enter, Modifiers::NONE); + assert_eq!(rig.endings, vec![Ending::Return]); + assert_eq!(rig.text, "Rome"); + assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); + } + + /// Esc with the menu open closes only the menu, as in native menus: the edit goes on, and a + /// second Esc gives back the text. + #[test] + fn escape_with_the_menu_open_closes_only_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Escape, Modifiers::NONE); + assert!(rig.rect(&format!("{FIELD}:cut")).is_none(), "the menu closed"); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); + assert!(rig.focused()); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Lisbon", vec![Ending::Escape])); + } + + /// Esc after Tab took the focus into the open menu also closes only the menu. + #[test] + fn escape_from_inside_the_menu_closes_only_the_menu() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click(FIELD, PointerButton::Secondary); + rig.key(Key::Tab, Modifiers::NONE); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!((rig.text.as_str(), rig.endings.clone()), ("Rome", vec![]), "the edit goes on"); + assert!(rig.focused()); + } + /// Clicking elsewhere ends the edit and keeps the text. #[test] fn clicking_away_ends_the_edit() {