diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d0888ce13..f67398d84 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -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 { - // 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 { }) } +/// 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, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 1e9c0d6b7..edbb56c2c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1609,6 +1609,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8182fa668..601061af0 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -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, + /// 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; } } diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs new file mode 100644 index 000000000..68d0a7893 --- /dev/null +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -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 { + 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 { + 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 { + 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) -> 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 { + 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 { + 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 { + 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"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 39807445b..ca96e8bba 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -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;