From c0f9c73ae9445fe689a07cc08fd104fcf83326a5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 04:28:19 -0400 Subject: [PATCH] =?UTF-8?q?M6:=20Prepare=20a=20form=20=E2=96=B8=20Preview?= =?UTF-8?q?=20(fill=20the=20form=20as=20a=20reader=20would;=20Edit=20field?= =?UTF-8?q?s=20goes=20back)=20and=20Field=20Properties=20=E2=96=B8=20Locke?= =?UTF-8?q?d=20(annotation=20Locked=20flag;=20properties=20greyed=20out=20?= =?UTF-8?q?until=20unlocked,=20values=20still=20fillable);=20form=5Fset=5F?= =?UTF-8?q?props=20takes=20locked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/forms.rs | 1 + crates/automation/src/tools.rs | 1 + crates/forms/src/author.rs | 26 +- crates/forms/src/lib.rs | 8 + crates/forms/src/tests.rs | 14 ++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/src/panels.rs | 17 +- crates/ui-egui/src/prepare.rs | 408 +++++++++++++++++--------------- crates/ui-egui/tests/prepare.rs | 34 +++ parity/acrobat-features.toml | 18 +- 11 files changed, 329 insertions(+), 209 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 3375a263..3faa7dc2 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -355,6 +355,7 @@ impl Automation { Some(o) => return Err(bad(format!("unknown alignment {o:?} (left, center, right)"))), }, default_value: a.opt_str("default")?.map(|d| (!d.is_empty()).then(|| d.to_owned())), + locked: a.opt_bool("locked")?, flags: match a.get("flags") { None => Vec::new(), Some(v) => { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 18ada45f..90a261ee 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -260,6 +260,7 @@ pub fn tools() -> Vec { "max_length": { "type": "integer", "minimum": 0 }, "options": { "type": "array", "items": { "type": "string" } }, "align": { "type": "string", "enum": ["left", "center", "right"] }, + "locked": { "type": "boolean", "description": "Lock the field's properties (only unlocking is accepted while locked)." }, "default": { "type": "string" }, "flags": { "type": "object", "additionalProperties": { "type": "boolean" } }, "font_size": { "type": "number", "minimum": 0 }, diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 79871c82..bb5c5719 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -82,6 +82,8 @@ pub struct FieldProps { pub quadding: Option, /// Default value (`/DV`): what Reset form restores. pub default_value: Option>, + /// Lock or unlock the field (its widgets' Locked flag). + pub locked: Option, } fn invalid(m: impl Into) -> Result { @@ -431,7 +433,15 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel kids.push(Object::Ref(widget)); d.set(b"Kids".to_vec(), Object::Array(kids)); })?; - let w = Widget { obj: widget, page: Some(page), rect, on_state: Some(export.clone()), state: Some("Off".into()), tab: usize::MAX }; + let w = Widget { + obj: widget, + page: Some(page), + rect, + on_state: Some(export.clone()), + state: Some("Off".into()), + tab: usize::MAX, + locked: false, + }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } @@ -548,6 +558,20 @@ fn empty_box(doc: &Document, w: &Widget) -> Stream { pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + // A locked field only takes unlocking (Acrobat greys its properties out). + if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return invalid(format!("{name} is locked: unlock it first")); + } + if let Some(lock) = props.locked { + for w in &f.widgets { + let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); + let v = if lock { flags | 128 } else { flags & !128 }; + doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; + } + if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return Ok(name.to_string()); + } + } let mut new_name = name.to_string(); if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { if n.is_empty() || n.contains('.') { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 43646c04..66624200 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -84,6 +84,8 @@ pub struct Widget { /// Position in the document's tab order (pages in order; within a page by its `/Tabs`: /// rows, columns, or annotation/structure order). `usize::MAX` when not on a page. pub tab: usize, + /// The widget's Locked flag (`/F` bit 8): its properties can't be changed. + pub locked: bool, } /// A terminal form field. @@ -122,6 +124,11 @@ impl Field { self.has(flags::READ_ONLY) } + /// Locked (Field Properties ▸ General ▸ Locked): its properties can't be changed. + pub fn locked(&self) -> bool { + self.widgets.iter().any(|w| w.locked) + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { @@ -535,6 +542,7 @@ fn walk( on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), state: wd.name(b"AS").map(|s| String::from_utf8_lossy(s).into_owned()), tab: usize::MAX, + locked: wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 128 != 0, }) }) .collect(); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 2e5bbfb8..420d7e9c 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -594,3 +594,17 @@ fn duplicating_a_field_across_pages_shares_its_value() { assert_eq!(fields(&doc).len(), 1); assert!(duplicate_field(&mut doc.clone(), &name, &[7]).is_err()); } + +#[test] +fn locked_fields_only_take_unlocking() { + let mut doc = one_page(); + let name = add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("ID")).unwrap(); + set_props(&mut doc, &name, &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc)[0].locked()); + assert!(set_props(&mut doc, &name, &FieldProps { required: Some(true), ..FieldProps::default() }).is_err(), "locked"); + // Values can still be filled in. + set_value(&mut doc, &name, &FieldValue::Text("42".into())).unwrap(); + set_props(&mut doc, &name, &FieldProps { locked: Some(false), required: Some(true), ..FieldProps::default() }).unwrap(); + let f = &fields(&doc)[0]; + assert!(!f.locked() && f.has(flags::REQUIRED)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3346095c..f74f5f87 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -846,7 +846,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } let want_thumbs = app.right == Some(RightPanel::Pages) || app.views[index].organize || app.dialog == Some(crate::Dialog::Print); // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. - let preparing = (app.left_open && app.left == crate::LeftPanel::Tool("form")) || matches!(app.quick_tool, QuickTool::Field(_)); + let preparing = app.is_preparing(); // Edit a PDF: added text and images can be selected, moved and edited. let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || app.quick_tool == QuickTool::AddText; let text_style = app.text_style.clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 810b80d5..b3aebf3e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -279,6 +279,8 @@ pub struct PrintCraftApp { pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. pub duplicate_draft: Option, + /// Prepare a form ▸ Preview: fill the form instead of editing its fields. + pub form_preview: bool, /// Where autosaves go (`None`: autosave off, e.g. on the web and in tests). pub recovery: Option, /// Entries left by a previous session, offered in the Recovery dialog. @@ -388,6 +390,7 @@ impl PrintCraftApp { system_clipboard: true, attach_override: None, duplicate_draft: None, + form_preview: false, recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), @@ -422,6 +425,12 @@ impl PrintCraftApp { } } + /// Fields are being edited (Prepare a form is open or a field tool is picked), unless the + /// form is being previewed. + pub fn is_preparing(&self) -> bool { + ((self.left_open && self.left == LeftPanel::Tool("form")) || matches!(self.quick_tool, QuickTool::Field(_))) && !self.form_preview + } + /// Open a document the way it asks to be opened: navigation panel, layout, magnification, /// page (Document Properties ▸ Initial View). fn apply_initial_view(&mut self, index: usize, v: &printcraft_engine::InitialView) { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index aed61adf..a9d88a84 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -130,6 +130,19 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat stamp_palette(app, ui, t); return; } + // Prepare a form: Preview fills the form as a reader would; Edit goes back. + if g.id == "form" { + ui.horizontal(|ui| { + let label = if app.form_preview { "Edit fields" } else { "Preview" }; + if widgets::pill_button(ui, label, app.form_preview).on_hover_text("Try the form as people filling it in will see it").clicked() { + app.form_preview = !app.form_preview; + if app.form_preview { + app.quick_tool = crate::QuickTool::Select; + } + } + }); + ui.add_space(6.0); + } // Edit a PDF shows Format text at the top while text is selected or being added. if g.id == "edit" { format_section(app, ui, t); @@ -329,14 +342,14 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let mut sig_action: Option = None; let mut bm_rename = app.bookmark_rename.clone(); let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); + // Prepare a form is open: the Fields panel orders tabs. + let preparing = app.is_preparing(); { let Some(doc) = app.session.get(id) else { return }; let info = &doc.info; let view = &mut app.views[index]; let prefs = &app.comment_prefs; let sig_expanded = &mut app.sig_expanded; - // Prepare a form is open: the Fields panel orders tabs. - let preparing = (app.left_open && app.left == LeftPanel::Tool("form")) || matches!(app.quick_tool, crate::QuickTool::Field(_)); let comment_allowed = doc.allows_annotation(); egui::Panel::right("right_panel") .resizable(true) diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index d7a420c2..2039f551 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -577,6 +577,8 @@ pub struct FieldDraft { pub new_option: String, /// `/Ff` as edited on the Options tab. pub flags: u32, + /// Field Properties ▸ General ▸ Locked. + pub locked: bool, /// `/Q`: 0 left, 1 centre, 2 right. pub quadding: i64, /// `/DV` (text and choices), or the default state (buttons: empty = off). @@ -619,6 +621,7 @@ impl FieldDraft { options: f.options.iter().map(|(_, d)| d.clone()).collect(), new_option: String::new(), flags: f.flags, + locked: f.locked(), quadding: f.quadding, default: f.default.first().cloned().unwrap_or_default(), on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(), @@ -681,6 +684,7 @@ impl FieldDraft { calculate: (self.calculate != o.calculate).then(|| self.calculate.clone()), flags: OPTION_FLAGS.iter().filter(|b| (self.flags ^ o.flags) & **b != 0).map(|b| (*b, self.flags & b != 0)).collect(), quadding: (self.quadding != o.quadding).then_some(self.quadding), + locked: ch(self.locked, o.locked), default_value: (self.default != o.default).then(|| (!self.default.is_empty()).then(|| self.default.clone())), }; (p != FieldProps::default()).then_some(p) @@ -732,225 +736,237 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.separator(); ui.add_space(6.0); let mut enter = false; - match d.tab { - FieldTab::General => { - egui::Grid::new("field-general").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - let l = ui.label("Name:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(340.0)).labelled_by(l.id); - enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - ui.end_row(); - let l = ui.label("Tooltip:"); - ui.add(egui::TextEdit::singleline(&mut d.tooltip).desired_width(340.0)).labelled_by(l.id); - ui.end_row(); - }); - ui.add_space(12.0); - widgets::section_title(ui, "Common Properties"); - ui.checkbox(&mut d.read_only, "Read Only"); - ui.checkbox(&mut d.required, "Required"); + // A locked field keeps its properties until it is unlocked (Acrobat greys them out). + let was_locked = d.original.as_ref().as_ref().is_some_and(|o| o.locked) && d.locked; + if was_locked { + ui.label(egui::RichText::new("This field is locked. Unlock it to change its properties.").small().color(t.text_muted)); + } + ui.scope(|ui| { + if was_locked { + ui.disable(); } - FieldTab::Appearance => { - egui::Grid::new("field-appearance").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - ui.label("Font Size:"); - let mut auto = d.font_size == 0.0; - ui.horizontal(|ui| { - if ui.checkbox(&mut auto, "Auto").changed() { - d.font_size = if auto { 0.0 } else { 12.0 }; - } - ui.add_enabled(!auto, egui::DragValue::new(&mut d.font_size).range(2.0..=100.0).speed(0.25).suffix(" pt")); + match d.tab { + FieldTab::General => { + egui::Grid::new("field-general").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + let l = ui.label("Name:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(340.0)).labelled_by(l.id); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + let l = ui.label("Tooltip:"); + ui.add(egui::TextEdit::singleline(&mut d.tooltip).desired_width(340.0)).labelled_by(l.id); + ui.end_row(); }); - ui.end_row(); - if let Some(l) = d.look.as_mut() { - ui.label("Font:"); - egui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { - for f in FieldFont::ALL { - ui.selectable_value(&mut l.font, f, f.label()); + ui.add_space(12.0); + widgets::section_title(ui, "Common Properties"); + ui.checkbox(&mut d.read_only, "Read Only"); + ui.checkbox(&mut d.required, "Required"); + } + FieldTab::Appearance => { + egui::Grid::new("field-appearance").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + ui.label("Font Size:"); + let mut auto = d.font_size == 0.0; + ui.horizontal(|ui| { + if ui.checkbox(&mut auto, "Auto").changed() { + d.font_size = if auto { 0.0 } else { 12.0 }; } + ui.add_enabled(!auto, egui::DragValue::new(&mut d.font_size).range(2.0..=100.0).speed(0.25).suffix(" pt")); }); ui.end_row(); - ui.label("Text Color:"); - if let Some(c) = crate::comments::swatch_grid(ui, Some(l.text)) { - l.text = c; - } - ui.end_row(); - } - }); - if let Some(l) = d.look.as_mut() { - widgets::section_title(ui, "Borders and Colors"); - egui::Grid::new("field-borders").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - for (label, slot) in [("Border Color:", &mut l.border), ("Fill Color:", &mut l.fill)] { - ui.label(label); - ui.horizontal(|ui| { - let mut none = slot.is_none(); - if ui.checkbox(&mut none, "No color").changed() { - *slot = if none { None } else { Some([0.0; 3]) }; - } - if let Some(c) = crate::comments::swatch_grid(ui, *slot) { - *slot = Some(c); + if let Some(l) = d.look.as_mut() { + ui.label("Font:"); + egui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { + for f in FieldFont::ALL { + ui.selectable_value(&mut l.font, f, f.label()); } }); ui.end_row(); - } - ui.label("Line Thickness:"); - let thick = |w: f64| { - if w <= 1.0 { - "Thin" - } else if w <= 2.0 { - "Medium" - } else { - "Thick" + ui.label("Text Color:"); + if let Some(c) = crate::comments::swatch_grid(ui, Some(l.text)) { + l.text = c; } - }; - egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { - for (w, label) in [(1.0, "Thin"), (2.0, "Medium"), (3.0, "Thick")] { - ui.selectable_value(&mut l.width, w, label); - } - }); - ui.end_row(); - ui.label("Line Style:"); - egui::ComboBox::from_id_salt("field-style").selected_text(l.style.label()).show_ui(ui, |ui| { - for s in BorderStyle::ALL { - ui.selectable_value(&mut l.style, s, s.label()); - } - }); - ui.end_row(); - }); - } - } - FieldTab::Position => { - egui::Grid::new("field-position").num_columns(4).spacing([12.0, 10.0]).show(ui, |ui| { - for (i, label) in ["Left:", "Bottom:", "Width:", "Height:"].into_iter().enumerate() { - ui.label(label); - let range = if i < 2 { -14_400.0..=14_400.0 } else { 4.0..=14_400.0 }; - ui.add(egui::DragValue::new(&mut d.position[i]).range(range).speed(0.5).suffix(" pt")); - if i % 2 == 1 { ui.end_row(); } - } - }); - ui.label(egui::RichText::new("Points from the page's bottom-left corner.").small().color(t.text_faint)); - } - FieldTab::Format => format_tab(ui, d, t), - FieldTab::Validate => validate_tab(ui, d), - FieldTab::Calculate => calculate_tab(ui, d, t), - FieldTab::Options => match d.kind { - FormFieldKind::Text => { - use printcraft_engine::field_flags as ff; - egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Alignment:"); - egui::ComboBox::from_id_salt("quadding").selected_text(["Left", "Center", "Right"][d.quadding.clamp(0, 2) as usize]).show_ui( - ui, - |ui| { - for (q, l) in [(0, "Left"), (1, "Center"), (2, "Right")] { - ui.selectable_value(&mut d.quadding, q, l); - } - }, - ); - ui.end_row(); - let l = ui.label("Default Value:"); - ui.add(egui::TextEdit::singleline(&mut d.default).desired_width(260.0)).labelled_by(l.id); - ui.end_row(); }); - ui.add_space(6.0); - ui.checkbox(&mut d.multiline, "Multi-line"); - flag_box(ui, &mut d.flags, ff::DO_NOT_SCROLL, true, "Scroll long text"); - flag_box(ui, &mut d.flags, ff::RICH_TEXT, false, "Allow Rich Text Formatting"); - ui.horizontal(|ui| { - ui.checkbox(&mut d.limit, "Limit of"); - ui.add_enabled(d.limit, egui::DragValue::new(&mut d.max_len).range(0..=10_000)); - ui.label("characters"); - }); - flag_box(ui, &mut d.flags, ff::PASSWORD, false, "Password"); - flag_box(ui, &mut d.flags, ff::FILE_SELECT, false, "Field is used for file selection"); - flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, "Check spelling"); - ui.horizontal(|ui| { - flag_box(ui, &mut d.flags, ff::COMB, false, "Comb of"); - if d.flags & ff::COMB != 0 { - d.limit = true; - d.multiline = false; - d.flags &= !(ff::PASSWORD | ff::FILE_SELECT); - if d.max_len == 0 { - d.max_len = 10; - } - } - ui.add_enabled(d.flags & ff::COMB != 0, egui::DragValue::new(&mut d.max_len).range(1..=500)); - ui.label("characters"); - }); - } - FormFieldKind::CheckBox | FormFieldKind::Radio => { - use printcraft_engine::field_flags as ff; - let on = d.on_state.clone(); - ui.label(format!("Export Value: {on}")); - let mut checked = !d.default.is_empty() && d.default == on; - let label = if d.kind == FormFieldKind::CheckBox { "Check box is checked by default" } else { "Button is checked by default" }; - if ui.checkbox(&mut checked, label).changed() { - d.default = if checked { on } else { String::new() }; - } - if d.kind == FormFieldKind::Radio { - flag_box(ui, &mut d.flags, ff::RADIOS_IN_UNISON, false, "Buttons with the same name and value are selected in unison"); - } - } - _ => { - ui.horizontal(|ui| { - let l = ui.label("Item:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.new_option).desired_width(240.0)).labelled_by(l.id); - let typed = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if (widgets::pill_button(ui, "Add", false).clicked() || typed) && !d.new_option.trim().is_empty() { - d.options.push(d.new_option.trim().to_string()); - d.new_option.clear(); - } - }); - ui.add_space(6.0); - widgets::section_title(ui, "Item List"); - let mut remove = None; - let mut up = None; - egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { - for (i, o) in d.options.iter().enumerate() { - ui.horizontal(|ui| { - ui.label(o); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Delete").on_hover_text(format!("Delete {o}")).clicked() { - remove = Some(i); + if let Some(l) = d.look.as_mut() { + widgets::section_title(ui, "Borders and Colors"); + egui::Grid::new("field-borders").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (label, slot) in [("Border Color:", &mut l.border), ("Fill Color:", &mut l.fill)] { + ui.label(label); + ui.horizontal(|ui| { + let mut none = slot.is_none(); + if ui.checkbox(&mut none, "No color").changed() { + *slot = if none { None } else { Some([0.0; 3]) }; } - if i > 0 && ui.small_button("Up").clicked() { - up = Some(i); + if let Some(c) = crate::comments::swatch_grid(ui, *slot) { + *slot = Some(c); } }); + ui.end_row(); + } + ui.label("Line Thickness:"); + let thick = |w: f64| { + if w <= 1.0 { + "Thin" + } else if w <= 2.0 { + "Medium" + } else { + "Thick" + } + }; + egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { + for (w, label) in [(1.0, "Thin"), (2.0, "Medium"), (3.0, "Thick")] { + ui.selectable_value(&mut l.width, w, label); + } }); + ui.end_row(); + ui.label("Line Style:"); + egui::ComboBox::from_id_salt("field-style").selected_text(l.style.label()).show_ui(ui, |ui| { + for s in BorderStyle::ALL { + ui.selectable_value(&mut l.style, s, s.label()); + } + }); + ui.end_row(); + }); + } + } + FieldTab::Position => { + egui::Grid::new("field-position").num_columns(4).spacing([12.0, 10.0]).show(ui, |ui| { + for (i, label) in ["Left:", "Bottom:", "Width:", "Height:"].into_iter().enumerate() { + ui.label(label); + let range = if i < 2 { -14_400.0..=14_400.0 } else { 4.0..=14_400.0 }; + ui.add(egui::DragValue::new(&mut d.position[i]).range(range).speed(0.5).suffix(" pt")); + if i % 2 == 1 { + ui.end_row(); + } } }); - if let Some(i) = remove { - d.options.remove(i); + ui.label(egui::RichText::new("Points from the page's bottom-left corner.").small().color(t.text_faint)); + } + FieldTab::Format => format_tab(ui, d, t), + FieldTab::Validate => validate_tab(ui, d), + FieldTab::Calculate => calculate_tab(ui, d, t), + FieldTab::Options => match d.kind { + FormFieldKind::Text => { + use printcraft_engine::field_flags as ff; + egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Alignment:"); + egui::ComboBox::from_id_salt("quadding").selected_text(["Left", "Center", "Right"][d.quadding.clamp(0, 2) as usize]).show_ui( + ui, + |ui| { + for (q, l) in [(0, "Left"), (1, "Center"), (2, "Right")] { + ui.selectable_value(&mut d.quadding, q, l); + } + }, + ); + ui.end_row(); + let l = ui.label("Default Value:"); + ui.add(egui::TextEdit::singleline(&mut d.default).desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + }); + ui.add_space(6.0); + ui.checkbox(&mut d.multiline, "Multi-line"); + flag_box(ui, &mut d.flags, ff::DO_NOT_SCROLL, true, "Scroll long text"); + flag_box(ui, &mut d.flags, ff::RICH_TEXT, false, "Allow Rich Text Formatting"); + ui.horizontal(|ui| { + ui.checkbox(&mut d.limit, "Limit of"); + ui.add_enabled(d.limit, egui::DragValue::new(&mut d.max_len).range(0..=10_000)); + ui.label("characters"); + }); + flag_box(ui, &mut d.flags, ff::PASSWORD, false, "Password"); + flag_box(ui, &mut d.flags, ff::FILE_SELECT, false, "Field is used for file selection"); + flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, "Check spelling"); + ui.horizontal(|ui| { + flag_box(ui, &mut d.flags, ff::COMB, false, "Comb of"); + if d.flags & ff::COMB != 0 { + d.limit = true; + d.multiline = false; + d.flags &= !(ff::PASSWORD | ff::FILE_SELECT); + if d.max_len == 0 { + d.max_len = 10; + } + } + ui.add_enabled(d.flags & ff::COMB != 0, egui::DragValue::new(&mut d.max_len).range(1..=500)); + ui.label("characters"); + }); } - if let Some(i) = up { - d.options.swap(i - 1, i); + FormFieldKind::CheckBox | FormFieldKind::Radio => { + use printcraft_engine::field_flags as ff; + let on = d.on_state.clone(); + ui.label(format!("Export Value: {on}")); + let mut checked = !d.default.is_empty() && d.default == on; + let label = if d.kind == FormFieldKind::CheckBox { "Check box is checked by default" } else { "Button is checked by default" }; + if ui.checkbox(&mut checked, label).changed() { + d.default = if checked { on } else { String::new() }; + } + if d.kind == FormFieldKind::Radio { + flag_box(ui, &mut d.flags, ff::RADIOS_IN_UNISON, false, "Buttons with the same name and value are selected in unison"); + } } - if d.options.is_empty() { - ui.label(egui::RichText::new("Add the choices people pick from.").color(t.text_muted)); - } - use printcraft_engine::field_flags as ff; - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label("Default:"); - let shown = if d.default.is_empty() { "(none)".to_string() } else { d.default.clone() }; - egui::ComboBox::from_id_salt("choice-default").selected_text(shown).show_ui(ui, |ui| { - ui.selectable_value(&mut d.default, String::new(), "(none)"); - for o in d.options.clone() { - ui.selectable_value(&mut d.default, o.clone(), o); + _ => { + ui.horizontal(|ui| { + let l = ui.label("Item:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.new_option).desired_width(240.0)).labelled_by(l.id); + let typed = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if (widgets::pill_button(ui, "Add", false).clicked() || typed) && !d.new_option.trim().is_empty() { + d.options.push(d.new_option.trim().to_string()); + d.new_option.clear(); } }); - }); - flag_box(ui, &mut d.flags, ff::SORT, false, "Sort items"); - if d.kind == FormFieldKind::Combo { - flag_box(ui, &mut d.flags, ff::EDIT, false, "Allow user to enter custom text"); - flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, "Check spelling"); - } else { - flag_box(ui, &mut d.flags, ff::MULTI_SELECT, false, "Multiple selection"); + ui.add_space(6.0); + widgets::section_title(ui, "Item List"); + let mut remove = None; + let mut up = None; + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for (i, o) in d.options.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(o); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Delete").on_hover_text(format!("Delete {o}")).clicked() { + remove = Some(i); + } + if i > 0 && ui.small_button("Up").clicked() { + up = Some(i); + } + }); + }); + } + }); + if let Some(i) = remove { + d.options.remove(i); + } + if let Some(i) = up { + d.options.swap(i - 1, i); + } + if d.options.is_empty() { + ui.label(egui::RichText::new("Add the choices people pick from.").color(t.text_muted)); + } + use printcraft_engine::field_flags as ff; + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Default:"); + let shown = if d.default.is_empty() { "(none)".to_string() } else { d.default.clone() }; + egui::ComboBox::from_id_salt("choice-default").selected_text(shown).show_ui(ui, |ui| { + ui.selectable_value(&mut d.default, String::new(), "(none)"); + for o in d.options.clone() { + ui.selectable_value(&mut d.default, o.clone(), o); + } + }); + }); + flag_box(ui, &mut d.flags, ff::SORT, false, "Sort items"); + if d.kind == FormFieldKind::Combo { + flag_box(ui, &mut d.flags, ff::EDIT, false, "Allow user to enter custom text"); + flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, "Check spelling"); + } else { + flag_box(ui, &mut d.flags, ff::MULTI_SELECT, false, "Multiple selection"); + } + flag_box(ui, &mut d.flags, ff::COMMIT_ON_SEL_CHANGE, false, "Commit selected value immediately"); } - flag_box(ui, &mut d.flags, ff::COMMIT_ON_SEL_CHANGE, false, "Commit selected value immediately"); - } - }, - } - ui.add_space(14.0); + }, + } + }); + ui.add_space(8.0); + ui.checkbox(&mut d.locked, "Locked"); + ui.add_space(6.0); let (mut apply, mut cancel) = (enter, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::pill_button(ui, "OK", true).clicked() { diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 6127acd9..f3ca09a7 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -284,3 +284,37 @@ fn aligning_distributing_and_sizing_several_fields() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Match field sizes")); assert!(arrange(&form(&h), &a, &[("b".into(), 0)], Arrange::DistributeV).is_none(), "distributing needs three"); } + +#[test] +fn preview_fills_the_form_and_locked_fields_keep_their_properties() { + let mut h = harness(); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(2); + assert!(h.state().is_preparing()); + h.get_by_label("Preview").click(); + h.run_steps(2); + assert!(!h.state().is_preparing(), "Preview fills the form"); + h.get_by_label("Edit fields").click(); + h.run_steps(2); + assert!(h.state().is_preparing()); + // Lock a field: its properties are then greyed out until unlocked. + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label("Locked").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + let locked = |h: &Harness<'static, PrintCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().locked() + }; + assert!(locked(&h)); + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label_contains("This field is locked"); + h.get_by_label("Locked").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(!locked(&h)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 85692ea0..29592ced 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4911,11 +4911,11 @@ title = "Rename and lock fields" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.prepare"] +tools = ["form_set_props"] +evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"] +notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" [[feature]] id = "form.tab-order" @@ -4947,11 +4947,11 @@ title = "Preview mode for testing a form" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] +status = "shipped" +commands = ["form.prepare"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties"] +notes = "Preview / Edit fields toggle in the Prepare a form panel" [[feature]] id = "form.auto-detect-fields"