diff --git a/contributors/people.toml b/contributors/people.toml index ce91b7fb..15ea7507 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index b8be06c3..5d889559 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -861,7 +861,7 @@ pub fn recalculate(doc: &mut Document) -> Result { /// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { - 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 { 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, ¤t, &now); + let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &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; } } diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 9e9a36f2..012dbb83 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -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 diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 45c55e24..aa98dd7c 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 210a5353..29c22e56 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -338,6 +338,10 @@ pub struct DocView { frame_visible: HashSet, /// Thumbnails that are out of date (still shown until their replacement arrives). stale_thumbs: HashSet, + /// 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, /// 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…). diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 9f176dbf..4dc0bcbf 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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 { } } +/// 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. /// diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 38197232..b34a9e6c 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -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, + /// 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, /// 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::>(); + let size = da + .iter() + .position(|x| *x == "Tf") + .and_then(|i| da.get(i.wrapping_sub(1))) + .and_then(|s| s.parse::().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 { let focus = view.forms.focus.clone()?; diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 0c7fb32a..69c06af7 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -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))); +}