From 1c3cb9d14d7b1a7a7b8c3ca1ef3b3fa856ef0cc3 Mon Sep 17 00:00:00 2001 From: fanyzp Date: Fri, 16 Oct 2026 01:15:06 +0800 Subject: [PATCH] feat(forms): nudge the selected field with the arrow keys in Prepare a form (#2608) --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 7 +++++ crates/ui-egui/src/prepare.rs | 48 ++++++++++++++++++++++++++++++++- crates/ui-egui/tests/prepare.rs | 40 +++++++++++++++++++++++++++ docs/ui-parity.md | 5 ++-- parity/acrobat-features.toml | 4 +-- 6 files changed, 100 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fe9723fdf..88bb4bb29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e..d753a0f73 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2813,6 +2813,13 @@ impl PdfCraftApp { { object_ui::nudge_keys(ctx, &mut self.views[i], doc); } + // A selected field moves with the arrows too (Prepare a form, #2608), before the view + // turns the page with them. + if self.is_preparing() + && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) + { + prepare::nudge_keys(ctx, &mut self.views[i], doc); + } canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); self.copy_key(i, ctx); self.image_pick_keys(i, ctx); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 386c42e87..28797de55 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -7,7 +7,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::form_scripts::{CalcOp, Calculate, DATE_PRESETS, Format, TIME_PRESETS, Validate, format_value}; -use pdfcraft_engine::{BorderStyle, Edit, FieldFont, FieldLook, FieldProps, FormField, FormFieldKind, NewField}; +use pdfcraft_engine::{BorderStyle, Document, Edit, FieldFont, FieldLook, FieldProps, FormField, FormFieldKind, NewField}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; @@ -565,6 +565,52 @@ pub(crate) fn delete_selected(view: &mut DocView) -> Option { Some(Edit::Batch { label, edits: names.into_iter().map(|name| Edit::DeleteField { name }).collect() }) } +/// The arrow keys move the selected field a point, Shift and an arrow ten points, as in Acrobat, +/// as the object nudge does (`object_ui::nudge_keys`) and as dragging it does (#2608). The keys +/// are consumed before the view turns the page with them; one press is one undo step. +pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document) { + if ctx.egui_wants_keyboard_input() || view.prepare.grab.is_some() { + return; + } + let Some((name, wi)) = view.prepare.selected.clone() else { return }; + let Some(w) = doc.form.iter().find(|f| f.name == name).and_then(|f| f.widgets.get(wi)) else { return }; + let Some(page) = w.page else { return }; + let Some(xf) = view.page_xform(page) else { return }; + use egui::{Key, Modifiers}; + let mut step = egui::Vec2::ZERO; + ctx.input_mut(|i| { + for (key, direction) in [ + (Key::ArrowLeft, egui::vec2(-1.0, 0.0)), + (Key::ArrowRight, egui::vec2(1.0, 0.0)), + (Key::ArrowUp, egui::vec2(0.0, -1.0)), + (Key::ArrowDown, egui::vec2(0.0, 1.0)), + ] { + // Shift first: a plain key pattern also matches a Shift press. + if i.consume_key(Modifiers::SHIFT, key) { + step += direction * 10.0; + } else if i.consume_key(Modifiers::NONE, key) { + step += direction; + } + } + }); + if step == egui::Vec2::ZERO { + return; + } + // A point in view space whatever the zoom and whatever way the view is turned; the page's own + // rotation turns it into the user-space move the widget's rect is stored in. + let c = xf.rect.center(); + let unit = |d: egui::Vec2| { + let (a, b) = (xf.screen_to_view(c), xf.screen_to_view(c + d)); + let v = egui::vec2(b.0 - a.0, b.1 - a.1); + if v.length() > 0.0 { v / v.length() } else { egui::Vec2::ZERO } + }; + let delta = unit(egui::vec2(1.0, 0.0)) * step.x + unit(egui::vec2(0.0, 1.0)) * step.y; + let Ok(offset) = doc.object_move_offset(page, [f64::from(delta.x), f64::from(delta.y)]) else { return }; + let r = w.rect; + let rect = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; + view.pending_edit = Some(Edit::SetFieldProps { name, props: Box::new(FieldProps { rect: Some((wi, rect)), ..Default::default() }) }); +} + /// Delete removes the selected fields; Escape clears the selection. pub(crate) fn select_all(view: &mut DocView, form: &[FormField]) { let mut selected = diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index eb25e42a0..cce42e337 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -116,6 +116,46 @@ fn placing_moving_editing_and_deleting_a_field() { assert_eq!((r[2] - r[0], r[3] - r[1]), (14.0, 14.0)); } +/// The arrow keys nudge the selected field a point, Shift and an arrow ten points (#2608), as they +/// do for a selected object or image and as dragging it does. One press is one undo step, the page +/// does not turn with them, and the field stays selected for the next press. +#[test] +fn arrow_keys_nudge_the_selected_field_instead_of_turning_the_page() { + let mut h = harness(); + h.state_mut().execute("form.add.text"); + h.run_steps(1); + // A click places a default-size text field and selects it. + let p = at(&h, 40.0, 250.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); + let id = h.state().views[0].id; + let name = h.state().views[0].prepare.selected.clone().expect("the placed field is selected").0; + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + let before = rect_of(&h, &name); + h.key_press(egui::Key::ArrowRight); + h.run_steps(2); + let right = rect_of(&h, &name); + assert!(near(right[0], before[0] + 1.0) && near(right[2], before[2] + 1.0), "{before:?} → {right:?}"); + assert!(near(right[2] - right[0], before[2] - before[0]), "moved, not resized"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Change field properties")); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowUp); + h.run_steps(2); + let up = rect_of(&h, &name); + assert!(near(up[1], right[1] + 10.0) && near(up[3], right[3] + 10.0), "{right:?} → {up:?}"); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::ArrowLeft); + h.run_steps(2); + let back = rect_of(&h, &name); + assert!(near(back[0], up[0] - 1.0) && near(back[1], up[1] - 1.0), "{up:?} → {back:?}"); + assert!(h.state().views[0].prepare.selected.is_some(), "it stays selected for the next nudge"); + assert_eq!(h.state().views[0].current, 0, "the arrows moved the field, not the page"); +} + #[test] fn format_validate_and_calculate_tabs() { use pdfcraft_engine::form_scripts::{Calculate, Format, Validate}; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a78409fad..93cc83086 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (column select #740, multi-object move #844, Shift-⌘-G #810 fixed) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (the selected field nudges with the arrow keys in Prepare a form, #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -52,7 +52,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | | Measuring snap to paths, endpoints, midpoints, intersections | yes | yes | shipped | -| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520) | shipped | +| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520); and in Prepare a form, for the selected field (#2608) | shipped | | Single-key tool accelerators (H, V, Z, …) | yes, opt-in | no | planned | | Context menus on pages, thumbnails, comments, fields | yes | thumbnails, comments, fields | partial | | Comment pop-ups as movable windows, connector lines | yes | inline in the panel | planned | @@ -81,5 +81,6 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-11 | minor | Column select shipped (#740, PR #765); several objects move together on one page (#844, PR #907); Shift-⌘-G goes back (#810, PR #835) | | 2026-10-10 | major | Created from the command registry, automation tools, the View area of the checklist and open UI issues | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b54..3e5eb9c0b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4740,8 +4740,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] -notes = "fields shown with blue fill and name tags; select, move, resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" +evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/prepare.rs::arrow_keys_nudge_the_selected_field_instead_of_turning_the_page", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +notes = "fields shown with blue fill and name tags; select, move (drag, or the arrow keys: a point, Shift ten points, #2608), resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" [[feature]] id = "form.add-text-field"