From 50b96608679438ab27ceb76433c65ff2b77ea2c8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 12:31:00 -0400 Subject: [PATCH] LR-BEHAV-TEXTFIELD: a shared text field with a Cut / Copy / Paste menu `text_field::TextField` is a standalone text field for every text box in the app: - right-click for Cut, Copy, Paste and Select All, shortcuts shown, acting on the selection the right-click would otherwise move (the browser build has no Paste item: a page cannot read the clipboard on request); - Esc gives back the text from before the edit, also after using the menu; - the caller learns how an edit ended (Return, focus away, Esc) and decides what that means; using the menu is not leaving the field; - optionally everything is selected when it takes focus; - an edit cut short when the field goes away is forgotten. The menu replays egui's own Cut and Copy events and asks the host for a paste, so passwords, character limits and undo work as egui does them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/text_field.rs | 478 +++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 crates/ui-egui/src/text_field.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7058189111..4ad22a5d9f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ pub mod shortcuts; pub mod softpaint; pub mod state; pub mod tasks; +pub mod text_field; pub mod theme; pub mod titlebar; pub mod widgets; diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs new file mode 100644 index 0000000000..060c1ba0b6 --- /dev/null +++ b/crates/ui-egui/src/text_field.rs @@ -0,0 +1,478 @@ +//! A text field that behaves the same everywhere in the app: right-click for Cut, Copy, Paste and +//! Select All; Esc gives back the text from before the edit; Return or leaving the field ends the +//! edit; optionally everything is selected when it takes focus, for fields that are retyped whole. +//! +//! Standalone: it knows nothing of the app. The caller owns the text and learns how an edit ended +//! from [`TextFieldResponse::ending`]; what an ending means (save, apply, close) is the caller's. +//! +//! The keyboard's ⌘X / ⌘C / ⌘V / ⌘A and undo are egui's own, and the app's shortcuts step aside +//! while a field has focus (`shortcuts::handle`, the native menu's `yields_to_text`). + +use egui::{Response, Ui}; + +/// How an edit ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ending { + /// Return in a one-line field: the text is meant. + Return, + /// The focus went elsewhere (a click outside, Tab): the text is kept. + Away, + /// Esc: the text is back to what it was before the edit. + Escape, +} + +pub struct TextFieldResponse { + pub response: Response, + /// Set on the frame an edit ends. + pub ending: Option, +} + +impl TextFieldResponse { + /// The edit ended with the text kept (Return or leaving the field). + pub fn committed(&self) -> bool { + matches!(self.ending, Some(Ending::Return | Ending::Away)) + } + + /// Esc ended the edit and the text is back as it was. + pub fn cancelled(&self) -> bool { + self.ending == Some(Ending::Escape) + } +} + +/// A one-line or multi-line text field, registered under a widget id for the control channel. +pub struct TextField<'a> { + widget: &'a str, + text: &'a mut String, + multiline: bool, + hint: Option, + width: Option, + select_on_focus: bool, +} + +impl<'a> TextField<'a> { + /// A one-line field (Return ends the edit) registered as `widget`. + pub fn singleline(widget: &'a str, text: &'a mut String) -> Self { + TextField { widget, text, multiline: false, hint: None, width: None, select_on_focus: false } + } + + /// A multi-line field: Return starts a new line; leaving the field ends the edit. + pub fn multiline(widget: &'a str, text: &'a mut String) -> Self { + TextField { multiline: true, ..Self::singleline(widget, text) } + } + + /// Grey text shown while the field is empty. + pub fn hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + pub fn width(mut self, width: f32) -> Self { + self.width = Some(width); + self + } + + /// Select all the text when the field takes focus, so typing replaces it (names, numbers). + pub fn select_on_focus(mut self, on: bool) -> Self { + self.select_on_focus = on; + self + } + + pub fn show(self, ui: &mut Ui) -> TextFieldResponse { + let id = egui::Id::new("lc-text-field").with(self.widget); + let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); + // a choice from the menu last frame: the field takes the focus back and does it, with + // egui's own handling of the clipboard events (a password is never copied) + if let Some(action) = memo.action.take() { + ui.memory_mut(|m| m.request_focus(id)); + match action { + Action::Cut => ui.input_mut(|i| i.events.push(egui::Event::Cut)), + Action::Copy => ui.input_mut(|i| i.events.push(egui::Event::Copy)), + // 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), + } + } + let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; + edit = edit.id(id); + if let Some(hint) = self.hint { + edit = edit.hint_text(hint); + } + if let Some(w) = self.width { + edit = edit.desired_width(w); + } + let (escape, enter) = ui.input(|i| (i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Enter))); + // a right-click places the cursor as a left one does: the selection the menu acts on + // would be gone + let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary)); + let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range()); + let mut response = ui.add(edit); + if right && response.contains_pointer() { + let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + state.cursor.set_char_range(selection); + state.store(ui.ctx(), id); + } + crate::widgets::register(ui.ctx(), self.widget, response.rect); + + // an edit not under way (none, or one cut short when the field went away) is forgotten + let in_menu = memo.away || memo.menu_open || memo.action.is_some(); + if !response.has_focus() && !response.lost_focus() && !in_menu { + memo.before = None; + } + // the start of an edit (not the focus coming back from the menu) + if response.has_focus() && memo.before.is_none() { + memo.before = Some(self.text.clone()); + if self.select_on_focus { + select_all(ui.ctx(), id, self.text); + } + } + let menu_was_open = memo.menu_open; + memo.menu_open = response.context_menu(|ui| menu(ui, self.widget, id, self.text, &mut memo.action)).is_some(); + + let mut ending = None; + // 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 let Some(before) = memo.before.take() + && *self.text != before + { + *self.text = before; + response.mark_changed(); + } + ending = Some(Ending::Escape); + } 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 && !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); + } + if response.has_focus() { + memo.away = false; + } + if ending.is_some() { + memo = Memo::default(); + } + ui.data_mut(|d| d.insert_temp(id.with("memo"), memo)); + TextFieldResponse { response, ending } + } +} + +/// What a field remembers between frames. +#[derive(Clone, Default)] +struct Memo { + /// The text when the edit started, for Esc. + before: Option, + /// The context menu was open at the end of the frame. + menu_open: bool, + /// The menu's choice, done next frame. + action: Option, + /// The focus left for the menu: the edit ends if the menu closes without a choice. + away: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Action { + Cut, + Copy, + Paste, + SelectAll, +} + +fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) { + let mut state = egui::text_edit::TextEditState::load(ctx, id).unwrap_or_default(); + let end = egui::text::CCursor::new(text.chars().count()); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); + state.store(ctx, id); +} + +/// Some text is selected in the field. +fn has_selection(ctx: &egui::Context, id: egui::Id) -> bool { + egui::text_edit::TextEditState::load(ctx, id).and_then(|s| s.cursor.char_range()).is_some_and(|r| !r.is_empty()) +} + +/// The field's context menu: Cut, Copy, Paste, Select All, with the keyboard's shortcuts beside them. +fn menu(ui: &mut Ui, widget: &str, id: egui::Id, text: &str, action: &mut Option) { + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let selected = has_selection(ui.ctx(), id); + let items = [ + (Action::Cut, "cut", "Cut", "Cmd+X", selected), + (Action::Copy, "copy", "Copy", "Cmd+C", selected), + // a browser only pastes from its own paste event, never on request + (Action::Paste, "paste", "Paste", "Cmd+V", !cfg!(target_arch = "wasm32")), + (Action::SelectAll, "selectAll", "Select All", "Cmd+A", !text.is_empty()), + ]; + for (a, key, label, shortcut, enabled) in items { + if a == Action::Paste && cfg!(target_arch = "wasm32") { + continue; + } + if a == Action::SelectAll { + ui.separator(); + } + let button = egui::Button::new(crate::i18n::tr(label)).shortcut_text(crate::menubar::shortcut_text(shortcut, mac)); + let r = ui.add_enabled(enabled, button); + crate::widgets::register(ui.ctx(), format!("{widget}:{key}"), r.rect); + if r.clicked() { + *action = Some(a); + ui.close(); + } + } +} + +#[cfg(test)] +mod tests { + use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect}; + + use super::*; + use crate::headless::HeadlessView; + + const FIELD: &str = "cityField"; + + /// A field alone in a window, with a blank area beside it to click away to. + struct Rig { + view: HeadlessView, + text: String, + select_on_focus: bool, + /// The field is on screen (a dialog that holds it may close). + shown: bool, + endings: Vec, + widgets: Vec<(String, Rect)>, + time: f64, + } + + impl Rig { + fn new(text: &str) -> Self { + let mut rig = Rig { + view: HeadlessView::new(), + text: text.to_string(), + select_on_focus: false, + shown: true, + endings: vec![], + widgets: vec![], + time: 0.0, + }; + rig.frame(vec![]); + rig + } + + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + let raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events); + let (text, select, shown, endings) = (&mut self.text, self.select_on_focus, self.shown, &mut self.endings); + self.view.run(raw, |ui| { + ui.add_space(20.0); + if !shown { + return; + } + let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui); + endings.extend(r.ending); + }); + self.widgets = crate::widgets::take_registry(&self.view.ctx); + } + + /// A few frames, for menus to open and requests to come back. + fn settle(&mut self) { + for _ in 0..4 { + self.frame(vec![]); + } + } + + fn rect(&self, id: &str) -> Option { + self.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) + } + + fn click_at(&mut self, at: Pos2, button: PointerButton) { + // far apart in time from the last click: two clicks never make a double-click + self.time += 1.0; + let press = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }; + self.frame(vec![Event::PointerMoved(at), press(true)]); + self.frame(vec![press(false)]); + self.settle(); + } + + fn click(&mut self, id: &str, button: PointerButton) { + let r = self.rect(id).unwrap_or_else(|| panic!("no {id} on screen: {:?}", self.widgets)); + self.click_at(r.center(), button); + } + + fn key(&mut self, key: Key, modifiers: Modifiers) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + self.frame(vec![ev(true), ev(false)]); + self.settle(); + } + + fn type_text(&mut self, text: &str) { + self.frame(vec![Event::Text(text.to_string())]); + self.settle(); + } + + fn focused(&self) -> bool { + self.view.ctx.memory(|m| m.focused()).is_some() + } + + /// Click into the field and select all its text with ⌘A. + fn select_all_by_keys(&mut self) { + self.click(FIELD, PointerButton::Primary); + self.key(Key::A, Modifiers::COMMAND); + } + + /// Right-click the field, then pick `item` from its menu. + fn menu(&mut self, item: &str) { + self.click(FIELD, PointerButton::Secondary); + self.click(&format!("{FIELD}:{item}"), PointerButton::Primary); + } + } + + /// Right-click offers Cut, Copy, Paste and Select All. + #[test] + fn right_click_offers_cut_copy_paste_and_select_all() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Secondary); + for item in ["cut", "copy", "paste", "selectAll"] { + assert!(rig.rect(&format!("{FIELD}:{item}")).is_some(), "{item}: {:?}", rig.widgets); + } + } + + /// Copy in the menu puts the selected text on the clipboard and leaves the field as it was, + /// still being edited. + #[test] + fn copy_in_the_menu_copies_the_selection() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.menu("copy"); + assert_eq!(rig.view.clipboard, "Lisbon"); + assert_eq!(rig.text, "Lisbon"); + assert!(rig.focused(), "still editing"); + assert_eq!(rig.endings, vec![], "using the menu is not leaving the field"); + } + + /// Cut in the menu puts the selected text on the clipboard and takes it out of the field. + #[test] + fn cut_in_the_menu_moves_the_selection_to_the_clipboard() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.menu("cut"); + assert_eq!(rig.view.clipboard, "Lisbon"); + assert_eq!(rig.text, ""); + } + + /// Paste in the menu puts the clipboard's text in place of the selection. + #[test] + fn paste_in_the_menu_replaces_the_selection() { + let mut rig = Rig::new("Lisbon"); + rig.view.clipboard = "Porto".into(); + rig.select_all_by_keys(); + rig.menu("paste"); + assert_eq!(rig.text, "Porto"); + } + + /// Select All in the menu selects the whole text: what is typed next replaces it. + #[test] + fn select_all_in_the_menu_selects_the_whole_text() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.menu("selectAll"); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto"); + } + + /// With nothing selected, Cut and Copy do nothing. + #[test] + fn cut_and_copy_need_a_selection() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.menu("cut"); + rig.menu("copy"); + assert_eq!((rig.text.as_str(), rig.view.clipboard.as_str()), ("Lisbon", "")); + } + + /// Esc gives back the text from before the edit and says the edit was cancelled. + #[test] + fn escape_gives_back_the_text_from_before_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + assert_eq!(rig.text, "Rome"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Lisbon"); + assert_eq!(rig.endings, vec![Ending::Escape]); + } + + /// Return ends the edit and keeps the text. + #[test] + fn return_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.key(Key::Enter, Modifiers::NONE); + assert_eq!(rig.text, "Rome"); + assert_eq!(rig.endings, vec![Ending::Return]); + } + + /// Clicking elsewhere ends the edit and keeps the text. + #[test] + fn clicking_away_ends_the_edit() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); + assert_eq!(rig.text, "Rome"); + assert_eq!(rig.endings, vec![Ending::Away]); + } + + /// Opening the menu and closing it without a choice (a click outside) leaves the field. + #[test] + fn dismissing_the_menu_leaves_the_field() { + let mut rig = Rig::new("Lisbon"); + rig.click(FIELD, PointerButton::Primary); + rig.click(FIELD, PointerButton::Secondary); + rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary); + assert!(!rig.focused()); + assert_eq!(rig.endings, vec![Ending::Away]); + } + + /// A field that selects on focus: clicking into it selects everything, so typing replaces it. + #[test] + fn a_field_that_selects_on_focus_is_retyped_whole() { + let mut rig = Rig::new("Lisbon"); + rig.select_on_focus = true; + rig.click(FIELD, PointerButton::Primary); + rig.type_text("Kyoto"); + assert_eq!(rig.text, "Kyoto"); + } + + /// Esc after using the menu still gives back the text from before the whole edit. + #[test] + fn escape_after_the_menu_gives_back_the_original_text() { + let mut rig = Rig::new("Lisbon"); + rig.view.clipboard = "Porto".into(); + rig.select_all_by_keys(); + rig.menu("paste"); + assert_eq!(rig.text, "Porto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Lisbon"); + } + + /// A field that goes away in the middle of an edit (its dialog closed) starts afresh when it + /// comes back: Esc then gives back the text it came back with. + #[test] + fn an_edit_cut_short_is_forgotten() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.type_text("Rome"); + rig.shown = false; + rig.settle(); + rig.text = "Porto".into(); + rig.shown = true; + rig.settle(); + rig.select_all_by_keys(); + rig.type_text("Kyoto"); + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "Porto"); + } +}