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"