diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eb0a11825d..7a858581fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 6cf90daa31..4ec22f0114 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -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 { + 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); + } } diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 0b60506c76..bb45f38d9f 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -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)))?; diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index e023ea209a..07f4c5484f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -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, } /// 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 = 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)), diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b99ca0c8a4..ef545cd433 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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 = 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; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8284c55aad..8020e868b2 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -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 = 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 { 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 = 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, diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 9cbed3ed7d..96a0a6a11e 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -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 { pdf_from_objects(&[