M6: make list boxes directly selectable and scroll to selection (#1524) (#1543)

* M6: make list boxes directly selectable and scroll to selection (#1524)

- In-place selection: List boxes are rendered directly on the page and
  selectable in place. Clicking an option row selects it immediately;
  Ctrl/Cmd-clicking toggles items in multi-select lists, and Shift-clicking
  selects ranges, without opening a popup menu.
- Appearance and scroll (/TI): When options are selected, /TI (top index)
  is automatically adjusted so the selected items are scrolled into view
  within the widget's visible rows, and option rows and selection highlights
  are drawn into the appearance stream.
- Unit and UI tests: Added tests in pdfcraft-forms verifying /TI scrolling
  and appearance generation for multi-select lists, and UI tests in
  pdfcraft-ui-egui covering in-place click and Ctrl-click toggling.
  Also ensured test harness in reload.rs explicitly sets language to en.

Fixes #1524

* List boxes: keep the engine's page appearance, consume arrow keys

Drop the overpaint of every fillable list box with an egui mock (it ignored the
PDF's fonts, colours and /MK /R; the engine redraws the appearance with the new
/TI), and consume ArrowUp/ArrowDown in the list overlay so they don't also
scroll the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lollinoo
2026-10-15 03:52:55 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent d1bc200115
commit fcd72747cd
7 changed files with 353 additions and 40 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ pub use pdfcraft_edit::{
pub use pdfcraft_forms::{
BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue,
Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
+113 -8
View File
@@ -318,6 +318,93 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
(c, 0.0)
}
/// The width [`frame`] insets the content by: the `/BS` border width when the widget has a
/// border colour, doubled for beveled and inset borders, and 0 without a border.
fn border_width(doc: &Document, wd: &Dict) -> f64 {
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0);
if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 {
return 0.0;
}
let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default();
if style == b"B" || style == b"I" { 2.0 * bw } else { bw }
}
/// Where a list box draws its options: one row per option from the top, starting with option
/// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share
/// it, so a click picks the option drawn under the pointer (#1524).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ListRows {
/// The first option shown (`/TI`).
pub top: usize,
/// From the widget's top edge to the first row, in points.
pub inset: f64,
/// The height of one row, in points.
pub row: f64,
/// The text size, in points.
pub size: f64,
}
impl ListRows {
/// How many whole rows fit in a list box `height` points tall (at least one).
pub fn visible(&self, height: f64) -> usize {
if !(height.is_finite() && self.row > 0.0) {
return 1;
}
let rows = ((height - self.inset) / self.row).floor();
if rows >= 1.0 { rows.min(10_000.0) as usize } else { 1 }
}
/// The option at `y` points below the widget's top edge when the list starts at option `top`,
/// or `None` above the first row. The caller checks it against the number of options.
pub fn option_at(&self, top: usize, y: f64) -> Option<usize> {
if !(y.is_finite() && self.row > 0.0) || y < self.inset {
return None;
}
let k = ((y - self.inset) / self.row).floor();
(k < 1.0e6).then(|| top.saturating_add(k as usize))
}
/// The top index that shows as many of the `selected` option indices as fit in `visible`
/// rows, staying at `current` when they are all shown already, among `count` options.
pub fn top_showing(current: usize, selected: &[usize], visible: usize, count: usize) -> usize {
let visible = visible.max(1);
let last_top = count.saturating_sub(visible);
let (Some(&first), Some(&last)) = (selected.iter().min(), selected.iter().max()) else { return current.min(last_top) };
let top = if first >= current && last < current.saturating_add(visible) {
current
} else if last.saturating_sub(first) >= visible || first < current {
first
} else {
// Scroll down just far enough that the last selected option is the bottom row.
last.saturating_add(1).saturating_sub(visible)
};
top.min(last_top)
}
}
/// `/TI` of a list box: on the widget, else on the field.
fn top_index(doc: &Document, wd: &Dict, field: Option<&Dict>) -> usize {
let ti = |d: &Dict| d.get(b"TI").and_then(|o| doc.resolve(o).as_int());
ti(wd).or_else(|| field.and_then(ti)).and_then(|i| usize::try_from(i).ok()).unwrap_or(0)
}
/// The rows of a list box whose field's `/DA` is `f_da`, in widget `wd`.
pub(crate) fn list_rows_of(doc: &Document, f_da: &str, field: Option<&Dict>, wd: &Dict) -> ListRows {
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da));
let size = if da.size > 0.0 { da.size } else { 12.0 };
ListRows { top: top_index(doc, wd, field), inset: 2.0 + border_width(doc, wd), row: size * 1.15, size }
}
/// The rows of list box `f` as drawn in widget `w`.
pub fn list_rows(doc: &Document, f: &Field, w: &Widget) -> ListRows {
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let fobj = doc.get(f.obj);
list_rows_of(doc, &f.da, fobj.as_dict(), &wd)
}
/// The appearance of a text or choice field's widget showing `values`. Characters its font can't
/// hold come out as `?`; [`field_appearance_drawn`] says which.
pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream {
@@ -400,14 +487,13 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
let mut size = da.size;
match f.kind {
FieldKind::List => {
// Every option, one per line from the top; selected ones highlighted.
if size == 0.0 {
size = 12.0;
}
let line = size * 1.15;
let top = wd.get(b"TI").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).max(0) as usize;
let mut y = height - pad;
for (export, display) in f.options.iter().skip(top) {
// Every option, one per line from the top (from `/TI`); selected ones highlighted.
let fobj = doc.get(f.obj);
let rows = list_rows_of(doc, &f.da, fobj.as_dict(), &wd);
size = rows.size;
let line = rows.row;
let mut y = height - rows.inset;
for (export, display) in f.options.iter().skip(rows.top) {
if y - line < 0.0 {
break;
}
@@ -618,4 +704,23 @@ mod tests {
assert_eq!(parse_da("garbage").font, "Helv");
assert_eq!(stroke_op("1 0 0 rg"), "1 0 0 RG");
}
#[test]
fn list_rows_visibility_and_scrolling() {
let rows = ListRows { top: 0, inset: 2.0, row: 11.5, size: 10.0 };
assert_eq!(rows.visible(46.0), 3);
assert_eq!(rows.option_at(0, 1.0), None);
assert_eq!(rows.option_at(0, 5.0), Some(0));
assert_eq!(rows.option_at(0, 15.0), Some(1));
assert_eq!(rows.option_at(2, 5.0), Some(2));
// When Tue (1) and Thu (3) are selected in 5 options with 3 visible rows:
assert_eq!(ListRows::top_showing(0, &[1, 3], 3, 5), 1);
// When staying within view:
assert_eq!(ListRows::top_showing(1, &[1, 2], 3, 5), 1);
// When selecting before current top:
assert_eq!(ListRows::top_showing(2, &[0], 3, 5), 0);
// When empty selection:
assert_eq!(ListRows::top_showing(0, &[], 3, 5), 0);
}
}
+1
View File
@@ -657,6 +657,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
locked: false,
hidden: false,
rotation: 0,
list: None,
};
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)))?;
+28
View File
@@ -103,6 +103,8 @@ pub struct Widget {
pub hidden: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
pub list: Option<appearance::ListRows>,
}
/// A terminal form field.
@@ -800,6 +802,7 @@ fn walk(
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
rotation: appearance::mk_rotation(doc, wd),
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
})
})
.collect();
@@ -1053,6 +1056,16 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
// /I lists option indices in ascending order; /V keeps the caller's value order.
// Every entry above is an integer, so this sorts by its numeric option index.
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,
};
doc.update_dict(f.obj, |d| {
match exports.as_slice() {
[] => {
@@ -1066,7 +1079,22 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
} else {
d.set(b"I".to_vec(), Object::Array(indices));
}
if let Some((old, new)) = top
&& old != new
{
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))))?;
}
}
}
redraw(doc, f, &exports, scripts)
}
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
+27
View File
@@ -607,6 +607,33 @@ fn choices_accept_exports_or_display_text() {
assert!(matches!(set_value(&mut doc, "country", &FieldValue::Choice(vec!["ca".into(), "fr".into()])), Err(FormError::Invalid(_))));
}
#[test]
fn list_box_selection_scrolls_to_show_selected_options() {
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 [6 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 /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (days) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 700 200 746] /P 3 0 R >>"
.into(),
];
let mut doc = document(&objs);
// Setting value to Tue and Thu (indices 1 and 3) in a 46 pt high box (fits 3 rows).
// It should scroll /TI to 1 so Tue (1), Wed (2), Thu (3) are drawn and highlighted.
set_value(&mut doc, "days", &FieldValue::Choice(vec!["Tue".into(), "Thu".into()])).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
let days = field(&all, "days");
assert_eq!(days.value, ["Tue", "Thu"]);
let ti = doc.get(days.obj).as_dict().unwrap().get(b"TI").and_then(|o| o.as_int());
assert_eq!(ti, Some(1), "TI scrolled to 1 so both selected options are visible");
let appearance = ap(&doc, &days.widgets[0]);
assert!(appearance.contains("(Tue) Tj"), "Tue is drawn: {appearance}");
assert!(appearance.contains("(Thu) Tj"), "Thu is drawn: {appearance}");
assert_eq!(appearance.matches("0.6 0.75 0.86 rg").count(), 2, "both Tue and Thu highlighted");
}
#[test]
fn multiselect_choice_indices_are_sorted_without_reordering_values() {
let mut forward_appearance = None;
+112 -31
View File
@@ -1,18 +1,19 @@
//! Filling in forms on the page (execution plan M6; Acrobat fills fields in every viewing mode).
//!
//! Text fields open an in-place editor over the widget (Enter or clicking away commits, Escape
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. Combo
//! and list boxes open a list of their options. Every change is one undoable engine edit.
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. List
//! boxes are directly selectable in-place on the page (clicking picks an option; Ctrl-click
//! toggles on multi-select lists). Combo boxes open a list of their options. Every change is one
//! undoable engine edit.
use std::collections::HashSet;
use egui::{Color32, CornerRadius, Rect, Stroke, vec2};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags};
use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
use crate::theme::Tokens;
use crate::widgets;
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// Background of the in-place text field editor, the same in every theme.
@@ -181,12 +182,48 @@ pub(crate) fn page_input(
x.request_focus = true;
}
}
FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => {
FormFieldKind::Text | FormFieldKind::Combo => {
if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
}
}
FormFieldKind::List => {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
let r = widget_rect(xf, info, page, w.rect);
let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) };
let zoom = (r.height() as f64) / height_pt;
let y_pt = (p.y - r.min.y) as f64 / zoom;
let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 });
let picked_indices: Vec<usize> = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect();
let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len());
if let Some(opt_idx) = rows.option_at(top, y_pt).filter(|&i| i < f.options.len()) {
let export = &f.options[opt_idx].0;
let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
let shift = ui.input(|i| i.modifiers.shift);
let multi = f.has(field_flags::MULTI_SELECT);
let new_value = if multi && ctrl {
let mut picked = f.value.clone();
if let Some(pos) = picked.iter().position(|x| x == export) {
picked.remove(pos);
} else {
picked.push(export.clone());
}
FieldValue::Choice(picked)
} else if multi && shift && !f.value.is_empty() {
let anchor = f.value.last().and_then(|last| f.options.iter().position(|(e, _)| e == last)).unwrap_or(opt_idx);
let (min, max) = (anchor.min(opt_idx), anchor.max(opt_idx));
let picked = f.options[min..=max].iter().map(|(e, _)| e.clone()).collect();
FieldValue::Choice(picked)
} else {
FieldValue::Choice(vec![export.clone()])
};
if new_value != FieldValue::Choice(f.value.clone()) {
view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: new_value });
}
}
}
}
FieldPointer::Owned
}
@@ -196,7 +233,6 @@ pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option<Edit> {
let f = form.iter().find(|f| f.name == focus.name)?;
let value = match f.kind {
FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()),
FormFieldKind::List if f.has(field_flags::MULTI_SELECT) && focus.picked != f.value => FieldValue::Choice(focus.picked.clone()),
_ => return None,
};
Some(Edit::SetFieldValue { name: f.name.clone(), value })
@@ -309,13 +345,11 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform,
let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi);
if focused {
painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside);
} else {
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
}
if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
}
if view.forms.unbaked.contains(&(f.name.clone(), page)) {
paint_unbaked_widget(painter, xf, f, w, r);
} else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) {
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside);
}
}
}
@@ -352,7 +386,7 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t
fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) {
match f.kind {
FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List => {
FormFieldKind::Text | FormFieldKind::Combo => {
let text = f.value.first().map(String::as_str).unwrap_or("");
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
@@ -374,6 +408,38 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField,
p.galley(align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK);
}
}
FormFieldKind::List => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
let unrotated_w = if xf.rot % 180 == 90 { xf.ph } else { xf.pw };
let zoom = (xf.rect.width() / unrotated_w.max(1.0)) as f64;
let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 });
let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) };
let picked_indices: Vec<usize> = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect();
let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len());
let font_size = (rows.size * zoom).clamp(6.0, 64.0) as f32;
let row_h = (rows.row * zoom) as f32;
let inset = (rows.inset * zoom) as f32;
let p = painter.with_clip_rect(r);
let mut y = r.min.y + inset;
for (export, display) in f.options.iter().skip(top) {
if y + row_h > r.max.y {
break;
}
let row_rect = Rect::from_min_size(egui::pos2(r.min.x + 1.0, y), egui::vec2(r.width() - 2.0, row_h));
if f.value.contains(export) {
p.rect_filled(row_rect, CornerRadius::ZERO, Color32::from_rgb(0x99, 0xC0, 0xDC));
}
p.text(
egui::pos2(r.min.x + 3.0, y + font_size * 0.15),
egui::Align2::LEFT_TOP,
display,
egui::FontId::proportional(font_size),
Color32::BLACK,
);
y += row_h;
}
}
FormFieldKind::CheckBox => {
painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4));
painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside);
@@ -502,8 +568,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
return view.pending_edit.take();
}
}
FormFieldKind::Combo | FormFieldKind::List => {
let multi = f.kind == FormFieldKind::List && f.has(field_flags::MULTI_SELECT);
FormFieldKind::Combo => {
let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0)))
.order(egui::Order::Foreground)
.fixed_pos(rect.left_bottom() + vec2(0.0, 2.0))
@@ -512,27 +577,14 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
ui.set_min_width(rect.width().max(160.0));
ui.label(egui::RichText::new(&f.name).small().color(t.text_faint));
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
let Some(fx) = view.forms.focus.as_mut() else { return };
for (export, display) in &f.options {
let on = if multi { fx.picked.contains(export) } else { f.value.first() == Some(export) };
if multi {
let mut c = on;
if ui.add(widgets::checkbox(&mut c, display)).changed() {
if c {
fx.picked.push(export.clone());
} else {
fx.picked.retain(|x| x != export);
}
}
} else if ui.selectable_label(on, display).clicked() {
let on = f.value.first() == Some(export);
if ui.selectable_label(on, display).clicked() {
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) });
close = true;
}
}
});
if multi && ui.button(tl!("Done")).clicked() {
commit(view, form);
}
});
});
let clicked_away = ctx.input(|i| i.pointer.any_click())
@@ -545,6 +597,35 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
next = Some(!shift);
} else if clicked_away {
commit(view, form);
close = true;
}
}
FormFieldKind::List => {
let (up, down, esc, tab, shift) = ctx.input_mut(|i| {
(
// Consumed, so the arrows move the selection without also scrolling the page.
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
i.key_pressed(egui::Key::Escape),
i.key_pressed(egui::Key::Tab),
i.modifiers.shift,
)
});
if esc {
close = true;
} else if tab {
next = Some(!shift);
} else if (up || down) && !f.options.is_empty() {
let current_idx = f.value.last().and_then(|v| f.options.iter().position(|(e, _)| e == v)).unwrap_or(0);
let next_idx = if up { current_idx.saturating_sub(1) } else { (current_idx + 1).min(f.options.len().saturating_sub(1)) };
if next_idx != current_idx && next_idx < f.options.len() {
let export = f.options[next_idx].0.clone();
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) });
}
}
let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
if clicked_away {
close = true;
}
}
_ => close = true,
+71
View File
@@ -872,6 +872,77 @@ fn the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced() {
assert_eq!(h.query_all_by_label("orphan").count(), 1);
}
#[test]
fn list_box_clicks_select_directly_and_ctrl_click_toggles_multi() {
let bytes = pdf_from_objects(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListOne) /Opt [(Red) (Green) (Blue)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListMulti) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 100 200 170] /P 3 0 R /F 4 >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
]);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("lists.pdf", None, bytes).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "100").unwrap();
app
});
h.run_steps(4);
// Click row 1 (Green) in ListOne
let r_one = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "ListOne").unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
};
let zoom_one = r_one.height() / 70.0;
let p_green = egui::pos2(r_one.center().x, r_one.min.y + 19.0 * zoom_one);
h.hover_at(p_green);
h.run_steps(1);
h.drag_at(p_green);
h.run_steps(1);
h.drop_at(p_green);
h.run_steps(3);
assert_eq!(value(&h, "ListOne"), ["Green"], "clicking row 1 in ListOne selected Green");
// Click row 1 (Tue) in ListMulti
let r_multi = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "ListMulti").unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
};
let zoom_multi = r_multi.height() / 70.0;
let p_tue = egui::pos2(r_multi.center().x, r_multi.min.y + 19.0 * zoom_multi);
h.hover_at(p_tue);
h.run_steps(1);
h.drag_at(p_tue);
h.run_steps(1);
h.drop_at(p_tue);
h.run_steps(3);
assert_eq!(value(&h, "ListMulti"), ["Tue"], "clicking row 1 in ListMulti selected Tue");
// Ctrl/Cmd-click row 3 (Thu) in ListMulti
let p_thu = egui::pos2(r_multi.center().x, r_multi.min.y + 42.0 * zoom_multi);
h.hover_at(p_thu);
h.run_steps(1);
h.event(egui::Event::PointerMoved(p_thu));
h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND));
h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::COMMAND });
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(3);
assert_eq!(value(&h, "ListMulti"), ["Tue", "Thu"], "Ctrl-clicking Thu toggled it on, keeping Tue");
}
/// Auto-size (`0 Tf`, inherited from the AcroForm) single-line and multiline fields, and a fixed 10 pt one.
fn auto_size_form() -> Vec<u8> {
pdf_from_objects(&[