From 42f09c156cc87bdb1b6b5f665ee443c18f91cdb7 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:56:58 -0400 Subject: [PATCH 01/12] M6: a drop-down shows its arrow, and its list looks like the field --- crates/ui-egui/src/forms_ui.rs | 62 +++++++++++++++++++++++++++------- crates/ui-egui/tests/forms.rs | 50 +++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 3 files changed, 102 insertions(+), 14 deletions(-) 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" From 034a1080b0bf8156a52a767c87b5ff064893b464 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:17:04 -0400 Subject: [PATCH 02/12] M6: a long drop-down list keeps its scroll bar in view --- crates/ui-egui/src/forms_ui.rs | 19 +++++++++++++++++++ crates/ui-egui/tests/forms.rs | 16 ++++++++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index f684c28934..2e86d2c5fe 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -605,6 +605,25 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f |ui| { ui.style_mut().visuals = egui::Visuals::light(); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + // A scroll bar that stays shown beside the items, so a longer list says so and + // where it is scrolled to; it replaces the fade at the bottom edge. + ui.spacing_mut().scroll = egui::style::ScrollStyle { + bar_width: 8.0, + dormant_background_opacity: 1.0, + active_background_opacity: 1.0, + interact_background_opacity: 1.0, + dormant_handle_opacity: 1.0, + active_handle_opacity: 1.0, + interact_handle_opacity: 1.0, + ..egui::style::ScrollStyle::solid() + }; + ui.spacing_mut().scroll.fade.strength = 0.0; + // The bar's track and handle, gray on the white list. + let v = ui.visuals_mut(); + v.extreme_bg_color = Color32::from_gray(236); + v.widgets.inactive.bg_fill = Color32::from_gray(168); + v.widgets.hovered.bg_fill = Color32::from_gray(132); + v.widgets.active.bg_fill = Color32::from_gray(100); 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); diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 7913701ac7..25db3e404d 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1122,3 +1122,19 @@ fn a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name() { h.run_steps(4); assert_eq!(value(&h, "country"), ["fr"]); } + +#[test] +fn a_long_drop_down_list_keeps_its_scroll_bar_beside_the_items() { + let mut h = harness(); + let options: Vec = (0..30).map(|i| format!("{i:02}00")).collect(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }), + })); + h.run_steps(3); + click_field(&mut h, "country", 0); + let country = field_rect(&h, "country"); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + // A scroll bar that stays shown takes its own room, so people see there is more below. + assert!(first.right() <= country.right() - 6.0, "{first:?} leaves room for the bar in {country:?}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c9439bf218..c37e458946 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4441,7 +4441,7 @@ status = "shipped" commands = ["form.fields"] tools = ["form_fill"] 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" +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, with a scroll bar shown beside a longer list; the list takes no arrow keys yet" [[feature]] id = "form.push-buttons" From 5d1303d12e7d62a467eba9e8827fce05aeea7697 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:27:36 -0400 Subject: [PATCH 03/12] M6: a drop-down's list follows the pointer and the keys, with a classic scroll bar --- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/forms_ui.rs | 271 +++++++++++++++++++++++++-------- crates/ui-egui/tests/forms.rs | 84 ++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 298 insertions(+), 62 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 91480ac7b5..c70e2fa56f 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1421,6 +1421,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + list: Default::default(), } } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 2e86d2c5fe..8c3b059527 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -40,6 +40,24 @@ pub struct Focus { /// The text the calendar last saw, so it moves to a typed date once, when the typing becomes /// a whole date, and otherwise keeps the month its arrows turned to. pub calendar_text: Option, + /// Drop-downs: where the open list is scrolled to and which item is current. + pub list: ChoiceList, +} + +/// A drop-down's open list: the first item shown, the item the keys or the pointer are on, and +/// where on the scroll bar's thumb a drag took hold. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ChoiceList { + pub first: usize, + pub current: Option, + pub grab: Option, + /// Wheel movement not yet a whole row. + pub wheel: f32, + /// The item the pointer was last over: entering another makes that one current, so a + /// resting pointer doesn't take the highlight back from the arrow keys. + pub pointer_on: Option, + /// Set on the first frame, from the field's value. + pub placed: bool, } #[derive(Clone, Debug, Default)] @@ -93,6 +111,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { calendar: None, calendar_rect: None, calendar_text: None, + list: ChoiceList::default(), } } @@ -596,66 +615,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f } } FormFieldKind::Combo => { - // 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); - // A scroll bar that stays shown beside the items, so a longer list says so and - // where it is scrolled to; it replaces the fade at the bottom edge. - ui.spacing_mut().scroll = egui::style::ScrollStyle { - bar_width: 8.0, - dormant_background_opacity: 1.0, - active_background_opacity: 1.0, - interact_background_opacity: 1.0, - dormant_handle_opacity: 1.0, - active_handle_opacity: 1.0, - interact_handle_opacity: 1.0, - ..egui::style::ScrollStyle::solid() - }; - ui.spacing_mut().scroll.fade.strength = 0.0; - // The bar's track and handle, gray on the white list. - let v = ui.visuals_mut(); - v.extreme_bg_color = Color32::from_gray(236); - v.widgets.inactive.bg_fill = Color32::from_gray(168); - v.widgets.hovered.bg_fill = Color32::from_gray(132); - v.widgets.active.bg_fill = Color32::from_gray(100); - 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); - 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 mut st = view.forms.focus.as_ref().map(|x| x.list).unwrap_or_default(); + let (chosen, resp) = choice_list(ctx, view.id.0, f, w, rect, zoom, &mut st); + if let Some(fx) = view.forms.focus.as_mut() { + fx.list = st; + } + if let Some((export, _)) = chosen.and_then(|i| f.options.get(i)) { + 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)); @@ -732,6 +700,189 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f edit.or_else(|| view.pending_edit.take()) } +/// Items a drop-down's list shows before it scrolls. +const LIST_ROWS: usize = 9; + +/// A drop-down's open list, under the field and drawn like it, as other viewers draw theirs: as +/// wide as the box, its items in the field's text size and alignment, black on white with a blue +/// highlight that follows the pointer and the arrow keys, and a scroll bar with step buttons when +/// there are more than nine items. Returns the item chosen (a click, or Enter on the current one) +/// and the list's response. +fn choice_list( + ctx: &egui::Context, + view: u64, + f: &FormField, + w: &FormWidget, + rect: Rect, + zoom: f32, + st: &mut ChoiceList, +) -> (Option, egui::InnerResponse<()>) { + let n = f.options.len(); + let shown = n.min(LIST_ROWS); + let max_first = n.saturating_sub(shown); + let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, "") * zoom).clamp(6.0, 48.0); + let row = (size * 1.45).round().max(8.0); + let bar = if n > shown { (row * 0.7).round().clamp(12.0, 22.0) } else { 0.0 }; + let width = rect.width().max(24.0 + bar); + if !st.placed { + st.current = f.value.first().and_then(|v| f.options.iter().position(|(e, _)| e == v)); + st.first = st.current.map_or(0, |c| c.min(max_first)); + st.placed = true; + } + // Consumed, so the keys move through the list rather than the page. + let key = |k| ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)); + let moved = if n == 0 { + None + } else if key(egui::Key::ArrowUp) { + Some(st.current.map_or(0, |c| c.saturating_sub(1))) + } else if key(egui::Key::ArrowDown) { + Some(st.current.map_or(0, |c| (c + 1).min(n - 1))) + } else if key(egui::Key::PageUp) { + Some(st.current.map_or(0, |c| c.saturating_sub(shown))) + } else if key(egui::Key::PageDown) { + Some(st.current.map_or(0, |c| (c + shown).min(n - 1))) + } else if key(egui::Key::Home) { + Some(0) + } else if key(egui::Key::End) { + Some(n - 1) + } else { + None + }; + if let Some(m) = moved { + st.current = Some(m); + st.first = if m < st.first { + m + } else if m >= st.first + shown { + m + 1 - shown + } else { + st.first + }; + } + let mut chosen = key(egui::Key::Enter).then_some(st.current).flatten(); + let align = match f.quadding { + 1 => egui::Align::Center, + 2 => egui::Align::Max, + _ => egui::Align::Min, + }; + let resp = egui::Area::new(egui::Id::new(("form-choices", view))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show(ctx, |ui| { + let (outer, area) = ui.allocate_exact_size(vec2(width, shown as f32 * row + 2.0), egui::Sense::hover()); + let p = ui.painter(); + p.rect_filled(outer, CornerRadius::ZERO, Color32::WHITE); + // The wheel scrolls the list, a row at a time, and not the page under it. + if area.hovered() && n > shown { + st.wheel += ctx.input(|i| i.smooth_scroll_delta.y); + ctx.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let steps = (st.wheel / row).trunc(); + if steps != 0.0 { + st.wheel -= steps * row; + st.first = (st.first as f32 - steps).clamp(0.0, max_first as f32) as usize; + } + } + let mut pointer_on = None; + let font = egui::FontId::proportional(size); + for k in 0..shown { + let i = st.first + k; + let Some((_, display)) = f.options.get(i) else { break }; + let r = Rect::from_min_size(outer.min + vec2(1.0, 1.0 + k as f32 * row), vec2(width - 2.0 - bar, row)); + let item = ui.interact(r, ui.id().with(("choice", i)), egui::Sense::click()); + item.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, st.current == Some(i), display)); + if item.hovered() { + pointer_on = Some(i); + if st.pointer_on != Some(i) { + st.current = Some(i); + } + } + if item.clicked() { + chosen = Some(i); + } + let on = st.current == Some(i); + if on { + p.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0x1F, 0x65, 0xD6)); + } + let galley = p.layout_no_wrap(display.clone(), font.clone(), if on { Color32::WHITE } else { Color32::BLACK }); + let inner = r.shrink2(vec2(3.0, 0.0)); + let x = match align { + egui::Align::Center => inner.center().x - galley.size().x / 2.0, + egui::Align::Max => inner.max.x - galley.size().x, + egui::Align::Min => inner.min.x, + }; + p.with_clip_rect(r).galley(egui::pos2(x, r.center().y - galley.size().y / 2.0), galley, Color32::BLACK); + } + st.pointer_on = pointer_on; + if bar > 0.0 { + scroll_bar(ui, Rect::from_min_max(egui::pos2(outer.max.x - 1.0 - bar, outer.min.y + 1.0), outer.max - vec2(1.0, 1.0)), shown, n, st); + } + p.rect_stroke(outer, CornerRadius::ZERO, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Inside); + }); + (chosen, resp) +} + +/// A list's classic scroll bar: step buttons at each end, a thumb sized to the share of the +/// items shown that can be dragged, and a track that pages when clicked. +fn scroll_bar(ui: &egui::Ui, track: Rect, shown: usize, n: usize, st: &mut ChoiceList) { + let max_first = n.saturating_sub(shown); + if max_first == 0 { + return; + } + let side = track.width(); + let up = Rect::from_min_size(track.min, vec2(side, side)); + let down = Rect::from_min_max(egui::pos2(track.min.x, track.max.y - side), track.max); + let trough = Rect::from_min_max(egui::pos2(track.min.x, up.max.y), egui::pos2(track.max.x, down.min.y)); + let thumb_len = (trough.height() * shown as f32 / n as f32).max(side * 0.75).min(trough.height()); + let travel = (trough.height() - thumb_len).max(1.0); + let thumb_at = |first: usize| trough.min.y + travel * first as f32 / max_first as f32; + let id = ui.id().with("choice-bar"); + let p = ui.painter(); + p.rect_filled(track, CornerRadius::ZERO, Color32::from_gray(240)); + for (r, step, pointing_up) in [(up, -1i64, true), (down, 1, false)] { + let b = ui.interact(r, id.with(pointing_up), egui::Sense::click()); + if b.clicked() { + st.first = (st.first as i64 + step).clamp(0, max_first as i64) as usize; + } + p.rect_filled(r.shrink(1.0), CornerRadius::ZERO, if b.hovered() { Color32::from_gray(205) } else { Color32::from_gray(222) }); + p.rect_stroke(r.shrink(1.0), CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(150)), egui::StrokeKind::Inside); + let (c, h) = (r.center(), (side * 0.18).max(2.0)); + let tip = if pointing_up { -h } else { h }; + let points = vec![egui::pos2(c.x - h * 1.4, c.y - tip / 2.0), egui::pos2(c.x + h * 1.4, c.y - tip / 2.0), egui::pos2(c.x, c.y + tip / 2.0)]; + p.add(egui::Shape::convex_polygon(points, Color32::from_gray(40), Stroke::NONE)); + } + let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len)); + let t = ui.interact(thumb, id.with("thumb"), egui::Sense::drag()); + let pointer = ui.input(|i| i.pointer.interact_pos()); + if t.drag_started() { + st.grab = pointer.map(|q| q.y - thumb.min.y); + } + if t.dragged() + && let (Some(q), Some(grab)) = (pointer, st.grab) + { + let share = ((q.y - grab - trough.min.y) / travel).clamp(0.0, 1.0); + st.first = (share * max_first as f32).round() as usize; + } + if t.drag_stopped() { + st.grab = None; + } + let page = ui.interact(trough, id.with("trough"), egui::Sense::click()); + if page.clicked() + && let Some(q) = pointer + && !thumb.contains(q) + { + st.first = if q.y < thumb.min.y { st.first.saturating_sub(shown) } else { (st.first + shown).min(max_first) }; + } + let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len)).shrink2(vec2(1.0, 0.5)); + p.rect_filled(thumb, CornerRadius::ZERO, if t.dragged() || t.hovered() { Color32::from_gray(176) } else { Color32::from_gray(196) }); + p.rect_stroke(thumb, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(140)), egui::StrokeKind::Inside); + // Grip lines across the middle of the thumb. + if thumb.height() > side { + for k in -1..=1 { + let y = thumb.center().y + k as f32 * 3.0; + p.line_segment( + [egui::pos2(thumb.min.x + side * 0.3, y), egui::pos2(thumb.max.x - side * 0.3, y)], + Stroke::new(1.0, Color32::from_gray(120)), + ); + } + } +} + /// Where a field's widget is on screen (tests and automation). pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: usize) -> Option { let w = f.widgets.get(widget)?; diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 25db3e404d..3bd77e9ec3 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1138,3 +1138,87 @@ fn a_long_drop_down_list_keeps_its_scroll_bar_beside_the_items() { // A scroll bar that stays shown takes its own room, so people see there is more below. assert!(first.right() <= country.right() - 6.0, "{first:?} leaves room for the bar in {country:?}"); } + +/// Where `text` is drawn in the last frame, if anywhere. +fn text_rect(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option { + fn walk(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| walk(s, text)), + egui::Shape::Text(t) if t.galley.text() == text => Some(egui::Rect::from_min_size(t.pos, t.galley.size())), + _ => None, + } + } + h.output().shapes.iter().find_map(|c| walk(&c.shape, text)) +} + +fn long_country(h: &mut Harness<'static, PdfCraftApp>) { + let options: Vec = (0..30).map(|i| format!("{i:02}00")).collect(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }), + })); + h.run_steps(3); +} + +#[test] +fn a_drop_downs_list_aligns_its_items_as_the_field_does() { + let mut h = harness(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { quadding: Some(1), ..Default::default() }), + })); + h.run_steps(3); + click_field(&mut h, "country", 0); + let country = field_rect(&h, "country"); + let france = text_rect(&h, "France").expect("drawn"); + assert!((france.center().x - country.center().x).abs() <= 2.0, "centered as the field is: {france:?} in {country:?}"); +} + +#[test] +fn the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses() { + let mut h = harness(); + click_field(&mut h, "country", 0); + h.key_press(egui::Key::ArrowDown); + h.run_steps(1); + h.key_press(egui::Key::ArrowDown); + h.run_steps(1); + assert!(value(&h, "country").is_empty(), "the arrows only move through the list"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["fr"]); + assert!(h.state().views[0].forms.focus.is_none(), "Enter closes the list"); + // Reopened, the list starts from the field's value. + click_field(&mut h, "country", 0); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["ca"]); +} + +#[test] +fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() { + let mut h = harness(); + long_country(&mut h); + click_field(&mut h, "country", 0); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_none(), "nine rows show"); + let country = field_rect(&h, "country"); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + // The bar's down button, at the bottom of the bar beside the ninth row. + let bar = country.right() - first.right(); + let down = egui::pos2(country.right() - bar / 2.0, first.top() + first.height() * 9.0 - bar / 2.0); + h.hover_at(down); + h.run_steps(1); + h.drag_at(down); + h.run_steps(1); + h.drop_at(down); + h.run_steps(2); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_some(), "one row on"); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none()); + h.key_press(egui::Key::End); + h.run_steps(2); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "2900").is_some(), "End goes to the last item"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["2900"]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c37e458946..764cc6c87a 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/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, with a scroll bar shown beside a longer list; the list takes no arrow keys yet" +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/ui-egui/tests/forms.rs::the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses", "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, in its text size and alignment, black on white with a highlight that follows the pointer; more than nine items get a scroll bar with step buttons, the wheel scrolls it, and the arrow keys, Page Up/Down, Home and End move through it, Enter choosing. The list is drawn in the interface font, not the field's (no serif interface font ships yet)" [[feature]] id = "form.push-buttons" From 21695302733b3ba0189863fcf18bb0d534be0e7f Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:37:32 -0400 Subject: [PATCH 04/12] M6: the mouse wheel scrolls a drop-down's list wherever the pointer is on it --- crates/ui-egui/src/forms_ui.rs | 2 +- crates/ui-egui/tests/forms.rs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8c3b059527..f5b3901fbe 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -769,7 +769,7 @@ fn choice_list( let p = ui.painter(); p.rect_filled(outer, CornerRadius::ZERO, Color32::WHITE); // The wheel scrolls the list, a row at a time, and not the page under it. - if area.hovered() && n > shown { + if area.contains_pointer() && n > shown { st.wheel += ctx.input(|i| i.smooth_scroll_delta.y); ctx.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); let steps = (st.wheel / row).trunc(); diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 3bd77e9ec3..e02cc2c537 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1222,3 +1222,26 @@ fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() { h.run_steps(3); assert_eq!(value(&h, "country"), ["2900"]); } + +#[test] +fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { + let mut h = harness(); + long_country(&mut h); + click_field(&mut h, "country", 0); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + let page = h.state().views[0].page_screen_rect(0); + h.hover_at(first.center() + egui::vec2(0.0, first.height() * 4.0)); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(2); + } + h.run_steps(6); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "the list scrolled down"); + assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); +} From acd8a5765c91edd038430749bbf677a67cb05f33 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:45:29 -0400 Subject: [PATCH 05/12] M6: an open form field keeps the arrow keys, Home and End from moving the page --- crates/ui-egui/src/canvas.rs | 5 +++++ crates/ui-egui/tests/forms.rs | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..0e90b81f5b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2294,6 +2294,11 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } + // An open form field takes the arrows, Page Up / Page Down, Home and End (a drop-down's or a + // list box's items), so the page under it stays put. + if view.forms.focus.is_some() { + return; + } if key(Key::Home) { view.go_to_page(0); } diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index e02cc2c537..94822e50d0 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1245,3 +1245,45 @@ fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "the list scrolled down"); assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); } + +#[test] +fn each_arrow_key_press_moves_a_drop_downs_list_one_item() { + let mut h = harness(); + long_country(&mut h); + click_field(&mut h, "country", 0); + h.key_press(egui::Key::Home); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["0100"], "one item down from the first"); +} + +#[test] +fn an_open_drop_down_keeps_the_arrow_keys_and_home_from_moving_the_page() { + let mut h = harness(); + long_country(&mut h); + // A page taller than the window, so the keys would scroll it. + h.state_mut().set_option("zoom", "300").unwrap(); + for _ in 0..30 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + } + click_field(&mut h, "country", 0); + // The pointer rests on the list, as it does after reaching for it. + let row = egui_kittest::kittest::Queryable::get_by_label(&h, "0300").rect(); + h.hover_at(row.center()); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0); + h.key_press(egui::Key::Home); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(4); + assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["0100"], "one item down from the first"); +} From 98b292ac2b846af7a8418cd15dbeb70f22e1af61 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:51:54 -0400 Subject: [PATCH 06/12] M6: drop a drop-down test the page-scroll one covers --- crates/ui-egui/tests/forms.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 94822e50d0..ab6c03239e 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1246,20 +1246,6 @@ fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); } -#[test] -fn each_arrow_key_press_moves_a_drop_downs_list_one_item() { - let mut h = harness(); - long_country(&mut h); - click_field(&mut h, "country", 0); - h.key_press(egui::Key::Home); - h.run_steps(2); - h.key_press(egui::Key::ArrowDown); - h.run_steps(2); - h.key_press(egui::Key::Enter); - h.run_steps(3); - assert_eq!(value(&h, "country"), ["0100"], "one item down from the first"); -} - #[test] fn an_open_drop_down_keeps_the_arrow_keys_and_home_from_moving_the_page() { let mut h = harness(); From eaf977b4ab109a448d6d0c9b3a4127423e9eba97 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:16:30 -0400 Subject: [PATCH 07/12] M6: a resting pointer leaves a drop-down's highlight to the keys --- crates/ui-egui/src/forms_ui.rs | 19 +++++++++---------- crates/ui-egui/tests/forms.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index f5b3901fbe..dbc5037228 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -53,9 +53,10 @@ pub struct ChoiceList { pub grab: Option, /// Wheel movement not yet a whole row. pub wheel: f32, - /// The item the pointer was last over: entering another makes that one current, so a - /// resting pointer doesn't take the highlight back from the arrow keys. - pub pointer_on: Option, + /// Where the pointer was last frame: only a pointer that moves makes the item under it + /// current, so a resting one keeps no claim on the highlight when the keys scroll the list + /// beneath it. + pub pointer_at: Option, /// Set on the first frame, from the field's value. pub placed: bool, } @@ -778,7 +779,8 @@ fn choice_list( st.first = (st.first as f32 - steps).clamp(0.0, max_first as f32) as usize; } } - let mut pointer_on = None; + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let pointer_moved = pointer != st.pointer_at; let font = egui::FontId::proportional(size); for k in 0..shown { let i = st.first + k; @@ -786,11 +788,8 @@ fn choice_list( let r = Rect::from_min_size(outer.min + vec2(1.0, 1.0 + k as f32 * row), vec2(width - 2.0 - bar, row)); let item = ui.interact(r, ui.id().with(("choice", i)), egui::Sense::click()); item.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, st.current == Some(i), display)); - if item.hovered() { - pointer_on = Some(i); - if st.pointer_on != Some(i) { - st.current = Some(i); - } + if item.hovered() && pointer_moved { + st.current = Some(i); } if item.clicked() { chosen = Some(i); @@ -808,7 +807,7 @@ fn choice_list( }; p.with_clip_rect(r).galley(egui::pos2(x, r.center().y - galley.size().y / 2.0), galley, Color32::BLACK); } - st.pointer_on = pointer_on; + st.pointer_at = pointer; if bar > 0.0 { scroll_bar(ui, Rect::from_min_max(egui::pos2(outer.max.x - 1.0 - bar, outer.min.y + 1.0), outer.max - vec2(1.0, 1.0)), shown, n, st); } diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index ab6c03239e..fd1f8267ad 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1273,3 +1273,19 @@ fn an_open_drop_down_keeps_the_arrow_keys_and_home_from_moving_the_page() { h.run_steps(3); assert_eq!(value(&h, "country"), ["0100"], "one item down from the first"); } + +#[test] +fn a_pointer_resting_on_a_list_the_keys_scroll_keeps_the_highlight_on_the_keys() { + let mut h = harness(); + long_country(&mut h); + click_field(&mut h, "country", 0); + let row = egui_kittest::kittest::Queryable::get_by_label(&h, "0300").rect(); + h.hover_at(row.center()); + h.run_steps(2); + // End scrolls the list, so another item comes under the resting pointer. + h.key_press(egui::Key::End); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["2900"], "the item End went to, not the one under the pointer"); +} From b0ef0e143c588383f6186ce89351af0f8b6e4cbd Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:42:33 -0400 Subject: [PATCH 08/12] M6: doc comments for the functions this change touches --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/forms_ui.rs | 2 ++ crates/ui-egui/tests/forms.rs | 21 +++++++++++++++++++++ 4 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0e90b81f5b..5e30ab006e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2219,6 +2219,9 @@ impl PageXform { } } +/// The document view's keyboard shortcuts: find, rotation, zoom (or the page grid's size), +/// history, Escape, and the paging and scrolling keys. An open form field keeps the paging +/// and scrolling keys for itself. pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { use egui::{Key, KeyboardShortcut, Modifiers}; if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index c70e2fa56f..7be091e76f 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1409,6 +1409,7 @@ mod revert_draft_tests { use crate::forms_ui::Focus; use pdfcraft_engine::FieldValue; + /// An open editor on the first widget of the field "name", holding `text` as typed. fn focus(text: &str) -> Focus { Focus { name: "name".into(), diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index dbc5037228..e373206805 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -100,6 +100,8 @@ fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } +/// Editor state for opening widget `widget` of `f`: it starts from the field's value, asks +/// for keyboard focus, and gives a drop-down a fresh list that places itself from that value. fn open_focus(f: &FormField, widget: usize) -> Focus { Focus { name: f.name.clone(), diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index fd1f8267ad..933efb9869 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1073,6 +1073,7 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } +/// Where the first widget of field `name` is on screen in the first view. 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(); @@ -1082,6 +1083,8 @@ fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { /// Filled triangles painted inside `within`, at any depth of the frame's shapes. fn triangles_in(h: &Harness<'static, PdfCraftApp>, within: egui::Rect) -> Vec> { + /// Collects the filled three-point paths of `shape` that lie inside `within`, descending into + /// nested shape lists. 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)), @@ -1098,6 +1101,7 @@ fn triangles_in(h: &Harness<'static, PdfCraftApp>, within: egui::Rect) -> Vec, text: &str) -> Option { + /// The rectangle of the text shape in `shape` that reads exactly `text`, searching nested + /// shape lists. fn walk(shape: &egui::Shape, text: &str) -> Option { match shape { egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| walk(s, text)), @@ -1151,6 +1161,7 @@ fn text_rect(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option) { let options: Vec = (0..30).map(|i| format!("{i:02}00")).collect(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { @@ -1160,6 +1171,7 @@ fn long_country(h: &mut Harness<'static, PdfCraftApp>) { h.run_steps(3); } +/// A centered drop-down (quadding 1) centers the items of its open list too. #[test] fn a_drop_downs_list_aligns_its_items_as_the_field_does() { let mut h = harness(); @@ -1174,6 +1186,8 @@ fn a_drop_downs_list_aligns_its_items_as_the_field_does() { assert!((france.center().x - country.center().x).abs() <= 2.0, "centered as the field is: {france:?} in {country:?}"); } +/// The arrow keys move through an open drop-down's list without changing the value, Enter +/// chooses the current item and closes the list, and a reopened list starts from the value. #[test] fn the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses() { let mut h = harness(); @@ -1196,6 +1210,8 @@ fn the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses() { assert_eq!(value(&h, "country"), ["ca"]); } +/// Pressing the scroll bar's down button moves a long drop-down list on one row, and End goes +/// to the last item, which Enter then chooses. #[test] fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() { let mut h = harness(); @@ -1223,6 +1239,7 @@ fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() { assert_eq!(value(&h, "country"), ["2900"]); } +/// The mouse wheel over an open long drop-down scrolls its list and leaves the page where it was. #[test] fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { let mut h = harness(); @@ -1246,6 +1263,8 @@ fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); } +/// With a page taller than the window and the pointer resting on an open drop-down's list, +/// Home and the down arrow move through the list and leave the page where it was. #[test] fn an_open_drop_down_keeps_the_arrow_keys_and_home_from_moving_the_page() { let mut h = harness(); @@ -1274,6 +1293,8 @@ fn an_open_drop_down_keeps_the_arrow_keys_and_home_from_moving_the_page() { assert_eq!(value(&h, "country"), ["0100"], "one item down from the first"); } +/// When End scrolls a drop-down's list under a resting pointer, Enter chooses the item End +/// went to, not the one that came under the pointer. #[test] fn a_pointer_resting_on_a_list_the_keys_scroll_keeps_the_highlight_on_the_keys() { let mut h = harness(); From f266edb8262d35aabc9fb4936607e6f772a159f4 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:53:08 -0400 Subject: [PATCH 09/12] M6: page keys still work with a check box or radio button focused, as main's form focus allows --- crates/ui-egui/src/canvas.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 742dcf5922..4d534380a5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2530,8 +2530,9 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } } // An open form field takes the arrows, Page Up / Page Down, Home and End (a drop-down's or a - // list box's items), so the page under it stays put. - if view.forms.focus.is_some() { + // list box's items), so the page under it stays put. A focused check box or radio button + // uses none of them, so the page keys still work there. + if view.forms.focus.is_some() && form_box.is_none() { return; } if key(Key::Home) { From db7b36f8c7e661584f4bab2b5b6b6d9201e88ac3 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:33:35 -0400 Subject: [PATCH 10/12] M6: an auto-sized drop-down's list draws its items at the field's text size --- crates/ui-egui/src/forms_ui.rs | 5 ++- crates/ui-egui/tests/forms.rs | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 1d523fef94..cc5e56886d 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -849,7 +849,10 @@ fn choice_list( let n = f.options.len(); let shown = n.min(LIST_ROWS); let max_first = n.saturating_sub(shown); - let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, "") * zoom).clamp(6.0, 48.0); + // Auto-sized text is fitted to what the field shows (its chosen item's shown name), so the + // list's items come out at the size the field's value is drawn at. + let value_text = f.value.first().map_or("", |v| f.options.iter().find(|(e, _)| e == v).map_or(v.as_str(), |(_, d)| d.as_str())); + let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, value_text) * zoom).clamp(6.0, 48.0); let row = (size * 1.45).round().max(8.0); let bar = if n > shown { (row * 0.7).round().clamp(12.0, 22.0) } else { 0.0 }; let width = rect.width().max(24.0 + bar); diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 6d0cbd0927..2a5cf8c24b 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1525,6 +1525,73 @@ fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); } +/// An auto-sized drop-down's list draws its items at the size the field shows its value at, so a +/// long chosen item fits the list as it fits the field instead of growing past it. +#[test] +fn an_auto_sized_drop_downs_list_shows_its_items_at_the_fields_text_size() { + let mut h = harness(); + let long = "The Commonwealth of the Northern Mariana Islands"; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { options: Some(vec!["Canada".into(), long.into()]), ..Default::default() }), + })); + assert!( + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue { + name: "country".into(), + value: pdfcraft_engine::FieldValue::Choice(vec![long.into()]) + }) + ); + h.run_steps(3); + click_field(&mut h, "country", 0); + let field = field_rect(&h, "country"); + let item = text_rect(&h, long).expect("the long item in the open list"); + assert!(item.width() <= field.width(), "the item fits as the field's value does: {} wide in a {} field", item.width(), field.width()); +} + +/// In single-page view, where the wheel turns pages, the wheel over an open drop-down's list +/// scrolls the list and leaves the page as it is; over the page itself it still turns it. +#[test] +fn in_single_page_view_the_wheel_over_an_open_list_scrolls_the_list_not_the_page() { + let mut h = harness(); + long_country(&mut h); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 })); + h.state_mut().set_option("continuous", "off").unwrap(); + h.key_press(egui::Key::Home); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() && h.state().views[0].page_screen_rect(0).is_some() { + break; + } + } + let wheel = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(2); + } + h.run_steps(6); + }; + // The control: over the page, the wheel turns it. + let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + wheel(&mut h, page.center()); + assert_eq!(h.state().views[0].current, 1, "over the page, the wheel turns it"); + h.key_press(egui::Key::Home); + h.run_steps(6); + assert_eq!(h.state().views[0].current, 0); + // Over the open list, it scrolls the list instead. + click_field(&mut h, "country", 0); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + wheel(&mut h, first.center() + egui::vec2(0.0, first.height() * 4.0)); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "the list scrolled down"); + assert_eq!(h.state().views[0].current, 0, "the page didn't turn"); +} + /// With a page taller than the window and the pointer resting on an open drop-down's list, /// Home and the down arrow move through the list and leave the page where it was. #[test] From 5d2b0bc06387b4793e88bbcc9445f5b419be1498 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:22:21 -0400 Subject: [PATCH 11/12] M6: Escape in an open drop-down closes only its list, and a note on the list's fixed colors --- crates/ui-egui/src/canvas.rs | 13 ++++++++----- crates/ui-egui/src/forms_ui.rs | 2 ++ crates/ui-egui/tests/forms.rs | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4d534380a5..3c485620df 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2519,8 +2519,13 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.view_history(true); } let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && ctx.input(|i| i.key_pressed(k)); + // An open form field (a drop-down's or a list box's items) takes the arrows, Page Up / Page + // Down, Home, End and Escape. A focused check box or radio button uses none of them, so the + // page keys still work there. + let field_open = view.forms.focus.is_some() && form_box.is_none(); // An open menu takes Escape first and closes; the selection stays for the next one (#3535). - if key(Key::Escape) && !egui::Popup::is_any_open(ctx) { + // So does an open field, whose list closes. + if key(Key::Escape) && !egui::Popup::is_any_open(ctx) && !field_open { if view.camera.is_some() || view.camera_done.is_some() { view.pending_action = Some(ViewAction::CancelCamera); } else if view.selection.is_some() { @@ -2529,10 +2534,8 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } - // An open form field takes the arrows, Page Up / Page Down, Home and End (a drop-down's or a - // list box's items), so the page under it stays put. A focused check box or radio button - // uses none of them, so the page keys still work there. - if view.forms.focus.is_some() && form_box.is_none() { + // The open field's keys leave the page under it where it is. + if field_open { return; } if key(Key::Home) { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index cc5e56886d..8dda436955 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -899,6 +899,8 @@ fn choice_list( let resp = egui::Area::new(egui::Id::new(("form-choices", view))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show(ctx, |ui| { let (outer, area) = ui.allocate_exact_size(vec2(width, shown as f32 * row + 2.0), egui::Sense::hover()); let p = ui.painter(); + // The list is drawn as the form's own, a white list in the field's font with a blue + // highlight, in light and dark themes alike, so its colors are fixed, not the app's tokens. p.rect_filled(outer, CornerRadius::ZERO, Color32::WHITE); // The wheel scrolls the list, a row at a time, and not the page under it. if area.contains_pointer() && n > shown { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 2a5cf8c24b..3871c46e05 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -1525,6 +1525,24 @@ fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); } +/// Escape in an open drop-down closes its list and nothing else: the find bar, which Escape +/// closes when no field is open, stays open. +#[test] +fn escape_closes_an_open_drop_downs_list_and_leaves_the_page_alone() { + let mut h = harness(); + long_country(&mut h); + h.state_mut().views[0].open_find(); + h.run_steps(3); + assert!(h.state().views[0].find.is_some()); + click_field(&mut h, "country", 0); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_some(), "the list is open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "Escape closed the list"); + assert!(h.state().views[0].forms.focus.is_none(), "and the field"); + assert!(h.state().views[0].find.is_some(), "the find bar stays open"); +} + /// An auto-sized drop-down's list draws its items at the size the field shows its value at, so a /// long chosen item fits the list as it fits the field instead of growing past it. #[test] From 193097945150ab4e8278c5c4d34f34ea4cb18504 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:54:58 -0500 Subject: [PATCH 12/12] The choice list's scroll thumb starts a drag with the primary button only --- crates/ui-egui/src/tools/forms_ui.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 04331e73f7..7f9637bec6 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -1201,7 +1201,8 @@ fn scroll_bar(ui: &egui::Ui, track: Rect, shown: usize, n: usize, st: &mut Choic let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len)); let t = ui.interact(thumb, id.with("thumb"), egui::Sense::drag()); let pointer = ui.input(|i| i.pointer.interact_pos()); - if t.drag_started() { + // The primary button only: a right-button drag moves nothing (AGENTS.md "Page presses"). + if t.drag_started_by(egui::PointerButton::Primary) { st.grab = pointer.map(|q| q.y - thumb.min.y); } if t.dragged()