diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 612b985e65..f684c28934 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -13,7 +13,6 @@ use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, fi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; -use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Background of the in-place text field editor, the same in every theme. @@ -363,10 +362,27 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, } 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); } + if f.kind == FormFieldKind::Combo && fillable(f) { + paint_drop_down_arrow(painter, r); + } } } } +/// A drop-down's arrow at the right of its box, which says the field opens a list (its page +/// appearance shows only the value). Drawn on screen only, not into the file. +fn paint_drop_down_arrow(painter: &egui::Painter, r: Rect) { + let width = (r.height() * 0.45).clamp(5.0, 12.0); + let inset = (r.height() * 0.25).clamp(2.0, 6.0); + // Too narrow a box keeps its value visible instead. + if r.width() < width * 3.0 { + return; + } + let (x, y, half) = (r.max.x - inset - width / 2.0, r.center().y, width / 2.0); + let points = vec![egui::pos2(x - half, y - half / 2.0), egui::pos2(x + half, y - half / 2.0), egui::pos2(x, y + half / 2.0)]; + painter.add(egui::Shape::convex_polygon(points, Color32::BLACK, Stroke::NONE)); +} + /// The size, in points, a text field shows `text` at on screen: its `/DA` size, or for auto size /// (`0 Tf`) the page appearance's fit, measured in the font the screen draws with so the text /// stays inside the box (the interface font runs wider than the page's Helvetica). @@ -490,7 +506,6 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f let xf = view.page_xform(page)?; let rect = widget_rect(&xf, info, page, w.rect); let zoom = xf.rect.width() / xf.pw.max(1.0); - let t = Tokens::get(ctx); let mut edit = None; let mut close = false; let mut next: Option = None; // Tab / Shift+Tab @@ -581,24 +596,47 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f } } 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)) - .show(ctx, |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - 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| { + // The list opens under the field and looks like it, as other viewers draw it: as wide as + // the box, in its text size, black on white, without the field's internal name. + let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, "") * zoom).clamp(6.0, 48.0); + let row = (size * 1.3).ceil(); + let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show( + ctx, + |ui| { + ui.style_mut().visuals = egui::Visuals::light(); + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + egui::Frame::new().fill(Color32::WHITE).stroke(Stroke::new(1.0, Color32::from_gray(64))).show(ui, |ui| { + let width = rect.width().max(24.0); + ui.set_width(width); + egui::ScrollArea::vertical().max_height(row * 9.0).show(ui, |ui| { for (export, display) in &f.options { let on = f.value.first() == Some(export); - if ui.selectable_label(on, display).clicked() { + let (r, item) = ui.allocate_exact_size(vec2(ui.available_width(), row), egui::Sense::click()); + item.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, display)); + let (fill, ink) = if on { + (Color32::from_rgb(0x2F, 0x6F, 0xE0), Color32::WHITE) + } else if item.hovered() { + (Color32::from_gray(222), Color32::BLACK) + } else { + (Color32::WHITE, Color32::BLACK) + }; + ui.painter().rect_filled(r, CornerRadius::ZERO, fill); + ui.painter().with_clip_rect(r).text( + egui::pos2(r.min.x + 3.0, r.center().y), + egui::Align2::LEFT_CENTER, + display, + egui::FontId::proportional(size), + ink, + ); + if item.clicked() { edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) }); close = true; } } }); }); - }); + }, + ); let clicked_away = ctx.input(|i| i.pointer.any_click()) && !resp.response.contains_pointer() && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index e153dcca94..7913701ac7 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1072,3 +1072,53 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { // An explicit size is kept, as in Acrobat: the text may then run past the box. assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } + +fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") +} + +/// Filled triangles painted inside `within`, at any depth of the frame's shapes. +fn triangles_in(h: &Harness<'static, PdfCraftApp>, within: egui::Rect) -> Vec> { + fn walk(shape: &egui::Shape, within: egui::Rect, out: &mut Vec>) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, within, out)), + egui::Shape::Path(p) if p.points.len() == 3 && p.fill != egui::Color32::TRANSPARENT && p.points.iter().all(|q| within.contains(*q)) => { + out.push(p.points.clone()) + } + _ => {} + } + } + let mut out = Vec::new(); + for c in &h.output().shapes { + walk(&c.shape, within, &mut out); + } + out +} + +#[test] +fn a_drop_down_shows_its_arrow_and_text_fields_do_not() { + let mut h = harness(); + h.run_steps(2); + let country = field_rect(&h, "country"); + let arrows = triangles_in(&h, country); + assert_eq!(arrows.len(), 1, "one arrow in the drop-down: {arrows:?}"); + let tip = arrows[0].iter().fold(egui::Pos2::ZERO, |a, p| egui::pos2(a.x.max(p.x), a.y.max(p.y))); + assert!(tip.x > country.center().x, "at the right of the box"); + assert!(triangles_in(&h, field_rect(&h, "city")).is_empty(), "a text field has none"); +} + +#[test] +fn a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name() { + let mut h = harness(); + click_field(&mut h, "country", 0); + let country = field_rect(&h, "country"); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "country").is_none(), "the field's name isn't shown"); + let item = egui_kittest::kittest::Queryable::get_by_label(&h, "France").rect(); + assert!((item.left() - country.left()).abs() <= 2.0 && item.right() <= country.right() + 1.0, "{item:?} in {country:?}"); + egui_kittest::kittest::Queryable::get_by_label(&h, "France").click(); + h.run_steps(4); + assert_eq!(value(&h, "country"), ["fr"]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..c9439bf218 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4440,8 +4440,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] -notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text" +evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/ui-egui/tests/forms.rs::a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] +notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text. A combo box shows its arrow on screen and opens its list under the field, as wide as the box and in its text size, black on white; the list takes no arrow keys yet" [[feature]] id = "form.push-buttons"