Forms: keep tiles and render unbaked field values during page re-render (#636)

* Forms: keep tiles and render unbaked field values during page re-render

In complex interactive forms, switching focus away from an edited field
caused its text to disappear for several seconds while background tiles
re-rendered.

- In DocView, retain existing tiles as stale rather than immediately
  purging them on page_changed so the canvas remains sharp without flashing.
- Prioritize visible tiles over backdrops in the render queue.
- Track unbaked form fields and paint an immediate egui widget preview
  for committed text, checkbox, and radio inputs until fresh raster
  tiles arrive.
- Scope page invalidation on form edits to pages containing the edited
  or calculated fields.
- Hoist fields lookup in recalculate_with and apply_changes to eliminate
  repeated parsing.
- Run JavaScript scripts via catch_unwind on the current thread instead of
  spawning a new thread per script invocation.
- Add contributor entry for Piohrun in contributors/people.toml.
- Add test switching_fields_keeps_committed_text_visible_immediately.

* Forms: address review feedback on tile stale tracking, hoist, and diff invalidation

- Revert crates/js to preserve dedicated 1 GiB stack thread for boa scripts.
- In DocView::page_changed, purge non-current-tag tiles and mark only current-tag tiles stale.
- Restore demand queue order in prepare_render_queue.
- Add unbaked overlay entries only on successful edit apply and only for fields whose values changed; fix font zoom calculation on 90°/270° pages.
- Re-read fields(doc) in recalculate_with only when script returns changes.
- Diff form before and after edit in apply_edit, invalidating pages with changed fields, falling back to document_changed for XFA or page-count changes.
- Add tests covering script recalculation side effects across pages, unbaked overlay cleanup on tile delivery/zoom/rejection, and checkbox recalc chaining.

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
PiohrunandBrandon Thomas authored and GitHub committed 2026-10-11 15:15:48 -04:00
1 parent 0c1528f17d
commit d576fe2b06
8 files changed
+336 -20

No files matched your search

+4
View File
@@ -75,6 +75,10 @@ sync_github_name = true
[people.jay-tau]
sync_github_name = true
[people.Piohrun]
real_name = "Grzegorz Martens"
sync_github_name = true
[people.krinry]
sync_github_name = true
+10 -9
View File
@@ -861,7 +861,7 @@ pub fn recalculate(doc: &mut Document) -> Result<usize, FormError> {
/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`.
pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result<usize, FormError> {
let all = fields(doc);
let mut all = fields(doc);
let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some();
if !all.iter().any(calculated) {
return Ok(0);
@@ -880,17 +880,18 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
}
let mut changed = 0;
for name in order {
let now = fields(doc);
let Some(f) = now.iter().find(|f| f.name == name) else { continue };
let Some(f) = all.iter().find(|f| f.name == name).cloned() else { continue };
let lookup = |n: &str| -> Vec<String> {
let prefix = format!("{n}.");
now.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect()
all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect()
};
let current = f.value.first().cloned().unwrap_or_default();
let v = if let Some(js) = &f.actions.scripts.calculate {
let f = f.clone();
let r = scripts.run(FieldEvent::Calculate, js, &f, &current, &now);
let r = scripts.run(FieldEvent::Calculate, js, &f, &current, &all);
scripting::apply_changes(doc, &r.changes, &f.name)?;
if !r.changes.is_empty() {
all = fields(doc);
}
if !r.rc {
continue;
}
@@ -899,12 +900,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue };
v
};
let now = fields(doc);
let Some(f) = now.iter().find(|f| f.name == name) else { continue };
if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
let f = f.clone();
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?;
redraw(doc, &f, std::slice::from_ref(&v), scripts)?;
if let Some(target) = all.iter_mut().find(|x| x.name == name) {
target.value = vec![v];
}
changed += 1;
}
}
+4 -1
View File
@@ -100,8 +100,11 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy
/// read-only, required and visibility as flags). `except` is the event's own field, whose value
/// the event sets.
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> {
if changes.is_empty() {
return Ok(());
}
let all = fields(doc);
for c in changes {
let all = fields(doc);
let Some(f) = all.iter().find(|f| f.name == c.name) else { continue };
if let Some(v) = &c.value
&& c.name != except
+43
View File
@@ -1494,6 +1494,49 @@ fn check_box_redraw_keeps_down_states_it_still_draws() {
assert!(!d.contains(b"Gone"), "a state the box no longer draws");
}
#[test]
fn recalc_chain_where_script_changes_checkbox() {
struct TestScripts;
impl Scripts for TestScripts {
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, fields: &[Field]) -> ScriptResult {
if script == "set_box" {
ScriptResult {
rc: true,
value: "calc1".into(),
changes: vec![FieldChange {
name: "agree".into(),
value: Some(vec!["Yes".into()]),
read_only: None,
required: None,
display: None,
}],
message: None,
}
} else if script == "read_box" {
let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default();
ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None }
} else {
ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None }
}
}
}
let mut doc = one_page();
add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &NewField::Text { multiline: false }, Some("trigger")).unwrap();
add_field(&mut doc, 0, [10.0, 40.0, 30.0, 60.0], &NewField::CheckBox, Some("agree")).unwrap();
add_field(&mut doc, 0, [10.0, 70.0, 100.0, 90.0], &NewField::Text { multiline: false }, Some("result")).unwrap();
set_field_script(&mut doc, "trigger", "calculate", Some("set_box")).unwrap();
set_field_script(&mut doc, "result", "calculate", Some("read_box")).unwrap();
let changed = recalculate_with(&mut doc, &mut TestScripts).unwrap();
assert!(changed >= 2);
let all = fields(&doc);
assert_eq!(field(&all, "agree").value, ["Yes"], "checkbox was set by script in recalc chain");
assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value");
}
#[test]
fn partial_appearance_keeps_custom_default_appearance_and_font_resources() {
let mut doc = fixture();
+34 -4
View File
@@ -338,6 +338,10 @@ pub struct DocView {
frame_visible: HashSet<usize>,
/// Thumbnails that are out of date (still shown until their replacement arrives).
stale_thumbs: HashSet<usize>,
/// Tiles that are out of date (still shown until their replacement arrives).
stale_tiles: HashSet<(usize, u64, u32, u32)>,
/// Scale tag of the current zoom level, as last drawn.
current_scale_tag: u64,
/// Print dialog: the current sheet's pages at the preview pane's device resolution.
print_pages: HashMap<usize, (u64, TextureHandle)>,
/// Sharp tiles of large pages: (page, scale tag, tile x, tile y) → texture. Tiles of an
@@ -574,6 +578,8 @@ impl DocView {
frame_queue: Vec::new(),
frame_visible: HashSet::new(),
stale_thumbs: HashSet::new(),
stale_tiles: HashSet::new(),
current_scale_tag: 0,
print_pages: HashMap::new(),
tiles: HashMap::new(),
texts: HashMap::new(),
@@ -753,6 +759,8 @@ impl DocView {
// Pages may have moved: a sharp render of another page would be worse than a soft one.
self.grid_pages.clear();
self.tiles.clear();
self.stale_tiles.clear();
self.forms.unbaked.clear();
self.texts.clear();
self.text_failed.clear();
self.errors.clear();
@@ -766,7 +774,7 @@ impl DocView {
}
/// Only `page` changed (a comment was added, edited or removed): re-render that page and
/// re-read its text, keep everything else.
/// re-read its text, keep everything else. Out-of-date tiles stay on screen until replacements arrive.
pub fn page_changed(&mut self, page: usize) {
if let Some(p) = self.pages.get_mut(&page) {
p.tag = STALE_TAG;
@@ -777,7 +785,18 @@ impl DocView {
if let Some(slot) = self.print_pages.get_mut(&page) {
slot.0 = STALE_TAG;
}
self.tiles.retain(|(p, _, _, _), _| *p != page);
let current_tag = if self.current_scale_tag != 0 {
self.current_scale_tag
} else {
self.tiles.keys().filter(|k| k.0 == page).map(|k| k.1).max().unwrap_or(0)
};
self.tiles.retain(|key, _| key.0 != page || key.1 == current_tag);
self.stale_tiles.retain(|key| key.0 != page || key.1 == current_tag);
for &key in self.tiles.keys() {
if key.0 == page && key.1 == current_tag {
self.stale_tiles.insert(key);
}
}
self.texts.remove(&page);
self.text_failed.remove(&page);
self.errors.remove(&page);
@@ -1255,6 +1274,7 @@ impl DocView {
self.grid_pages.clear();
self.print_pages.clear();
self.stale_thumbs.clear();
self.stale_tiles.clear();
self.waiting_since.clear();
// Signature previews own a separate renderer and image/background textures.
self.signature_drag = Default::default();
@@ -1322,7 +1342,7 @@ impl DocView {
}
tile_bytes = tile_bytes.saturating_add(bytes);
tile_tags.insert(req.tag);
if !self.tiles.contains_key(&key) {
if !self.tiles.contains_key(&key) || self.stale_tiles.contains(&key) {
queue.push(*req);
}
} else {
@@ -1353,6 +1373,7 @@ impl DocView {
// Tiles of an earlier zoom (not a demanded scale) stay while the canvas keeps them, a
// bounded few under a page whose current tiles are still missing (OLD_TILES).
self.tiles.retain(|key, _| tiles.contains(key) || !tile_tags.contains(&key.1));
self.stale_tiles.retain(|key| self.tiles.contains_key(key));
queue.extend(grid);
// Opening (or turning to) a page queues its pixels alongside thumbnails and
// text extraction. The workers would otherwise spend the first seconds on
@@ -1483,8 +1504,13 @@ impl DocView {
// The length was checked above, so the renderer's buffer becomes the texture data as is.
let img = texture_image([r.width as usize, r.height as usize], r.rgba);
if let Some(t) = r.request.tile {
let key = (page, r.request.tag, t.x / TILE, t.y / TILE);
let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR);
self.tiles.insert((page, r.request.tag, t.x / TILE, t.y / TILE), tex);
self.tiles.insert(key, tex);
self.stale_tiles.remove(&key);
if !self.stale_tiles.iter().any(|k| k.0 == page) {
self.forms.unbaked.retain(|(_, p)| *p != page);
}
} else if r.request.tag & PRINT_TAG != 0 {
let tex = ctx.load_texture(format!("print-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.print_pages.insert(page, (r.request.tag, tex));
@@ -1500,6 +1526,9 @@ impl DocView {
self.pages.insert(page, PageTex { tag: r.request.tag, tex });
self.signature_drag.page_received(page);
self.waiting_since.remove(&page);
if !self.stale_tiles.iter().any(|k| k.0 == page) {
self.forms.unbaked.retain(|(_, p)| *p != page);
}
}
bytes
}
@@ -2112,6 +2141,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let ppp = ui.ctx().pixels_per_point();
let scale = view.render_scale(ppp);
let tag = scale_tag(scale);
view.current_scale_tag = tag;
let hand = app.quick_tool == QuickTool::Hand;
let tool = app.quick_tool;
// Text selection runs for the Select tool and for the markup tools (highlight…).
+78 -3
View File
@@ -32,16 +32,74 @@ impl PdfCraftApp {
pub fn apply_edit(&mut self, edit: Edit) -> bool {
let Some((i, id)) = self.active_ids() else { return false };
let label = edit.label();
let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) {
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some())
} else {
(None, 0, false)
};
match self.session.apply(id, edit.clone()) {
Ok(()) => {
let Some(doc) = self.session.get(id) else { return true };
let info = &doc.info;
let view = &mut self.views[i];
view.signature_drag.committed(&edit, doc.edit_generation());
match comment_page(&edit) {
if let Some(page) = comment_page(&edit) {
// Comment edits change one page: keep every other raster.
Some(page) => view.page_changed(page),
None => view.document_changed(info),
view.page_changed(page);
} else if let Some(form_before) = form_before
&& doc.info.pages.len() == page_count_before
&& doc.xfa.is_some() == had_xfa
&& !doc.xfa.is_some()
&& is_form_edit(&edit)
{
let mut changed_pages = std::collections::BTreeSet::new();
for f in doc.form.iter() {
match form_before.iter().find(|old| old.name == f.name) {
Some(old) => {
if old != f {
for w in &f.widgets {
if let Some(p) = w.page {
changed_pages.insert(p);
}
}
for w in &old.widgets {
if let Some(p) = w.page {
changed_pages.insert(p);
}
}
if old.value != f.value {
for w in &f.widgets {
if let Some(p) = w.page {
view.forms.unbaked.insert((f.name.clone(), p));
}
}
}
}
}
None => {
for w in &f.widgets {
if let Some(p) = w.page {
changed_pages.insert(p);
view.forms.unbaked.insert((f.name.clone(), p));
}
}
}
}
}
for old in form_before.iter() {
if !doc.form.iter().any(|f| f.name == old.name) {
for w in &old.widgets {
if let Some(p) = w.page {
changed_pages.insert(p);
}
}
}
}
for page in changed_pages {
view.page_changed(page);
}
} else {
view.document_changed(info);
}
// Keep the pages the user acted on selected, where they now are.
match edit {
@@ -660,6 +718,7 @@ impl PdfCraftApp {
// Otherwise a later Save could write a rejected draft back into the file.
view.forms.focus = None;
view.forms.committed = None;
view.forms.unbaked.clear();
view.pending_edit = None;
}
self.notify_tr("Reverted to the last saved version");
@@ -791,6 +850,22 @@ fn comment_page(edit: &Edit) -> Option<usize> {
}
}
/// Whether `edit` is a form-related modification.
fn is_form_edit(edit: &Edit) -> bool {
matches!(
edit,
Edit::SetFieldValue { .. }
| Edit::ResetForm { .. }
| Edit::AddField { .. }
| Edit::DeleteField { .. }
| Edit::DuplicateField { .. }
| Edit::SetFieldProps { .. }
| Edit::SetFieldImage { .. }
| Edit::SetFieldScript { .. }
| Edit::ApplyScriptChanges { .. }
)
}
/// Whether `path` is the temporary copy an email client opened an attachment from, so that Save
/// asks where to keep the document instead of writing to it.
///
+62 -3
View File
@@ -4,8 +4,10 @@
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. Combo
//! and list boxes open a list of their options. Every change is one undoable engine edit.
use std::collections::HashSet;
use egui::{Color32, CornerRadius, Rect, Stroke, vec2};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, field_flags};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
@@ -39,6 +41,9 @@ pub struct FormView {
/// The draft whose edit is queued (`DocView::pending_edit`): if the field refuses the value,
/// the editor reopens with it rather than losing the typing.
pub(crate) committed: Option<Focus>,
/// Fields modified by the user whose appearance stream has not been rasterized by the render worker yet.
/// Tracked as (field_name, page_index) so committed edits stay immediately visible without flicker.
pub unbaked: HashSet<(String, usize)>,
/// A message for the app to show (e.g. "buttons run JavaScript").
pub notice: Option<FormNotice>,
/// A push button was clicked: (its field name, what it does).
@@ -264,13 +269,67 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform,
let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi);
if focused {
painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside);
} else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
} else {
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
}
if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
}
}
}
}
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) {
match f.kind {
FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => {
let text = f.value.first().map(String::as_str).unwrap_or("");
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
if !text.is_empty() {
let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw };
let zoom = xf.rect.width() / unrotated_w.max(1.0);
let da = f.da.split_whitespace().collect::<Vec<_>>();
let size = da
.iter()
.position(|x| *x == "Tf")
.and_then(|i| da.get(i.wrapping_sub(1)))
.and_then(|s| s.parse::<f32>().ok())
.filter(|s| *s > 0.0);
let font_size = (size.unwrap_or(((w.rect[3] - w.rect[1]) as f32 * 0.6).clamp(6.0, 12.0)) * zoom).clamp(6.0, 64.0);
let display_text = if f.has(field_flags::PASSWORD) { "•".repeat(text.chars().count()) } else { text.to_string() };
let p = painter.with_clip_rect(r);
let (align, pos) = match f.quadding {
1 => (egui::Align2::CENTER_TOP, egui::pos2(r.center().x, r.min.y + 1.0)),
2 => (egui::Align2::RIGHT_TOP, egui::pos2(r.max.x - 3.0, r.min.y + 1.0)),
_ => (egui::Align2::LEFT_TOP, egui::pos2(r.min.x + 3.0, r.min.y + 1.0)),
};
p.text(pos, align, display_text, egui::FontId::proportional(font_size), Color32::BLACK);
}
}
FormFieldKind::CheckBox => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let checked = f.value.first().is_some_and(|v| v != "Off" && !v.is_empty());
if checked {
let sz = (r.height() * 0.75).clamp(8.0, 32.0);
painter.text(r.center(), egui::Align2::CENTER_CENTER, "✔", egui::FontId::proportional(sz), Color32::BLACK);
}
}
FormFieldKind::Radio => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let selected = f.value.first().map(String::as_str) == w.on_state.as_deref();
if selected {
let radius = (r.height().min(r.width()) * 0.25).clamp(2.0, 16.0);
painter.circle_filled(r.center(), radius, Color32::BLACK);
}
}
_ => {}
}
}
/// The in-place editor or option list for the focused field. Returns an edit to apply.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField], today: (i64, u32, u32)) -> Option<Edit> {
let focus = view.forms.focus.clone()?;
+101
View File
@@ -549,3 +549,104 @@ mod revert_tests {
assert!(output.text.unwrap().plain_text().contains("Saved original"), "saved AP still draws the original value");
}
}
#[test]
fn calculated_field_and_script_side_effect_on_another_page_re_render() {
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 }));
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 1,
rect: [20.0, 20.0, 100.0, 40.0],
kind: pdfcraft_engine::NewField::Text { multiline: false },
name: Some("page2_field".into()),
}));
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldScript {
name: "page2_field".into(),
event: "calculate".into(),
script: Some("event.value = 'from_' + getField('name').value;".into()),
}));
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("hello".into()));
h.run_steps(2);
h.key_press(egui::Key::Tab);
h.run_steps(2);
assert_eq!(value(&h, "page2_field"), ["from_hello"]);
// Wait for all re-renders to complete.
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(value(&h, "name"), ["hello"]);
assert_eq!(value(&h, "page2_field"), ["from_hello"]);
}
#[test]
fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() {
let mut h = harness();
// 1. Fresh tiles: edit puts field in unbaked, and after tiles arrive, unbaked clears.
click_field(&mut h, "name", 0);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("GoodVal".into()));
h.run_steps(2);
h.key_press(egui::Key::Tab);
h.run_steps(2);
assert_eq!(value(&h, "name"), ["GoodVal"]);
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)));
// 2. Rejected edit: typing rejected by validate script leaves unbaked clean.
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldScript {
name: "city".into(),
event: "validate".into(),
script: Some("event.rc = false;".into()),
}));
click_field(&mut h, "city", 0);
h.event(egui::Event::Text("BadVal".into()));
h.run_steps(2);
h.key_press(egui::Key::Tab);
h.run_steps(2);
assert!(!h.state().views[0].forms.unbaked.contains(&("city".to_string(), 0)));
h.key_press(egui::Key::Escape);
h.run_steps(2);
// 3. Zoom then edit: zoom to another scale, let tiles settle, then edit.
h.state_mut().views[0].zoom_step(true);
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
click_field(&mut h, "name", 0);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("ZoomedVal".into()));
h.run_steps(2);
h.key_press(egui::Key::Tab);
h.run_steps(2);
assert_eq!(value(&h, "name"), ["ZoomedVal"]);
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)));
}