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:
Brandon Thomas 2026-10-02 04:28:19 -04:00
parent 4df7a73a1a
commit c0f9c73ae9
11 changed files with 329 additions and 209 deletions

View File

@ -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) => {

View File

@ -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 },

View File

@ -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('.') {

View File

@ -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();

View File

@ -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));
}

View File

@ -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();

View File

@ -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) {

View File

@ -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)

View File

@ -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() {

View File

@ -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));
}

View File

@ -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"