fix(ui): a tap on an open field or paragraph editor is not a click away

The form field editors and the Edit text paragraph editor are drawn over the pages,
outside the page pointer, and close on a click away. With a finger, or a pen on
Windows (winit reports it as touch), they closed on taps inside them:
- A tap on a combo box opened its list and closed it in the same frame, so a choice
  could not be picked by touch.
- A tap on a list box row picked it, but the list lost focus, so the arrow keys no
  longer moved the choice.
- A tap on a date field's calendar arrows closed the field.
- With a paragraph open in Edit text, a tap on a Format text control that only
  opens a list, such as Line spacing, applied and closed the paragraph, and the list
  went with it.

Two things were wrong. The combo and list boxes judged the click by hover_pos, and
the paragraph's "outside the document" by latest_pos. Both are None on the frame egui
reports a tap, since egui-winit sends PointerGone with every touch release. They now
read interact_pos, which keeps the tap's position on that frame (egui's own
clicked_elsewhere does the same). And egui 0.36 reports a lost focus for two frames.
The text field editor and the paragraph editor judged their exceptions again on the
second frame, when a tap has no position left and a mouse may have moved on. They now
judge a loss once, on its own frame (edit_text_ui::lost_focus_once, with a flag kept
per editor). A focus taken later in a frame is still seen on the next one, as before.

