M6: Field Properties ▸ Options for every field type: text alignment, default value, scroll long text, rich text, password, file selection, spell check, comb of characters (with its rules); choice defaults, sort items (the list is reordered), custom text, multiple selection, commit immediately; check box / radio checked by default and radios in unison. Reset form restores defaults. form_set_props takes align, default and flags

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 03:28:44 -04:00
parent 09c1404d95
commit dfae15ec67
12 changed files with 310 additions and 10 deletions

View File

@ -347,12 +347,49 @@ impl Automation {
format: a.get("format").map(format_arg).transpose()?,
validate: a.get("validate").map(validate_arg).transpose()?,
calculate: a.get("calculate").map(calculate_arg).transpose()?,
quadding: match a.opt_str("align")? {
None => None,
Some("left") => Some(0),
Some("center" | "centre") => Some(1),
Some("right") => Some(2),
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())),
flags: match a.get("flags") {
None => Vec::new(),
Some(v) => {
use printcraft_engine::field_flags as ff;
let obj = v.as_object().ok_or_else(|| bad("flags must be an object of booleans"))?;
let mut out = Vec::new();
for (k, on) in obj {
let on = on.as_bool().ok_or_else(|| bad(format!("flags.{k} must be true or false")))?;
// Acrobat's wording: "Scroll long text" and "Check spelling" are the
// inverse of their bits.
let (bit, inverted) = match k.as_str() {
"scroll" => (ff::DO_NOT_SCROLL, true),
"rich_text" => (ff::RICH_TEXT, false),
"password" => (ff::PASSWORD, false),
"file_select" => (ff::FILE_SELECT, false),
"spell_check" => (ff::DO_NOT_SPELL_CHECK, true),
"comb" => (ff::COMB, false),
"sort" => (ff::SORT, false),
"editable" => (ff::EDIT, false),
"multi_select" => (ff::MULTI_SELECT, false),
"commit_immediately" => (ff::COMMIT_ON_SEL_CHANGE, false),
"radios_in_unison" => (ff::RADIOS_IN_UNISON, false),
other => return Err(bad(format!("unknown flag {other:?}"))),
};
out.push((bit, on != inverted));
}
out
}
},
};
if props == FieldProps::default() {
return Err(ToolError::InvalidArgs("nothing to change".into()));
}
let new_name = props.name.clone().unwrap_or(name.clone());
let mut out = self.apply(a, Edit::SetFieldProps { name, props })?;
let mut out = self.apply(a, Edit::SetFieldProps { name, props: Box::new(props) })?;
out["field"] = json!(new_name);
Ok(out)
}

View File

@ -246,7 +246,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"Field properties",
"Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Only the given ones change. Undoable.",
"Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }. Only the given ones change. Undoable.",
)
.with(schema(
json!({
@ -259,6 +259,9 @@ pub fn tools() -> Vec<ToolDef> {
"multiline": { "type": "boolean" },
"max_length": { "type": "integer", "minimum": 0 },
"options": { "type": "array", "items": { "type": "string" } },
"align": { "type": "string", "enum": ["left", "center", "right"] },
"default": { "type": "string" },
"flags": { "type": "object", "additionalProperties": { "type": "boolean" } },
"font_size": { "type": "number", "minimum": 0 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },

View File

@ -616,6 +616,17 @@ fn preparing_a_form_through_tools() {
assert_eq!(f[1]["options"], json!(["S", "L"]));
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "full name": "Ada", "size": "L" } }));
assert!(page_text(&mut a, doc)[0].contains("Ada"));
// Options tab: alignment, default, flags (comb needs a limit).
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "full name", "flags": { "comb": true } })), Err(ToolError::Failed(_))));
ok(
&mut a,
"form_set_props",
json!({ "doc": doc, "field": "full name", "align": "center", "default": "Anon", "max_length": 8, "flags": { "comb": true, "spell_check": false } }),
);
assert!(matches!(
a.call("form_set_props", &json!({ "doc": doc, "field": "full name", "flags": { "sparkles": true } })),
Err(ToolError::InvalidArgs(_))
));
assert!(matches!(
a.call("form_add_field", &json!({ "doc": doc, "page": 1, "type": "slider", "rect": [0, 0, 9, 9] })),
Err(ToolError::InvalidArgs(_))

View File

@ -579,7 +579,7 @@ pub enum Edit {
/// Field Properties ▸ General / Options.
SetFieldProps {
name: String,
props: FieldProps,
props: Box<FieldProps>,
},
/// Delete a field and all its widgets.
DeleteField {

View File

@ -677,7 +677,7 @@ fn preparing_a_form_adds_renames_and_deletes_fields() {
assert_eq!(names(&s), ["Text1", "Check Box1", "Text2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add field"));
let props = FieldProps { name: Some("email".into()), tooltip: Some("Your e-mail".into()), required: Some(true), ..Default::default() };
s.apply(id, Edit::SetFieldProps { name: "Text2".into(), props }).unwrap();
s.apply(id, Edit::SetFieldProps { name: "Text2".into(), props: Box::new(props) }).unwrap();
s.apply(id, Edit::DeleteField { name: "Check Box1".into() }).unwrap();
assert_eq!(names(&s), ["Text1", "email"]);
s.apply(id, Edit::SetFieldValue { name: "email".into(), value: FieldValue::Text("ada@example.org".into()) }).unwrap();

View File

@ -76,6 +76,12 @@ pub struct FieldProps {
pub format: Option<crate::af::Format>,
pub validate: Option<crate::af::Validate>,
pub calculate: Option<crate::af::Calculate>,
/// Options-tab flags (`/Ff` bits from [`crate::flags`]) to set or clear.
pub flags: Vec<(u32, bool)>,
/// Alignment (`/Q`): 0 left, 1 centre, 2 right.
pub quadding: Option<i64>,
/// Default value (`/DV`): what Reset form restores.
pub default_value: Option<Option<String>>,
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
@ -580,6 +586,26 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if f.kind == FieldKind::Text {
set_flag(flags::MULTILINE, props.multiline);
}
for (flag, on) in &props.flags {
set_flag(*flag, Some(*on));
}
// A comb spreads a fixed number of characters across the field: it needs a limit and
// excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229).
if f.kind == FieldKind::Text && ff & flags::COMB != 0 {
let limit = match props.max_len {
Some(m) => m,
None => f.max_len,
};
if limit.is_none_or(|m| m == 0) {
return invalid("a comb of characters needs a character limit");
}
ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT);
}
if let Some(q) = props.quadding
&& !(0..=2).contains(&q)
{
return invalid("alignment is 0 (left), 1 (centre) or 2 (right)");
}
if props.options.as_ref().is_some_and(|o| o.is_empty()) && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
return invalid("a list needs at least one option");
}
@ -603,6 +629,37 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some(opts) = &props.options {
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
}
// "Sort items": the list itself is kept in order (export values stay with their items).
if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
let mut items: Vec<(String, String)> = match &props.options {
Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(),
None => f.options.clone(),
};
items.sort_by_key(|(_, display)| display.to_lowercase());
let opt = items
.iter()
.map(|(e, disp)| {
if e == disp {
Object::String(PdfString::text(disp))
} else {
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
}
})
.collect();
d.set(b"Opt".to_vec(), Object::Array(opt));
}
if let Some(q) = props.quadding {
d.set(b"Q".to_vec(), Object::Int(q));
}
match &props.default_value {
// Buttons name their default state; text and choices hold a string.
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::name(v)),
Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)),
Some(None) => {
d.remove(b"DV");
}
None => {}
}
if props.font_size.is_some() || props.look.is_some() {
d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes()));
}

View File

@ -57,10 +57,16 @@ pub mod flags {
pub const PUSH_BUTTON: u32 = 1 << 16;
pub const COMBO: u32 = 1 << 17;
pub const EDIT: u32 = 1 << 18;
pub const SORT: u32 = 1 << 19;
pub const FILE_SELECT: u32 = 1 << 20;
pub const MULTI_SELECT: u32 = 1 << 21;
pub const DO_NOT_SPELL_CHECK: u32 = 1 << 22;
pub const DO_NOT_SCROLL: u32 = 1 << 23;
pub const COMB: u32 = 1 << 24;
/// Buttons: radios in unison. Text fields use the same bit for rich text.
pub const RADIOS_IN_UNISON: u32 = 1 << 25;
pub const RICH_TEXT: u32 = 1 << 25;
pub const COMMIT_ON_SEL_CHANGE: u32 = 1 << 26;
}
/// One widget (the field's appearance on a page).

View File

