mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
Claude Opus 5.5
parent
5697c40361
commit
2cd1bd8664
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user