From df693a24be7a09200a5d63e91f5138e78dff52f4 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:59:24 -0400 Subject: [PATCH 1/8] M9: empty signature fields show a Sign tab An empty signature field that a click would sign now carries a small accent tab, with the signature icon and "Sign", inside its top-left corner while the Select tool is active (not while preparing a form). It follows the field click's own checks: the document allows filling, the field isn't read-only, and its widget is shown. The tab drops its label, then itself, on a field too small at the current zoom. Like the validation badges it is screen-only chrome, never written into the file, exports or printing. The label reuses the catalog's "Sign". The tab sits inside the field, so clicking it starts signing that field and hovering it shows the pointing hand. Unit tests cover which fields get a tab (signed, invisible, other-page, unplaced, non-finite and huge rectangles, read-only fields, refused filling, hidden or moved widgets) and the layout; a kittest frame test checks the painted tab, the cursor and the click. --- crates/ui-egui/src/canvas.rs | 347 ++++++++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 2 files changed, 347 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..72491f214a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -343,6 +343,87 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa .collect() } +/// The empty signature fields on one page that get a sign tab, as their widget rectangles +/// (user space). Each is unsigned and visible, on this page, with a rectangle that passes the +/// same screen-space guard as [`signature_badges`]. A click must also be able to sign it, as +/// in `forms_ui::page_input`: the document allows filling (`can_fill`), and the form field of +/// that name is a signature field that isn't read-only, with a shown widget at that place. +pub(crate) fn sign_tabs( + signatures: &[pdfcraft_engine::SignatureInfo], + form: &[pdfcraft_engine::FormField], + page: usize, + can_fill: bool, +) -> Vec<[f64; 4]> { + let mut empty = signatures + .iter() + .filter(|s| can_fill && !s.signed && s.visible && s.page == Some(page)) + .filter_map(|s| s.rect.filter(|r| r.iter().all(|v| v.is_finite() && v.abs() < f32::MAX as f64)).map(|r| (s.field.as_str(), r))) + .peekable(); + if empty.peek().is_none() { + return Vec::new(); + } + // The widgets a click signs, by field name and exact rectangle, from one pass over the form: + // a file with thousands of fields costs no more per frame than the field hit test does. + let signable: HashSet<(&str, [u64; 4])> = form + .iter() + .filter(|f| f.kind == pdfcraft_engine::FormFieldKind::Signature && !f.read_only()) + .flat_map(|f| f.widgets.iter().filter(|w| w.page == Some(page) && !w.hidden).map(move |w| (f.name.as_str(), w.rect.map(f64::to_bits)))) + .collect(); + empty.filter(|(name, r)| signable.contains(&(*name, r.map(f64::to_bits)))).map(|(_, r)| r).collect() +} + +/// Height of a sign tab, in points. +const SIGN_TAB_HEIGHT: f32 = 18.0; +/// Size of the signature icon on a sign tab. +const SIGN_TAB_ICON: f32 = 12.0; +/// Gap between a sign tab and its field's edges. +const SIGN_TAB_INSET: f32 = 3.0; +/// Room left and right of a sign tab's contents, and between its icon and label. +const SIGN_TAB_PAD: f32 = 5.0; + +/// Where one sign tab is drawn on screen: its chip, the icon's square and, when the label fits, +/// the point where the label starts (its left edge, on the chip's center line). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SignTab { + pub rect: Rect, + pub icon: Rect, + pub label: Option, +} + +/// Lays out a sign tab inside the top-left corner of `field` (the widget on screen) for a label +/// `label_width` points wide. The tab takes at most half the field's width: it drops the label +/// when only the icon fits, and is left out on a field too small (or not finite) for even that. +pub(crate) fn sign_tab_layout(field: Rect, label_width: f32) -> Option { + if !field.is_finite() || field.height() < SIGN_TAB_HEIGHT + 2.0 * SIGN_TAB_INSET { + return None; + } + let room = field.width() / 2.0 - SIGN_TAB_INSET; + let full = SIGN_TAB_PAD + SIGN_TAB_ICON + SIGN_TAB_PAD + label_width + SIGN_TAB_PAD; + let with_label = label_width.is_finite() && label_width > 0.0 && full <= room; + let width = if with_label { full } else { SIGN_TAB_HEIGHT }; + if width > room { + return None; + } + let rect = Rect::from_min_size(field.min + vec2(SIGN_TAB_INSET, SIGN_TAB_INSET), vec2(width, SIGN_TAB_HEIGHT)); + let icon_x = if with_label { rect.left() + SIGN_TAB_PAD } else { rect.center().x - SIGN_TAB_ICON / 2.0 }; + let icon = Rect::from_min_size(pos2(icon_x, rect.center().y - SIGN_TAB_ICON / 2.0), Vec2::splat(SIGN_TAB_ICON)); + let label = with_label.then(|| pos2(icon.right() + SIGN_TAB_PAD, rect.center().y)); + Some(SignTab { rect, icon, label }) +} + +/// Paints the sign tab of an empty signature field whose widget is at `field` on screen: an +/// accent chip with the signature icon and "Sign" in white, as the app's primary buttons are. +/// Screen-only chrome, like the validation badges. +fn paint_sign_tab(ui: &egui::Ui, painter: &egui::Painter, field: Rect, t: &Tokens) { + let galley = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(11.0), Color32::WHITE)); + let Some(tab) = sign_tab_layout(field, galley.size().x) else { return }; + painter.rect_filled(tab.rect, CornerRadius::same(4), t.accent); + icons::paint(ui, tab.icon, "signature", SIGN_TAB_ICON, Color32::WHITE); + if let Some(at) = tab.label { + painter.galley(at - vec2(0.0, galley.size().y / 2.0), galley, Color32::WHITE); + } +} + /// The name shown for a document tab, the window title and the Combine picker. /// A snapshot reads like its source document, plus the snapshot suffix that tells /// the tabs apart. Without a title the file name (which already carries the suffix) @@ -2354,8 +2435,8 @@ const SUPPRESS_SECS: f64 = 0.25; /// Short rolling extension while egui still has an unprocessed wheel tail. const SUPPRESS_TAIL_SECS: f64 = 0.05; -// The viewer moves between docking and the central panel as modes change; keep its scroll -// identity tied to the document instead of either parent UI. +/// The viewer moves between docking and the central panel as modes change; keep its scroll +/// identity tied to the document instead of either parent UI. fn page_scroll( ui: &mut egui::Ui, scroll: egui::ScrollArea, @@ -2365,6 +2446,8 @@ fn page_scroll( ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner } +/// The document area of view `index`: its pages with their overlays and viewer chrome (form +/// fields, comments, signature badges and sign tabs), and the page input of the active tool. pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // Keyboard scrolling applies to this frame's page view only, never later. @@ -3550,6 +3633,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.flash = None; } } + // An empty field a click would sign carries a small tab saying so, shown only while + // a click on the field signs it (the Select tool, not while preparing a form). Like + // the validation badges, it never enters the file, exports or printing. + if tool == QuickTool::Select && !preparing { + for [x0, y0, x1, y1] in sign_tabs(&doc.signatures, &form, i, can_fill) { + paint_sign_tab(ui, painter, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), &t); + } + } // Validation is viewer chrome over the signer's original artwork, which // may still say "Signature Not Verified". These badges never enter document // streams, image/text exports, vector printing or Print as Image. @@ -5314,6 +5405,258 @@ fn grayscale(mut img: egui::ColorImage) -> egui::ColorImage { img } +#[cfg(test)] +mod sign_tab_tests { + use super::*; + use pdfcraft_engine::{FormField, FormFieldKind, FormWidget, SignatureInfo}; + + /// The widget rectangle the unit tests use (user space). + const RECT: [f64; 4] = [20.0, 110.0, 140.0, 180.0]; + + /// An unsigned, visible signature listing for field `name` with its widget on `page` at + /// `rect`. Each test changes the one thing it is about. + fn listing(name: &str, page: usize, rect: [f64; 4]) -> SignatureInfo { + SignatureInfo { + field: name.into(), + signed: false, + page: Some(page), + rect: Some(rect), + visible: true, + signer: None, + certificate: None, + chain: Vec::new(), + date: None, + signing_time: None, + reason: None, + location: None, + contact: None, + sub_filter: None, + doc_timestamp: false, + certify: None, + revision: 1, + signed_len: 0, + digest: None, + algorithm: None, + timestamp: false, + timestamp_time: None, + status: pdfcraft_engine::SignatureStatus::Unknown, + modification: pdfcraft_engine::sign::Modification::None, + details: Vec::new(), + } + } + + /// The signature form field `name` with one shown widget on `page` at `rect`. + fn field(name: &str, page: usize, rect: [f64; 4]) -> FormField { + FormField { + name: name.into(), + obj: pdfcraft_cos::ObjRef::new(4, 0), + kind: FormFieldKind::Signature, + value: Vec::new(), + default: Vec::new(), + flags: 0, + max_len: None, + options: Vec::new(), + da: String::new(), + quadding: 0, + tooltip: None, + widgets: vec![FormWidget { + obj: pdfcraft_cos::ObjRef::new(4, 0), + page: Some(page), + rect, + on_state: None, + state: None, + tab: 0, + locked: false, + hidden: false, + rotation: 0, + caption: None, + list: None, + }], + actions: Default::default(), + button: None, + } + } + + /// The cases (by name) whose single listing and form still got a tab on page 0. + fn tabbed<'a>(cases: &[(&'a str, SignatureInfo, Vec, bool)]) -> Vec<&'a str> { + cases.iter().filter(|(_, s, form, can_fill)| !sign_tabs(std::slice::from_ref(s), form, 0, *can_fill).is_empty()).map(|c| c.0).collect() + } + + /// An empty, visible signature field that a click signs gets one tab, at its widget, on its + /// own page only. + #[test] + fn sign_tabs_mark_an_empty_field_that_a_click_signs() { + let tabs = sign_tabs(&[listing("Approver", 0, RECT)], &[field("Approver", 0, RECT)], 0, true); + assert_eq!(tabs, vec![RECT]); + let later = [listing("Witness", 1, RECT)]; + assert_eq!(sign_tabs(&later, &[field("Witness", 1, RECT)], 1, true), vec![RECT]); + assert!(sign_tabs(&later, &[field("Witness", 1, RECT)], 0, true).is_empty(), "not on the page before"); + } + + /// Signed, invisible, other-page and unplaced fields get no tab. Each case is checked on its + /// own, so a failure names every case that wrongly got one. + #[test] + fn sign_tabs_skip_signed_invisible_other_page_and_unplaced_fields() { + let form = || vec![field("Approver", 0, RECT)]; + let cases = [ + ("signed", SignatureInfo { signed: true, ..listing("Approver", 0, RECT) }, form(), true), + ("invisible", SignatureInfo { visible: false, ..listing("Approver", 0, RECT) }, form(), true), + ("on another page", listing("Approver", 1, RECT), vec![field("Approver", 1, RECT)], true), + ("on no page", SignatureInfo { page: None, ..listing("Approver", 0, RECT) }, form(), true), + ("without a rectangle", SignatureInfo { rect: None, ..listing("Approver", 0, RECT) }, form(), true), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); + } + + /// NaN, infinite and gigantic rectangles never reach the painter, even when the form's + /// widget carries the same numbers. + #[test] + fn sign_tabs_skip_rectangles_that_cannot_reach_the_screen() { + let hostile = |name: &'static str, rect: [f64; 4]| (name, listing("Approver", 0, rect), vec![field("Approver", 0, rect)], true); + let cases = [ + hostile("NaN", [f64::NAN, 110.0, 140.0, 180.0]), + hostile("infinite", [20.0, f64::INFINITY, 140.0, 180.0]), + hostile("minus infinite", [20.0, 110.0, f64::NEG_INFINITY, 180.0]), + hostile("huge", [20.0, 110.0, 1e300, 180.0]), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); + } + + /// Fields a click would not sign get no tab: a read-only field, a document that refuses + /// filling, a listing with no form field of its name, a field of another kind of that name, + /// and a widget that is hidden or elsewhere. + #[test] + fn sign_tabs_skip_fields_that_a_click_cannot_sign() { + let empty = || listing("Approver", 0, RECT); + let with = |change: fn(&mut FormField)| { + let mut f = field("Approver", 0, RECT); + change(&mut f); + vec![f] + }; + let cases = [ + ("read-only", empty(), with(|f| f.flags = pdfcraft_engine::field_flags::READ_ONLY), true), + ("filling refused", empty(), with(|_| {}), false), + ("no form field", empty(), Vec::new(), true), + ("another kind", empty(), with(|f| f.kind = FormFieldKind::Text), true), + ("hidden widget", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.hidden = true)), true), + ("widget elsewhere", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.rect = [160.0, 110.0, 280.0, 180.0])), true), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); + } + + /// A roomy field gets the whole tab inside its top-left corner: the icon, then the label, + /// on no more than half the field's width. + #[test] + fn sign_tab_layout_puts_icon_and_label_in_the_top_left_corner() { + let field = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 120.0)); + let tab = sign_tab_layout(field, 30.0).expect("fits"); + assert_eq!(tab.rect.min, field.min + vec2(SIGN_TAB_INSET, SIGN_TAB_INSET)); + assert_eq!(tab.rect.height(), SIGN_TAB_HEIGHT); + assert!(field.contains_rect(tab.rect) && tab.rect.width() <= field.width() / 2.0, "{tab:?}"); + assert!(tab.rect.contains_rect(tab.icon), "{tab:?}"); + let at = tab.label.expect("the label fits"); + assert!(at.x >= tab.icon.right() && at.x + 30.0 <= tab.rect.right(), "{tab:?}"); + assert_eq!(at.y, tab.rect.center().y); + } + + /// A field with room for the icon but not the label gets the icon alone, and one too small + /// for even that, or with a rectangle that isn't finite, gets no tab. A label that can't be + /// measured is left out instead of drawn at a NaN position. + #[test] + fn sign_tab_layout_drops_the_label_then_the_tab_on_small_fields() { + let narrow = Rect::from_min_size(Pos2::ZERO, vec2(60.0, 40.0)); + let icon_only = sign_tab_layout(narrow, 30.0).expect("the icon fits"); + assert_eq!(icon_only.label, None); + assert!(narrow.contains_rect(icon_only.rect) && icon_only.rect.contains_rect(icon_only.icon), "{icon_only:?}"); + let cases = [ + ("too short", Rect::from_min_size(Pos2::ZERO, vec2(400.0, 20.0))), + ("too narrow", Rect::from_min_size(Pos2::ZERO, vec2(30.0, 40.0))), + ("infinite", Rect::from_min_max(Pos2::ZERO, pos2(f32::INFINITY, 40.0))), + ("NaN", Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(400.0, 40.0))), + ]; + let laid_out: Vec<&str> = cases.iter().filter(|(_, r)| sign_tab_layout(*r, 30.0).is_some()).map(|c| c.0).collect(); + assert_eq!(laid_out, Vec::<&str>::new()); + let roomy = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 120.0)); + assert_eq!(sign_tab_layout(roomy, f32::NAN).map(|t| t.label), Some(None)); + } + + /// One page with three signature fields: Approver is empty, Witness is signed (a signature + /// dictionary as its value is enough to list it as signed) and Locked is empty but read-only. + const SIGN_FIELDS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Approver) /Rect [20 110 140 180] /P 3 0 R /F 4 >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Witness) /V << /Type /Sig >> /Rect [160 110 280 180] /P 3 0 R /F 4 >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Locked) /Ff 1 /Rect [20 20 140 90] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + /// Every shape painted in the last frame, with nested lists flattened. + fn painted(h: &egui_kittest::Harness<'static, PdfCraftApp>) -> Vec { + let mut stack: Vec = h.output().shapes.iter().map(|c| c.shape.clone()).collect(); + let mut out = Vec::new(); + while let Some(shape) = stack.pop() { + match shape { + egui::Shape::Vec(inner) => stack.extend(inner), + shape => out.push(shape), + } + } + out + } + + /// Where the named field's first widget is on screen. + fn field_on_screen(h: &egui_kittest::Harness<'static, PdfCraftApp>, name: &str) -> Rect { + let app = h.state(); + let view = &app.views[0]; + let doc = app.session.get(view.id).expect("open"); + let f = doc.form.iter().find(|f| f.name == name).expect("field"); + crate::forms_ui::field_screen_rect(view, &doc.info, f, 0).expect("on screen") + } + + /// In the real shell, an empty signature field shows its accent tab with "Sign" in its + /// top-left corner, and the signed and read-only fields show none. The tab is part of the + /// field: hovering it shows the pointing hand, and clicking it starts signing that field. + #[test] + fn empty_signature_fields_show_a_sign_tab_that_signs_them() { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("sign-fields.pdf", None, SIGN_FIELDS.to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app + }); + h.run_steps(6); + let accent = Tokens::get(&h.ctx).accent; + let shapes = painted(&h); + let chips: Vec = shapes + .iter() + .filter_map(|s| match s { + egui::Shape::Rect(r) if r.fill == accent => Some(r.rect), + _ => None, + }) + .collect(); + let (approver, witness, locked) = (field_on_screen(&h, "Approver"), field_on_screen(&h, "Witness"), field_on_screen(&h, "Locked")); + let tab = chips.iter().copied().find(|c| approver.contains_rect(*c)).unwrap_or_else(|| panic!("no tab in {approver:?}: {chips:?}")); + assert!(tab.min.x - approver.min.x < 8.0 && tab.min.y - approver.min.y < 8.0, "in the top-left corner: {tab:?} in {approver:?}"); + let label = shapes.iter().any(|s| matches!(s, egui::Shape::Text(t) if t.galley.text() == "Sign" && tab.contains(t.pos))); + assert!(label, "the tab says Sign"); + assert!(!chips.iter().any(|c| witness.intersects(*c)), "a signed field has no tab: {chips:?}"); + assert!(!chips.iter().any(|c| locked.intersects(*c)), "a read-only field has no tab: {chips:?}"); + + h.hover_at(tab.center()); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + h.drag_at(tab.center()); + h.run_steps(1); + h.drop_at(tab.center()); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(crate::Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); + } +} + #[cfg(test)] mod tests { #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..64216ff736 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5984,8 +5984,8 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it"] -notes = "" +evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it", "crates/ui-egui/src/canvas.rs::empty_signature_fields_show_a_sign_tab_that_signs_them", "crates/ui-egui/src/canvas.rs::sign_tabs_skip_fields_that_a_click_cannot_sign"] +notes = "Clicking an empty signature field signs it (Use a certificate for that field). With the Select tool, each empty field a click would sign (filling allowed, the field not read-only, its widget shown) carries a small accent tab with the signature icon and Sign in its top-left corner. The tab is screen-only chrome, never written into the file, exports or printing; it drops its label, then itself, on a field too small at the current zoom, and is hidden while preparing a form. Missing: the tab is painted only, with no accessibility node or keyboard focus of its own" [[feature]] id = "sign.invisible-signature" From ea57770e24722bab37ec6cf3d1401ff57340df0b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:54:11 -0400 Subject: [PATCH 2/8] M9: size the Sign tab to its field The tab was a fixed 18 pt chip that needed a 24 pt field: too big on a 29 pt signature box and missing on a 19 pt one. It is now 0.4 of the field's height on screen, from 10 to 18 pt, and its inset, icon, padding, label font and corners scale with it (3, 12, 5, 11 and 4 pt on the 18 pt tab). The label shows from 14 pt up while the whole tab fits in half the field's width; otherwise the tab is a square icon chip, and it is left out only when that chip doesn't fit or the field isn't finite. Which fields get a tab, the click and the tool condition are unchanged. The layout tests now cover an 18 pt tab with its label, an 11.6 pt and a 10 pt icon chip on 29 pt and 19 pt fields, no tab on a 12 pt field, the label and then the tab dropping as a field narrows, and the scaled font and corners. --- crates/ui-egui/src/canvas.rs | 159 +++++++++++++++++++++++------------ parity/acrobat-features.toml | 2 +- 2 files changed, 108 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 72491f214a..ad046803c9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -372,54 +372,69 @@ pub(crate) fn sign_tabs( empty.filter(|(name, r)| signable.contains(&(*name, r.map(f64::to_bits)))).map(|(_, r)| r).collect() } -/// Height of a sign tab, in points. -const SIGN_TAB_HEIGHT: f32 = 18.0; -/// Size of the signature icon on a sign tab. -const SIGN_TAB_ICON: f32 = 12.0; -/// Gap between a sign tab and its field's edges. -const SIGN_TAB_INSET: f32 = 3.0; -/// Room left and right of a sign tab's contents, and between its icon and label. -const SIGN_TAB_PAD: f32 = 5.0; +/// The largest sign tab's height, in points. Every other size is a share of the tab's height, +/// taken from this one: a 3 pt inset, a 12 pt icon, 5 pt padding, an 11 pt label and 4 pt corners. +const SIGN_TAB_MAX: f32 = 18.0; +/// The smallest sign tab's height, in points: a field too small for this chip gets no tab. +const SIGN_TAB_MIN: f32 = 10.0; +/// The share of its field's height on screen that a sign tab takes, between the two sizes. +const SIGN_TAB_SHARE: f32 = 0.4; +/// The smallest sign tab that carries the label; below it the tab is the icon alone. +const SIGN_TAB_LABEL_MIN: f32 = 14.0; +/// The label's font size on the largest sign tab; it shrinks with the tab. +const SIGN_TAB_FONT: f32 = 11.0; /// Where one sign tab is drawn on screen: its chip, the icon's square and, when the label fits, -/// the point where the label starts (its left edge, on the chip's center line). +/// the point where the label starts (its left edge, on the chip's center line), with the +/// label's font size and the chip's corner radius at this tab's size. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct SignTab { pub rect: Rect, pub icon: Rect, pub label: Option, + pub font: f32, + pub radius: f32, } -/// Lays out a sign tab inside the top-left corner of `field` (the widget on screen) for a label -/// `label_width` points wide. The tab takes at most half the field's width: it drops the label -/// when only the icon fits, and is left out on a field too small (or not finite) for even that. +/// Lays out a sign tab inside the top-left corner of `field` (the widget on screen), for a label +/// `label_width` points wide at [`SIGN_TAB_FONT`], its size on the largest tab. The tab is +/// [`SIGN_TAB_SHARE`] of the field's height, from [`SIGN_TAB_MIN`] to [`SIGN_TAB_MAX`], and its +/// inset, icon, padding, label and corners scale with it. It carries the label from +/// [`SIGN_TAB_LABEL_MIN`] up when the whole tab fits in half the field's width, and is a square +/// icon chip otherwise. It is left out when even that chip doesn't fit, or the field isn't finite. pub(crate) fn sign_tab_layout(field: Rect, label_width: f32) -> Option { - if !field.is_finite() || field.height() < SIGN_TAB_HEIGHT + 2.0 * SIGN_TAB_INSET { + if !field.is_finite() { return None; } - let room = field.width() / 2.0 - SIGN_TAB_INSET; - let full = SIGN_TAB_PAD + SIGN_TAB_ICON + SIGN_TAB_PAD + label_width + SIGN_TAB_PAD; - let with_label = label_width.is_finite() && label_width > 0.0 && full <= room; - let width = if with_label { full } else { SIGN_TAB_HEIGHT }; - if width > room { + // Finite, so the clamp has a number to work on (and its bounds are constants in order). + let h = (field.height() * SIGN_TAB_SHARE).clamp(SIGN_TAB_MIN, SIGN_TAB_MAX); + let scale = h / SIGN_TAB_MAX; + let (inset, icon_side, pad) = (3.0 * scale, 12.0 * scale, 5.0 * scale); + if field.height() < h + 2.0 * inset || h > field.width() / 2.0 { return None; } - let rect = Rect::from_min_size(field.min + vec2(SIGN_TAB_INSET, SIGN_TAB_INSET), vec2(width, SIGN_TAB_HEIGHT)); - let icon_x = if with_label { rect.left() + SIGN_TAB_PAD } else { rect.center().x - SIGN_TAB_ICON / 2.0 }; - let icon = Rect::from_min_size(pos2(icon_x, rect.center().y - SIGN_TAB_ICON / 2.0), Vec2::splat(SIGN_TAB_ICON)); - let label = with_label.then(|| pos2(icon.right() + SIGN_TAB_PAD, rect.center().y)); - Some(SignTab { rect, icon, label }) + let full = pad + icon_side + pad + label_width * scale + pad; + let with_label = h >= SIGN_TAB_LABEL_MIN && label_width.is_finite() && label_width > 0.0 && full <= field.width() / 2.0; + let width = if with_label { full } else { h }; + let rect = Rect::from_min_size(field.min + Vec2::splat(inset), vec2(width, h)); + let icon_x = if with_label { rect.left() + pad } else { rect.center().x - icon_side / 2.0 }; + let icon = Rect::from_min_size(pos2(icon_x, rect.center().y - icon_side / 2.0), Vec2::splat(icon_side)); + let label = with_label.then(|| pos2(icon.right() + pad, rect.center().y)); + Some(SignTab { rect, icon, label, font: SIGN_TAB_FONT * scale, radius: 4.0 * scale }) } /// Paints the sign tab of an empty signature field whose widget is at `field` on screen: an /// accent chip with the signature icon and "Sign" in white, as the app's primary buttons are. -/// Screen-only chrome, like the validation badges. +/// The label is measured at its largest size to lay the tab out and drawn at the tab's size; +/// text scales with its size, and the padding takes up any rounding. Screen-only chrome, like +/// the validation badges. fn paint_sign_tab(ui: &egui::Ui, painter: &egui::Painter, field: Rect, t: &Tokens) { - let galley = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(11.0), Color32::WHITE)); - let Some(tab) = sign_tab_layout(field, galley.size().x) else { return }; - painter.rect_filled(tab.rect, CornerRadius::same(4), t.accent); - icons::paint(ui, tab.icon, "signature", SIGN_TAB_ICON, Color32::WHITE); + let measure = |size: f32| ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(size), Color32::WHITE)); + let Some(tab) = sign_tab_layout(field, measure(SIGN_TAB_FONT).size().x) else { return }; + painter.rect_filled(tab.rect, egui::epaint::CornerRadiusF32::same(tab.radius), t.accent); + icons::paint(ui, tab.icon, "signature", tab.icon.width(), Color32::WHITE); if let Some(at) = tab.label { + let galley = measure(tab.font); painter.galley(at - vec2(0.0, galley.size().y / 2.0), galley, Color32::WHITE); } } @@ -5544,42 +5559,82 @@ mod sign_tab_tests { assert_eq!(tabbed(&cases), Vec::<&str>::new()); } - /// A roomy field gets the whole tab inside its top-left corner: the icon, then the label, - /// on no more than half the field's width. - #[test] - fn sign_tab_layout_puts_icon_and_label_in_the_top_left_corner() { - let field = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 120.0)); - let tab = sign_tab_layout(field, 30.0).expect("fits"); - assert_eq!(tab.rect.min, field.min + vec2(SIGN_TAB_INSET, SIGN_TAB_INSET)); - assert_eq!(tab.rect.height(), SIGN_TAB_HEIGHT); - assert!(field.contains_rect(tab.rect) && tab.rect.width() <= field.width() / 2.0, "{tab:?}"); - assert!(tab.rect.contains_rect(tab.icon), "{tab:?}"); - let at = tab.label.expect("the label fits"); - assert!(at.x >= tab.icon.right() && at.x + 30.0 <= tab.rect.right(), "{tab:?}"); - assert_eq!(at.y, tab.rect.center().y); + /// Whether two layout sizes match, to a hundredth of a point. + fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 } - /// A field with room for the icon but not the label gets the icon alone, and one too small - /// for even that, or with a rectangle that isn't finite, gets no tab. A label that can't be - /// measured is left out instead of drawn at a NaN position. + /// What a field `width` by `height` points on screen shows, for a label 30 pt wide on the + /// largest tab: "label" (the full tab), "icon" (the square chip) or "none". + fn shows(width: f32, height: f32) -> &'static str { + match sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(width, height)), 30.0) { + Some(tab) if tab.label.is_some() => "label", + Some(_) => "icon", + None => "none", + } + } + + /// The tab is 0.4 of the field's height on screen, at most 18 pt: a tall, wide field gets the + /// 18 pt tab with its label 3 pt inside the corner, and a 29 pt field (a 22 pt box at fit + /// width) an 11.6 pt icon chip, too small for the label, with the inset and icon scaled to it. #[test] - fn sign_tab_layout_drops_the_label_then_the_tab_on_small_fields() { - let narrow = Rect::from_min_size(Pos2::ZERO, vec2(60.0, 40.0)); - let icon_only = sign_tab_layout(narrow, 30.0).expect("the icon fits"); - assert_eq!(icon_only.label, None); - assert!(narrow.contains_rect(icon_only.rect) && icon_only.rect.contains_rect(icon_only.icon), "{icon_only:?}"); + fn sign_tab_layout_scales_the_chip_with_the_field_height() { + let large = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 60.0)); + let tab = sign_tab_layout(large, 30.0).expect("fits"); + assert!(near(tab.rect.height(), 18.0) && tab.rect.min == large.min + vec2(3.0, 3.0), "{tab:?}"); + assert!(near(tab.icon.width(), 12.0) && tab.rect.contains_rect(tab.icon), "{tab:?}"); + let at = tab.label.expect("the label fits"); + assert!(at.x >= tab.icon.right() && at.x + 30.0 <= tab.rect.right() && at.y == tab.rect.center().y, "{tab:?}"); + + let short = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 29.0)); + let tab = sign_tab_layout(short, 30.0).expect("fits"); + assert!(near(tab.rect.height(), 11.6) && near(tab.rect.width(), 11.6) && tab.label.is_none(), "{tab:?}"); + let inset = tab.rect.min - short.min; + assert!(near(inset.x, 11.6 / 6.0) && near(inset.y, 11.6 / 6.0) && short.contains_rect(tab.rect), "{tab:?}"); + assert!(near(tab.icon.width(), 11.6 * 2.0 / 3.0) && tab.rect.contains_rect(tab.icon), "{tab:?}"); + } + + /// A 19 pt field (a 14 pt box at fit width) still gets the smallest chip, 10 pt with the icon + /// alone. A 12 pt field is too short for even that, and a rectangle that isn't finite gets + /// nothing. A label that can't be measured is left out instead of drawn at a NaN position. + #[test] + fn sign_tab_layout_keeps_a_small_icon_on_short_fields_and_none_below() { + let field = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 19.0)); + let tab = sign_tab_layout(field, 30.0).expect("the smallest chip fits"); + assert!(near(tab.rect.height(), 10.0) && near(tab.rect.width(), 10.0) && tab.label.is_none(), "{tab:?}"); + assert!(field.contains_rect(tab.rect) && tab.rect.contains_rect(tab.icon), "{tab:?}"); let cases = [ - ("too short", Rect::from_min_size(Pos2::ZERO, vec2(400.0, 20.0))), - ("too narrow", Rect::from_min_size(Pos2::ZERO, vec2(30.0, 40.0))), + ("12 pt tall", Rect::from_min_size(Pos2::ZERO, vec2(400.0, 12.0))), ("infinite", Rect::from_min_max(Pos2::ZERO, pos2(f32::INFINITY, 40.0))), ("NaN", Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(400.0, 40.0))), ]; let laid_out: Vec<&str> = cases.iter().filter(|(_, r)| sign_tab_layout(*r, 30.0).is_some()).map(|c| c.0).collect(); assert_eq!(laid_out, Vec::<&str>::new()); - let roomy = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 120.0)); + let roomy = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 60.0)); assert_eq!(sign_tab_layout(roomy, f32::NAN).map(|t| t.label), Some(None)); } + /// The label's font and the chip's corners shrink with the tab: 11 pt and 4 pt on the largest + /// tab, 8.8 pt and 3.2 pt on the 14.4 pt tab of a 36 pt field. + #[test] + fn sign_tab_layout_scales_the_label_font_and_corners_with_the_chip() { + let large = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 60.0)), 30.0).expect("fits"); + assert!(near(large.font, 11.0) && near(large.radius, 4.0), "{large:?}"); + let small = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 36.0)), 30.0).expect("fits"); + assert!(near(small.font, 8.8) && near(small.radius, 3.2) && small.label.is_some(), "{small:?}"); + } + + /// As a 36 pt field (a 14.4 pt tab, its label 24 pt wide at that size) narrows, the tab keeps + /// its label while the whole tab fits in half the width (91.2 pt), then shows the icon alone + /// while the chip does (28.8 pt), then nothing. + #[test] + fn sign_tab_layout_drops_the_label_then_the_tab_as_the_field_narrows() { + let widths = [400.0, 92.0, 90.0, 29.0, 28.0]; + assert_eq!(widths.map(|w| shows(w, 36.0)), ["label", "label", "icon", "icon", "none"]); + let tab = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(92.0, 36.0)), 30.0).expect("fits"); + assert!(near(tab.rect.height(), 14.4) && tab.rect.width() <= 46.0, "{tab:?}"); + } + /// One page with three signature fields: Approver is empty, Witness is signed (a signature /// dictionary as its value is enough to list it as signed) and Locked is empty but read-only. const SIGN_FIELDS: &[u8] = b"%PDF-1.7 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 64216ff736..1ae6b43191 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5985,7 +5985,7 @@ status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it", "crates/ui-egui/src/canvas.rs::empty_signature_fields_show_a_sign_tab_that_signs_them", "crates/ui-egui/src/canvas.rs::sign_tabs_skip_fields_that_a_click_cannot_sign"] -notes = "Clicking an empty signature field signs it (Use a certificate for that field). With the Select tool, each empty field a click would sign (filling allowed, the field not read-only, its widget shown) carries a small accent tab with the signature icon and Sign in its top-left corner. The tab is screen-only chrome, never written into the file, exports or printing; it drops its label, then itself, on a field too small at the current zoom, and is hidden while preparing a form. Missing: the tab is painted only, with no accessibility node or keyboard focus of its own" +notes = "Clicking an empty signature field signs it (Use a certificate for that field). With the Select tool, each empty field a click would sign (filling allowed, the field not read-only, its widget shown) carries a small accent tab in its top-left corner, sized to the field (0.4 of its height on screen, from 10 to 18 pt): the signature icon and Sign from 14 pt up, the icon alone below that or when the label would take more than half the field's width, and no tab on a field too small for the 10 pt chip. The tab is screen-only chrome, never written into the file, exports or printing, and is hidden while preparing a form. Missing: the tab is painted only, with no accessibility node or keyboard focus of its own" [[feature]] id = "sign.invisible-signature" From 515ba9677f87d1c10cc6148fdb8f1716156c47fb Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:58:00 -0400 Subject: [PATCH 3/8] M9: a click on a sign tab signs its field where another field overlaps it --- crates/ui-egui/src/canvas.rs | 157 ++++++++++++++++++++++++--------- crates/ui-egui/src/forms_ui.rs | 26 ++++-- 2 files changed, 132 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cbea2fe56a..ad514f4cc9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -343,17 +343,17 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa .collect() } -/// The empty signature fields on one page that get a sign tab, as their widget rectangles -/// (user space). Each is unsigned and visible, on this page, with a rectangle that passes the -/// same screen-space guard as [`signature_badges`]. A click must also be able to sign it, as -/// in `forms_ui::page_input`: the document allows filling (`can_fill`), and the form field of -/// that name is a signature field that isn't read-only, with a shown widget at that place. -pub(crate) fn sign_tabs( - signatures: &[pdfcraft_engine::SignatureInfo], +/// The empty signature fields on one page that get a sign tab, as their names and widget +/// rectangles (user space). Each is unsigned and visible, on this page, with a rectangle that +/// passes the same screen-space guard as [`signature_badges`]. A click must also be able to sign +/// it, as in `forms_ui::page_input`: the document allows filling (`can_fill`), and the form field +/// of that name is a signature field that isn't read-only, with a shown widget at that place. +pub(crate) fn sign_tabs<'a>( + signatures: &'a [pdfcraft_engine::SignatureInfo], form: &[pdfcraft_engine::FormField], page: usize, can_fill: bool, -) -> Vec<[f64; 4]> { +) -> Vec<(&'a str, [f64; 4])> { let mut empty = signatures .iter() .filter(|s| can_fill && !s.signed && s.visible && s.page == Some(page)) @@ -369,7 +369,7 @@ pub(crate) fn sign_tabs( .filter(|f| f.kind == pdfcraft_engine::FormFieldKind::Signature && !f.read_only()) .flat_map(|f| f.widgets.iter().filter(|w| w.page == Some(page) && !w.hidden).map(move |w| (f.name.as_str(), w.rect.map(f64::to_bits)))) .collect(); - empty.filter(|(name, r)| signable.contains(&(*name, r.map(f64::to_bits)))).map(|(_, r)| r).collect() + empty.filter(|(name, r)| signable.contains(&(*name, r.map(f64::to_bits)))).collect() } /// The largest sign tab's height, in points. Every other size is a share of the tab's height, @@ -423,18 +423,26 @@ pub(crate) fn sign_tab_layout(field: Rect, label_width: f32) -> Option Some(SignTab { rect, icon, label, font: SIGN_TAB_FONT * scale, radius: 4.0 * scale }) } -/// Paints the sign tab of an empty signature field whose widget is at `field` on screen: an -/// accent chip with the signature icon and "Sign" in white, as the app's primary buttons are. -/// The label is measured at its largest size to lay the tab out and drawn at the tab's size; -/// text scales with its size, and the padding takes up any rounding. Screen-only chrome, like -/// the validation badges. -fn paint_sign_tab(ui: &egui::Ui, painter: &egui::Painter, field: Rect, t: &Tokens) { - let measure = |size: f32| ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(size), Color32::WHITE)); - let Some(tab) = sign_tab_layout(field, measure(SIGN_TAB_FONT).size().x) else { return }; +/// The sign tab's label, "Sign", laid out at `size` points. +fn sign_tab_label(ui: &egui::Ui, size: f32) -> std::sync::Arc { + ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(size), Color32::WHITE)) +} + +/// The sign tab of an empty signature field whose widget is at `field` on screen, laid out for +/// its label measured at its largest size: where it is painted and where a click on it signs. +fn sign_tab_at(ui: &egui::Ui, field: Rect) -> Option { + sign_tab_layout(field, sign_tab_label(ui, SIGN_TAB_FONT).size().x) +} + +/// Paints a sign tab laid out by [`sign_tab_at`]: an accent chip with the signature icon and +/// "Sign" in white, as the app's primary buttons are. The label is drawn at the tab's size; text +/// scales with its size, and the padding takes up any rounding. Screen-only chrome, like the +/// validation badges. +fn paint_sign_tab(ui: &egui::Ui, painter: &egui::Painter, tab: &SignTab, t: &Tokens) { painter.rect_filled(tab.rect, egui::epaint::CornerRadiusF32::same(tab.radius), t.accent); icons::paint(ui, tab.icon, "signature", tab.icon.width(), Color32::WHITE); if let Some(at) = tab.label { - let galley = measure(tab.font); + let galley = sign_tab_label(ui, tab.font); painter.galley(at - vec2(0.0, galley.size().y / 2.0), galley, Color32::WHITE); } } @@ -3247,6 +3255,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.current = i; open_boxes = true; } + // An empty field a click would sign carries a small tab saying so, shown only while + // a click on the field signs it (the Select tool, not while preparing a form). The tab + // is drawn over every field, so a click on it signs its field (`forms_ui::page_input`). + let tabs: Vec<(&str, SignTab)> = if tool == QuickTool::Select && !preparing { + sign_tabs(&doc.signatures, &form, i, can_fill) + .into_iter() + .filter_map(|(name, [x0, y0, x1, y1])| { + sign_tab_at(ui, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32])).map(|tab| (name, tab)) + }) + .collect() + } else { + Vec::new() + }; // `over_field`: a form field is under the pointer and has set its cursor. let (on_field, over_field) = if preparing { let field_tool = match tool { @@ -3266,7 +3287,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view)) && !comments::select_grabs(ui, &pcx, view) { - crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view) + crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, &tabs, can_fill, view) } else { FieldPointer::Away }; @@ -3674,13 +3695,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.flash = None; } } - // An empty field a click would sign carries a small tab saying so, shown only while - // a click on the field signs it (the Select tool, not while preparing a form). Like - // the validation badges, it never enters the file, exports or printing. - if tool == QuickTool::Select && !preparing { - for [x0, y0, x1, y1] in sign_tabs(&doc.signatures, &form, i, can_fill) { - paint_sign_tab(ui, painter, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), &t); - } + // The sign tabs (above). Like the validation badges, they never enter the file, + // exports or printing. + for (_, tab) in &tabs { + paint_sign_tab(ui, painter, tab, &t); } // Validation is viewer chrome over the signer's original artwork, which // may still say "Signature Not Verified". These badges never enter document @@ -5540,10 +5558,10 @@ mod sign_tab_tests { /// own page only. #[test] fn sign_tabs_mark_an_empty_field_that_a_click_signs() { - let tabs = sign_tabs(&[listing("Approver", 0, RECT)], &[field("Approver", 0, RECT)], 0, true); - assert_eq!(tabs, vec![RECT]); + let approver = [listing("Approver", 0, RECT)]; + assert_eq!(sign_tabs(&approver, &[field("Approver", 0, RECT)], 0, true), vec![("Approver", RECT)]); let later = [listing("Witness", 1, RECT)]; - assert_eq!(sign_tabs(&later, &[field("Witness", 1, RECT)], 1, true), vec![RECT]); + assert_eq!(sign_tabs(&later, &[field("Witness", 1, RECT)], 1, true), vec![("Witness", RECT)]); assert!(sign_tabs(&later, &[field("Witness", 1, RECT)], 0, true).is_empty(), "not on the page before"); } @@ -5708,29 +5726,58 @@ trailer << /Root 1 0 R >> crate::forms_ui::field_screen_rect(view, &doc.info, f, 0).expect("on screen") } - /// In the real shell, an empty signature field shows its accent tab with "Sign" in its - /// top-left corner, and the signed and read-only fields show none. The tab is part of the - /// field: hovering it shows the pointing hand, and clicking it starts signing that field. - #[test] - fn empty_signature_fields_show_a_sign_tab_that_signs_them() { - let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + /// One page where the text field Name, first in the form's order, covers the top-left corner + /// of the empty signature field Approver, where its sign tab is drawn. + const SIGN_UNDER_TEXT: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [10 120 130 190] /P 3 0 R /F 4 >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Approver) /Rect [20 110 140 180] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + + /// The real shell with `bytes` open, the side panes closed, after the first frames. + fn shell(bytes: &'static [u8]) -> egui_kittest::Harness<'static, PdfCraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("sign-fields.pdf", None, SIGN_FIELDS.to_vec()).unwrap(); + app.open_bytes("sign-fields.pdf", None, bytes.to_vec()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); app }); h.run_steps(6); + h + } + + /// The accent chips painted in the last frame: the sign tabs. + fn chips(h: &egui_kittest::Harness<'static, PdfCraftApp>) -> Vec { let accent = Tokens::get(&h.ctx).accent; - let shapes = painted(&h); - let chips: Vec = shapes + painted(h) .iter() .filter_map(|s| match s { egui::Shape::Rect(r) if r.fill == accent => Some(r.rect), _ => None, }) - .collect(); + .collect() + } + + /// Clicks at `at` and lets the shell answer. + fn click_at(h: &mut egui_kittest::Harness<'static, PdfCraftApp>, at: Pos2) { + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + } + + /// In the real shell, an empty signature field shows its accent tab with "Sign" in its + /// top-left corner, and the signed and read-only fields show none. The tab is part of the + /// field: hovering it shows the pointing hand, and clicking it starts signing that field. + #[test] + fn empty_signature_fields_show_a_sign_tab_that_signs_them() { + let mut h = shell(SIGN_FIELDS); + let (shapes, chips) = (painted(&h), chips(&h)); let (approver, witness, locked) = (field_on_screen(&h, "Approver"), field_on_screen(&h, "Witness"), field_on_screen(&h, "Locked")); let tab = chips.iter().copied().find(|c| approver.contains_rect(*c)).unwrap_or_else(|| panic!("no tab in {approver:?}: {chips:?}")); assert!(tab.min.x - approver.min.x < 8.0 && tab.min.y - approver.min.y < 8.0, "in the top-left corner: {tab:?} in {approver:?}"); @@ -5742,13 +5789,37 @@ trailer << /Root 1 0 R >> h.hover_at(tab.center()); h.run_steps(1); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); - h.drag_at(tab.center()); - h.run_steps(1); - h.drop_at(tab.center()); - h.run_steps(3); + click_at(&mut h, tab.center()); assert_eq!(h.state().dialog, Some(crate::Dialog::Sign)); assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); } + + /// A sign tab is drawn over every field, so a click on it signs its field even where a field + /// earlier in the form's order overlaps it, which would otherwise take the click. Beside the + /// tab, the overlapping field still takes clicks as before. + #[test] + fn a_sign_tab_signs_its_field_where_an_earlier_field_overlaps_it() { + let mut h = shell(SIGN_UNDER_TEXT); + let (name, approver) = (field_on_screen(&h, "Name"), field_on_screen(&h, "Approver")); + let tab = chips(&h).into_iter().find(|c| approver.contains_rect(*c)).expect("Approver has a tab"); + assert!(name.contains_rect(tab), "the text field covers the tab: {tab:?} in {name:?}"); + + h.hover_at(tab.center()); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + click_at(&mut h, tab.center()); + assert_eq!(h.state().dialog, Some(crate::Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); + assert!(h.state().views[0].forms.focus.is_none(), "the text field didn't open"); + + let beside = pos2(tab.right() + 6.0, tab.center().y); + assert!(name.contains(beside) && approver.contains(beside), "beside the tab, both fields are under the pointer"); + h.state_mut().dialog = None; + h.state_mut().sign_draft = None; + h.run_steps(1); + click_at(&mut h, beside); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("Name"), "the earlier field takes the click"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 27c97a240e..d5aab8dc38 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -12,7 +12,7 @@ use egui::{Color32, CornerRadius, Rect, Stroke, vec2}; use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance}; use pdfcraft_render::DocInfo; -use crate::canvas::{DocView, PageXform}; +use crate::canvas::{DocView, PageXform, SignTab}; use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -110,7 +110,18 @@ pub(crate) enum FieldPointer { Owned, } -/// Clicks on form widgets of one page. +/// The first shown widget of `f` on `page` under the pointer at `p`, with its index. +fn widget_at<'a>(xf: &PageXform, info: &DocInfo, page: usize, f: &'a FormField, p: egui::Pos2) -> Option<(&'a FormField, usize, &'a FormWidget)> { + f.widgets + .iter() + .enumerate() + .find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) + .map(|(i, w)| (f, i, w)) +} + +/// Clicks on form widgets of one page. The pointer is on the first widget under it in the form's +/// order, except on a sign tab (`tabs`, with the signature field each one signs), which is drawn +/// over every field and so is on its own field. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, @@ -119,18 +130,17 @@ pub(crate) fn page_input( page: usize, info: &DocInfo, form: &[FormField], + tabs: &[(&str, SignTab)], allowed: bool, view: &mut DocView, ) -> FieldPointer { let pointer = ui.input(|i| i.pointer.hover_pos()); let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away }; - let hit = form.iter().find_map(|f| { - f.widgets - .iter() - .enumerate() - .find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) - .map(|(i, w)| (f, i, w)) + // A tab lies inside its field's widget, so that widget is under the pointer too. + let on_tab = tabs.iter().find(|(_, tab)| tab.rect.contains(p)).and_then(|(name, _)| { + form.iter().filter(|f| f.name == *name && f.kind == FormFieldKind::Signature).find_map(|f| widget_at(xf, info, page, f, p)) }); + let hit = on_tab.or_else(|| form.iter().find_map(|f| widget_at(xf, info, page, f, p))); let Some((f, wi, w)) = hit else { return FieldPointer::Away }; // Empty signature fields are signed by clicking them (Use a certificate); push buttons run // their action. From 2f7f99d21a1c2137bc18928a9bccbfa71b1b767e Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:11:27 -0400 Subject: [PATCH 4/8] M9: a sign tab stays on the page, and the one painted last takes the click --- crates/ui-egui/src/canvas.rs | 45 ++++++++++++++++++++++++++++++---- crates/ui-egui/src/forms_ui.rs | 11 ++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad514f4cc9..493533b581 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -428,10 +428,18 @@ fn sign_tab_label(ui: &egui::Ui, size: f32) -> std::sync::Arc { ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(size), Color32::WHITE)) } -/// The sign tab of an empty signature field whose widget is at `field` on screen, laid out for -/// its label measured at its largest size: where it is painted and where a click on it signs. -fn sign_tab_at(ui: &egui::Ui, field: Rect) -> Option { - sign_tab_layout(field, sign_tab_label(ui, SIGN_TAB_FONT).size().x) +/// The sign tab of an empty signature field whose widget is at `field` on screen, on the page at +/// `page`, laid out for its label measured at its largest size: where it is painted and where a +/// click on it signs. +fn sign_tab_at(ui: &egui::Ui, field: Rect, page: Rect) -> Option { + sign_tab_on_page(field, page, sign_tab_label(ui, SIGN_TAB_FONT).size().x) +} + +/// [`sign_tab_layout`] in the part of `field` that is on `page`: form fields take clicks on the +/// page only, so a widget that straddles the page's edge gets its tab where a click reaches it, +/// or none when that part has no room for one. +pub(crate) fn sign_tab_on_page(field: Rect, page: Rect, label_width: f32) -> Option { + sign_tab_layout(field.intersect(page), label_width) } /// Paints a sign tab laid out by [`sign_tab_at`]: an accent chip with the signature icon and @@ -3262,7 +3270,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { sign_tabs(&doc.signatures, &form, i, can_fill) .into_iter() .filter_map(|(name, [x0, y0, x1, y1])| { - sign_tab_at(ui, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32])).map(|tab| (name, tab)) + sign_tab_at(ui, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), xf.rect).map(|tab| (name, tab)) }) .collect() } else { @@ -5692,6 +5700,33 @@ mod sign_tab_tests { assert!(near(tab.rect.height(), 14.4) && tab.rect.width() <= 46.0, "{tab:?}"); } + /// A field that straddles the page's edge gets its tab in the part on the page, where clicks + /// reach it, or none when that part has no room for one. + #[test] + fn a_sign_tab_stays_on_the_page() { + let page = Rect::from_min_size(pos2(100.0, 50.0), vec2(600.0, 800.0)); + let straddling = Rect::from_min_size(pos2(60.0, 20.0), vec2(300.0, 90.0)); + let tab = sign_tab_on_page(straddling, page, 30.0).expect("the part on the page has room"); + assert!(page.contains_rect(tab.rect), "{tab:?} on {page:?}"); + assert_eq!(tab.rect.height(), SIGN_TAB_MAX, "sized to the 60 px on the page"); + let sliver = Rect::from_min_size(pos2(60.0, 20.0), vec2(300.0, 36.0)); + assert_eq!(sign_tab_on_page(sliver, page, 30.0), None, "6 px on the page"); + let off = Rect::from_min_size(pos2(60.0, 900.0), vec2(300.0, 90.0)); + assert_eq!(sign_tab_on_page(off, page, 30.0), None, "wholly off the page"); + } + + /// Where two sign tabs overlap, a click takes the one painted last, which is on top. + #[test] + fn the_sign_tab_painted_last_takes_the_click() { + let tab = |x: f32| sign_tab_layout(Rect::from_min_size(pos2(x, 0.0), vec2(300.0, 60.0)), 30.0).expect("fits"); + let tabs = [("Approver", tab(0.0)), ("Witness", tab(10.0))]; + let both = tabs[1].1.rect.left_center() + vec2(2.0, 0.0); + assert!(tabs[0].1.rect.contains(both), "the tabs overlap there"); + assert_eq!(crate::forms_ui::tab_under(&tabs, both), Some("Witness")); + assert_eq!(crate::forms_ui::tab_under(&tabs, tabs[0].1.rect.left_center() + vec2(2.0, 0.0)), Some("Approver")); + assert_eq!(crate::forms_ui::tab_under(&tabs, pos2(-5.0, -5.0)), None); + } + /// One page with three signature fields: Approver is empty, Witness is signed (a signature /// dictionary as its value is enough to list it as signed) and Locked is empty but read-only. const SIGN_FIELDS: &[u8] = b"%PDF-1.7 diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index d5aab8dc38..13cd15506d 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -119,6 +119,12 @@ fn widget_at<'a>(xf: &PageXform, info: &DocInfo, page: usize, f: &'a FormField, .map(|(i, w)| (f, i, w)) } +/// The field whose sign tab is under `p`, of `tabs` in the order they are painted: where tabs +/// overlap, the one painted last is on top. +pub(crate) fn tab_under<'a>(tabs: &[(&'a str, SignTab)], p: egui::Pos2) -> Option<&'a str> { + tabs.iter().rev().find(|(_, tab)| tab.rect.contains(p)).map(|(name, _)| *name) +} + /// Clicks on form widgets of one page. The pointer is on the first widget under it in the form's /// order, except on a sign tab (`tabs`, with the signature field each one signs), which is drawn /// over every field and so is on its own field. @@ -137,9 +143,8 @@ pub(crate) fn page_input( let pointer = ui.input(|i| i.pointer.hover_pos()); let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away }; // A tab lies inside its field's widget, so that widget is under the pointer too. - let on_tab = tabs.iter().find(|(_, tab)| tab.rect.contains(p)).and_then(|(name, _)| { - form.iter().filter(|f| f.name == *name && f.kind == FormFieldKind::Signature).find_map(|f| widget_at(xf, info, page, f, p)) - }); + let on_tab = tab_under(tabs, p) + .and_then(|name| form.iter().filter(|f| f.name == name && f.kind == FormFieldKind::Signature).find_map(|f| widget_at(xf, info, page, f, p))); let hit = on_tab.or_else(|| form.iter().find_map(|f| widget_at(xf, info, page, f, p))); let Some((f, wi, w)) = hit else { return FieldPointer::Away }; // Empty signature fields are signed by clicking them (Use a certificate); push buttons run From 596b28a19d4ded74e223a79be8fdedb7b3a41cd2 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:16:26 -0400 Subject: [PATCH 5/8] M9: no sign tab on a widget that is read-only on its own --- crates/ui-egui/src/canvas.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 344f286665..5567b387d8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -362,7 +362,8 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa /// rectangles (user space). Each is unsigned and visible, on this page, with a rectangle that /// passes the same screen-space guard as [`signature_badges`]. A click must also be able to sign /// it, as in `forms_ui::page_input`: the document allows filling (`can_fill`), and the form field -/// of that name is a signature field that isn't read-only, with a shown widget at that place. +/// of that name is a signature field that isn't read-only, with a shown widget at that place +/// that isn't read-only on its own. pub(crate) fn sign_tabs<'a>( signatures: &'a [pdfcraft_engine::SignatureInfo], form: &[pdfcraft_engine::FormField], @@ -382,7 +383,9 @@ pub(crate) fn sign_tabs<'a>( let signable: HashSet<(&str, [u64; 4])> = form .iter() .filter(|f| f.kind == pdfcraft_engine::FormFieldKind::Signature && !f.read_only()) - .flat_map(|f| f.widgets.iter().filter(|w| w.page == Some(page) && !w.hidden).map(move |w| (f.name.as_str(), w.rect.map(f64::to_bits)))) + .flat_map(|f| { + f.widgets.iter().filter(|w| w.page == Some(page) && !w.hidden && !w.read_only).map(move |w| (f.name.as_str(), w.rect.map(f64::to_bits))) + }) .collect(); empty.filter(|(name, r)| signable.contains(&(*name, r.map(f64::to_bits)))).collect() } @@ -5860,6 +5863,7 @@ mod sign_tab_tests { tab: 0, locked: false, hidden: false, + read_only: false, rotation: 0, caption: None, list: None, @@ -5916,7 +5920,7 @@ mod sign_tab_tests { /// Fields a click would not sign get no tab: a read-only field, a document that refuses /// filling, a listing with no form field of its name, a field of another kind of that name, - /// and a widget that is hidden or elsewhere. + /// and a widget that is hidden, read-only on its own or elsewhere. #[test] fn sign_tabs_skip_fields_that_a_click_cannot_sign() { let empty = || listing("Approver", 0, RECT); @@ -5931,6 +5935,7 @@ mod sign_tab_tests { ("no form field", empty(), Vec::new(), true), ("another kind", empty(), with(|f| f.kind = FormFieldKind::Text), true), ("hidden widget", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.hidden = true)), true), + ("read-only widget", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.read_only = true)), true), ("widget elsewhere", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.rect = [160.0, 110.0, 280.0, 180.0])), true), ]; assert_eq!(tabbed(&cases), Vec::<&str>::new()); From 166727d9f22f46c8e97b1a8cb87ac7acc8fd5bf5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:23:06 -0400 Subject: [PATCH 6/8] M9: the parity note says how to list the fields a sign tab marks headlessly --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cf784aac57..9b32d011c9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6014,7 +6014,7 @@ status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] evidence = ["crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/signing.rs::clicking_an_empty_signature_field_signs_it", "crates/ui-egui/src/canvas.rs::empty_signature_fields_show_a_sign_tab_that_signs_them", "crates/ui-egui/src/canvas.rs::sign_tabs_skip_fields_that_a_click_cannot_sign"] -notes = "Clicking an empty signature field signs it (Use a certificate for that field). With the Select tool, each empty field a click would sign (filling allowed, the field not read-only, its widget shown) carries a small accent tab in its top-left corner, sized to the field (0.4 of its height on screen, from 10 to 18 pt): the signature icon and Sign from 14 pt up, the icon alone below that or when the label would take more than half the field's width, and no tab on a field too small for the 10 pt chip. The tab is screen-only chrome, never written into the file, exports or printing, and is hidden while preparing a form. Missing: the tab is painted only, with no accessibility node or keyboard focus of its own" +notes = "Clicking an empty signature field signs it (Use a certificate for that field). With the Select tool, each empty field a click would sign (filling allowed, the field not read-only, its widget shown) carries a small accent tab in its top-left corner, sized to the field (0.4 of its height on screen, from 10 to 18 pt): the signature icon and Sign from 14 pt up, the icon alone below that or when the label would take more than half the field's width, and no tab on a field too small for the 10 pt chip. The tab is screen-only chrome, never written into the file, exports or printing, and is hidden while preparing a form. Headless, the fields it marks are the ones sign_list reports unsigned and visible on a page, with their rect, that form_fields doesn't report read-only. Missing: the tab is painted only, with no accessibility node or keyboard focus of its own" [[feature]] id = "sign.invisible-signature" From c4353194a75cbd4d428422bdc49fea01939bd5dd Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:26:33 -0400 Subject: [PATCH 7/8] M9: the sign tab's parity evidence names its tests' new file Main's module layout (#4889) moves the sign-tab unit tests to canvas/sign_tab_tests.rs. --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 07f1b5240b..a469747e92 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6060,7 +6060,7 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/it/signing.rs::clicking_an_empty_signature_field_signs_it", "crates/ui-egui/src/canvas.rs::empty_signature_fields_show_a_sign_tab_that_signs_them", "crates/ui-egui/src/canvas.rs::sign_tabs_skip_fields_that_a_click_cannot_sign"] +evidence = ["crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/ui-egui/tests/it/signing.rs::clicking_an_empty_signature_field_signs_it", "crates/ui-egui/src/canvas/sign_tab_tests.rs::empty_signature_fields_show_a_sign_tab_that_signs_them", "crates/ui-egui/src/canvas/sign_tab_tests.rs::sign_tabs_skip_fields_that_a_click_cannot_sign"] notes = "Clicking an empty signature field signs it (Use a certificate for that field). With the Select tool, each empty field a click would sign (filling allowed, the field not read-only, its widget shown) carries a small accent tab in its top-left corner, sized to the field (0.4 of its height on screen, from 10 to 18 pt): the signature icon and Sign from 14 pt up, the icon alone below that or when the label would take more than half the field's width, and no tab on a field too small for the 10 pt chip. The tab is screen-only chrome, never written into the file, exports or printing, and is hidden while preparing a form. Headless, the fields it marks are the ones sign_list reports unsigned and visible on a page, with their rect, that form_fields doesn't report read-only. Missing: the tab is painted only, with no accessibility node or keyboard focus of its own" [[feature]] From 0d80c48e70f5479e365878b00bf2508e0f13122e Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:41:29 -0500 Subject: [PATCH 8/8] Move the sign-tab setup out of page_tool_input to keep it within the complexity limit --- crates/ui-egui/src/canvas.rs | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1eba7aef93..8ba5322fa1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1513,16 +1513,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< // An empty field a click would sign carries a small tab saying so, shown only while // a click on the field signs it (the Select tool, not while preparing a form). The tab // is drawn over every field, so a click on it signs its field (`forms_ui::hit`). - let tabs: Vec<(&str, SignTab)> = if tool == QuickTool::Select && !preparing { - sign_tabs(&doc.signatures, form, i, can_fill) - .into_iter() - .filter_map(|(name, [x0, y0, x1, y1])| { - sign_tab_at(ui, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), xf.rect).map(|tab| (name, tab)) - }) - .collect() - } else { - Vec::new() - }; + let tabs = page_sign_tabs(ui, doc, form, info, slot, can_fill, tool == QuickTool::Select && !preparing); let hits = PageHits { page: i, xf: &xf, @@ -1742,6 +1733,30 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< PageInput { consumed, area_tool, sign_tabs: tabs.into_iter().map(|(_, tab)| tab).collect() } } +/// The sign tabs on the slot's page, on empty fields a click would sign, each with the field it +/// signs; none unless `shown`. +fn page_sign_tabs<'a>( + ui: &egui::Ui, + doc: &'a pdfcraft_engine::Document, + form: &[pdfcraft_engine::FormField], + info: &DocInfo, + slot: &PageSlot, + can_fill: bool, + shown: bool, +) -> Vec<(&'a str, SignTab)> { + let PageSlot { i, xf, .. } = slot; + let i = *i; + if !shown { + return Vec::new(); + } + sign_tabs(&doc.signatures, form, i, can_fill) + .into_iter() + .filter_map(|(name, [x0, y0, x1, y1])| { + sign_tab_at(ui, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), xf.rect).map(|tab| (name, tab)) + }) + .collect() +} + /// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. fn edit_inventory( view: &mut DocView,