diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 4e491d514c..f0c5b46a3a 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -363,6 +363,9 @@ fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) } } +/// 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))) { @@ -407,8 +410,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() || view.all_text { @@ -418,6 +426,10 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } + // The open field's keys leave the page under it where it is. + if field_open { + return; + } // Home and End, and the keys listed with them for Go to first / last page: Shift+Ctrl // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434). let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 21698e7f95..9246bb83f5 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -241,7 +241,7 @@ const RAW_READS: [&str; 21] = [ /// Functions in the page modules that are not page input and read egui's pointer state /// themselves: (file, function, why). -const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 12] = [ ("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), ("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), ("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), @@ -249,6 +249,8 @@ const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ ("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), ("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), ("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), + ("tools/forms_ui.rs", "choice_list", "a drop-down's open list is an Area of its own over the page"), + ("tools/forms_ui.rs", "scroll_bar", "the scroll bar of a drop-down's open list, in its Area over the page"), ("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), ("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"), diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 95b3f4585e..46f4683c8a 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1201,6 +1201,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(), @@ -1214,6 +1215,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + list: Default::default(), had_focus: false, } } diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 5b876f19fe..046747ccf2 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -17,7 +17,6 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; use crate::page_router::{Hit, Owner, PagePress}; -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. @@ -49,11 +48,30 @@ 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, /// Whether the text box had the keyboard at the end of the editor's last frame /// ([`crate::edit_text_ui::lost_focus_once`]). pub(crate) had_focus: bool, } +/// 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, + /// 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, +} + #[derive(Clone, Debug, Default)] pub struct FormView { pub focus: Option, @@ -182,6 +200,8 @@ fn list_pick_edit(form: &[FormField], f: &FormField, picked: Vec) -> Edit Edit::SetFieldValue { name: f.name.clone(), value } } +/// 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(), @@ -195,6 +215,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { calendar: None, calendar_rect: None, calendar_text: None, + list: ChoiceList::default(), had_focus: false, } } @@ -618,10 +639,27 @@ pub(crate) fn paint_page( } else if fillable(f) && hovered == Some((fi, wi)) { 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). @@ -924,7 +962,6 @@ pub(crate) fn overlay( // winit reports Windows pen input as touch), which clears `hover_pos`. let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); let zoom = xf.zoom(); - let t = Tokens::get(ctx); let mut edit = None; let mut close = false; let mut next: Option = None; // Tab / Shift+Tab @@ -1011,28 +1048,21 @@ pub(crate) fn overlay( } FormFieldKind::Combo => { // An editable combo box has a text box over the field as well as its list (ISO - // 32000-2 Table 233, Edit). + // 32000-2 Table 233, Edit); the keys are the text box's then, and the list takes + // the pointer. if editable_combo(f) { combo_editor(ctx, view, ComboEditor { form, field: f, widget: w, rect, zoom, index: focus.widget }); } - let resp = egui::Area::new(egui::Id::unique(("form-choices", view.view_key()))) - .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| { - for (export, display) in &f.options { - let on = f.value.first() == Some(export); - if ui.selectable_label(on, display).clicked() { - edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) }); - close = true; - } - } - }); - }); - }); + let mut st = view.forms.focus.as_ref().map(|x| x.list).unwrap_or_default(); + let list = DropDownList { view: view.view_key(), field: f, widget: w, rect, zoom, keys: !editable_combo(f) }; + let (chosen, resp) = choice_list(ctx, list, &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 = !resp.response.contains_pointer() && clicked_outside(); let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift)); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { @@ -1132,6 +1162,202 @@ pub(crate) fn overlay( edit.or_else(|| view.pending_edit.take()) } +/// Where a drop-down's open list ([`choice_list`]) goes and what it lists. +struct DropDownList<'a> { + /// The document view's pane key, which the list's own ids are made from. + view: u64, + field: &'a FormField, + widget: &'a FormWidget, + /// The field's rectangle on screen; the list opens under it. + rect: Rect, + zoom: f32, + /// Whether the list takes the arrow keys and Enter: not for an editable combo box, whose + /// text box has them. + keys: bool, +} + +/// 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. With `keys` off (an editable combo box, whose text box has the keys) +/// only the pointer moves through and picks from it. +fn choice_list(ctx: &egui::Context, list: DropDownList<'_>, st: &mut ChoiceList) -> (Option, egui::InnerResponse<()>) { + let DropDownList { view, field: f, widget: w, rect, zoom, keys } = list; + let n = f.options.len(); + let shown = n.min(LIST_ROWS); + let max_first = n.saturating_sub(shown); + // 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); + 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| keys && 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::unique(("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 { + 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 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; + 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.scope_id().with(("choice", i)), egui::Sense::click()); + item.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, st.current == Some(i), display)); + if item.hovered() && pointer_moved { + 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_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); + } + 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.scope_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()); + // 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() + && 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/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 23c596a7ee..5a45b4f3e5 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -2114,3 +2114,328 @@ fn clicking_the_shown_row_of_a_single_select_list_holding_an_array_writes_one_va let f = field(&h); assert_eq!((f.value, f.selected, f.single_value_is_array), (vec!["TW".to_string()], vec![0], false), "Taiwan, now one value"); } + +/// 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(); + 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> { + /// 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)), + 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 +} + +/// A drop-down draws one filled arrow at the right of its box, and a text field draws none. +#[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"); +} + +/// A drop-down's list opens on the field's left edge and no wider than the field, without +/// showing the field's name, and clicking an item chooses it. +#[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"]); +} + +/// A drop-down with 30 options keeps its scroll bar in its own room, so the items stop short +/// of the field's right edge. +#[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:?}"); +} + +/// Where `text` is drawn in the last frame, if anywhere. +fn text_rect(h: &Harness<'static, PdfCraftApp>, 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)), + 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)) +} + +/// Gives the "country" drop-down 30 options, "0000" to "2900", more than its list shows at once. +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); +} + +/// 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(); + 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:?}"); +} + +/// 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(); + 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"]); +} + +/// 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(); + 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"]); +} + +/// 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(); + 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, + source: egui::MouseWheelSource::Unknown, + 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"); +} + +/// 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] +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, + source: egui::MouseWheelSource::Unknown, + 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] +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"); +} + +/// 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(); + 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 89b4d48159..e88161b9c6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4662,8 +4662,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/ui-egui/tests/it/forms.rs::an_editable_combo_box_takes_a_typed_value", "crates/ui-egui/tests/it/forms.rs::a_combo_box_without_edit_takes_no_typing", "crates/automation/tests/it/automation.rs::form_fill_takes_free_text_only_in_an_editable_combo_box", "crates/forms/src/tests.rs::a_list_box_with_duplicate_export_values_selects_the_option_i_names", "crates/forms/src/tests.rs::an_unusable_i_falls_back_to_the_first_option_with_the_value", "crates/forms/src/tests.rs::picking_one_of_two_options_with_the_same_export_value_round_trips", "crates/ui-egui/tests/it/forms.rs::a_list_box_click_picks_one_of_two_options_with_the_same_export_value", "crates/automation/tests/it/list_selection.rs::form_fields_reports_which_of_two_options_with_one_export_value_is_selected", "crates/forms/src/tests.rs::a_single_select_list_given_several_values_shows_the_first", "crates/forms/src/tests.rs::picking_in_a_single_select_list_given_several_values_writes_one", "crates/ui-egui/tests/it/forms.rs::a_single_select_list_box_given_two_values_shows_and_takes_one", "crates/automation/tests/it/list_selection.rs::form_fields_reports_one_value_for_a_single_select_list_given_two"] -notes = "combo and list boxes, multi-select lists; an editable combo box (Edit flag) has a text box over the field as well as its list, and takes typed text on the page and through form_fill; a combo box without Edit takes only its options. a list box whose options share an export value shows and picks the one /I names (ISO 32000-2 Table 234): /I is used when it is integers, ascending, in range and names the same export values as /V, otherwise /V decides and each value selects the first option exporting it; a click or arrow key picks the row by position and writes its export value to /V and its position to /I, and form_fields reports selected (1-based positions); a list box without MultiSelect given an array /V or /DV holds, shows and resets to the first value only (Table 233), leaving the file as it is until the field is filled, when /V becomes one string and /I its position. No type-ahead or spell check in the typed box; a combo box whose options share an export value still shows the first of them" +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/ui-egui/tests/it/forms.rs::an_editable_combo_box_takes_a_typed_value", "crates/ui-egui/tests/it/forms.rs::a_combo_box_without_edit_takes_no_typing", "crates/automation/tests/it/automation.rs::form_fill_takes_free_text_only_in_an_editable_combo_box", "crates/forms/src/tests.rs::a_list_box_with_duplicate_export_values_selects_the_option_i_names", "crates/forms/src/tests.rs::an_unusable_i_falls_back_to_the_first_option_with_the_value", "crates/forms/src/tests.rs::picking_one_of_two_options_with_the_same_export_value_round_trips", "crates/ui-egui/tests/it/forms.rs::a_list_box_click_picks_one_of_two_options_with_the_same_export_value", "crates/automation/tests/it/list_selection.rs::form_fields_reports_which_of_two_options_with_one_export_value_is_selected", "crates/forms/src/tests.rs::a_single_select_list_given_several_values_shows_the_first", "crates/forms/src/tests.rs::picking_in_a_single_select_list_given_several_values_writes_one", "crates/ui-egui/tests/it/forms.rs::a_single_select_list_box_given_two_values_shows_and_takes_one", "crates/automation/tests/it/list_selection.rs::form_fields_reports_one_value_for_a_single_select_list_given_two", "crates/ui-egui/tests/it/forms.rs::a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name", "crates/ui-egui/tests/it/forms.rs::the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses"] +notes = "combo and list boxes, multi-select lists; an editable combo box (Edit flag) has a text box over the field as well as its list, and takes typed text on the page and through form_fill; a combo box without Edit takes only its options. a list box whose options share an export value shows and picks the one /I names (ISO 32000-2 Table 234): /I is used when it is integers, ascending, in range and names the same export values as /V, otherwise /V decides and each value selects the first option exporting it; a click or arrow key picks the row by position and writes its export value to /V and its position to /I, and form_fields reports selected (1-based positions); a list box without MultiSelect given an array /V or /DV holds, shows and resets to the first value only (Table 233), leaving the file as it is until the field is filled, when /V becomes one string and /I its position. 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 (in an editable combo box the keys are its text box's, and the list takes the pointer). The list is drawn in the interface font, not the field's (no serif interface font ships yet). No type-ahead or spell check in the typed box; a combo box whose options share an export value still shows the first of them" [[feature]] id = "form.push-buttons"