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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* test(ui): raw_input_hook caps text the control channel queues

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FinnNk
2026-10-23 17:41:14 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5697c40361
commit 2cd1bd8664
2 changed files with 142 additions and 7 deletions
+2
View File
@@ -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);
+140 -7
View File
@@ -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<usize>) -> 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<usize> = 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<f32>) -> Harness<'static, (f32, u32)> {
let mut h = Harness::new_ui_state(