From 3ed4f2379e65c3b4f46fe0af3d9b1d780370df48 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 27 Oct 2026 09:51:32 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/forms/src/author/edit.rs | 29 ++++++++++++++++--------- crates/forms/src/tests.rs | 30 +++++++++++++++++++++++--- crates/forms/src/values.rs | 38 +++++++++++++++++++++------------ parity/acrobat-features.toml | 2 +- 4 files changed, 71 insertions(+), 28 deletions(-) diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index c7df5481cd..d893125f43 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -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 { let mut indices: Vec = 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 Result Result Option { + 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 = 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(); diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9dd79f0a70..a060822ef0 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -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 = 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 = 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"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2511971f80..bc61a611ca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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]]