mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
0c1528f17d
commit
d576fe2b06
8 files changed
+336
-20
No files matched your search
@@ -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
@@ -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, ¤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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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…).
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Block a user