diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5f5ef32f7..a6ecacbe4 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -357,8 +357,13 @@ pub(crate) fn page_input( .and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)) .map(|(_, a)| screen_rect(xf, info, page, a.content.rect())); let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); + // Cursors only where the page shows: the mode bar and side panels over a page scrolled under + // them keep their own (#2755). + let shows = resp.contains_pointer(); if adding_text && hit.is_none() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + if shows { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } if resp.clicked() { // Text that can't be drawn stays where it is being typed (#125). if cv.hold_blocked() { @@ -383,9 +388,9 @@ pub(crate) fn page_input( } return consumed; } - if let Some((_, left, top)) = corner { + if let Some((_, left, top)) = corner.filter(|_| shows) { ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); - } else if hit.is_some() { + } else if hit.is_some() && shows { ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { @@ -523,56 +528,63 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad let (mut commit, mut cancel, mut done) = (false, false, false); let mut undrawable = None; let memo = &view.content.undrawable; - egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).constrain(false).show(ctx, |ui| { - let Some(t) = view.content.draft.as_mut() else { return }; - ui.set_width(r.width() + 64.0); - if let Some(source_rect) = source_rect.filter(|_| !background_ready) { - ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE); - } - // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). - ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); - let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); - ui.horizontal_top(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let resp = ui.add( - egui::TextEdit::multiline(&mut t.text) - .font(font) - .desired_width(r.width().max(60.0)) - .desired_rows(1) - .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) - .text_color(Color32::from_rgb(cr, cg, cb)) - .hint_text(tl!("Type text")) - .id_salt("added-text-edit"), - ); - if t.focus { - resp.request_focus(); - t.focus = false; + // Over the page, so only where the document shows: text scrolled under the mode bar or a side + // panel must leave them their pointer (#2755). + egui::Area::new(egui::Id::new(("added-text", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(r.min) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + let Some(t) = view.content.draft.as_mut() else { return }; + ui.set_width(r.width() + 64.0); + if let Some(source_rect) = source_rect.filter(|_| !background_ready) { + ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE); + } + // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); + let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let resp = ui.add( + egui::TextEdit::multiline(&mut t.text) + .font(font) + .desired_width(r.width().max(60.0)) + .desired_rows(1) + .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) + .text_color(Color32::from_rgb(cr, cg, cb)) + .hint_text(tl!("Type text")) + .id_salt("added-text-edit"), + ); + if t.focus { + resp.request_focus(); + t.focus = false; + } + let buttons = egui::Frame::NONE + .fill(tokens.card) + .stroke(Stroke::new(1.0, tokens.border)) + .corner_radius(CornerRadius::same(6)) + .inner_margin(2) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); + ui.horizontal(|ui| { + done = crate::icons::button(ui, "check", 24.0, false, tl!("Done adding text")).clicked(); + cancel = crate::icons::button(ui, "x", 24.0, false, tl!("Discard this text (Esc)")).clicked(); + }); + }) + .response + .rect; + // Pressing a button takes the focus from the text: that is not a click away. + let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); + cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); + commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor; + }); + // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125). + undrawable = self::undrawable(t, memo); + if let Some(c) = undrawable { + ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE)); } - let buttons = egui::Frame::NONE - .fill(tokens.card) - .stroke(Stroke::new(1.0, tokens.border)) - .corner_radius(CornerRadius::same(6)) - .inner_margin(2) - .show(ui, |ui| { - ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); - ui.horizontal(|ui| { - done = crate::icons::button(ui, "check", 24.0, false, tl!("Done adding text")).clicked(); - cancel = crate::icons::button(ui, "x", 24.0, false, tl!("Discard this text (Esc)")).clicked(); - }); - }) - .response - .rect; - // Pressing a button takes the focus from the text: that is not a click away. - let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); - cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); - commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor; }); - // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125). - undrawable = self::undrawable(t, memo); - if let Some(c) = undrawable { - ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE)); - } - }); if cancel { view.content.draft = None; return (None, false); diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 794fe0bf1..6677ab157 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -706,33 +706,41 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let zoom = xf.rect.width() / xf.pw.max(1.0); let mut commit = false; let mut cancel = false; - egui::Area::new(egui::Id::new(("fill-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { - let Some(t) = view.fill_text.as_mut() else { return }; - // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). - ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); - let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); - // The area's max rect is last frame's size, which caps the edit's width; widen it so the - // box grows as text is typed. - ui.set_min_width(width); - let r = ui.add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) - .desired_width(width) - .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) - .text_color(Color32::BLACK) - .hint_text(tl!("Type text")) - .id_salt("fill-text-edit"), - ); - if t.focus { - r.request_focus(); - t.focus = false; - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - cancel = true; - } else if r.lost_focus() { - commit = true; - } - }); + // Over the page, so only where the document shows: a box scrolled under the mode bar or a + // side panel must leave them their pointer (#2755). It stays where it was placed, cut off as + // the page is, rather than pushed back into the window. + egui::Area::new(egui::Id::new(("fill-text", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(pos) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + let Some(t) = view.fill_text.as_mut() else { return }; + // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); + let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); + // The area's max rect is last frame's size, which caps the edit's width; widen it so the + // box grows as text is typed. + ui.set_min_width(width); + let r = ui.add( + egui::TextEdit::singleline(&mut t.text) + .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + .desired_width(width) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) + .text_color(Color32::BLACK) + .hint_text(tl!("Type text")) + .id_salt("fill-text-edit"), + ); + if t.focus { + r.request_focus(); + t.focus = false; + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel = true; + } else if r.lost_focus() { + commit = true; + } + }); if cancel { view.fill_text = None; return None; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8020e868b..33c301545 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -132,11 +132,15 @@ pub(crate) fn page_input( // their action. let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some(); let usable = allowed && (fillable(f) || pressable || f.kind == FormFieldKind::Signature); - ui.ctx().set_cursor_icon(match (usable, f.kind) { - (false, _) => egui::CursorIcon::NotAllowed, - (true, FormFieldKind::Text) => egui::CursorIcon::Text, - _ => egui::CursorIcon::PointingHand, - }); + // Only where the page shows: the mode bar and side panels over a field scrolled under them + // keep their own cursor (#2755). + if resp.contains_pointer() { + ui.ctx().set_cursor_icon(match (usable, f.kind) { + (false, _) => egui::CursorIcon::NotAllowed, + (true, FormFieldKind::Text) => egui::CursorIcon::Text, + _ => egui::CursorIcon::PointingHand, + }); + } // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()); @@ -485,78 +489,87 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f match f.kind { FormFieldKind::Text => { let multiline = f.has(field_flags::MULTILINE); - egui::Area::new(egui::Id::new(("form-editor", view.id.0))).order(egui::Order::Foreground).fixed_pos(rect.min).show(ctx, |ui| { - ui.set_min_size(rect.size()); - let Some(fx) = view.forms.focus.as_mut() else { return }; - // Sized from the text being typed, so an auto-sized field shrinks to keep it in view. - let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); - // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). - ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); - let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; - te = te - // Wraps (inside the 3 px side margins) at the width the size was fitted to: - // the box less 3 pt each side, scaled. - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) - .font(egui::FontId::proportional(font)) - .background_color(FIELD_FILL) - .text_color(Color32::BLACK) - .margin(egui::Margin::symmetric(3, 1)) - .password(f.has(field_flags::PASSWORD)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); - if let Some(max) = f.max_len { - te = te.char_limit(max); - } - if multiline { - te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); - } - let mut laid_out = None; - let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }); - if let Some(press) = fx.press.take() - && let Some((galley, pos)) = laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); - st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { - ui.ctx().set_dragged_id(r.id); + // Over the page, so only where the document shows: a field scrolled under the mode bar + // or a side panel must leave them their pointer (#2755). It stays on its field, cut off + // as the page is, rather than pushed back into the window. + egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(rect.size()); + let Some(fx) = view.forms.focus.as_mut() else { return }; + // Sized from the text being typed, so an auto-sized field shrinks to keep it in view. + let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); + // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; + te = te + // Wraps (inside the 3 px side margins) at the width the size was fitted to: + // the box less 3 pt each side, scaled. + .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + .font(egui::FontId::proportional(font)) + .background_color(FIELD_FILL) + .text_color(Color32::BLACK) + .margin(egui::Margin::symmetric(3, 1)) + .password(f.has(field_flags::PASSWORD)) + .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); + if let Some(max) = f.max_len { + te = te.char_limit(max); } - } - if fx.request_focus { - r.request_focus(); - fx.request_focus = false; - if std::mem::take(&mut fx.select_all) + if multiline { + te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); + } + let mut laid_out = None; + let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }); + if let Some(press) = fx.press.take() + && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { - let n = fx.text.chars().count(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + // The press went to the page, not to this editor, which did not exist yet. + let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); + if ui.input(|i| i.pointer.primary_down()) { + ui.ctx().set_dragged_id(r.id); + } } - } - 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)); - if esc { - close = true; - } else if tab { - next = Some(!shift); - } else if r.lost_focus() - && (!multiline || !enter) - && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) - { - if pressed { - // The field reopened with a refused value; keep it open and focused. - fx.request_focus = true; - } else { - // Enter in a single-line field, or clicking elsewhere, commits. - commit(view, form); + if fx.request_focus { + r.request_focus(); + fx.request_focus = false; + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } } - } - }); + 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) + }); + if esc { + close = true; + } else if tab { + next = Some(!shift); + } else if r.lost_focus() + && (!multiline || !enter) + && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) + { + if pressed { + // The field reopened with a refused value; keep it open and focused. + fx.request_focus = true; + } else { + // Enter in a single-line field, or clicking elsewhere, commits. + commit(view, form); + } + } + }); // Date fields: a calendar under the field (Acrobat's date picker). if let pdfcraft_engine::form_scripts::Format::Date(fmt) = &f.actions.format && let Some(picked) = calendar(ctx, view, rect, fmt, today) diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index dd2c032ef..39c21de89 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -585,3 +585,75 @@ fn released_text_does_not_jump_back_before_the_page_is_redrawn() { let (old, new) = ink_at(&mut h, from, to); assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each +/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign. +fn mode_tab_cursors( + h: &mut Harness<'static, PdfCraftApp>, + what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect, +) -> [(&'static str, egui::CursorIcon); 4] { + let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen"); + let y = what(h).center().y - page(h).top(); + let tabs = mode_tab(h, "Convert").center().y; + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let top = page(h).top(); + h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height())); + h.run_steps(4); + let under = what(h); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(under.contains(at), "{under:?} lies under {label} at {at:?}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// Add text, with the page scrolled up under the mode bar, leaves its tabs the hand (#2755): the +/// tool gave them its I-beam, added text its four-way arrow, and the open editor floated over +/// them and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_added_text_scrolled_under_them() { + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.state_mut().set_option("left", "closed").unwrap(); + // The page across the window, so text placed at its left edge reaches under the tabs. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(4); + assert_eq!(h.state().quick_tool, QuickTool::AddText); + // The empty page, ready for text. + assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, [10.0, 290.0, 210.0, 305.0])), HAND_ON_EVERY_TAB, "the page under the tabs"); + // Text kept by a click lower down, which starts a second box (#74). + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let p = at(&h, 10.0, 40.0); + click(&mut h, p); + h.event(egui::Event::Text("Reviewed".into())); + h.run_steps(2); + let p = at(&h, 10.0, 120.0); + click(&mut h, p); + h.event(egui::Event::Text("Approved".into())); + h.run_steps(2); + assert_eq!(texts(&h), ["Reviewed"]); + let kept = added(&h)[0].content.rect(); + assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, kept)), HAND_ON_EVERY_TAB, "added text under the tabs"); + // The second box, still being typed. + let draft = |h: &Harness<'static, PdfCraftApp>| item_on_screen(h, h.state().views[0].content.draft.as_ref().expect("the open box").rect); + assert_eq!(mode_tab_cursors(&mut h, draft), HAND_ON_EVERY_TAB, "the open box under the tabs"); + let convert = mode_tab(&h, "Convert").center(); + click(&mut h, convert); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the box"); +} diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 27fd6dd8a..a83cf6fa8 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -1515,3 +1515,64 @@ fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() { assert_eq!(h.state().views.len(), tabs, "nothing opened behind the dialog"); std::fs::remove_file(photo).unwrap(); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each +/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign. +fn mode_tab_cursors( + h: &mut Harness<'static, PdfCraftApp>, + what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect, +) -> [(&'static str, egui::CursorIcon); 4] { + let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen"); + let y = what(h).center().y - page(h).top(); + let tabs = mode_tab(h, "Convert").center().y; + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let top = page(h).top(); + h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height())); + h.run_steps(4); + let under = what(h); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(under.contains(at), "{under:?} lies under {label} at {at:?}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// The Fill & Sign text box scrolled up under the mode bar leaves its tabs the hand (#2755): it +/// floated over them, gave them its I-beam and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { + let mut h = harness(); + assert!(h.state_mut().execute("sign.fill.text")); + h.state_mut().set_option("left", "closed").unwrap(); + // The page across the window, so the box reaches under the tabs. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(4); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text)); + click(&mut h, 10.0, 360.0); + h.event(egui::Event::Text("Ada Lovelace, Countess of Lovelace".into())); + h.run_steps(3); + assert_eq!(mode_tab_cursors(&mut h, type_box_rect), HAND_ON_EVERY_TAB); + let convert = mode_tab(&h, "Convert").center(); + h.hover_at(convert); + h.run_steps(1); + h.drag_at(convert); + h.run_steps(1); + h.drop_at(convert); + h.run_steps(3); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); +} diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 96a0a6a11..b92e6a407 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -36,6 +36,10 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str, widget: usize) 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, widget).expect("on screen").center() }; + click_at(h, p); +} + +fn click_at(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) { h.hover_at(p); h.run_steps(1); h.drag_at(p); @@ -1008,3 +1012,66 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { // An explicit size is kept, as in Acrobat: the text may then run past the box. assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + use egui_kittest::kittest::Queryable; + h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each +/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign. +fn mode_tab_cursors( + h: &mut Harness<'static, PdfCraftApp>, + what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect, +) -> [(&'static str, egui::CursorIcon); 4] { + let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen"); + let y = what(h).center().y - page(h).top(); + let tabs = mode_tab(h, "Convert").center().y; + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let top = page(h).top(); + h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height())); + h.run_steps(4); + let under = what(h); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(under.contains(at), "{under:?} lies under {label} at {at:?}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// A text field scrolled up under the mode bar leaves its tabs the hand (#2755). The field gave +/// them its I-beam, and its open editor floated over them and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() { + let mut h = harness(); + // The page across the window, so the name field reaches under the tabs. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(4); + let name = |h: &Harness<'static, PdfCraftApp>| { + 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") + }; + assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the field under the tabs"); + // Open it, where it shows. + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + click_field(&mut h, "name", 0); + assert!(h.state().views[0].forms.focus.is_some(), "the editor opened"); + assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the open field under the tabs"); + let convert = mode_tab(&h, "Convert").center(); + click_at(&mut h, convert); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor"); +}