Merge PR #2235: fix(forms): Tab and Space reach check boxes and radio buttons, and a click focuses them

This commit is contained in:
aiongg
2026-10-22 01:45:58 +00:00
4 changed files with 274 additions and 30 deletions
+6 -2
View File
@@ -2238,9 +2238,13 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
view.find_step(true);
}
}
if ctx.egui_wants_keyboard_input() {
// A focused check box or radio button holds egui's keyboard focus only for Space, and a
// radio button for the arrows that move within its group: the other page keys still work.
let form_box = view.forms.keys.filter(|(id, _)| ctx.memory(|m| m.focused()) == Some(*id));
if ctx.egui_wants_keyboard_input() && form_box.is_none() {
return;
}
let arrows = !form_box.is_some_and(|(_, radio)| radio);
// Rotation first: egui matches ⌘+ loosely with respect to Shift.
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus))
|| pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals))
@@ -2287,7 +2291,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
if pressed(cmd(Key::CloseBracket)) {
view.view_history(true);
}
let key = |k| ctx.input(|i| i.key_pressed(k));
let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && ctx.input(|i| i.key_pressed(k));
if key(Key::Escape) {
if view.camera.is_some() || view.camera_done.is_some() {
view.pending_action = Some(ViewAction::CancelCamera);
+118 -19
View File
@@ -1,10 +1,10 @@
//! Filling in forms on the page (execution plan M6; Acrobat fills fields in every viewing mode).
//!
//! Text fields open an in-place editor over the widget (Enter or clicking away commits, Escape
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click. List
//! boxes are directly selectable in-place on the page (clicking picks an option; Ctrl-click
//! toggles on multi-select lists). Combo boxes open a list of their options. Every change is one
//! undoable engine edit.
//! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click, or on
//! Space once Tab or a click has focused them. List boxes are directly selectable in-place on the
//! page (clicking picks an option; Ctrl-click toggles on multi-select lists). Combo boxes open a
//! list of their options. Every change is one undoable engine edit.
use std::collections::HashSet;
@@ -60,6 +60,13 @@ pub struct FormView {
pub notice: Option<FormNotice>,
/// A push button was clicked: (its field name, what it does).
pub button: Option<(String, pdfcraft_engine::form_scripts::ButtonAction)>,
/// The focused check box or radio button was clicked. It toggles once no other edit is
/// queued: the text the click committed goes first, and the toggle is dropped if that text
/// is refused and its field reopens.
pub(crate) toggle: bool,
/// The widget holding egui's keyboard focus for the focused check box or radio button, and
/// whether it takes the arrows (radio buttons). The page keys still work around it.
pub(crate) keys: Option<(egui::Id, bool)>,
}
/// A form message for the app to show, with document data kept separate so the visible
@@ -97,6 +104,29 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
}
}
/// Select a radio button, if it isn't selected already.
fn select_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
(w.on_state.as_ref() != f.value.first()).then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(w.on_state.clone()) })
}
/// What clicking a check box or radio button does.
fn toggle_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
let value = match f.kind {
FormFieldKind::CheckBox => FieldValue::Check(f.value.is_empty()),
FormFieldKind::Radio => {
let on = w.on_state.clone();
// Clicking the selected button again turns it off unless the group forbids it.
let value = if f.value.first() == on.as_ref() && !f.has(field_flags::NO_TOGGLE_TO_OFF) { None } else { on };
if value.as_ref() == f.value.first() {
return None;
}
FieldValue::Radio(value)
}
_ => return None,
};
Some(Edit::SetFieldValue { name: f.name.clone(), value })
}
/// Where the pointer is relative to one page's form widgets.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FieldPointer {
@@ -163,18 +193,14 @@ pub(crate) fn page_input(
None => view.forms.notice = Some(FormNotice::NoAction(f.name.clone())),
},
FormFieldKind::Signature => view.sign.field = Some(f.name.clone()),
FormFieldKind::CheckBox => {
view.forms.focus = None;
view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Check(f.value.is_empty()) });
}
FormFieldKind::Radio => {
view.forms.focus = None;
let on = w.on_state.clone();
// Clicking the selected button again turns it off unless the group forbids it.
let value = if f.value.first() == on.as_ref() && !f.has(field_flags::NO_TOGGLE_TO_OFF) { None } else { on };
if value.as_ref() != f.value.first() {
view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(value) });
FormFieldKind::CheckBox | FormFieldKind::Radio => {
// The box takes the focus, as in Acrobat, so Tab goes on from it (#2223). The overlay
// toggles it, after the edit for any text this commits.
if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) {
commit(view, form);
view.forms.focus = Some(open_focus(f, wi));
}
view.forms.toggle = true;
}
// The press already committed the previous field and opened this one (above), unless
// press and release came in one frame. Committing again would re-run a validation that
@@ -486,6 +512,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
if !ctx.input(|i| i.pointer.primary_down()) {
view.forms.pressed = false;
}
let toggle = std::mem::take(&mut view.forms.toggle);
view.forms.keys = None;
let focus = view.forms.focus.clone()?;
let Some(f) = form.iter().find(|f| f.name == focus.name) else {
view.forms.focus = None;
@@ -493,7 +521,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
};
let w = f.widgets.get(focus.widget)?;
let page = w.page?;
let xf = view.page_xform(page)?;
let Some(xf) = view.page_xform(page) else {
// A check box or radio button scrolled out of view lets go (it has no draft to keep):
// taking the keyboard back when its page returns would turn scrolling arrows into
// choices, or pull the keyboard from wherever the user went meanwhile.
if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
view.forms.focus = None;
}
return None;
};
let rect = widget_rect(&xf, info, page, w.rect);
let zoom = xf.rect.width() / xf.pw.max(1.0);
let t = Tokens::get(ctx);
@@ -646,6 +682,69 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
close = true;
}
}
FormFieldKind::CheckBox | FormFieldKind::Radio => {
// Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so
// Space, Tab and the arrows come here instead of turning or scrolling the page.
let id = egui::Id::new(("form-field", view.id.0, &f.name, focus.widget));
let mut has_focus = false;
// Not interactable, so clicks still reach the page under it.
let area = egui::Area::new(egui::Id::new(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false);
area.fixed_pos(rect.min).show(ctx, |ui| {
let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive());
if let Some(fx) = view.forms.focus.as_mut()
&& std::mem::take(&mut fx.request_focus)
{
r.request_focus();
}
let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: false };
ui.memory_mut(|m| m.set_focus_lock_filter(id, filter));
has_focus = r.has_focus();
});
view.forms.keys = Some((id, f.kind == FormFieldKind::Radio));
let (esc, tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift));
let space = has_focus && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Space));
let arrow = has_focus && f.kind == FormFieldKind::Radio;
let step = ctx.input(|i| {
if !arrow {
None
} else if i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::ArrowDown) {
Some(true)
} else if i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowUp) {
Some(false)
} else {
None
}
});
let clicked_away = ctx.input(|i| i.pointer.any_click() && !i.pointer.hover_pos().is_some_and(|p| rect.contains(p)));
if esc || clicked_away {
close = true;
} else if tab {
next = Some(!shift);
} else if toggle && view.pending_edit.is_some() {
view.forms.toggle = true;
} else if toggle {
edit = toggle_edit(f, w);
} else if space {
// Space toggles a check box and selects a radio button, as in browsers: it never
// turns the selected button off.
edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) };
} else if let Some(forward) = step {
// The arrows move to the group's next or previous button and select it, wrapping.
let stops: Vec<usize> = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect();
if let Some(pos) = stops.iter().position(|&i| i == focus.widget) {
let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() };
if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) {
view.forms.focus = Some(open_focus(f, wi));
edit = select_edit(f, target);
if let Some(p) = target.page
&& view.page_screen_rect(p).is_none()
{
view.go_to_page(p);
}
}
}
}
}
_ => close = true,
}
if close {
@@ -654,11 +753,11 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f
if let Some(forward) = next {
commit(view, form);
let pending = view.pending_edit.take();
// The next widget that takes typing or a choice, in the document's tab order (each
// page's /Tabs: rows, columns or structure), wrapping.
// The next field that can be filled in, check boxes and radio buttons included (#2223), in
// the document's tab order (each page's /Tabs: rows, columns or structure), wrapping.
let mut order: Vec<(usize, &FormField, usize)> = form
.iter()
.filter(|x| fillable(x) && matches!(x.kind, FormFieldKind::Text | FormFieldKind::Combo | FormFieldKind::List))
.filter(|x| fillable(x))
.flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi)))
.collect();
order.sort_by_key(|o| o.0);
+148 -7
View File
@@ -54,12 +54,8 @@ fn typing_into_a_text_field_and_tabbing_on() {
h.key_press(egui::Key::Tab);
h.run_steps(4);
assert_eq!(value(&h, "name"), ["Ada Lovelace"]);
assert_eq!(
h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()),
Some("country"),
"Tab moves to the next field that takes typing or a choice"
);
// The country list is open; Escape closes it. Escape also abandons a draft.
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("agree"), "Tab moves to the next field");
// Escape leaves the check box. It also abandons a draft.
h.key_press(egui::Key::Escape);
h.run_steps(2);
click_field(&mut h, "city", 0);
@@ -96,6 +92,144 @@ fn check_boxes_radios_and_choices() {
assert_eq!(value(&h, "country"), ["fr"]);
}
fn focused(h: &Harness<'static, PdfCraftApp>) -> Option<(String, usize)> {
h.state().views[0].forms.focus.as_ref().map(|f| (f.name.clone(), f.widget))
}
/// A text field, a check box, another text field and a pair of radio buttons, one under the
/// other, then two empty pages.
fn check_box_harness() -> Harness<'static, PdfCraftApp> {
let bytes = pdf_from_objects(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R 9 0 R 13 0 R] /Count 3 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (first) /Rect [20 350 280 372] /P 3 0 R /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [20 310 36 326] /P 3 0 R /F 4 /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (last) /Rect [20 270 280 292] /P 3 0 R /F 4 >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream",
"<< /Type /Page /Parent 2 0 R >>",
"<< /FT /Btn /T (pick) /Ff 32768 /V /Off /Kids [11 0 R 12 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /AS /Off /Rect [20 230 36 246] /P 3 0 R /F 4 /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /AS /Off /Rect [60 230 76 246] /P 3 0 R /F 4 /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>",
"<< /Type /Page /Parent 2 0 R >>",
]);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("agree.pdf", None, bytes).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
app
});
h.run_steps(6);
h
}
#[test]
fn tab_and_space_fill_in_a_check_box_between_text_fields() {
// Issue #2223: Tab went from `first` straight to `last`, and Space could not tick `agree`.
let mut h = check_box_harness();
click_field(&mut h, "first", 0);
h.event(egui::Event::Text("Ada".into()));
h.run_steps(1);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(value(&h, "first"), ["Ada"]);
assert_eq!(focused(&h), Some(("agree".into(), 0)), "Tab stops on the check box");
assert!(h.ctx.egui_wants_keyboard_input(), "the check box has the keyboard, not the page");
h.key_press(egui::Key::Space);
h.run_steps(3);
assert_eq!(value(&h, "agree"), ["Yes"], "Space ticks it");
assert_eq!(focused(&h), Some(("agree".into(), 0)), "and it keeps the focus");
h.key_press(egui::Key::Space);
h.run_steps(3);
assert!(value(&h, "agree").is_empty(), "Space again clears it");
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(focused(&h), Some(("last".into(), 0)));
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab);
h.run_steps(3);
assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab comes back to it");
}
#[test]
fn clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box() {
// Issue #2223: the click ticked the box but left no field focused, so Tab started over.
let mut h = check_box_harness();
click_field(&mut h, "first", 0);
h.event(egui::Event::Text("Ada".into()));
h.run_steps(1);
click_field(&mut h, "agree", 0);
assert_eq!(value(&h, "agree"), ["Yes"]);
assert_eq!(value(&h, "first"), ["Ada"], "the text typed before the click is kept");
assert_eq!(focused(&h), Some(("agree".into(), 0)), "the click focuses the check box");
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(focused(&h), Some(("last".into(), 0)), "so Tab goes on from there");
}
#[test]
fn page_keys_still_work_with_a_check_box_or_radio_button_focused() {
// The widget that takes Space for a focused box must not swallow the page keys.
let mut h = check_box_harness();
click_field(&mut h, "agree", 0);
assert_eq!(focused(&h), Some(("agree".into(), 0)));
h.key_press(egui::Key::End);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 2, "End goes to the last page");
assert!(h.state().views[0].page_screen_rect(0).is_none(), "where the check box is out of view");
// Scrolled away, the box lets go: back on its page, Space no longer toggles it.
h.key_press(egui::Key::Home);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 0);
assert_eq!(focused(&h), None, "the box let go of the focus");
h.key_press(egui::Key::Space);
h.run_steps(3);
assert_eq!(value(&h, "agree"), ["Yes"]);
// A radio button keeps the arrows for its group, so → selects B instead of turning the page.
click_field(&mut h, "pick", 0);
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_eq!(value(&h, "pick"), ["B"]);
assert_eq!(h.state().views[0].current, 0, "the arrow stayed in the group");
// Once scrolled away, an arrow scrolls again instead of changing the choice.
h.key_press(egui::Key::End);
h.run_steps(4);
h.key_press(egui::Key::Home);
h.run_steps(4);
h.key_press(egui::Key::ArrowUp);
h.run_steps(3);
assert_eq!((focused(&h), value(&h, "pick")), (None, vec!["B".to_string()]));
}
#[test]
fn radio_buttons_take_tab_space_and_arrows() {
let mut h = harness();
click_field(&mut h, "agree", 0);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(focused(&h), Some(("size".into(), 0)));
h.key_press(egui::Key::Space);
h.run_steps(3);
assert_eq!(value(&h, "size"), ["S"], "Space selects the focused button");
h.key_press(egui::Key::Space);
h.run_steps(3);
assert_eq!(value(&h, "size"), ["S"], "and doesn't turn it off again");
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_eq!(focused(&h), Some(("size".into(), 1)), "an arrow moves within the group");
assert_eq!(value(&h, "size"), ["L"], "and selects the button it lands on");
h.key_press(egui::Key::ArrowLeft);
h.run_steps(3);
assert_eq!((focused(&h), value(&h, "size")), (Some(("size".into(), 0)), vec!["S".to_string()]));
h.key_press(egui::Key::Tab);
h.run_steps(3);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(focused(&h), Some(("country".into(), 0)), "past the group's second button");
}
#[test]
fn tabbing_into_a_filled_field_selects_it_so_typing_replaces() {
let mut h = harness();
@@ -108,10 +242,17 @@ fn tabbing_into_a_filled_field_selects_it_so_typing_replaces() {
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "city"), ["Paris"]);
// name → (Tab) country → (Tab) city, whose text is selected on entry.
// name → (Tab) agree → (Tab) size, both buttons → (Tab) country → (Tab) city, whose text is
// selected on entry.
click_field(&mut h, "name", 0);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(focused(&h), Some(("agree".into(), 0)));
for _ in 0..3 {
h.key_press(egui::Key::Tab);
h.run_steps(3);
}
assert_eq!(focused(&h), Some(("country".into(), 0)));
h.key_press(egui::Key::Tab);
h.run_steps(4);
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
+2 -2
View File
@@ -4428,8 +4428,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::check_boxes_and_radios_switch_states"]
notes = "NoToggleToOff honoured; widgets without appearances get our own"
evidence = ["crates/ui-egui/tests/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/forms.rs::radio_buttons_take_tab_space_and_arrows"]
notes = "NoToggleToOff honoured; widgets without appearances get our own. Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too"
[[feature]]
id = "form.fill-choice"