mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
* 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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
d1bc200115
commit
fcd72747cd
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))?;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(&[
|
||||
|
||||
Reference in New Issue
Block a user