Tests: tests/it/editor_taps.rs taps with support::input::touch_tap, which sends
the release and PointerGone in one frame as egui-winit does, and pairs each tap with a
mouse click as the control. That click leaves the pointer in the window: kittest's
drop_at sends PointerGone a frame later, which the second-frame judging also turned
into a click away. With the source reverted, the four tap tests fail at their bug
assertion and the controls pass. Reverting one part at a time fails only its own
tests: hover_pos in the combo and list check, egui's lost_focus in the field editor
(the calendar) or in the paragraph editor, and latest_pos for the paragraph's outside.
A tap inside an open text field already worked and has a guard test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehel
2026-10-24 16:54:28 +02:00
co-authored by Claude Opus 5.5
parent 1bda33975d
commit 9f2e1e3293
5 changed files with 310 additions and 9 deletions
+20 -3
View File
@@ -246,6 +246,9 @@ pub struct LineEditor {
size: f32,
/// Take the keyboard focus on the next frame.
pub(crate) focus: bool,
/// Whether the text box had the keyboard at the end of the editor's last frame
/// ([`lost_focus_once`]).
had_focus: bool,
/// The Format text panel's values, and what the paragraph had (to send only changes).
pub look: pdfcraft_engine::AddedText,
look0: pdfcraft_engine::AddedText,
@@ -1196,6 +1199,7 @@ fn open_editor(
max_width,
size: (l.size as f32 * scale).clamp(8.0, 72.0),
focus: true,
had_focus: false,
look: look_of(l),
look0: look_of(l),
family_chosen: false,
@@ -1556,8 +1560,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width:
/// The inline editor; returns the edit once the text is applied.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
// Clicks outside the document (the Format text panel) keep the paragraph open.
let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
// Clicks outside the document (the Format text panel) keep the paragraph open. Where the
// click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts.
let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
let page = view.line_editor.as_ref()?.page;
let xf = view.page_xform(page)?;
let hold = view.objects.hold_editor;
@@ -1703,11 +1708,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
r.request_focus();
ed.focus = false;
}
let lost = lost_focus_once(&r, &mut ed.had_focus);
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
if esc {
done = Some(false);
} else if apply || (r.lost_focus() && !outside && !hold) {
} else if apply || (lost && !outside && !hold) {
done = Some(true);
}
});
@@ -1742,6 +1748,16 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option<Edit> {
})
}
/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at
/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second
/// frame as well, and an editor that judges a click away by where the click landed must judge it
/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends
/// `PointerGone` with every touch release) and a mouse may have moved on.
pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool {
let has = r.has_focus();
std::mem::replace(had, has) && !has
}
/// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones.
pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b());
@@ -1799,6 +1815,7 @@ mod tests {
max_width: 400.0,
size: block.size as f32,
focus: true,
had_focus: false,
look: look_of(&block),
look0: look_of(&block),
family_chosen: false,
+1
View File
@@ -1609,6 +1609,7 @@ mod revert_draft_tests {
calendar: None,
calendar_rect: None,
calendar_text: None,
had_focus: false,
}
}
+12 -6
View File
@@ -43,6 +43,9 @@ 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<String>,
/// 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,
}
#[derive(Clone, Debug, Default)]
@@ -121,6 +124,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
calendar: None,
calendar_rect: None,
calendar_text: None,
had_focus: false,
}
}
@@ -652,6 +656,10 @@ pub(crate) fn overlay(
return None;
};
let rect = widget_rect(&xf, info, page, w.rect);
// Whether a click this frame landed outside the field. Where it landed is `interact_pos`: a
// tap's release comes with `PointerGone` (egui-winit sends one with every touch release, and
// 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;
@@ -738,6 +746,7 @@ pub(crate) fn overlay(
st.store(ui.ctx(), r.id);
}
}
let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus);
let (enter, esc, tab, shift) = ui.input(|i| {
(i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift)
});
@@ -745,7 +754,7 @@ pub(crate) fn overlay(
close = true;
} else if tab {
next = Some(!shift);
} else if r.lost_focus()
} else if lost
&& (!multiline || !enter)
&& !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p))))
{
@@ -793,9 +802,7 @@ pub(crate) fn overlay(
});
});
});
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));
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)) {
close = true;
@@ -829,8 +836,7 @@ pub(crate) fn overlay(
edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) });
}
}
let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p));
if clicked_away {
if clicked_outside() {
close = true;
}
}
+276
View File
@@ -0,0 +1,276 @@
//! A finger tap, or a pen tap on Windows, on an editor open over the page acts as a mouse click
//! there. The field editors and the Edit text paragraph editor are drawn over the pages, outside
//! the page pointer, and close on a click away. On the frame egui reports a tap there is no
//! hover position (egui-winit sends `PointerGone` with every touch release), so they must judge
//! where the click landed by the click's own position.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::PdfCraftApp;
use crate::support::input::touch_tap;
/// A PDF of `objects`, numbered from 1 with the catalog first.
fn pdf(objects: &[&str]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>";
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
/// A 300×400 page with a text field `name`, a combo box `country` and a list box `colour`
/// (Red, Green, Blue) 70 pt tall.
fn form_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [40 340 260 360] /P 3 0 R /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (country) /Opt [[(ca) (Canada)] [(fr) (France)] [(no) (Norway)]] /Rect [40 290 260 310] /P 3 0 R /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (colour) /Opt [(Red) (Green) (Blue)] /Rect [40 150 200 220] /P 3 0 R /F 4 >>",
HELVETICA,
])
}
/// A 300×400 page with one line of 12 pt text at x 40, y 300.
fn text_pdf() -> Vec<u8> {
let line = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET";
let content = format!("<< /Length {} >>\nstream\n{line}\nendstream", line.len() + 1);
pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
content.as_str(),
HELVETICA,
])
}
/// The app with `pdf` open at 150 % and its first frames run.
fn open(pdf: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("test.pdf", None, pdf).unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("zoom", "150").unwrap();
app
});
for _ in 0..30 {
h.run_steps(2);
}
h
}
/// A mouse click: move, press and release in frames of their own, and the pointer stays there.
/// (`drop_at` also sends `PointerGone` a frame later, as if the mouse left the window.)
fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(4);
}
/// Where field `name`'s widget is on screen.
fn field(h: &Harness<'_, 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")
}
fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
}
/// The field whose editor or list is open.
fn focus(h: &Harness<'_, PdfCraftApp>) -> Option<String> {
h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone())
}
/// The Green row of the list box: the second, rows being 11.5 pt under a 2 pt inset.
fn green(h: &Harness<'_, PdfCraftApp>) -> egui::Pos2 {
let r = field(h, "colour");
egui::pos2(r.center().x, r.min.y + 19.0 * r.height() / 70.0)
}
// ---------------------------------------------------------------------------------------------
// Form fields.
#[test]
fn mouse_click_opens_a_combo_box() {
// Control for the tap below.
let mut h = open(form_pdf());
let p = field(&h, "country").center();
mouse_click(&mut h, p);
assert_eq!(focus(&h).as_deref(), Some("country"), "a click opens the list of choices");
}
#[test]
fn touch_tap_opens_a_combo_box_and_picks_a_choice() {
let mut h = open(form_pdf());
let p = field(&h, "country").center();
touch_tap(&mut h, p);
assert_eq!(focus(&h).as_deref(), Some("country"), "a tap opens the list of choices, as a click does");
let france = h.get_by_label("France").rect().center();
touch_tap(&mut h, france);
assert_eq!(value(&h, "country"), ["fr"], "and a tap on a choice picks it");
}
#[test]
fn mouse_click_in_a_list_box_keeps_it_focused() {
// Control for the tap below.
let mut h = open(form_pdf());
let p = green(&h);
mouse_click(&mut h, p);
assert_eq!((value(&h, "colour"), focus(&h).as_deref()), (vec!["Green".to_string()], Some("colour")), "a click picks the row");
h.key_press(egui::Key::ArrowDown);
h.run_steps(3);
assert_eq!(value(&h, "colour"), ["Blue"], "and the list keeps the keyboard");
}
#[test]
fn touch_tap_in_a_list_box_keeps_it_focused() {
let mut h = open(form_pdf());
let p = green(&h);
touch_tap(&mut h, p);
assert_eq!(
(value(&h, "colour"), focus(&h).as_deref()),
(vec!["Green".to_string()], Some("colour")),
"a tap picks the row and keeps the list focused, as a click does"
);
h.key_press(egui::Key::ArrowDown);
h.run_steps(3);
assert_eq!(value(&h, "colour"), ["Blue"], "so the arrow keys go on from it");
}
#[test]
fn touch_tap_inside_an_open_text_field_keeps_typing_there() {
// A guard more than a probe: the text editor closes when egui takes its focus away, and
// egui judges that from the tap's own position.
let mut h = open(form_pdf());
let r = field(&h, "name");
touch_tap(&mut h, r.center());
assert_eq!(focus(&h).as_deref(), Some("name"), "a tap opens the field for typing");
touch_tap(&mut h, r.left_center() + egui::vec2(6.0, 0.0));
assert_eq!(focus(&h).as_deref(), Some("name"), "a tap inside the open field keeps it open");
h.event(egui::Event::Text("Ada".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "name"), ["Ada"], "and the typing lands in it");
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// Adds a date field `due` at x 40..160, y 240..260, its calendar under it.
fn add_date_field(h: &mut Harness<'_, PdfCraftApp>) {
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [40.0, 240.0, 160.0, 260.0],
kind: pdfcraft_engine::NewField::Date,
name: Some("due".into()),
});
h.run_steps(4);
}
/// Next month's calendar heading, and its 15th as the date field writes it.
fn next_month(h: &Harness<'_, PdfCraftApp>) -> (String, String) {
let (y, m, _) = h.state().session.today();
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
(format!("{} {ny}", MONTHS[(nm - 1) as usize]), format!("{nm:02}/15/{ny}"))
}
#[test]
fn mouse_click_on_a_date_fields_calendar_keeps_it_open() {
// Control for the tap below.
let mut h = open(form_pdf());
add_date_field(&mut h);
let (heading, picked) = next_month(&h);
let p = field(&h, "due").center();
mouse_click(&mut h, p);
let next = h.get_by_label("›").rect().center();
mouse_click(&mut h, next);
assert_eq!(focus(&h).as_deref(), Some("due"), "a click on the calendar's next month leaves the field open");
h.get_by_label(&heading);
let day = h.get_by_label("15").rect().center();
mouse_click(&mut h, day);
assert_eq!(value(&h, "due"), [picked], "and a click on a day picks it");
}
#[test]
fn touch_tap_on_a_date_fields_calendar_keeps_it_open() {
let mut h = open(form_pdf());
add_date_field(&mut h);
let (heading, picked) = next_month(&h);
let p = field(&h, "due").center();
touch_tap(&mut h, p);
let next = h.get_by_label("›").rect().center();
touch_tap(&mut h, next);
assert_eq!(focus(&h).as_deref(), Some("due"), "a tap on the calendar's next month leaves the field open, as a click does");
h.get_by_label(&heading);
let day = h.get_by_label("15").rect().center();
touch_tap(&mut h, day);
assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it");
}
// ---------------------------------------------------------------------------------------------
// Edit text: a click on the Format text panel keeps the paragraph open. These open the Line
// spacing list: a control that changes the paragraph on the click gives it the keyboard back in
// that same frame, which hides a click away.
/// Opens the line of `text_pdf` for typing with Edit text.
fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) {
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("page on screen");
let k = r.width() / 300.0;
let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k);
mouse_click(h, p);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("edit-text-line-input"))), "with the keyboard");
}
/// The open paragraph's line spacing, if a paragraph is open.
fn line_spacing(h: &Harness<'_, PdfCraftApp>) -> Option<f64> {
h.state().views[0].line_editor.as_ref().map(|e| e.extras.line_spacing)
}
#[test]
fn mouse_click_on_format_text_keeps_the_paragraph_open() {
// Control for the tap below.
let mut h = open(text_pdf());
edit_paragraph(&mut h);
let spacing = h.get_by_value("Auto").rect().center();
mouse_click(&mut h, spacing);
assert_eq!(line_spacing(&h), Some(0.0), "a click opens the Line spacing list and leaves the paragraph open");
let double = h.get_by_label("2").rect().center();
mouse_click(&mut h, double);
assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked");
}
#[test]
fn touch_tap_on_format_text_keeps_the_paragraph_open() {
let mut h = open(text_pdf());
edit_paragraph(&mut h);
let spacing = h.get_by_value("Auto").rect().center();
touch_tap(&mut h, spacing);
assert_eq!(line_spacing(&h), Some(0.0), "a tap opens the Line spacing list and leaves the paragraph open, as a click does");
let double = h.get_by_label("2").rect().center();
touch_tap(&mut h, double);
assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked");
}
+1
View File
@@ -39,6 +39,7 @@ mod date_format;
mod dialog_chrome;
mod document_links;
mod editing;
mod editor_taps;
mod export;
mod fill_sign;
mod folders;