diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 263569367..9713791e3 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -16,6 +16,14 @@ use crate::page_pointer::PagePointer; use crate::theme; use crate::widgets; +mod draft; +mod properties; +mod tabs; + +pub use draft::{ACTION_KINDS, ActionDraft, FieldDraft, FieldTab}; +pub(crate) use draft::{da_font, da_size}; +pub(crate) use properties::body; + const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Acrobat's field fill in Prepare Form (light blue). pub(crate) const FIELD_FILL: Color32 = Color32::from_rgba_premultiplied(0x1B, 0x34, 0x54, 0x59); @@ -727,1013 +735,6 @@ pub(crate) fn after_refresh(view: &mut DocView, form: &[FormField]) { } } -// ───────────────────────────────────────────────────────────────────────── Field Properties - -impl crate::PdfCraftApp { - /// Open Field Properties for a field of the active document. - pub fn open_field_props(&mut self, name: &str, widget: usize) { - let Some((index, id)) = self.active_ids() else { return }; - let names = self.views[index].prepare.names(); - if names.len() > 1 && names.iter().any(|n| n == name) { - let draft = self.session.get(id).map_or(Err("The document is no longer open."), |doc| crate::bulk_fields::Draft::new(doc, names)); - match draft { - Ok(draft) => { - self.field_props = None; - self.bulk_field_props = Some(draft); - self.dialog = Some(crate::Dialog::BulkFieldProps); - } - Err(why) => self.notify_tr(why), - } - return; - } - self.bulk_field_props = None; - let Some(f) = self.session.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).cloned()) else { return }; - let mut d = FieldDraft::new(&f, widget); - d.look = self.session.get(id).and_then(|doc| doc.field_look(name)); - d.check_style = self.session.get(id).and_then(|doc| doc.field_check_style(name)); - if let Some(o) = d.original.as_mut() { - o.look = d.look; - o.check_style = d.check_style; - } - let others: Vec = - self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); - d.others = others.clone(); - d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default(); - d.chained = self.session.get(id).map(|doc| doc.field_action_chains(name)).unwrap_or_default(); - if let Some(o) = d.original.as_mut() { - o.others = others; - o.actions = d.actions.clone(); - } - self.field_props = Some(d); - self.dialog = Some(crate::Dialog::FieldProps); - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldTab { - General, - Appearance, - Position, - Options, - Format, - Validate, - Calculate, - Actions, -} - -/// Actions tab: the "Select Action" choices. -pub const ACTION_KINDS: [&str; 8] = [ - "Run a JavaScript", - "Open a web link", - "Reset a form", - "Execute a menu item", - "Go to a page view", - "Show a field", - "Hide a field", - "Submit a form", -]; - -/// Actions tab: the action being added. -#[derive(Clone, Debug, PartialEq)] -pub struct ActionDraft { - pub trigger: pdfcraft_engine::FieldTrigger, - pub kind: usize, - /// The script, URL, field names (comma-separated), menu item or page number. - pub text: String, -} - -impl Default for ActionDraft { - fn default() -> Self { - ActionDraft { trigger: pdfcraft_engine::FieldTrigger::MouseUp, kind: 0, text: String::new() } - } -} - -impl ActionDraft { - pub fn action(&self) -> Result { - use pdfcraft_engine::FieldAction as A; - let t = self.text.trim(); - let names = || t.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect::>(); - Ok(match self.kind { - 0 => A::JavaScript(self.text.clone()), - 1 if !t.is_empty() => A::Uri(t.to_string()), - 2 => A::Reset(names()), - 3 => A::Named(if t.is_empty() { "Print".into() } else { t.to_string() }), - 4 => A::GoTo(t.parse::().ok().filter(|p| *p >= 1).ok_or("Enter a page number")? - 1), - 5 if !t.is_empty() => A::ShowHide { fields: names(), hide: false }, - 6 if !t.is_empty() => A::ShowHide { fields: names(), hide: true }, - 7 if !t.is_empty() => A::Submit(t.to_string()), - 1 | 7 => return Err("Enter a URL".into()), - _ => return Err("Enter the field names".into()), - }) - } -} - -/// The Field Properties dialog's working copy. -#[derive(Clone, Debug, PartialEq)] -pub struct FieldDraft { - pub field: String, - pub kind: FormFieldKind, - pub widget: usize, - pub tab: FieldTab, - pub name: String, - pub tooltip: String, - pub caption: String, - pub read_only: bool, - pub required: bool, - pub multiline: bool, - pub limit: bool, - pub max_len: usize, - pub options: Vec, - 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). - pub default: String, - /// Check boxes and radio buttons: this widget's "on" state (export value). - pub on_state: String, - /// 0 = auto. - pub font_size: f64, - /// Left, bottom, width, height in points. - pub position: [f64; 4], - /// `/MK /R`: 0, 90, 180 or 270 degrees counterclockwise. - pub rotation: i64, - pub look: Option, - /// Check boxes and radio buttons: the mark when on (Options tab). - pub check_style: Option, - pub format: Format, - pub validate: Validate, - pub calculate: Calculate, - /// Every other field's name (the Calculate tab picks from them). - pub others: Vec, - /// Actions tab: each trigger's action, and the one being added. - pub actions: Vec<(pdfcraft_engine::FieldTrigger, pdfcraft_engine::FieldAction)>, - pub new_action: ActionDraft, - /// Actions tab: the trigger whose script `new_action` is editing (Edit), if any. - pub editing: Option, - /// Actions tab: how many actions the document chains after each trigger's first one. They are - /// kept when that first action is replaced. - pub chained: Vec<(pdfcraft_engine::FieldTrigger, usize)>, - original: Box>, -} - -pub(crate) fn da_size(da: &str) -> f64 { - let mut previous = ""; - let mut size = 0.0; - for token in da.split_whitespace() { - if token == "Tf" { - size = previous.parse::().ok().filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); - } - previous = token; - } - size -} - -pub(crate) fn da_font(da: &str) -> &str { - let (mut two_back, mut previous, mut font) = ("", "", "Helv"); - for token in da.split_whitespace() { - if token == "Tf" { - font = two_back.trim_start_matches('/'); - } - two_back = previous; - previous = token; - } - font -} - -impl FieldDraft { - pub fn new(f: &FormField, widget: usize) -> Self { - let r = f.widgets.get(widget).map(|w| w.rect).unwrap_or_default(); - let mut d = FieldDraft { - field: f.name.clone(), - kind: f.kind, - widget, - tab: FieldTab::General, - // The full name: a dotted name moves the field under those parent fields (#2696). - name: f.name.clone(), - tooltip: f.tooltip.clone().unwrap_or_default(), - caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), - read_only: f.read_only(), - required: f.has(pdfcraft_engine::field_flags::REQUIRED), - multiline: f.has(pdfcraft_engine::field_flags::MULTILINE), - limit: f.max_len.is_some(), - max_len: f.max_len.unwrap_or(0), - 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(), - font_size: da_size(&f.da), - position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], - rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0), - look: None, - check_style: None, - format: f.actions.format.clone(), - validate: f.actions.validate.clone(), - calculate: f.actions.calculate.clone(), - others: Vec::new(), - actions: Vec::new(), - new_action: ActionDraft::default(), - editing: None, - chained: Vec::new(), - original: Box::new(None), - }; - d.original = Box::new(Some(d.clone())); - d - } - - pub fn title(&self) -> &'static str { - match self.kind { - FormFieldKind::Text => "Text Field Properties", - FormFieldKind::CheckBox => "Check Box Properties", - FormFieldKind::Radio => "Radio Button Properties", - FormFieldKind::Combo => "Dropdown Properties", - FormFieldKind::List => "List Box Properties", - FormFieldKind::PushButton => "Button Properties", - FormFieldKind::Signature => "Digital Signature Properties", - } - } - - fn tabs(&self) -> Vec<(FieldTab, &'static str)> { - let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")]; - if self.kind != FormFieldKind::Signature { - t.push((FieldTab::Options, "Options")); - } - if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) { - t.extend([(FieldTab::Format, "Format"), (FieldTab::Validate, "Validate"), (FieldTab::Calculate, "Calculate")]); - } - t.push((FieldTab::Actions, "Actions")); - t - } - - /// The properties that changed (`None` when nothing did). - pub fn props(&self) -> Option { - let o = self.original.as_ref().as_ref()?; - let ch = |a: bool, b: bool| (a != b).then_some(a); - let p = FieldProps { - name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()), - tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()), - caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())), - read_only: ch(self.read_only, o.read_only), - required: ch(self.required, o.required), - multiline: ch(self.multiline, o.multiline), - max_len: ((self.limit, self.max_len) != (o.limit, o.max_len)).then_some((self.limit && self.max_len > 0).then_some(self.max_len)), - options: (self.options != o.options).then(|| self.options.clone()), - font_size: ((self.font_size - o.font_size).abs() > 1e-6).then_some(self.font_size), - rect: (self.position != o.position).then(|| { - let [x, y, w, h] = self.position; - (self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)]) - }), - rotation: (self.rotation != o.rotation).then_some((self.widget, self.rotation)), - look: (self.look != o.look).then_some(self.look).flatten(), - appearance: None, - check_style: (self.check_style != o.check_style).then_some(self.check_style).flatten(), - format: (self.format != o.format).then(|| self.format.clone()), - validate: (self.validate != o.validate).then(|| self.validate.clone()), - 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), - actions: (self.actions != o.actions).then(|| self.actions.clone()), - default_value: (self.default != o.default).then(|| (!self.default.is_empty()).then(|| self.default.clone())), - }; - (p != FieldProps::default()).then_some(p) - } -} - -/// The `/Ff` bits the Options tab edits. -const OPTION_FLAGS: [u32; 10] = { - use pdfcraft_engine::field_flags as ff; - [ - ff::DO_NOT_SCROLL, - ff::RICH_TEXT, - ff::PASSWORD, - ff::FILE_SELECT, - ff::DO_NOT_SPELL_CHECK, - ff::COMB, - ff::SORT, - ff::EDIT, - ff::MULTI_SELECT, - ff::COMMIT_ON_SEL_CHANGE, - ] -}; - -/// A check box for a `/Ff` bit (`inverted`: checked when the bit is clear). -fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: &str) { - let mut on = (*flags & bit != 0) != inverted; - if ui.add(widgets::checkbox(&mut on, label)).changed() { - if on != inverted { - *flags |= bit; - } else { - *flags &= !bit; - } - } -} - -/// Shared PDF `/Q` picker for text and choice-field Options. -fn alignment(ui: &mut egui::Ui, quadding: &mut i64) { - ui.label(tl!("Alignment:")); - egui::ComboBox::from_id_salt("quadding") - .selected_text(tl!(match (*quadding).clamp(0, 2) { - 1 => "Center", - 2 => "Right", - _ => "Left", - })) - .show_ui(ui, |ui| { - for (q, label) in [(0, tl!("Left")), (1, tl!("Center")), (2, tl!("Right"))] { - ui.selectable_value(quadding, q, label); - } - }); -} - -/// Draw Field Properties; returns (apply, cancel). -pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) -> (bool, bool) { - use crate::widgets; - ui.set_width(600.0); - ui.label(egui::RichText::new(tl!(d.title())).font(theme::semibold(18.0))); - ui.add_space(6.0); - ui.horizontal_wrapped(|ui| { - for (tab, label) in d.tabs() { - if widgets::mode_tab(ui, tl!(label), d.tab == tab).clicked() { - d.tab = tab; - } - } - }); - ui.separator(); - ui.add_space(6.0); - let mut enter = false; - // 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(tl!("This field is locked. Unlock it to change its properties.")).small().color(t.text_muted)); - } - ui.scope(|ui| { - if was_locked { - ui.disable(); - } - 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(tl!("Name:")); - let r = ui.add(crate::widgets::line(&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(tl!("Tooltip:")); - ui.add(crate::widgets::line(&mut d.tooltip).desired_width(340.0)).labelled_by(l.id); - ui.end_row(); - }); - ui.add_space(12.0); - widgets::section_title(ui, tl!("Common Properties")); - ui.add(widgets::checkbox(&mut d.read_only, tl!("Read Only"))); - ui.add(widgets::checkbox(&mut d.required, tl!("Required"))); - } - FieldTab::Appearance => { - egui::Grid::new("field-appearance").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - ui.label(tl!("Font Size:")); - let mut auto = d.font_size == 0.0; - ui.horizontal(|ui| { - if ui.add(widgets::checkbox(&mut auto, tl!("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).clamp_existing_to_range(false).speed(0.25).suffix(" pt"), - ); - }); - ui.end_row(); - if let Some(l) = d.look.as_mut() { - ui.label(tl!("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(tl!("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, tl!("Borders and Colors")); - egui::Grid::new("field-borders").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - for (index, (label, slot)) in - [(tl!("Border Color:"), &mut l.border), (tl!("Fill Color:"), &mut l.fill)].into_iter().enumerate() - { - ui.label(label); - ui.push_id(("field-color", index), |ui| { - ui.horizontal(|ui| { - let mut none = slot.is_none(); - if ui.add(widgets::checkbox(&mut none, tl!("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); - } - }); - }); - ui.end_row(); - } - ui.label(tl!("Line Thickness:")); - let thick = |w: f64| { - if w <= 1.0 { - tl!("Thin") - } else if w <= 2.0 { - tl!("Medium") - } else { - tl!("Thick") - } - }; - egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { - for (w, label) in [(1.0, tl!("Thin")), (2.0, tl!("Medium")), (3.0, tl!("Thick"))] { - ui.selectable_value(&mut l.width, w, label); - } - }); - ui.end_row(); - ui.label(tl!("Line Style:")); - egui::ComboBox::from_id_salt("field-style").selected_text(tl!(l.style.label())).show_ui(ui, |ui| { - for s in BorderStyle::ALL { - ui.selectable_value(&mut l.style, s, tl!(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 [tl!("Left:"), tl!("Bottom:"), tl!("Width:"), tl!("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(tl!("Points from the page's bottom-left corner.")).small().color(t.text_faint)); - ui.add_space(8.0); - ui.horizontal(|ui| { - let l = ui.label(tl!("Rotation:")); - let before = d.rotation; - egui::ComboBox::from_id_salt("field-rotation") - .selected_text(format!("{}°", d.rotation)) - .show_ui(ui, |ui| { - for r in [0, 90, 180, 270] { - if ui.selectable_value(&mut d.rotation, r, format!("{r}°")).changed() && (before % 180 == 0) != (r % 180 == 0) { - // Keep the same center. set_props skips its own swap when the rect changed too. - let [x, y, w, h] = d.position; - let (cx, cy) = (x + w / 2.0, y + h / 2.0); - d.position = [cx - h / 2.0, cy - w / 2.0, h, w]; - } - } - }) - .response - .labelled_by(l.id); - }); - } - FieldTab::Format => format_tab(ui, d, t), - FieldTab::Validate => validate_tab(ui, d), - FieldTab::Calculate => calculate_tab(ui, d, t), - FieldTab::Actions => actions_tab(ui, d, t), - FieldTab::Options => match d.kind { - FormFieldKind::PushButton => { - ui.horizontal(|ui| { - let l = ui.label(tl!("Label")); - ui.add(crate::widgets::line(&mut d.caption).desired_width(340.0)).labelled_by(l.id); - }); - } - FormFieldKind::Text => { - use pdfcraft_engine::field_flags as ff; - egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - alignment(ui, &mut d.quadding); - ui.end_row(); - let l = ui.label(tl!("Default Value:")); - ui.add(crate::widgets::line(&mut d.default).desired_width(260.0)).labelled_by(l.id); - ui.end_row(); - }); - ui.add_space(6.0); - ui.add(widgets::checkbox(&mut d.multiline, tl!("Multi-line"))); - flag_box(ui, &mut d.flags, ff::DO_NOT_SCROLL, true, tl!("Scroll long text")); - flag_box(ui, &mut d.flags, ff::RICH_TEXT, false, tl!("Allow Rich Text Formatting")); - ui.horizontal(|ui| { - ui.add(widgets::checkbox(&mut d.limit, tl!("Limit of"))); - ui.add_enabled(d.limit, egui::DragValue::new(&mut d.max_len).range(0..=10_000)); - ui.label(tl!("characters")); - }); - flag_box(ui, &mut d.flags, ff::PASSWORD, false, tl!("Password")); - flag_box(ui, &mut d.flags, ff::FILE_SELECT, false, tl!("Field is used for file selection")); - flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, tl!("Check spelling")); - ui.horizontal(|ui| { - flag_box(ui, &mut d.flags, ff::COMB, false, tl!("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(tl!("characters")); - }); - } - FormFieldKind::CheckBox | FormFieldKind::Radio => { - use pdfcraft_engine::field_flags as ff; - let on = d.on_state.clone(); - ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", &on)])); - let mut checked = !d.default.is_empty() && d.default == on; - let label = - if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") }; - if ui.add(widgets::checkbox(&mut checked, label)).changed() { - d.default = if checked { on } else { String::new() }; - } - if let Some(style) = d.check_style.as_mut() { - ui.horizontal(|ui| { - let l = ui.label(tl!(if d.kind == FormFieldKind::CheckBox { "Check Box Style:" } else { "Button Style:" })); - egui::ComboBox::from_id_salt("check-style") - .selected_text(tl!(style.label())) - .show_ui(ui, |ui| { - for s in pdfcraft_engine::CheckStyle::ALL { - ui.selectable_value(style, s, tl!(s.label())); - } - }) - .response - .labelled_by(l.id); - }); - } - if d.kind == FormFieldKind::Radio { - flag_box(ui, &mut d.flags, ff::RADIOS_IN_UNISON, false, tl!("Buttons with the same name and value are selected in unison")); - } - } - _ => { - ui.horizontal(|ui| alignment(ui, &mut d.quadding)); - ui.add_space(6.0); - ui.horizontal(|ui| { - let l = ui.label(tl!("Item:")); - let r = ui.add(crate::widgets::line(&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, tl!("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, tl!("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(tl!("Delete")).on_hover_text(crate::i18n::fmt(tl!("Delete {o}"), &[("o", o)])).clicked() { - remove = Some(i); - } - if i > 0 && ui.small_button(tl!("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(tl!("Add the choices people pick from.")).color(t.text_muted)); - } - use pdfcraft_engine::field_flags as ff; - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(tl!("Default:")); - let shown = if d.default.is_empty() { tl!("(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(), tl!("(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, tl!("Sort items")); - if d.kind == FormFieldKind::Combo { - flag_box(ui, &mut d.flags, ff::EDIT, false, tl!("Allow user to enter custom text")); - flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, tl!("Check spelling")); - } else { - flag_box(ui, &mut d.flags, ff::MULTI_SELECT, false, tl!("Multiple selection")); - } - flag_box(ui, &mut d.flags, ff::COMMIT_ON_SEL_CHANGE, false, tl!("Commit selected value immediately")); - } - }, - } - }); - ui.add_space(8.0); - ui.add(widgets::checkbox(&mut d.locked, tl!("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, tl!("OK"), true).clicked() { - apply = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - cancel = true; - } - }); - (apply, cancel) -} - -/// The categories of the Format tab. -#[derive(Clone, Copy, PartialEq)] -enum Category { - None, - Number, - Percent, - Date, - Time, - Special, -} - -fn category(f: &Format) -> Category { - match f { - Format::None => Category::None, - Format::Number { .. } => Category::Number, - Format::Percent { .. } => Category::Percent, - Format::Date(_) => Category::Date, - Format::Time(_) => Category::Time, - Format::Special(_) | Format::Mask(_) => Category::Special, - } -} - -const SEPARATORS: [&str; 5] = ["1,234.56", "1234.56", "1.234,56", "1234,56", "1'234.56"]; -const NEGATIVES: [&str; 4] = ["-1,234.01", "1,234.01 (red)", "(1,234.01)", "(1,234.01) (red)"]; -const SPECIALS: [&str; 5] = ["Zip Code", "Zip Code + 4", "Phone Number", "Social Security Number", "Arbitrary Mask"]; - -/// Draw the field-format editor for the selected category and its options. -fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { - let cat = category(&d.format); - let mut picked = cat; - ui.horizontal(|ui| { - ui.label(tl!("Select format category:")); - egui::ComboBox::from_id_salt("format-cat") - .selected_text(tl!(match cat { - Category::None => "None", - Category::Number => "Number", - Category::Percent => "Percentage", - Category::Date => "Date", - Category::Time => "Time", - Category::Special => "Special", - })) - .show_ui(ui, |ui| { - for (c, l) in [ - (Category::None, tl!("None")), - (Category::Number, tl!("Number")), - (Category::Percent, tl!("Percentage")), - (Category::Date, tl!("Date")), - (Category::Time, tl!("Time")), - (Category::Special, tl!("Special")), - ] { - ui.selectable_value(&mut picked, c, l); - } - }); - }); - if picked != cat { - d.format = match picked { - Category::None => Format::None, - Category::Number => Format::Number { decimals: 2, sep: 0, neg: 0, currency: String::new(), prepend: true }, - Category::Percent => Format::Percent { decimals: 2, sep: 0 }, - Category::Date => Format::Date("mm/dd/yyyy".into()), - Category::Time => Format::Time("HH:MM".into()), - Category::Special => Format::Special(0), - }; - } - ui.add_space(8.0); - let sample = match &d.format { - Format::Number { .. } | Format::Percent { .. } => "-1234.5", - Format::Date(_) => "10/1/2026", - Format::Time(_) => "14:05", - Format::Special(2) => "5551234567", - Format::Special(3) => "123456789", - Format::Special(_) => "123456789", - _ => "", - }; - match &mut d.format { - Format::None => { - ui.label(egui::RichText::new(tl!("The value is shown as typed.")).color(t.text_muted)); - } - Format::Number { decimals, sep, neg, currency, prepend } => { - egui::Grid::new("fmt-number").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Decimal places:")); - ui.add(egui::DragValue::new(decimals).range(0..=10)); - ui.end_row(); - ui.label(tl!("Separator style:")); - egui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { - for (i, s) in SEPARATORS.iter().enumerate() { - ui.selectable_value(sep, i as u8, *s); - } - }); - ui.end_row(); - ui.label(tl!("Currency symbol:")); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("fmt-cur") - .selected_text(if currency.is_empty() { tl!("None") } else { currency.as_str() }) - .width(70.0) - .show_ui(ui, |ui| { - for c in ["", "$", "€", "£", "¥", "CHF "] { - ui.selectable_value(currency, c.to_string(), if c.is_empty() { tl!("None") } else { c }); - } - }); - ui.add(widgets::checkbox(prepend, tl!("Before the number"))); - }); - ui.end_row(); - ui.label(tl!("Negative number style:")); - egui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { - for (i, s) in NEGATIVES.iter().enumerate() { - ui.selectable_value(neg, i as u8, *s); - } - }); - ui.end_row(); - }); - } - Format::Percent { decimals, sep } => { - egui::Grid::new("fmt-pct").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Decimal places:")); - ui.add(egui::DragValue::new(decimals).range(0..=10)); - ui.end_row(); - ui.label(tl!("Separator style:")); - egui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { - for (i, s) in SEPARATORS.iter().enumerate() { - ui.selectable_value(sep, i as u8, *s); - } - }); - ui.end_row(); - }); - } - Format::Date(p) | Format::Time(p) => { - let presets: Vec<&str> = if matches!(cat, Category::Time) || picked == Category::Time { - TIME_PRESETS.to_vec() - } else { - DATE_PRESETS.iter().copied().chain(["m/d/yyyy", "mm/dd/yyyy", "yyyy-mm-dd", "dd/mm/yyyy"]).collect() - }; - ui.horizontal(|ui| { - ui.label(tl!("Format:")); - egui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { - for pr in presets { - ui.selectable_value(p, pr.to_string(), pr); - } - }); - let custom = ui.label(tl!("Custom:")); - ui.add(crate::widgets::line(p).desired_width(120.0)).labelled_by(custom.id); - }); - } - Format::Special(n) => { - let mut idx = *n as usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[idx.min(4)])).show_ui(ui, |ui| { - for (i, s) in SPECIALS.iter().enumerate() { - ui.selectable_value(&mut idx, i, tl!(s)); - } - }); - if idx == 4 { - d.format = Format::Mask("999-999".into()); - } else { - *n = idx as u8; - } - } - Format::Mask(m) => { - let mut idx = 4usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[4])).show_ui(ui, |ui| { - for (i, s) in SPECIALS.iter().enumerate() { - ui.selectable_value(&mut idx, i, tl!(s)); - } - }); - ui.horizontal(|ui| { - let mask = ui.label(tl!("Mask:")); - ui.add(crate::widgets::line(m).desired_width(160.0)).labelled_by(mask.id); - }); - ui.label(egui::RichText::new(tl!("9 digit, A letter, O letter or digit, X any character")).small().color(t.text_faint)); - if idx < 4 { - d.format = Format::Special(idx as u8); - } - } - } - if !sample.is_empty() && d.format != Format::None { - ui.add_space(6.0); - ui.label( - egui::RichText::new(crate::i18n::fmt(tl!("Example: {sample}"), &[("sample", &format_value(&d.format, sample))])).color(t.text_muted), - ); - } -} - -fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { - let mut ranged = matches!(d.validate, Validate::Range { .. }); - widgets::radio_value(ui, &mut ranged, false, tl!("Field value is not validated")); - widgets::radio_value(ui, &mut ranged, true, tl!("Field value is in range:")); - if !ranged { - d.validate = Validate::None; - return; - } - if d.validate == Validate::None { - d.validate = Validate::Range { min: Some(0.0), max: Some(100.0) }; - } - if let Validate::Range { min, max } = &mut d.validate { - ui.horizontal(|ui| { - let mut has = min.is_some(); - ui.add(widgets::checkbox(&mut has, tl!("From:"))); - let mut v = min.unwrap_or(0.0); - ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); - *min = has.then_some(v); - let mut has = max.is_some(); - ui.add(widgets::checkbox(&mut has, tl!("To:"))); - let mut v = max.unwrap_or(100.0); - ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); - *max = has.then_some(v); - }); - } -} - -fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { - use pdfcraft_engine::FieldTrigger as T; - let editing = d.editing.is_some(); - ui.label(egui::RichText::new(if editing { tl!("Edit Action") } else { tl!("Add an Action") }).font(theme::semibold(13.0))); - let a = &mut d.new_action; - egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Select Trigger:")); - // Editing changes the script of one trigger's action: its trigger and kind stay. - ui.add_enabled_ui(!editing, |ui| { - egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { - for tr in T::ALL { - ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); - } - }); - }); - ui.end_row(); - ui.label(tl!("Select Action:")); - ui.add_enabled_ui(!editing, |ui| { - egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { - for (i, k) in ACTION_KINDS.iter().enumerate() { - ui.selectable_value(&mut a.kind, i, tl!(k)); - } - }); - }); - ui.end_row(); - let hint = match a.kind { - 0 => "JavaScript", - 1 => "https://…", - 2 => tl!("Fields to reset (comma-separated; empty: all)"), - 3 => tl!("Print, NextPage, PrevPage, FirstPage or LastPage"), - 4 => tl!("Page number"), - 5 | 6 => tl!("Field names (comma-separated)"), - _ => tl!("URL to submit to"), - }; - ui.label(if a.kind == 0 { tl!("Script:") } else { tl!("Value:") }); - if a.kind == 0 { - ui.add(crate::widgets::block(&mut a.text).code_editor().desired_rows(3).desired_width(340.0).hint_text(hint).id_salt("action-text")); - } else { - ui.add(crate::widgets::line(&mut a.text).desired_width(340.0).hint_text(hint).id_salt("action-text")); - } - ui.end_row(); - }); - let mut error = None; - let (mut commit, mut discard) = (false, false); - ui.horizontal(|ui| { - commit = ui.button(if editing { tl!("Update") } else { tl!("Add") }).clicked(); - discard = editing && ui.button(tl!("Discard")).clicked(); - }); - if commit { - match d.new_action.action() { - Ok(act) => { - let tr = d.new_action.trigger; - d.actions.retain(|(x, _)| *x != tr); - d.actions.push((tr, act)); - d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x)); - d.new_action.text.clear(); - d.editing = None; - } - Err(e) => error = Some(e), - } - } - if discard { - d.new_action = ActionDraft::default(); - d.editing = None; - } - if let Some(e) = error { - ui.label(egui::RichText::new(tl!(&e)).small().color(t.text_muted)); - } - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Actions")).font(theme::semibold(13.0))); - egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { - ui.set_width(ui.available_width()); - if d.actions.is_empty() { - ui.label(egui::RichText::new(tl!("No actions")).color(t.text_muted)); - } - let (mut remove, mut edit) = (None, None); - for (i, (tr, act)) in d.actions.iter().enumerate() { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(tr.label())).strong()); - let more = d.chained.iter().find(|(x, _)| x == tr).map_or(0, |(_, n)| *n); - if more == 0 { - ui.label(act.describe()); - } else { - let then = crate::i18n::trn(crate::i18n::current(), more as u64, "then {n} more action", "then {n} more actions"); - ui.label(format!("{}, {then}", act.describe())); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.push_id(i, |ui| { - if ui.button(tl!("Delete")).clicked() { - remove = Some(i); - } - if let pdfcraft_engine::FieldAction::JavaScript(js) = act - && ui.add_enabled(d.editing.is_none(), egui::Button::new(tl!("Edit"))).clicked() - { - edit = Some((*tr, js.clone())); - } - }); - }); - }); - } - if let Some((trigger, text)) = edit { - d.new_action = ActionDraft { trigger, kind: 0, text }; - d.editing = Some(trigger); - } - if let Some(i) = remove { - if d.actions.get(i).map(|(x, _)| Some(*x)) == Some(d.editing) { - d.new_action = ActionDraft::default(); - d.editing = None; - } - d.actions.remove(i); - } - }); -} - -fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { - #[derive(PartialEq, Clone, Copy)] - enum Mode { - None, - Simple, - Notation, - } - let mode = match d.calculate { - Calculate::None => Mode::None, - Calculate::Simple { .. } => Mode::Simple, - Calculate::Notation(_) => Mode::Notation, - }; - let mut m = mode; - widgets::radio_value(ui, &mut m, Mode::None, tl!("Value is not calculated")); - widgets::radio_value(ui, &mut m, Mode::Simple, tl!("Value is the")); - if m != mode { - d.calculate = match m { - Mode::None => Calculate::None, - Mode::Simple => Calculate::Simple { op: CalcOp::Sum, fields: Vec::new() }, - Mode::Notation => Calculate::Notation(String::new()), - }; - } - if let Calculate::Simple { op, fields } = &mut d.calculate { - ui.horizontal(|ui| { - ui.add_space(24.0); - egui::ComboBox::from_id_salt("calc-op").selected_text(tl!(op.label())).show_ui(ui, |ui| { - for o in CalcOp::ALL { - ui.selectable_value(op, o, tl!(o.label())); - } - }); - ui.label(tl!("of the following fields:")); - }); - egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { - for name in &d.others { - let mut on = fields.contains(name); - if ui.add(widgets::checkbox(&mut on, name)).changed() { - if on { - fields.push(name.clone()); - } else { - fields.retain(|f| f != name); - } - } - } - }); - } - let mut m2 = match d.calculate { - Calculate::Notation(_) => Mode::Notation, - _ => m, - }; - if widgets::radio_value(ui, &mut m2, Mode::Notation, tl!("Simplified field notation:")).clicked() - && !matches!(d.calculate, Calculate::Notation(_)) - { - d.calculate = Calculate::Notation(String::new()); - } - if let Calculate::Notation(expr) = &mut d.calculate { - ui.add(crate::widgets::block(expr).hint_text("Price * Quantity").desired_rows(2).desired_width(420.0)); - ui.label(egui::RichText::new(tl!("Field names with + - * / and parentheses; put \\ before spaces in names.")).small().color(t.text_faint)); - } -} - #[cfg(test)] mod tests { use egui::pos2; diff --git a/crates/ui-egui/src/prepare/draft.rs b/crates/ui-egui/src/prepare/draft.rs new file mode 100644 index 000000000..5bba8fe18 --- /dev/null +++ b/crates/ui-egui/src/prepare/draft.rs @@ -0,0 +1,297 @@ +use super::*; + +// ───────────────────────────────────────────────────────────────────────── Field Properties + +impl crate::PdfCraftApp { + /// Open Field Properties for a field of the active document. + pub fn open_field_props(&mut self, name: &str, widget: usize) { + let Some((index, id)) = self.active_ids() else { return }; + let names = self.views[index].prepare.names(); + if names.len() > 1 && names.iter().any(|n| n == name) { + let draft = self.session.get(id).map_or(Err("The document is no longer open."), |doc| crate::bulk_fields::Draft::new(doc, names)); + match draft { + Ok(draft) => { + self.field_props = None; + self.bulk_field_props = Some(draft); + self.dialog = Some(crate::Dialog::BulkFieldProps); + } + Err(why) => self.notify_tr(why), + } + return; + } + self.bulk_field_props = None; + let Some(f) = self.session.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).cloned()) else { return }; + let mut d = FieldDraft::new(&f, widget); + d.look = self.session.get(id).and_then(|doc| doc.field_look(name)); + d.check_style = self.session.get(id).and_then(|doc| doc.field_check_style(name)); + if let Some(o) = d.original.as_mut() { + o.look = d.look; + o.check_style = d.check_style; + } + let others: Vec = + self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); + d.others = others.clone(); + d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default(); + d.chained = self.session.get(id).map(|doc| doc.field_action_chains(name)).unwrap_or_default(); + if let Some(o) = d.original.as_mut() { + o.others = others; + o.actions = d.actions.clone(); + } + self.field_props = Some(d); + self.dialog = Some(crate::Dialog::FieldProps); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldTab { + General, + Appearance, + Position, + Options, + Format, + Validate, + Calculate, + Actions, +} + +/// Actions tab: the "Select Action" choices. +pub const ACTION_KINDS: [&str; 8] = [ + "Run a JavaScript", + "Open a web link", + "Reset a form", + "Execute a menu item", + "Go to a page view", + "Show a field", + "Hide a field", + "Submit a form", +]; + +/// Actions tab: the action being added. +#[derive(Clone, Debug, PartialEq)] +pub struct ActionDraft { + pub trigger: pdfcraft_engine::FieldTrigger, + pub kind: usize, + /// The script, URL, field names (comma-separated), menu item or page number. + pub text: String, +} + +impl Default for ActionDraft { + fn default() -> Self { + ActionDraft { trigger: pdfcraft_engine::FieldTrigger::MouseUp, kind: 0, text: String::new() } + } +} + +impl ActionDraft { + pub fn action(&self) -> Result { + use pdfcraft_engine::FieldAction as A; + let t = self.text.trim(); + let names = || t.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect::>(); + Ok(match self.kind { + 0 => A::JavaScript(self.text.clone()), + 1 if !t.is_empty() => A::Uri(t.to_string()), + 2 => A::Reset(names()), + 3 => A::Named(if t.is_empty() { "Print".into() } else { t.to_string() }), + 4 => A::GoTo(t.parse::().ok().filter(|p| *p >= 1).ok_or("Enter a page number")? - 1), + 5 if !t.is_empty() => A::ShowHide { fields: names(), hide: false }, + 6 if !t.is_empty() => A::ShowHide { fields: names(), hide: true }, + 7 if !t.is_empty() => A::Submit(t.to_string()), + 1 | 7 => return Err("Enter a URL".into()), + _ => return Err("Enter the field names".into()), + }) + } +} + +/// The Field Properties dialog's working copy. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldDraft { + pub field: String, + pub kind: FormFieldKind, + pub widget: usize, + pub tab: FieldTab, + pub name: String, + pub tooltip: String, + pub caption: String, + pub read_only: bool, + pub required: bool, + pub multiline: bool, + pub limit: bool, + pub max_len: usize, + pub options: Vec, + 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). + pub default: String, + /// Check boxes and radio buttons: this widget's "on" state (export value). + pub on_state: String, + /// 0 = auto. + pub font_size: f64, + /// Left, bottom, width, height in points. + pub position: [f64; 4], + /// `/MK /R`: 0, 90, 180 or 270 degrees counterclockwise. + pub rotation: i64, + pub look: Option, + /// Check boxes and radio buttons: the mark when on (Options tab). + pub check_style: Option, + pub format: Format, + pub validate: Validate, + pub calculate: Calculate, + /// Every other field's name (the Calculate tab picks from them). + pub others: Vec, + /// Actions tab: each trigger's action, and the one being added. + pub actions: Vec<(pdfcraft_engine::FieldTrigger, pdfcraft_engine::FieldAction)>, + pub new_action: ActionDraft, + /// Actions tab: the trigger whose script `new_action` is editing (Edit), if any. + pub editing: Option, + /// Actions tab: how many actions the document chains after each trigger's first one. They are + /// kept when that first action is replaced. + pub chained: Vec<(pdfcraft_engine::FieldTrigger, usize)>, + pub(super) original: Box>, +} + +pub(crate) fn da_size(da: &str) -> f64 { + let mut previous = ""; + let mut size = 0.0; + for token in da.split_whitespace() { + if token == "Tf" { + size = previous.parse::().ok().filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); + } + previous = token; + } + size +} + +pub(crate) fn da_font(da: &str) -> &str { + let (mut two_back, mut previous, mut font) = ("", "", "Helv"); + for token in da.split_whitespace() { + if token == "Tf" { + font = two_back.trim_start_matches('/'); + } + two_back = previous; + previous = token; + } + font +} + +impl FieldDraft { + pub fn new(f: &FormField, widget: usize) -> Self { + let r = f.widgets.get(widget).map(|w| w.rect).unwrap_or_default(); + let mut d = FieldDraft { + field: f.name.clone(), + kind: f.kind, + widget, + tab: FieldTab::General, + // The full name: a dotted name moves the field under those parent fields (#2696). + name: f.name.clone(), + tooltip: f.tooltip.clone().unwrap_or_default(), + caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), + read_only: f.read_only(), + required: f.has(pdfcraft_engine::field_flags::REQUIRED), + multiline: f.has(pdfcraft_engine::field_flags::MULTILINE), + limit: f.max_len.is_some(), + max_len: f.max_len.unwrap_or(0), + 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(), + font_size: da_size(&f.da), + position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], + rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0), + look: None, + check_style: None, + format: f.actions.format.clone(), + validate: f.actions.validate.clone(), + calculate: f.actions.calculate.clone(), + others: Vec::new(), + actions: Vec::new(), + new_action: ActionDraft::default(), + editing: None, + chained: Vec::new(), + original: Box::new(None), + }; + d.original = Box::new(Some(d.clone())); + d + } + + pub fn title(&self) -> &'static str { + match self.kind { + FormFieldKind::Text => "Text Field Properties", + FormFieldKind::CheckBox => "Check Box Properties", + FormFieldKind::Radio => "Radio Button Properties", + FormFieldKind::Combo => "Dropdown Properties", + FormFieldKind::List => "List Box Properties", + FormFieldKind::PushButton => "Button Properties", + FormFieldKind::Signature => "Digital Signature Properties", + } + } + + pub(super) fn tabs(&self) -> Vec<(FieldTab, &'static str)> { + let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")]; + if self.kind != FormFieldKind::Signature { + t.push((FieldTab::Options, "Options")); + } + if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) { + t.extend([(FieldTab::Format, "Format"), (FieldTab::Validate, "Validate"), (FieldTab::Calculate, "Calculate")]); + } + t.push((FieldTab::Actions, "Actions")); + t + } + + /// The properties that changed (`None` when nothing did). + pub fn props(&self) -> Option { + let o = self.original.as_ref().as_ref()?; + let ch = |a: bool, b: bool| (a != b).then_some(a); + let p = FieldProps { + name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()), + tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()), + caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())), + read_only: ch(self.read_only, o.read_only), + required: ch(self.required, o.required), + multiline: ch(self.multiline, o.multiline), + max_len: ((self.limit, self.max_len) != (o.limit, o.max_len)).then_some((self.limit && self.max_len > 0).then_some(self.max_len)), + options: (self.options != o.options).then(|| self.options.clone()), + font_size: ((self.font_size - o.font_size).abs() > 1e-6).then_some(self.font_size), + rect: (self.position != o.position).then(|| { + let [x, y, w, h] = self.position; + (self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)]) + }), + rotation: (self.rotation != o.rotation).then_some((self.widget, self.rotation)), + look: (self.look != o.look).then_some(self.look).flatten(), + appearance: None, + check_style: (self.check_style != o.check_style).then_some(self.check_style).flatten(), + format: (self.format != o.format).then(|| self.format.clone()), + validate: (self.validate != o.validate).then(|| self.validate.clone()), + 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), + actions: (self.actions != o.actions).then(|| self.actions.clone()), + default_value: (self.default != o.default).then(|| (!self.default.is_empty()).then(|| self.default.clone())), + }; + (p != FieldProps::default()).then_some(p) + } +} + +/// The `/Ff` bits the Options tab edits. +const OPTION_FLAGS: [u32; 10] = { + use pdfcraft_engine::field_flags as ff; + [ + ff::DO_NOT_SCROLL, + ff::RICH_TEXT, + ff::PASSWORD, + ff::FILE_SELECT, + ff::DO_NOT_SPELL_CHECK, + ff::COMB, + ff::SORT, + ff::EDIT, + ff::MULTI_SELECT, + ff::COMMIT_ON_SEL_CHANGE, + ] +}; diff --git a/crates/ui-egui/src/prepare/properties.rs b/crates/ui-egui/src/prepare/properties.rs new file mode 100644 index 000000000..d37f20a72 --- /dev/null +++ b/crates/ui-egui/src/prepare/properties.rs @@ -0,0 +1,332 @@ +use super::draft::{FieldDraft, FieldTab}; +use super::tabs::{actions_tab, calculate_tab, format_tab, validate_tab}; +use super::*; + +/// A check box for a `/Ff` bit (`inverted`: checked when the bit is clear). +fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: &str) { + let mut on = (*flags & bit != 0) != inverted; + if ui.add(widgets::checkbox(&mut on, label)).changed() { + if on != inverted { + *flags |= bit; + } else { + *flags &= !bit; + } + } +} + +/// Shared PDF `/Q` picker for text and choice-field Options. +fn alignment(ui: &mut egui::Ui, quadding: &mut i64) { + ui.label(tl!("Alignment:")); + egui::ComboBox::from_id_salt("quadding") + .selected_text(tl!(match (*quadding).clamp(0, 2) { + 1 => "Center", + 2 => "Right", + _ => "Left", + })) + .show_ui(ui, |ui| { + for (q, label) in [(0, tl!("Left")), (1, tl!("Center")), (2, tl!("Right"))] { + ui.selectable_value(quadding, q, label); + } + }); +} + +/// Draw Field Properties; returns (apply, cancel). +pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) -> (bool, bool) { + use crate::widgets; + ui.set_width(600.0); + ui.label(egui::RichText::new(tl!(d.title())).font(theme::semibold(18.0))); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + for (tab, label) in d.tabs() { + if widgets::mode_tab(ui, tl!(label), d.tab == tab).clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + ui.add_space(6.0); + let mut enter = false; + // 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(tl!("This field is locked. Unlock it to change its properties.")).small().color(t.text_muted)); + } + ui.scope(|ui| { + if was_locked { + ui.disable(); + } + 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(tl!("Name:")); + let r = ui.add(crate::widgets::line(&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(tl!("Tooltip:")); + ui.add(crate::widgets::line(&mut d.tooltip).desired_width(340.0)).labelled_by(l.id); + ui.end_row(); + }); + ui.add_space(12.0); + widgets::section_title(ui, tl!("Common Properties")); + ui.add(widgets::checkbox(&mut d.read_only, tl!("Read Only"))); + ui.add(widgets::checkbox(&mut d.required, tl!("Required"))); + } + FieldTab::Appearance => { + egui::Grid::new("field-appearance").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + ui.label(tl!("Font Size:")); + let mut auto = d.font_size == 0.0; + ui.horizontal(|ui| { + if ui.add(widgets::checkbox(&mut auto, tl!("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).clamp_existing_to_range(false).speed(0.25).suffix(" pt"), + ); + }); + ui.end_row(); + if let Some(l) = d.look.as_mut() { + ui.label(tl!("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(tl!("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, tl!("Borders and Colors")); + egui::Grid::new("field-borders").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (index, (label, slot)) in + [(tl!("Border Color:"), &mut l.border), (tl!("Fill Color:"), &mut l.fill)].into_iter().enumerate() + { + ui.label(label); + ui.push_id(("field-color", index), |ui| { + ui.horizontal(|ui| { + let mut none = slot.is_none(); + if ui.add(widgets::checkbox(&mut none, tl!("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); + } + }); + }); + ui.end_row(); + } + ui.label(tl!("Line Thickness:")); + let thick = |w: f64| { + if w <= 1.0 { + tl!("Thin") + } else if w <= 2.0 { + tl!("Medium") + } else { + tl!("Thick") + } + }; + egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { + for (w, label) in [(1.0, tl!("Thin")), (2.0, tl!("Medium")), (3.0, tl!("Thick"))] { + ui.selectable_value(&mut l.width, w, label); + } + }); + ui.end_row(); + ui.label(tl!("Line Style:")); + egui::ComboBox::from_id_salt("field-style").selected_text(tl!(l.style.label())).show_ui(ui, |ui| { + for s in BorderStyle::ALL { + ui.selectable_value(&mut l.style, s, tl!(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 [tl!("Left:"), tl!("Bottom:"), tl!("Width:"), tl!("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(tl!("Points from the page's bottom-left corner.")).small().color(t.text_faint)); + ui.add_space(8.0); + ui.horizontal(|ui| { + let l = ui.label(tl!("Rotation:")); + let before = d.rotation; + egui::ComboBox::from_id_salt("field-rotation") + .selected_text(format!("{}°", d.rotation)) + .show_ui(ui, |ui| { + for r in [0, 90, 180, 270] { + if ui.selectable_value(&mut d.rotation, r, format!("{r}°")).changed() && (before % 180 == 0) != (r % 180 == 0) { + // Keep the same center. set_props skips its own swap when the rect changed too. + let [x, y, w, h] = d.position; + let (cx, cy) = (x + w / 2.0, y + h / 2.0); + d.position = [cx - h / 2.0, cy - w / 2.0, h, w]; + } + } + }) + .response + .labelled_by(l.id); + }); + } + FieldTab::Format => format_tab(ui, d, t), + FieldTab::Validate => validate_tab(ui, d), + FieldTab::Calculate => calculate_tab(ui, d, t), + FieldTab::Actions => actions_tab(ui, d, t), + FieldTab::Options => match d.kind { + FormFieldKind::PushButton => { + ui.horizontal(|ui| { + let l = ui.label(tl!("Label")); + ui.add(crate::widgets::line(&mut d.caption).desired_width(340.0)).labelled_by(l.id); + }); + } + FormFieldKind::Text => { + use pdfcraft_engine::field_flags as ff; + egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + alignment(ui, &mut d.quadding); + ui.end_row(); + let l = ui.label(tl!("Default Value:")); + ui.add(crate::widgets::line(&mut d.default).desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + }); + ui.add_space(6.0); + ui.add(widgets::checkbox(&mut d.multiline, tl!("Multi-line"))); + flag_box(ui, &mut d.flags, ff::DO_NOT_SCROLL, true, tl!("Scroll long text")); + flag_box(ui, &mut d.flags, ff::RICH_TEXT, false, tl!("Allow Rich Text Formatting")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.limit, tl!("Limit of"))); + ui.add_enabled(d.limit, egui::DragValue::new(&mut d.max_len).range(0..=10_000)); + ui.label(tl!("characters")); + }); + flag_box(ui, &mut d.flags, ff::PASSWORD, false, tl!("Password")); + flag_box(ui, &mut d.flags, ff::FILE_SELECT, false, tl!("Field is used for file selection")); + flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, tl!("Check spelling")); + ui.horizontal(|ui| { + flag_box(ui, &mut d.flags, ff::COMB, false, tl!("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(tl!("characters")); + }); + } + FormFieldKind::CheckBox | FormFieldKind::Radio => { + use pdfcraft_engine::field_flags as ff; + let on = d.on_state.clone(); + ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", &on)])); + let mut checked = !d.default.is_empty() && d.default == on; + let label = + if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") }; + if ui.add(widgets::checkbox(&mut checked, label)).changed() { + d.default = if checked { on } else { String::new() }; + } + if let Some(style) = d.check_style.as_mut() { + ui.horizontal(|ui| { + let l = ui.label(tl!(if d.kind == FormFieldKind::CheckBox { "Check Box Style:" } else { "Button Style:" })); + egui::ComboBox::from_id_salt("check-style") + .selected_text(tl!(style.label())) + .show_ui(ui, |ui| { + for s in pdfcraft_engine::CheckStyle::ALL { + ui.selectable_value(style, s, tl!(s.label())); + } + }) + .response + .labelled_by(l.id); + }); + } + if d.kind == FormFieldKind::Radio { + flag_box(ui, &mut d.flags, ff::RADIOS_IN_UNISON, false, tl!("Buttons with the same name and value are selected in unison")); + } + } + _ => { + ui.horizontal(|ui| alignment(ui, &mut d.quadding)); + ui.add_space(6.0); + ui.horizontal(|ui| { + let l = ui.label(tl!("Item:")); + let r = ui.add(crate::widgets::line(&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, tl!("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, tl!("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(tl!("Delete")).on_hover_text(crate::i18n::fmt(tl!("Delete {o}"), &[("o", o)])).clicked() { + remove = Some(i); + } + if i > 0 && ui.small_button(tl!("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(tl!("Add the choices people pick from.")).color(t.text_muted)); + } + use pdfcraft_engine::field_flags as ff; + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(tl!("Default:")); + let shown = if d.default.is_empty() { tl!("(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(), tl!("(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, tl!("Sort items")); + if d.kind == FormFieldKind::Combo { + flag_box(ui, &mut d.flags, ff::EDIT, false, tl!("Allow user to enter custom text")); + flag_box(ui, &mut d.flags, ff::DO_NOT_SPELL_CHECK, true, tl!("Check spelling")); + } else { + flag_box(ui, &mut d.flags, ff::MULTI_SELECT, false, tl!("Multiple selection")); + } + flag_box(ui, &mut d.flags, ff::COMMIT_ON_SEL_CHANGE, false, tl!("Commit selected value immediately")); + } + }, + } + }); + ui.add_space(8.0); + ui.add(widgets::checkbox(&mut d.locked, tl!("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, tl!("OK"), true).clicked() { + apply = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (apply, cancel) +} diff --git a/crates/ui-egui/src/prepare/tabs.rs b/crates/ui-egui/src/prepare/tabs.rs new file mode 100644 index 000000000..730061c61 --- /dev/null +++ b/crates/ui-egui/src/prepare/tabs.rs @@ -0,0 +1,386 @@ +use super::draft::{ACTION_KINDS, ActionDraft, FieldDraft}; +use super::*; + +/// The categories of the Format tab. +#[derive(Clone, Copy, PartialEq)] +enum Category { + None, + Number, + Percent, + Date, + Time, + Special, +} + +fn category(f: &Format) -> Category { + match f { + Format::None => Category::None, + Format::Number { .. } => Category::Number, + Format::Percent { .. } => Category::Percent, + Format::Date(_) => Category::Date, + Format::Time(_) => Category::Time, + Format::Special(_) | Format::Mask(_) => Category::Special, + } +} + +const SEPARATORS: [&str; 5] = ["1,234.56", "1234.56", "1.234,56", "1234,56", "1'234.56"]; + +const NEGATIVES: [&str; 4] = ["-1,234.01", "1,234.01 (red)", "(1,234.01)", "(1,234.01) (red)"]; + +const SPECIALS: [&str; 5] = ["Zip Code", "Zip Code + 4", "Phone Number", "Social Security Number", "Arbitrary Mask"]; + +/// Draw the field-format editor for the selected category and its options. +pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { + let cat = category(&d.format); + let mut picked = cat; + ui.horizontal(|ui| { + ui.label(tl!("Select format category:")); + egui::ComboBox::from_id_salt("format-cat") + .selected_text(tl!(match cat { + Category::None => "None", + Category::Number => "Number", + Category::Percent => "Percentage", + Category::Date => "Date", + Category::Time => "Time", + Category::Special => "Special", + })) + .show_ui(ui, |ui| { + for (c, l) in [ + (Category::None, tl!("None")), + (Category::Number, tl!("Number")), + (Category::Percent, tl!("Percentage")), + (Category::Date, tl!("Date")), + (Category::Time, tl!("Time")), + (Category::Special, tl!("Special")), + ] { + ui.selectable_value(&mut picked, c, l); + } + }); + }); + if picked != cat { + d.format = match picked { + Category::None => Format::None, + Category::Number => Format::Number { decimals: 2, sep: 0, neg: 0, currency: String::new(), prepend: true }, + Category::Percent => Format::Percent { decimals: 2, sep: 0 }, + Category::Date => Format::Date("mm/dd/yyyy".into()), + Category::Time => Format::Time("HH:MM".into()), + Category::Special => Format::Special(0), + }; + } + ui.add_space(8.0); + let sample = match &d.format { + Format::Number { .. } | Format::Percent { .. } => "-1234.5", + Format::Date(_) => "10/1/2026", + Format::Time(_) => "14:05", + Format::Special(2) => "5551234567", + Format::Special(3) => "123456789", + Format::Special(_) => "123456789", + _ => "", + }; + match &mut d.format { + Format::None => { + ui.label(egui::RichText::new(tl!("The value is shown as typed.")).color(t.text_muted)); + } + Format::Number { decimals, sep, neg, currency, prepend } => { + egui::Grid::new("fmt-number").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Decimal places:")); + ui.add(egui::DragValue::new(decimals).range(0..=10)); + ui.end_row(); + ui.label(tl!("Separator style:")); + egui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + for (i, s) in SEPARATORS.iter().enumerate() { + ui.selectable_value(sep, i as u8, *s); + } + }); + ui.end_row(); + ui.label(tl!("Currency symbol:")); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("fmt-cur") + .selected_text(if currency.is_empty() { tl!("None") } else { currency.as_str() }) + .width(70.0) + .show_ui(ui, |ui| { + for c in ["", "$", "€", "£", "¥", "CHF "] { + ui.selectable_value(currency, c.to_string(), if c.is_empty() { tl!("None") } else { c }); + } + }); + ui.add(widgets::checkbox(prepend, tl!("Before the number"))); + }); + ui.end_row(); + ui.label(tl!("Negative number style:")); + egui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { + for (i, s) in NEGATIVES.iter().enumerate() { + ui.selectable_value(neg, i as u8, *s); + } + }); + ui.end_row(); + }); + } + Format::Percent { decimals, sep } => { + egui::Grid::new("fmt-pct").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Decimal places:")); + ui.add(egui::DragValue::new(decimals).range(0..=10)); + ui.end_row(); + ui.label(tl!("Separator style:")); + egui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + for (i, s) in SEPARATORS.iter().enumerate() { + ui.selectable_value(sep, i as u8, *s); + } + }); + ui.end_row(); + }); + } + Format::Date(p) | Format::Time(p) => { + let presets: Vec<&str> = if matches!(cat, Category::Time) || picked == Category::Time { + TIME_PRESETS.to_vec() + } else { + DATE_PRESETS.iter().copied().chain(["m/d/yyyy", "mm/dd/yyyy", "yyyy-mm-dd", "dd/mm/yyyy"]).collect() + }; + ui.horizontal(|ui| { + ui.label(tl!("Format:")); + egui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { + for pr in presets { + ui.selectable_value(p, pr.to_string(), pr); + } + }); + let custom = ui.label(tl!("Custom:")); + ui.add(crate::widgets::line(p).desired_width(120.0)).labelled_by(custom.id); + }); + } + Format::Special(n) => { + let mut idx = *n as usize; + egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[idx.min(4)])).show_ui(ui, |ui| { + for (i, s) in SPECIALS.iter().enumerate() { + ui.selectable_value(&mut idx, i, tl!(s)); + } + }); + if idx == 4 { + d.format = Format::Mask("999-999".into()); + } else { + *n = idx as u8; + } + } + Format::Mask(m) => { + let mut idx = 4usize; + egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[4])).show_ui(ui, |ui| { + for (i, s) in SPECIALS.iter().enumerate() { + ui.selectable_value(&mut idx, i, tl!(s)); + } + }); + ui.horizontal(|ui| { + let mask = ui.label(tl!("Mask:")); + ui.add(crate::widgets::line(m).desired_width(160.0)).labelled_by(mask.id); + }); + ui.label(egui::RichText::new(tl!("9 digit, A letter, O letter or digit, X any character")).small().color(t.text_faint)); + if idx < 4 { + d.format = Format::Special(idx as u8); + } + } + } + if !sample.is_empty() && d.format != Format::None { + ui.add_space(6.0); + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Example: {sample}"), &[("sample", &format_value(&d.format, sample))])).color(t.text_muted), + ); + } +} + +pub(super) fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { + let mut ranged = matches!(d.validate, Validate::Range { .. }); + widgets::radio_value(ui, &mut ranged, false, tl!("Field value is not validated")); + widgets::radio_value(ui, &mut ranged, true, tl!("Field value is in range:")); + if !ranged { + d.validate = Validate::None; + return; + } + if d.validate == Validate::None { + d.validate = Validate::Range { min: Some(0.0), max: Some(100.0) }; + } + if let Validate::Range { min, max } = &mut d.validate { + ui.horizontal(|ui| { + let mut has = min.is_some(); + ui.add(widgets::checkbox(&mut has, tl!("From:"))); + let mut v = min.unwrap_or(0.0); + ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); + *min = has.then_some(v); + let mut has = max.is_some(); + ui.add(widgets::checkbox(&mut has, tl!("To:"))); + let mut v = max.unwrap_or(100.0); + ui.add_enabled(has, egui::DragValue::new(&mut v).speed(1.0)); + *max = has.then_some(v); + }); + } +} + +pub(super) fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { + use pdfcraft_engine::FieldTrigger as T; + let editing = d.editing.is_some(); + ui.label(egui::RichText::new(if editing { tl!("Edit Action") } else { tl!("Add an Action") }).font(theme::semibold(13.0))); + let a = &mut d.new_action; + egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Select Trigger:")); + // Editing changes the script of one trigger's action: its trigger and kind stay. + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { + for tr in T::ALL { + ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); + } + }); + }); + ui.end_row(); + ui.label(tl!("Select Action:")); + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { + for (i, k) in ACTION_KINDS.iter().enumerate() { + ui.selectable_value(&mut a.kind, i, tl!(k)); + } + }); + }); + ui.end_row(); + let hint = match a.kind { + 0 => "JavaScript", + 1 => "https://…", + 2 => tl!("Fields to reset (comma-separated; empty: all)"), + 3 => tl!("Print, NextPage, PrevPage, FirstPage or LastPage"), + 4 => tl!("Page number"), + 5 | 6 => tl!("Field names (comma-separated)"), + _ => tl!("URL to submit to"), + }; + ui.label(if a.kind == 0 { tl!("Script:") } else { tl!("Value:") }); + if a.kind == 0 { + ui.add(crate::widgets::block(&mut a.text).code_editor().desired_rows(3).desired_width(340.0).hint_text(hint).id_salt("action-text")); + } else { + ui.add(crate::widgets::line(&mut a.text).desired_width(340.0).hint_text(hint).id_salt("action-text")); + } + ui.end_row(); + }); + let mut error = None; + let (mut commit, mut discard) = (false, false); + ui.horizontal(|ui| { + commit = ui.button(if editing { tl!("Update") } else { tl!("Add") }).clicked(); + discard = editing && ui.button(tl!("Discard")).clicked(); + }); + if commit { + match d.new_action.action() { + Ok(act) => { + let tr = d.new_action.trigger; + d.actions.retain(|(x, _)| *x != tr); + d.actions.push((tr, act)); + d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x)); + d.new_action.text.clear(); + d.editing = None; + } + Err(e) => error = Some(e), + } + } + if discard { + d.new_action = ActionDraft::default(); + d.editing = None; + } + if let Some(e) = error { + ui.label(egui::RichText::new(tl!(&e)).small().color(t.text_muted)); + } + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Actions")).font(theme::semibold(13.0))); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + if d.actions.is_empty() { + ui.label(egui::RichText::new(tl!("No actions")).color(t.text_muted)); + } + let (mut remove, mut edit) = (None, None); + for (i, (tr, act)) in d.actions.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!(tr.label())).strong()); + let more = d.chained.iter().find(|(x, _)| x == tr).map_or(0, |(_, n)| *n); + if more == 0 { + ui.label(act.describe()); + } else { + let then = crate::i18n::trn(crate::i18n::current(), more as u64, "then {n} more action", "then {n} more actions"); + ui.label(format!("{}, {then}", act.describe())); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.push_id(i, |ui| { + if ui.button(tl!("Delete")).clicked() { + remove = Some(i); + } + if let pdfcraft_engine::FieldAction::JavaScript(js) = act + && ui.add_enabled(d.editing.is_none(), egui::Button::new(tl!("Edit"))).clicked() + { + edit = Some((*tr, js.clone())); + } + }); + }); + }); + } + if let Some((trigger, text)) = edit { + d.new_action = ActionDraft { trigger, kind: 0, text }; + d.editing = Some(trigger); + } + if let Some(i) = remove { + if d.actions.get(i).map(|(x, _)| Some(*x)) == Some(d.editing) { + d.new_action = ActionDraft::default(); + d.editing = None; + } + d.actions.remove(i); + } + }); +} + +pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { + #[derive(PartialEq, Clone, Copy)] + enum Mode { + None, + Simple, + Notation, + } + let mode = match d.calculate { + Calculate::None => Mode::None, + Calculate::Simple { .. } => Mode::Simple, + Calculate::Notation(_) => Mode::Notation, + }; + let mut m = mode; + widgets::radio_value(ui, &mut m, Mode::None, tl!("Value is not calculated")); + widgets::radio_value(ui, &mut m, Mode::Simple, tl!("Value is the")); + if m != mode { + d.calculate = match m { + Mode::None => Calculate::None, + Mode::Simple => Calculate::Simple { op: CalcOp::Sum, fields: Vec::new() }, + Mode::Notation => Calculate::Notation(String::new()), + }; + } + if let Calculate::Simple { op, fields } = &mut d.calculate { + ui.horizontal(|ui| { + ui.add_space(24.0); + egui::ComboBox::from_id_salt("calc-op").selected_text(tl!(op.label())).show_ui(ui, |ui| { + for o in CalcOp::ALL { + ui.selectable_value(op, o, tl!(o.label())); + } + }); + ui.label(tl!("of the following fields:")); + }); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + for name in &d.others { + let mut on = fields.contains(name); + if ui.add(widgets::checkbox(&mut on, name)).changed() { + if on { + fields.push(name.clone()); + } else { + fields.retain(|f| f != name); + } + } + } + }); + } + let mut m2 = match d.calculate { + Calculate::Notation(_) => Mode::Notation, + _ => m, + }; + if widgets::radio_value(ui, &mut m2, Mode::Notation, tl!("Simplified field notation:")).clicked() + && !matches!(d.calculate, Calculate::Notation(_)) + { + d.calculate = Calculate::Notation(String::new()); + } + if let Calculate::Notation(expr) = &mut d.calculate { + ui.add(crate::widgets::block(expr).hint_text("Price * Quantity").desired_rows(2).desired_width(420.0)); + ui.label(egui::RichText::new(tl!("Field names with + - * / and parentheses; put \\ before spaces in names.")).small().color(t.text_faint)); + } +}