M6: Prepare a form ▸ Preview (fill the form as a reader would; Edit fields goes back) and Field Properties ▸ Locked (annotation Locked flag; properties greyed out until unlocked, values still fillable); form_set_props takes locked
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4df7a73a1a
commit
c0f9c73ae9
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"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 },
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ pub struct FieldProps {
|
|||
pub quadding: Option<i64>,
|
||||
/// Default value (`/DV`): what Reset form restores.
|
||||
pub default_value: Option<Option<String>>,
|
||||
/// Lock or unlock the field (its widgets' Locked flag).
|
||||
pub locked: Option<bool>,
|
||||
}
|
||||
|
||||
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
|
||||
|
|
@ -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<String, FormError> {
|
||||
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('.') {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -279,6 +279,8 @@ pub struct PrintCraftApp {
|
|||
pub attach_override: Option<(String, Vec<u8>)>,
|
||||
/// Duplicate Field: which field and onto which pages.
|
||||
pub duplicate_draft: Option<DuplicateDraft>,
|
||||
/// 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<RecoveryStore>,
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -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<crate::sign_ui::PanelAction> = 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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue