mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
M6: a list box's top row follows its options when they change
Changing a list box's options could leave /TI (the first visible row) pointing at a row that moved or no longer exists. A widget's own /TI is read before the field's, and one past the end draws no options at all. set_props now runs the same ListRows::top_showing rule set_value uses (ISO 32000-2 12.7.5.4): /TI stays put while the selected rows are still visible, otherwise it scrolls to them, clamped to the last full page. The field's /TI is updated, and so is every widget that carries its own. The scroll helpers move out of set_value so both paths share them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0ea9a23466
commit
3ed4f2379e
@@ -14,22 +14,19 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
|
||||
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out)
|
||||
}
|
||||
|
||||
/// Re-point a choice field's `/I` (and `/TI`), which are positions in `/Opt`, at the new list
|
||||
/// `exports` after `/Opt` was replaced (ISO 32000-2 §12.7.5.4): `/I` names the positions of the
|
||||
/// selected `values` still offered, and a `/TI` past the end is dropped.
|
||||
fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String]) {
|
||||
/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was
|
||||
/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered, by
|
||||
/// `set_value`'s rule. Returns them.
|
||||
fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String]) -> Vec<usize> {
|
||||
let mut indices: Vec<usize> = values.iter().filter_map(|v| exports.iter().position(|e| e == v)).collect();
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
if indices.is_empty() {
|
||||
d.remove(b"I");
|
||||
} else {
|
||||
d.set(b"I".to_vec(), Object::Array(indices.into_iter().map(|i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect()));
|
||||
}
|
||||
let top = d.get(b"TI").and_then(Object::as_int);
|
||||
if top.is_some_and(|t| usize::try_from(t).map_or(true, |t| t >= exports.len())) {
|
||||
d.remove(b"TI");
|
||||
d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect()));
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
@@ -191,6 +188,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
|
||||
return invalid("only check boxes and radio buttons have a check style");
|
||||
}
|
||||
let mut list_top = None;
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
|
||||
if let Some(t) = &props.tooltip {
|
||||
@@ -237,7 +235,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if let Some(exports) = &new_exports
|
||||
&& matches!(f.kind, FieldKind::Combo | FieldKind::List)
|
||||
{
|
||||
repoint_selection(d, &f.value, exports);
|
||||
let picked = repoint_selection(d, &f.value, exports);
|
||||
// A list box scrolls, as for a new value, so its first row is still an option and
|
||||
// the selection is in view.
|
||||
if let Some((old, new)) = crate::values::list_scroll(&f, &picked, exports.len())
|
||||
&& old != new
|
||||
{
|
||||
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
|
||||
list_top = Some(new);
|
||||
}
|
||||
}
|
||||
if let Some(q) = props.quadding {
|
||||
d.set(b"Q".to_vec(), Object::Int(q));
|
||||
@@ -255,6 +261,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes()));
|
||||
}
|
||||
})?;
|
||||
if let Some(top) = list_top {
|
||||
crate::values::scroll_widgets(doc, &f, top)?;
|
||||
}
|
||||
if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) {
|
||||
let patch = &props.appearance.unwrap_or_default();
|
||||
for w in &f.widgets {
|
||||
|
||||
@@ -4599,11 +4599,10 @@ fn changing_a_lists_options_repoints_its_selected_indices() {
|
||||
let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() };
|
||||
set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1]));
|
||||
assert!(doc.get(f.obj).as_dict().unwrap().get(b"TI").is_some(), "row 2 still exists");
|
||||
// Removing B leaves only C selected; the top row now points past the end.
|
||||
// Removing B leaves only C selected; the top row would point past the end.
|
||||
set_props(&mut doc, &list, &opts(&["C"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0]));
|
||||
assert!(doc.get(f.obj).as_dict().unwrap().get(b"TI").is_none(), "a /TI past the list is dropped");
|
||||
assert_eq!(top_row(&doc, f.obj), Some(0), "the list scrolls back to its only row");
|
||||
// No selected value is offered any more.
|
||||
set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
@@ -4611,6 +4610,31 @@ fn changing_a_lists_options_repoints_its_selected_indices() {
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
}
|
||||
|
||||
fn top_row(doc: &Document, obj: ObjRef) -> Option<i64> {
|
||||
doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let widget = ObjRef::new(7, 0);
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(selection_indices(&doc, "pick"), Some(vec![0]));
|
||||
// The widget's own /TI is read before the field's; row 5 no longer exists.
|
||||
assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0");
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(top_row(&doc, widget), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_a_combo_boxs_items_repoints_its_selected_index() {
|
||||
let mut doc = one_page();
|
||||
|
||||
+24
-14
@@ -288,14 +288,8 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
indices.sort_unstable_by_key(Object::as_int);
|
||||
// A list box scrolls (`/TI`) so the picked options are among the rows it draws, as
|
||||
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
|
||||
let top = match (f.kind, f.widgets.first()) {
|
||||
(FieldKind::List, Some(w)) => w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len()))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
let top = list_scroll(f, &picked, f.options.len());
|
||||
doc.update_dict(f.obj, |d| {
|
||||
match exports.as_slice() {
|
||||
[] => {
|
||||
@@ -315,15 +309,10 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
|
||||
}
|
||||
})?;
|
||||
// A widget with its own `/TI` (read before the field's) scrolls too.
|
||||
if let Some((old, new)) = top
|
||||
&& old != new
|
||||
{
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
scroll_widgets(doc, f, new)?;
|
||||
}
|
||||
redraw(doc, f, &exports, scripts)
|
||||
}
|
||||
@@ -333,6 +322,27 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
}
|
||||
}
|
||||
|
||||
/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of
|
||||
/// its `count` options are among the rows it draws, as in other viewers: a pick below the last
|
||||
/// visible row would otherwise not show (#1524). `None` for any other field.
|
||||
pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> {
|
||||
let w = f.widgets.first().filter(|_| f.kind == FieldKind::List)?;
|
||||
w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count))
|
||||
})
|
||||
}
|
||||
|
||||
/// Give every widget of `f` with its own `/TI` (read before the field's) the first row `top`.
|
||||
pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, top: usize) -> Result<(), FormError> {
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(top).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget.
|
||||
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> {
|
||||
let v = on.unwrap_or("Off");
|
||||
|
||||
@@ -4991,7 +4991,7 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.field.properties"]
|
||||
tools = ["form_set_props"]
|
||||
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items"]
|
||||
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items"]
|
||||
notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
|
||||
|
||||
[[feature]]
|
||||
|
||||
Reference in New Issue
Block a user