From 2cd1bd86644a808a13f309c8549dd8da94fd921b Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:41:14 +0100 Subject: [PATCH] fix(ui): cap text fields and oversized text events (#8722) * fix(ui): cap text fields and oversized text events Plain text fields took any amount of text, so pasting a huge string grew the string (and its laid-out galley) without bound; #2258 fixed this for numeric fields only. The shared text_field now holds at most 1024 characters unless its caller sets a limit, and the app cuts every text, paste and IME event to 1 MiB of characters (at a character boundary) before any widget sees it, so the dozens of hand-built TextEdits are bounded too. Part of #2150 Co-Authored-By: Claude Opus 5.5 * fix(ui): bound oversized existing text and synthetic text events A text_field whose value is already past its limit lays out a bounded copy and writes it back only on an edit; the text-event cap runs after the control channel's synthetic events join the input. Co-Authored-By: Claude Opus 5.5 * test(ui): raw_input_hook caps text the control channel queues Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/widgets.rs | 147 ++++++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e37aaf6ef4..cd9216893a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2357,6 +2357,8 @@ impl eframe::App for PhotocraftApp { wheel_nav::fold_legacy_pinch(ctx, raw_input); } raw_input.events.extend(self.take_synthetic_step()); + // After the control channel's own events (`ui.type`) join the OS's, before any widget. + widgets::cap_text_events(&mut raw_input.events); self.control_click.normalize(&mut raw_input.events, cfg!(target_os = "macos")); // A pen held still is no touch screen's press-and-hold right click (stylus.rs). let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 223d0cfa8e..ad5cba2105 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -814,6 +814,40 @@ pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::o out } +/// The most characters a [`text_field`] holds unless its caller sets a limit: far beyond any +/// search, name or hex value, yet a pasted multi-gigabyte string is cut at the field instead of +/// being laid out every frame (#2150). +pub const TEXT_FIELD_LIMIT: usize = 1024; + +/// The most characters one typed, pasted or IME-committed text event carries into the app +/// ([`cap_text_events`]): a megabyte of text, more than any field or text layer is pasted. +pub const INPUT_EVENT_LIMIT: usize = 1 << 20; + +/// Cuts every text, paste and IME event to [`INPUT_EVENT_LIMIT`] characters (at a character +/// boundary) before any widget sees it, so no text field -- whatever its own limit -- has to +/// lay out an 8 GB paste (#2150). +pub fn cap_text_events(events: &mut [egui::Event]) { + fn cut(text: &mut String) -> bool { + let Some((end, _)) = text.char_indices().nth(INPUT_EVENT_LIMIT) else { return false }; + text.truncate(end); + true + } + for event in events { + match event { + egui::Event::Text(text) | egui::Event::Paste(text) | egui::Event::Ime(egui::ImeEvent::Commit(text)) => { + cut(text); + } + // The highlighted range indexes the old text; past the cut it would point nowhere. + egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { + if cut(text) && active_range_chars.as_ref().is_some_and(|r| r.end > INPUT_EVENT_LIMIT) { + *active_range_chars = None; + } + } + _ => {} + } + } +} + /// Height of a [`text_field`]'s box, matching a [`value_field`]. pub const TEXT_FIELD_H: f32 = 24.0; @@ -832,7 +866,8 @@ pub fn text_field(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, widt text_field_ex(ui, id, text, hint, width, mono, None) } -/// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say). +/// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say), or to +/// [`TEXT_FIELD_LIMIT`] without one. pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, mono: bool, char_limit: Option) -> Response { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width.max(16.0), TEXT_FIELD_H), Sense::hover()); @@ -842,13 +877,25 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w } let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; let inner = rect.shrink2(TEXT_FIELD_PAD); + let limit = char_limit.unwrap_or(TEXT_FIELD_LIMIT); + // A value already past the limit (restored from a state file, set by automation) is shown and + // edited as its first `limit` characters, so it is never laid out whole; the caller's string + // changes only when that copy is edited. + let mut bounded = text.char_indices().nth(limit).map(|(end, _)| text[..end].to_string()); + let shown = match bounded.as_mut() { + Some(b) => b, + None => &mut *text, + }; // TextEdit draws its text at the top of its rect; fill the box and centre the line in it, so // the text sits on the field's middle like a dropdown's or value field's. - let mut edit = - egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font).min_size(inner.size()).vertical_align(egui::Align::Center); - if let Some(limit) = char_limit { - edit = edit.char_limit(limit); - } + let edit = egui::TextEdit::singleline(shown) + .id(id) + .hint_text(hint) + .frame(egui::Frame::NONE) + .font(font) + .min_size(inner.size()) + .vertical_align(egui::Align::Center) + .char_limit(limit); // A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected // list rows and slider trails, which must keep the theme's. let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown); @@ -856,7 +903,13 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w // The theme's 24 pt interact height would make the edit taller than the box's inside. child.spacing_mut().interact_size.y = inner.height(); child.style_mut().visuals.selection.bg_fill = t.field_selection(); - child.add(edit) + let response = child.add(edit); + if let Some(b) = bounded + && response.changed() + { + *text = b; + } + response } fn first_field_id() -> egui::Id { @@ -2225,6 +2278,86 @@ mod tests { assert_eq!(h.state().0, "abc", "typing reaches the bound string"); } + /// #2150: a huge paste into a plain text field stops at its limit instead of growing the + /// string (and its laid-out text) without bound. + #[test] + fn text_field_caps_a_huge_paste() { + use egui::accesskit::Role; + let mut h = Harness::new_ui_state(|ui, s: &mut String| drop(super::text_field(ui, egui::Id::new("tf"), s, "", 140.0, false)), String::new()); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + h.get_by_role(Role::TextInput).click(); + h.run_steps(1); + h.event(egui::Event::Paste("9".repeat(200_000))); + h.run_steps(1); + assert_eq!(h.state().chars().count(), super::TEXT_FIELD_LIMIT); + // A second paste adds nothing past the limit. + h.event(egui::Event::Paste("9".repeat(10))); + h.run_steps(1); + assert_eq!(h.state().chars().count(), super::TEXT_FIELD_LIMIT); + } + + /// #2150: a value already past the limit is laid out only up to it, and stays the caller's + /// until the field is edited, which keeps the bounded copy. + #[test] + fn text_field_bounds_an_oversized_existing_value() { + use egui::accesskit::Role; + let long = "9".repeat(super::TEXT_FIELD_LIMIT * 4); + let mut h = Harness::new_ui_state(|ui, s: &mut String| drop(super::text_field(ui, egui::Id::new("tf"), s, "", 140.0, false)), long.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + assert_eq!(*h.state(), long, "drawing alone leaves the caller's value alone"); + assert_eq!(h.get_by_role(Role::TextInput).value().map(|v| v.chars().count()), Some(super::TEXT_FIELD_LIMIT)); + h.get_by_role(Role::TextInput).click(); + h.run_steps(1); + h.event(egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().chars().count() < super::TEXT_FIELD_LIMIT, "an edit keeps the bounded copy"); + } + + /// #2150: text queued by the control channel (`ui.type`) joins the input in `raw_input_hook` + /// and is cut like the OS's, so the cap must run after it is appended. + #[test] + fn raw_input_hook_caps_synthetic_text() { + use eframe::App as _; + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.synthetic.push(egui::Event::Text("x".repeat(super::INPUT_EVENT_LIMIT + 10))); + let ctx = egui::Context::default(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + let texts: Vec = raw.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.chars().count()) } else { None }).collect(); + assert_eq!(texts, [super::INPUT_EVENT_LIMIT]); + } + + /// #2150: every text-carrying event is cut to the app-wide limit at a character boundary + /// before any widget sees it; other events and short text pass untouched. + #[test] + fn oversized_text_events_are_cut_before_widgets_see_them() { + let long = "é".repeat(super::INPUT_EVENT_LIMIT + 5); + let mut events = vec![ + egui::Event::Paste(long.clone()), + egui::Event::Text(long.clone()), + egui::Event::Ime(egui::ImeEvent::Commit(long.clone())), + egui::Event::Ime(egui::ImeEvent::Preedit { text: long, active_range_chars: Some(0..super::INPUT_EVENT_LIMIT + 2) }), + egui::Event::Text("ok".into()), + egui::Event::Copy, + ]; + super::cap_text_events(&mut events); + for e in &events[..4] { + let (egui::Event::Paste(t) + | egui::Event::Text(t) + | egui::Event::Ime(egui::ImeEvent::Commit(t)) + | egui::Event::Ime(egui::ImeEvent::Preedit { text: t, .. })) = e + else { + panic!("{e:?}") + }; + assert_eq!(t.chars().count(), super::INPUT_EVENT_LIMIT); + } + assert!(matches!(&events[3], egui::Event::Ime(egui::ImeEvent::Preedit { active_range_chars: None, .. })), "the range past the cut is dropped"); + assert_eq!(events[4], egui::Event::Text("ok".into())); + assert_eq!(events[5], egui::Event::Copy); + } + /// Sets up a test with one value field. Its state is its value and how many times it changed. fn field(value: f32, range: std::ops::RangeInclusive) -> Harness<'static, (f32, u32)> { let mut h = Harness::new_ui_state(