mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user