@ -474,3 +474,51 @@ fn tab_order_follows_the_page_setting() {
assert_eq!(order(&doc), "ACBD", "saved as /Tabs");
assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err());
}
#[test]
fn options_tab_flags_alignment_and_defaults() {
let mut doc = one_page();
let text = add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, None).unwrap();
let combo = add_field(
&mut doc,
0,
[50.0, 600.0, 250.0, 620.0],
&NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false },
None,
)
.unwrap();
let check = add_field(&mut doc, 0, [50.0, 500.0, 64.0, 514.0], &NewField::CheckBox, None).unwrap();
// A comb needs a limit.
let comb = FieldProps { flags: vec![(flags::COMB, true)], ..FieldProps::default() };
assert!(matches!(set_props(&mut doc, &text, &comb), Err(FormError::Invalid(_))));
set_props(
&mut doc,
&text,
&FieldProps {
flags: vec![(flags::COMB, true), (flags::DO_NOT_SPELL_CHECK, true)],
max_len: Some(Some(6)),
quadding: Some(1),
default_value: Some(Some("ABC123".into())),
..FieldProps::default()
},
)
.unwrap();
let f = fields(&doc).into_iter().find(|f| f.name == text).unwrap();
assert!(f.has(flags::COMB) && f.has(flags::DO_NOT_SPELL_CHECK) && !f.has(flags::MULTILINE));
assert_eq!((f.max_len, f.quadding, f.default.clone()), (Some(6), 1, vec!["ABC123".to_string()]));
// Reset form restores the default.
set_value(&mut doc, &text, &FieldValue::Text("XYZ".into())).unwrap();
reset(&mut doc, None).unwrap();
assert_eq!(fields(&doc).into_iter().find(|f| f.name == text).unwrap().value, vec!["ABC123".to_string()]);
// Sort items orders the list; custom text allowed.
set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true), (flags::EDIT, true)], ..FieldProps::default() }).unwrap();
let c = fields(&doc).into_iter().find(|f| f.name == combo).unwrap();
assert_eq!(c.options.iter().map(|(_, d)| d.as_str()).collect::<Vec<_>>(), vec!["apple", "Fig", "Pear"]);
assert!(c.has(flags::EDIT));
// Checked by default: the on state is the default.
let on = fields(&doc).into_iter().find(|f| f.name == check).unwrap().widgets[0].on_state.clone().unwrap();
set_props(&mut doc, &check, &FieldProps { default_value: Some(Some(on.clone())), ..FieldProps::default() }).unwrap();
reset(&mut doc, None).unwrap();
assert_eq!(fields(&doc).into_iter().find(|f| f.name == check).unwrap().value, vec![on]);
assert!(set_props(&mut doc, &text, &FieldProps { quadding: Some(5), ..FieldProps::default() }).is_err());
}

View File

@ -858,7 +858,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
if field_props_now
&& let Some(d) = app.field_props.take()
&& let Some(props) = d.props()
&& app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props })
&& app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
&& let Some(i) = app.active
{
// Keep the (possibly renamed) field selected.

View File

@ -227,7 +227,8 @@ pub(crate) fn page_input(
if delta.length() >= 1.0 {
let r = dragged(screen_rect(xf, info, page, w.rect), grab, delta);
let rect = user_rect(xf, info, page, r);
view.pending_edit = Some(Edit::SetFieldProps { name, props: FieldProps { rect: Some((wi, rect)), ..Default::default() } });
view.pending_edit =
Some(Edit::SetFieldProps { name, props: Box::new(FieldProps { rect: Some((wi, rect)), ..Default::default() }) });
}
}
}
@ -441,6 +442,14 @@ pub struct FieldDraft {
pub max_len: usize,
pub options: Vec<String>,
pub new_option: String,
/// `/Ff` as edited on the Options tab.
pub flags: u32,
/// `/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.
@ -476,6 +485,10 @@ impl FieldDraft {
max_len: f.max_len.unwrap_or(0),
options: f.options.iter().map(|(_, d)| d.clone()).collect(),
new_option: String::new(),
flags: f.flags,
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]],
look: None,
@ -503,7 +516,7 @@ impl FieldDraft {
fn tabs(&self) -> Vec<(FieldTab, &'static str)> {
let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")];
if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List) {
if !matches!(self.kind, FormFieldKind::PushButton | FormFieldKind::Signature) {
t.push((FieldTab::Options, "Options"));
}
if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) {
@ -533,11 +546,43 @@ impl FieldDraft {
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),
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 printcraft_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.checkbox(&mut on, label).changed() {
if on != inverted {
*flags |= bit;
} else {
*flags &= !bit;
}
}
}
/// 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;
@ -656,12 +701,60 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
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| {
@ -701,6 +794,26 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
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");
}
},
}

View File

@ -169,3 +169,28 @@ fn appearance_tab_restyles_the_field() {
let l = s.session.get(s.views[0].id).unwrap().field_look("city").unwrap();
assert_eq!((l.style, l.font, l.fill), (BorderStyle::Dashed, FieldFont::Courier, Some([1.0, 1.0, 0.8])));
}
#[test]
fn options_tab_sets_flags_alignment_and_defaults() {
use printcraft_engine::field_flags as ff;
let mut h = harness();
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_label("Check spelling").click();
h.get_by_label("Password").click();
h.run_steps(1);
{
let d = h.state_mut().field_props.as_mut().unwrap();
d.quadding = 2;
d.default = "Paris".into();
}
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").cloned().unwrap();
assert!(f.has(ff::PASSWORD) && f.has(ff::DO_NOT_SPELL_CHECK), "{:b}", f.flags);
assert_eq!((f.quadding, f.default.clone()), (2, vec!["Paris".to_string()]));
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change field properties"));
}

View File

@ -4791,11 +4791,11 @@ title = "Field properties: options per field type"
area = "F"
tier = "P0"
milestone = "M6"
status = "partial"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props"]
evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools"]
notes = "text: multi-line, character limit; choices: item list add/delete/reorder"
evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults"]
notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison"
[[feature]]
id = "form.props-actions"