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:
KenM76
2026-10-27 09:51:32 -04:00
co-authored by Claude Opus 5.5
parent 0ea9a23466
commit 3ed4f2379e
4 changed files with 71 additions and 28 deletions
+19 -10
View File
@@ -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 {
+27 -3
View File
@@ -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
View File
@@ -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");
+1 -1
View File
@@ -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]]