Merge PR #3022: fix(ui): on-page text editors leave the mode tabs their pointer when scrolled under them

Conflicts:
- crates/ui-egui/tests/content.rs: both sides' new tests kept, one after the other
This commit is contained in:
aiongg
2026-10-22 01:46:17 +00:00
6 changed files with 382 additions and 149 deletions
+63 -51
View File
@@ -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);
+35 -27
View File
@@ -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;
+84 -71
View File
@@ -136,11 +136,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());
@@ -503,78 +507,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)
+72
View File
@@ -642,3 +642,75 @@ fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() {
h.run_steps(2);
assert!(h.state().text_style.bold);
}
/// 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");
}
+61
View File
@@ -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");
}
+67
View File
@@ -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);
@@ -1072,3 +1076,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");
}