M6: a drop-down shows its arrow, and its list looks like the field

This commit is contained in:
Eric Kaden
2026-10-19 17:56:58 -04:00
parent ec0a965074
commit 42f09c156c
3 changed files with 102 additions and 14 deletions
+50 -12
View File
@@ -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<bool> = 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));
+50
View File
@@ -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<Vec<egui::Pos2>> {
fn walk(shape: &egui::Shape, within: egui::Rect, out: &mut Vec<Vec<egui::Pos2>>) {
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"]);
}
+2 -2
View File
@@ -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"