fix(ui): keys under a dialog or a menu leave the pages alone (keyboard routing, part 1) (#6608)

This commit is contained in:
Aleksander Helgaker
2026-11-10 14:22:20 +07:00
committed by GitHub
parent ea2e13370c
commit a1769246bd
41 changed files with 1650 additions and 246 deletions
+1
View File
@@ -175,6 +175,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Page keys.** Page code (everything under `canvas/` and `tools/`, `app/keys.rs`, `app/clipboard.rs` and the Pages panel's `panels/pages.rs`) reads keys and clipboard events through `PageKeys` (`crates/ui-egui/src/canvas/page_keys.rs`), which has none while something is over the pages (a dialog, the palette, a popup, a menu: `PdfCraftApp::over_pages`), never egui's raw key state. The functions `NOT_PAGE_KEYS` lists, each with why, read raw: a window's or a field's own keys, which `PageKeys` would hide, and the few page reads it can't make (chords that wait for the menu bar instead, Escape ending what the pages hold, any key stopping a glide, the clipboard's hand-off to text fields). `page_pointer::tests::page_modules_read_keys_only_through_page_keys` enforces it.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p <crate> --all-targets -- -D warnings` and `cargo test -p <crate>` to the crates you touched; the workspace sweep is CI's job.
+64 -5
View File
@@ -299,6 +299,8 @@ impl PdfCraftApp {
deferred_commands: Vec::new(),
native_menu: None,
menu_input: Vec::new(),
menu_pastes: 0,
page_keys: Default::default(),
dialog_seen: None,
dialog_epoch: 0,
ctx: None,
@@ -377,6 +379,61 @@ impl PdfCraftApp {
self.active.and_then(|i| self.views.get(i).map(|v| (i, v.id)))
}
/// Whether something over the pages has the keyboard: what covers them
/// ([`Self::covers_pages`]), the hotbar's tools window, a popup, a menu of the menu bar, or the
/// bar holding the keyboard (Alt, F10).
pub(crate) fn over_pages(&self, ctx: &egui::Context) -> bool {
self.covers_pages()
|| self.hotbar.picker_over_pages(ctx)
|| egui::Popup::is_any_open(ctx)
|| craft_menu::bar::is_open(ctx)
|| craft_menu::bar::is_focused(ctx)
}
/// Edit ▸ Copy, Cut, Select all and Delete stand for keys, and Edit ▸ Paste for the paste the
/// platform answers with. They go where the user's would: to a text field that has the
/// keyboard, else to the pages. They join the input once the menu bar has let go of the keys,
/// which an in-window menu's click does a pass later (craft-menu stores the menu as open on
/// the pass its click closes it). A native menu's click leaves egui's popups and the Hotbar
/// tools window open, which hold the pages' keys; a click anywhere else closes them, and so
/// does this. Edit ▸ Paste asks the platform for the clipboard only then, so its answer, on a
/// later pass, is an ordinary paste. With a text field focused, a native click on Copy, Cut,
/// Paste or Select all doesn't come here: craft-menu taps the shortcut for the field instead.
fn release_menu_input(&mut self, ctx: &egui::Context) {
if self.menu_input.is_empty() && self.menu_pastes == 0 {
return;
}
if craft_menu::bar::is_open(ctx) || craft_menu::bar::is_focused(ctx) {
ctx.request_repaint();
return;
}
if !ctx.text_edit_focused() {
egui::Popup::close_all(ctx);
self.hotbar.close_picker();
}
let events = std::mem::take(&mut self.menu_input);
ctx.input_mut(|i| i.events.extend(events));
for _ in 0..std::mem::take(&mut self.menu_pastes) {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
}
}
/// Read mode and full screen hide the hotbar (the quick tools), and its tools window with it.
pub(crate) fn hotbar_hidden(&self) -> bool {
self.mode == crate::Mode::Read || self.full_screen
}
/// The pages are covered, and no page key reaches them, a menu click's included: by a dialog or
/// prompt that turns the menus off (`modal_open`), by the full-screen and update prompts, which
/// leave the menus on, by the palette, which turns them off, or by the OCR Verify screen, which
/// takes the pages' place.
pub(crate) fn covers_pages(&self) -> bool {
// Full screen draws the page alone: not the palette, nor the OCR Verify screen it was
// entered over. Dialogs and prompts show there too.
let shown = !(self.full_screen && self.active.is_some());
self.modal_open() || self.pending_full_screen.is_some() || self.updates.prompt_open() || (self.palette_open || self.ocr_verify.open) && shown
}
/// A dialog or prompt is open over the window: the keyboard is its own, and nothing may act
/// on the document underneath it.
pub(crate) fn modal_open(&self) -> bool {
@@ -674,11 +731,13 @@ impl PdfCraftApp {
// Shortcuts deferred last frame: the text field has taken that frame's typing since.
let deferred = std::mem::take(&mut self.deferred_commands);
self.native_menu_clicks(ctx);
// The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys.
if !self.menu_input.is_empty() {
let events = std::mem::take(&mut self.menu_input);
ctx.input_mut(|i| i.events.extend(events));
}
// The input a menu click stands for (⌘C, Delete…), before anything reads this frame's keys.
self.release_menu_input(ctx);
// A command's chord pressed with the keyboard on the menu bar (Alt, F10) takes it off the
// bar, so the chord runs in the same press, the pages' as well as the registry's.
craft_menu::bar::leave_for_shortcut(ctx);
self.hotbar.close_hidden_picker(ctx, self.hotbar_hidden());
self.page_keys = page_keys::PageKeys::new(self.over_pages(ctx));
self.shortcuts(ctx);
// Scrolling and group-drag previews are transient: never resume after changing tabs,
// opening a modal/palette, or returning to a window that lost focus.
+3 -2
View File
@@ -46,7 +46,8 @@ impl PdfCraftApp {
}
/// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field
/// has focus). With no text selected, ⌘C copies the selected comments, each with its replies.
/// has focus, or something is over the pages). With no text selected, ⌘C copies the selected
/// comments, each with its replies.
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
let pastes: Vec<_> = ctx.input(|input| {
@@ -60,7 +61,7 @@ impl PdfCraftApp {
})
.collect()
});
let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing();
let free = self.dialog.is_none() && !self.palette_open && !self.page_keys.over() && !ctx.egui_wants_keyboard_input() && !self.is_preparing();
if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) {
self.receive_pastes(i, pastes, false);
return;
+53 -37
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::page_keys::PageKeys;
impl PdfCraftApp {
pub(super) fn shortcuts(&mut self, ctx: &egui::Context) {
@@ -31,57 +32,42 @@ impl PdfCraftApp {
return;
}
// The in-window menu bar holds the keys while one of its menus is open or it has the
// keyboard (Alt, F10); a command shortcut leaves it and runs.
craft_menu::bar::leave_for_shortcut(ctx);
if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) {
// keyboard (Alt, F10); a command shortcut has left it already (`logic`) and runs. The
// chords wait meanwhile: the registered commands', Select all and the view's.
let chords = !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx);
// The pages' own chords (Select all, the view's) don't reach pages something covers: the
// full-screen and update prompts, or the OCR Verify screen in their place (a dialog has
// returned above, and the palette holds the keyboard).
let page_chords = !self.covers_pages();
if chords {
self.registry_shortcuts(ctx);
}
// (Esc closes a Combine file shown large first.)
if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) {
if self.full_screen && self.combine_tab.preview.is_none() && self.page_keys.pressed(ctx, Key::Escape) {
self.set_full_screen(ctx, false);
}
if let Some(i) = self.active {
// Select all belongs to the document or page grid, unless a text field or
// overlay owns the keyboard. Other canvas shortcuts keep their own handling.
let pages_owns_keyboard =
self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id)));
if self.dialog.is_none()
&& !self.palette_open
&& (pages_owns_keyboard || !ctx.egui_wants_keyboard_input())
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
{
let view = &self.views[i];
// A paragraph or text box open for typing keeps ⌘A for its own text.
let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none();
if pages_owns_keyboard {
self.views[i].select_all_pages();
} else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) {
object_ui::select_all(&mut self.views[i], doc);
} else if self.is_preparing() {
if let Some(doc) = self.session.get(self.views[i].id) {
prepare::select_all(&mut self.views[i], &doc.form);
}
} else {
self.views[i].select_all();
}
if page_chords && chords {
self.select_all_keys(i, ctx);
}
self.tool_keys(i, ctx);
let keys = self.page_keys;
if self.quick_tool == QuickTool::EditText
&& let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification())
{
object_ui::nudge_keys(ctx, &mut self.views[i], doc);
object_ui::delete_keys(ctx, &mut self.views[i]);
object_ui::nudge_keys(ctx, keys, &mut self.views[i], doc);
object_ui::delete_keys(ctx, keys, &mut self.views[i]);
} else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) {
comments::nudge_keys(ctx, &mut self.views[i], &doc.info);
comments::nudge_keys(ctx, keys, &mut self.views[i], &doc.info);
}
// A selected field moves with the arrows too (Prepare a form, #2608), before the view
// turns the page with them.
if self.is_preparing()
&& let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification())
{
prepare::nudge_keys(ctx, &mut self.views[i], doc);
prepare::nudge_keys(ctx, keys, &mut self.views[i], doc);
}
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
canvas::shortcuts(&mut self.views[i], ctx, keys, chords && page_chords, self.scroll_speed);
self.clipboard_keys(i, ctx);
self.image_pick_keys(i, ctx);
}
@@ -89,13 +75,43 @@ impl PdfCraftApp {
}
impl PdfCraftApp {
/// ⌘A selects all of the document or the page grid, unless a text field or overlay owns the
/// keyboard.
fn select_all_keys(&mut self, i: usize, ctx: &egui::Context) {
use egui::Key;
let pages_owns_keyboard =
self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id)));
if self.dialog.is_none()
&& !self.palette_open
&& (pages_owns_keyboard || !ctx.egui_wants_keyboard_input())
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
{
let view = &self.views[i];
// A paragraph or text box open for typing keeps ⌘A for its own text.
let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none();
if pages_owns_keyboard {
self.views[i].select_all_pages();
} else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) {
object_ui::select_all(&mut self.views[i], doc);
} else if self.is_preparing() {
if let Some(doc) = self.session.get(self.views[i].id) {
prepare::select_all(&mut self.views[i], &doc.form);
}
} else {
self.views[i].select_all();
}
}
}
/// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it
/// is released. Plain letters, so not while a text field, a form field, a dialog or the
/// palette has the keyboard.
/// is released. Plain letters, so not while a text field, a form field, a dialog, the palette
/// or a menu has the keyboard.
fn tool_keys(&mut self, i: usize, ctx: &egui::Context) {
use egui::Key;
let keys = self.page_keys;
let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && self.views[i].forms.focus.is_none();
let (plain, space) = ctx.input(|input| (input.modifiers.is_none(), input.key_down(Key::Space)));
let free = free && !keys.over();
let (plain, space) = (ctx.input(|input| input.modifiers.is_none()), PageKeys::held(ctx, Key::Space));
if let Some(previous) = self.space_hand
&& !space
{
@@ -107,11 +123,11 @@ impl PdfCraftApp {
if !free || !plain {
return;
}
if ctx.input(|input| input.key_pressed(Key::V)) {
if keys.pressed(ctx, Key::V) {
self.quick_tool = QuickTool::Select;
self.space_hand = None;
}
if ctx.input(|input| input.key_pressed(Key::H)) {
if keys.pressed(ctx, Key::H) {
self.quick_tool = QuickTool::Hand;
self.space_hand = None;
}
+25 -17
View File
@@ -30,6 +30,7 @@ mod navigation;
mod organize;
pub(super) mod page_cursor;
mod page_input;
pub(super) mod page_keys;
pub(super) mod page_pointer;
pub(super) mod page_router;
mod presenter;
@@ -59,7 +60,7 @@ use cache::{
use cache::{OLD_TILES, PageTex, STALE_TAG, TEXT_TAG, TILE_BYTES, scale_tag, tag_scale, texture_bytes, tile_side};
pub use find::Find;
use find::find_bar;
pub use navigation::shortcuts;
pub(crate) use navigation::shortcuts;
pub(crate) use organize::drop_gap;
#[cfg(test)]
pub(crate) use organize::grid_page_rect;
@@ -1345,6 +1346,8 @@ struct PageEnv<'a> {
initials: &'a Option<crate::fill_sign::SavedSig>,
key_scroll: f32,
key_side: f32,
/// This frame's keys as the pages see them.
keys: crate::page_keys::PageKeys,
middle_gesture: bool,
modal: bool,
navigation_enabled: bool,
@@ -1697,7 +1700,7 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document
}
fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) {
let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env;
let PageEnv { allowed, comments_hidden, doc, editing_content, info, keys, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env;
let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame;
let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp);
if !r.intersects(visible.expand(400.0)) {
@@ -1738,7 +1741,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i
if frame.env.text_links {
view.refresh_text_links(i);
}
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys };
// A cancelled press takes nothing until the button is up, though the page's menu still opens
// where a right-click is. A page full screen doesn't show yet takes nothing at all. (A press
// there gives nothing on the page an owner, so its release, once the page is in, takes nothing
@@ -2793,8 +2796,9 @@ fn scroll_direction(dir: i8, dy: f32, resized: bool) -> i8 {
/// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the
/// text editor was finished.
fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool {
let PageEnv { added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } =
*env;
let PageEnv {
added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, keys, prefs, t, today, tool, units, unobstructed, ..
} = *env;
if let Some((pos, text)) = hover_text {
// Click-through, as a tooltip is: the pages lose the pointer under anything that isn't.
let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false);
@@ -2810,7 +2814,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim
if tool == QuickTool::EnhanceCamera {
crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify);
}
if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) {
if let Some(e) = comments::composer(ui.ctx(), keys, view, info, prefs) {
view.pending_edit = Some(e);
}
if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) {
@@ -2821,17 +2825,17 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim
None => e,
});
}
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) {
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), keys, view, info) {
view.pending_edit = Some(e);
}
let text_box = crate::forms_ui::text_box(doc, view, form);
if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) {
if let Some(e) = crate::forms_ui::overlay(ui.ctx(), keys, view, info, form, primary_down, text_box.as_ref(), today) {
view.pending_edit = Some(e);
}
if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) {
if let Some(e) = crate::fill_sign::type_box(ui.ctx(), keys, view, info, author) {
view.pending_edit = Some(e);
}
let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added);
let (typed, text_done) = crate::content_ui::editor(ui.ctx(), keys, view, info, added);
if typed.is_some() {
view.pending_edit = typed;
}
@@ -3205,13 +3209,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// No dialog, close prompt or palette over the page: only then does page input count.
let modal = app.modal_open();
let unobstructed = !modal && !app.palette_open;
// A menu click can open a dialog after the frame's first look.
app.page_keys = app.page_keys.also(app.covers_pages());
let keys = app.page_keys;
let organize_zoom = app.organize_zoom;
let invert_middle = app.invert_middle_drag;
let view = &mut app.views[index];
if view.organize {
// One remembered grid size for every document, kept in the settings.
view.set_grid_zoom(organize_zoom);
organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, invert_middle, ui, &t);
organize_grid(view, doc, unobstructed, keys, app.show_page_edge_shadows, invert_middle, ui, &t);
app.organize_zoom = view.grid_zoom();
return;
}
@@ -3316,7 +3323,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
} else {
view.prepare.selected = None;
}
view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx());
view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx(), keys);
view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale);
let added = if editing_content { doc.added().to_vec() } else { Vec::new() };
let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() };
@@ -3364,6 +3371,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
initials: &initials,
key_scroll,
key_side,
keys,
middle_gesture,
modal,
navigation_enabled,
@@ -3470,19 +3478,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// One crop, then back to selecting (as Acrobat does).
let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. }));
let mut tool = app.quick_tool;
comments::keys(ui.ctx(), view, info, &mut tool, allowed);
comments::keys(ui.ctx(), keys, view, info, &mut tool, allowed);
if preparing {
crate::prepare::keys(ui.ctx(), view);
crate::prepare::keys(ui.ctx(), keys, view);
}
if tool != QuickTool::EditText {
view.image_selection = None;
crate::content_ui::release_image_selection(view, &added);
}
if editing_content {
crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText);
crate::content_ui::keys(ui.ctx(), keys, view, tool == QuickTool::EditText);
}
if tool == QuickTool::Link {
crate::link_ui::keys(ui.ctx(), view);
crate::link_ui::keys(ui.ctx(), keys, view);
} else {
view.links.selected = None;
}
@@ -3805,7 +3813,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
// Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459);
// the quick tools come back with the window.
// A document that hides the toolbars (`HideToolbar`) hides this one too.
if app.mode != crate::Mode::Read && !app.full_screen && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) {
if !app.hotbar_hidden() && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) {
// The page nearest the left edge, whose margin the bar sits in.
let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min);
quick_bar(app, avail, page_left, ui);
+2 -2
View File
@@ -422,7 +422,7 @@ impl PdfCraftApp {
if ctx.egui_wants_keyboard_input() {
return;
}
if ctx.input(|inp| inp.key_pressed(egui::Key::Escape)) {
if self.page_keys.pressed(ctx, egui::Key::Escape) {
if let Some(view) = self.views.get_mut(i) {
view.picked_image = None;
view.pending_pick = None;
@@ -430,7 +430,7 @@ impl PdfCraftApp {
return;
}
let Some(picked) = self.views.get(i).and_then(|v| v.picked_image) else { return };
let copy = ctx.input(|inp| inp.events.iter().any(|e| matches!(e, egui::Event::Copy)));
let copy = self.page_keys.clipboard(ctx).copy;
if !copy || self.views[i].selected_text().is_some() {
return;
}
+50 -11
View File
@@ -379,16 +379,33 @@ fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut)
}
}
/// The document view's keyboard shortcuts: find, rotation, zoom (or the page grid's size),
/// history, Escape, and the paging and scrolling keys. An open form field keeps the paging
/// and scrolling keys for itself.
pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) {
/// The view's keys: its chords, then its plain keys. `chords`: no menu of the menu bar has the
/// keyboard and nothing covers the pages, so chords may run (they wait for a menu, as the
/// registered commands' do). An open form field keeps the paging and scrolling keys for itself.
pub(crate) fn shortcuts(
view: &mut DocView,
ctx: &egui::Context,
keys: crate::page_keys::PageKeys,
chords: bool,
speed: crate::scroll_speed::ScrollSpeed,
) {
if let Some((arrows, field_open)) = view_chords(view, ctx, chords) {
view_keys(view, ctx, keys, speed, arrows, field_open);
}
}
/// The view's chords: Find and its steps, rotation, zoom and the view history, unless a menu of the
/// menu bar has the keyboard or something covers the pages (`chords`). `None` while a text field
/// has the keyboard, and then it has the plain keys too; else whether the arrows are the view's (a
/// focused radio button keeps them), and whether an open form field (a drop-down's or a list box's
/// items) keeps the plain keys.
fn view_chords(view: &mut DocView, ctx: &egui::Context, chords: bool) -> Option<(bool, bool)> {
use egui::{Key, KeyboardShortcut, Modifiers};
if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) {
let pressed = |s: KeyboardShortcut| chords && ctx.input_mut(|i| i.consume_shortcut(&s));
if pressed(KeyboardShortcut::new(Modifiers::COMMAND, Key::F)) {
view.open_find();
}
let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k);
let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s));
// ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview
// (#2669): ahead of the text-input check, but only while there is a search to step.
if view.find.is_some() {
@@ -402,7 +419,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
// radio button for the arrows that move within its group: the other page keys still work.
let form_box = view.forms.keys.filter(|(id, _)| ctx.memory(|m| m.focused()) == Some(*id));
if ctx.egui_wants_keyboard_input() && form_box.is_none() {
return;
return None;
}
let arrows = !form_box.is_some_and(|(_, radio)| radio);
// Rotation first: egui matches ⌘+ loosely with respect to Shift.
@@ -418,11 +435,26 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
if pressed(cmd(Key::CloseBracket)) || pressed(alt(Key::ArrowRight)) {
view.view_history(true);
}
let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && ctx.input(|i| i.key_pressed(k));
// An open form field (a drop-down's or a list box's items) takes the arrows, Page Up / Page
// Down, Home, End and Escape. A focused check box or radio button uses none of them, so the
// page keys still work there.
let field_open = view.forms.focus.is_some() && form_box.is_none();
Some((arrows, field_open))
}
/// The view's plain keys: Escape, Home and End, full screen's keys, the arrows and Page Up / Page
/// Down. Not while something is over the pages or a form field is open (`field_open`), which keeps
/// them.
fn view_keys(
view: &mut DocView,
ctx: &egui::Context,
keys: crate::page_keys::PageKeys,
speed: crate::scroll_speed::ScrollSpeed,
arrows: bool,
field_open: bool,
) {
use egui::Key;
let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && keys.pressed(ctx, k);
// An open menu takes Escape first and closes; the selection stays for the next one (#3535).
// So does an open field, whose list closes.
if key(Key::Escape) && !egui::Popup::is_any_open(ctx) && !field_open {
@@ -443,9 +475,16 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
// (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434; *Adobe Acrobat Help*, 2019,
// "Keys for navigating a PDF", p. 83). These take the key from the page turning and
// scrolling below.
let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k);
let to_start = pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp));
let to_end = pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown));
let (mut to_start, mut to_end) = (false, false);
keys.take_presses(ctx, |k, m| {
let shift_cmd = m.matches_logically(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
match k {
Key::ArrowUp | Key::PageUp if shift_cmd => to_start = true,
Key::ArrowDown | Key::PageDown if shift_cmd => to_end = true,
_ => return false,
}
true
});
if key(Key::Home) || to_start {
view.go_to_page(0);
}
+10 -14
View File
@@ -4,6 +4,7 @@ use super::{
Align2, Color32, CornerRadius, DocInfo, DocView, Edit, Pos2, Rect, RenderRequest, RequestKind, Sense, Stroke, Tokens, Vec2, ViewAction,
device_pixels, icons, pos2, theme, vec2, widgets,
};
use crate::page_keys::PageKeys;
/// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes
/// the size on every frame, and a slightly soft or slightly large image is fine meanwhile.
@@ -92,7 +93,7 @@ pub(crate) fn grid_page_rect(cell: Rect, page: Vec2) -> Rect {
}
/// The organize toolbar: page operations on the selection (Acrobat's Organize Pages bar).
fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) {
fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, keys: PageKeys, ui: &mut egui::Ui, t: &Tokens) {
let targets = view.target_pages();
let n = info.pages.len();
let (first, last) = (targets.first().copied().unwrap_or(0), targets.last().copied().unwrap_or(0));
@@ -153,19 +154,12 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b
});
// Keys act on the selection unless a text field or a dialog has them (Delete under
// Preferences deleted the selected pages).
if editable && unobstructed && !ui.ctx().egui_wants_keyboard_input() {
use egui::{Key, Modifiers};
let (del, esc) = ui.input_mut(|i| {
(
i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace),
i.consume_key(Modifiers::NONE, Key::Escape),
)
});
if editable && !ui.ctx().egui_wants_keyboard_input() {
use egui::Key;
let ctx = ui.ctx();
let (del, esc) = (keys.take(ctx, Key::Delete) || keys.take(ctx, Key::Backspace), keys.take(ctx, Key::Escape));
// ⌘C / ⌘X / ⌘V copy, cut and paste pages (egui delivers them as clipboard events).
let (copy, cut, paste) = ui.input(|i| {
let any = |f: &dyn Fn(&egui::Event) -> bool| i.events.iter().any(f);
(any(&|e| matches!(e, egui::Event::Copy)), any(&|e| matches!(e, egui::Event::Cut)), any(&|e| matches!(e, egui::Event::Paste(_))))
});
let crate::page_keys::Clipboard { copy, cut, paste } = keys.clipboard(ctx);
if copy || cut {
view.pending_action = Some(ViewAction::CopyPages { cut });
}
@@ -267,10 +261,12 @@ fn request_sharper(
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn organize_grid(
view: &mut DocView,
doc: &pdfcraft_engine::Document,
unobstructed: bool,
keys: PageKeys,
show_page_edge_shadows: bool,
invert_middle: bool,
ui: &mut egui::Ui,
@@ -285,7 +281,7 @@ pub(super) fn organize_grid(
let anchor = view.grid_anchor.take();
let reveal = view.grid_reveal.take();
let mut open_page = None;
organize_toolbar(view, info, editable, dirty, unobstructed, ui, t);
organize_toolbar(view, info, editable, dirty, keys, ui, t);
let viewport = ui.available_rect_before_wrap();
view.viewport_screen = viewport;
let auto_delta = if unobstructed {
+12 -6
View File
@@ -10,6 +10,7 @@ use super::{
signature_badges, verdict_mark_rect,
};
use crate::page_cursor::{Layer, Subject};
use crate::page_keys::PageKeys;
use crate::page_pointer::PagePointer;
use crate::page_router::{Owner, PagePress, top};
use crate::{QuickTool, comments, icons};
@@ -45,6 +46,8 @@ pub(crate) struct PageToolContext<'a> {
pub page: usize,
pub doc: &'a pdfcraft_engine::Document,
pub info: &'a DocInfo,
/// This frame's keys as the pages see them.
pub keys: PageKeys,
}
/// The tools' input on one page. The hits at the press point decide who holds the press, and the
@@ -64,6 +67,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
editing_content,
form,
info,
keys,
placing,
prefs,
preparing,
@@ -76,8 +80,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
} = frame.env;
let PageSlot { i, xf, .. } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys };
let (lines, images) =
if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() };
let field_tool = match tool {
@@ -210,7 +214,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
// the palette, another window or another tab has it (`gate`), as before.
let gate = !(tool == QuickTool::EditText && view.objects.input_blocked());
if tool == QuickTool::EditText && can_modify && gate {
crate::edit_text_ui::picture_keys(ui.ctx(), view, i, &images, ptr.any_down);
crate::edit_text_ui::picture_keys(ui.ctx(), keys, view, i, &images, ptr.any_down);
}
let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view);
let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText);
@@ -346,6 +350,7 @@ fn tool_input(
doc,
info,
initials,
keys,
signature,
tool,
units,
@@ -356,7 +361,7 @@ fn tool_input(
} = frame.env;
let PageSlot { i, xf, .. } = *slot;
let PageFrame { resp, ptr, .. } = *frame;
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys };
let free = press == PagePress::Free;
if let QuickTool::Measure(measure_tool) = tool {
crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units);
@@ -892,6 +897,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
form,
generation,
info,
keys,
prepare_tab_order,
prefs,
preparing,
@@ -905,8 +911,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
let PageSlot { i, r, xf } = *slot;
let PageFrame { resp, ptr, pointer, .. } = *frame;
let painter = ui.painter();
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info, keys };
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark, keys };
// A press nobody holds picks the picture under it: a link's click follows the link only (ruling
// 5), though its drag or double-click still lets the picked picture go, as does a drag the page
// it is over first sees, whichever page it began on.
+173
View File
@@ -0,0 +1,173 @@
//! The keyboard as the pages see it.
//!
//! A dialog, the palette or a menu over the pages has the keys, but the pages are drawn before the
//! dialogs and read egui's keys first. So page modules read keys through [`PageKeys`], which has
//! none while something is over the pages: Delete under the Keyboard shortcuts window deleted the
//! comment selected behind it, and the Escape that closed it deselected the comment too.
//!
//! Each module still asks for itself whether a text field has the keyboard.
use egui::{Context, Event, Key, Modifiers};
/// A frame's Copy, Cut and Paste, as egui delivers them (⌘C, ⌘X, ⌘V, or a menu's).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Clipboard {
pub copy: bool,
pub cut: bool,
pub paste: bool,
}
/// One frame's keyboard, as the pages see it: their keys, or none while something is over them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct PageKeys {
/// A dialog, the palette or a menu is over the pages.
over: bool,
}
impl PageKeys {
pub(crate) fn new(over: bool) -> Self {
Self { over }
}
/// The keys once something more is found over the pages this frame. Only ever more covered: a
/// menu that this frame's Escape closes still had that Escape.
#[must_use]
pub(crate) fn also(self, over: bool) -> Self {
Self { over: self.over || over }
}
/// `key` was pressed for the pages this frame. It stays for whatever reads it next.
pub(crate) fn pressed(self, ctx: &Context, key: Key) -> bool {
!self.over && ctx.input(|i| i.key_pressed(key))
}
/// Take `key`, pressed without ⌘ or Ctrl (egui lets an extra Shift or Alt through), for the
/// pages: nothing after reads it.
pub(crate) fn take(self, ctx: &Context, key: Key) -> bool {
!self.over && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, key))
}
/// Take the frame's key presses that `take` wants for the pages, each judged by its own
/// modifiers (the frame's can differ when a key is let go in the same frame). `take` says
/// whether it took the press; a press taken is gone for whatever reads keys next.
pub(crate) fn take_presses(self, ctx: &Context, mut take: impl FnMut(Key, Modifiers) -> bool) {
if self.over {
return;
}
ctx.input_mut(|i| {
i.events.retain(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } => !take(*key, *modifiers),
_ => true,
});
});
}
/// The frame's Copy, Cut and Paste for the pages: none while something is over them. They stay
/// for whatever reads them next.
pub(crate) fn clipboard(self, ctx: &Context) -> Clipboard {
if self.over {
return Clipboard::default();
}
ctx.input(|i| {
let any = |f: fn(&Event) -> bool| i.events.iter().any(f);
Clipboard { copy: any(|e| matches!(e, Event::Copy)), cut: any(|e| matches!(e, Event::Cut)), paste: any(|e| matches!(e, Event::Paste(_))) }
})
}
/// Something is over the pages: their keys wait.
pub(crate) fn over(self) -> bool {
self.over
}
/// `key` is held down. Not the pages' alone: a key let go while something is over them is let
/// go all the same.
pub(crate) fn held(ctx: &Context, key: Key) -> bool {
ctx.input(|i| i.key_down(key))
}
}
#[cfg(test)]
mod tests {
use egui::RawInput;
use super::*;
fn press(key: Key) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
}
/// Run one frame with `events`, calling `f` with its context.
fn frame(events: Vec<Event>, f: impl FnOnce(&Context)) {
let ctx = Context::default();
let mut f = Some(f);
let mut output = ctx.run_ui(RawInput { events, ..Default::default() }, |ui| {
if let Some(f) = f.take() {
f(ui.ctx());
}
});
// An input-only test has no renderer to take the font texture.
output.textures_delta.clear();
}
#[test]
fn something_found_over_the_pages_stays_over_them_for_the_frame() {
assert_eq!(PageKeys::new(false).also(true), PageKeys::new(true));
assert_eq!(PageKeys::new(true).also(false), PageKeys::new(true));
assert_eq!(PageKeys::new(false).also(false), PageKeys::new(false));
}
#[test]
fn with_something_over_the_pages_they_have_no_keys_and_leave_them_there() {
frame(vec![press(Key::Delete)], |ctx| {
let keys = PageKeys::new(true);
assert!(!keys.pressed(ctx, Key::Delete));
assert!(!keys.take(ctx, Key::Delete));
assert!(ctx.input(|i| i.key_pressed(Key::Delete)), "the key stays for the dialog");
});
}
#[test]
fn the_pages_take_the_presses_they_want_with_their_own_modifiers() {
let shifted = Event::Key { key: Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT };
frame(vec![press(Key::ArrowRight), shifted, Event::Copy, press(Key::Escape)], |ctx| {
let mut seen = Vec::new();
PageKeys::new(true).take_presses(ctx, |key, _| {
seen.push(key);
true
});
assert!(seen.is_empty(), "nothing while something is over the pages");
PageKeys::new(false).take_presses(ctx, |key, modifiers| {
seen.push(key);
key == Key::ArrowLeft && modifiers.shift
});
assert_eq!(seen, [Key::ArrowRight, Key::ArrowLeft, Key::Escape]);
assert!(!ctx.input(|i| i.key_pressed(Key::ArrowLeft)), "the press taken is gone");
assert!(ctx.input(|i| i.key_pressed(Key::ArrowRight) && i.key_pressed(Key::Escape)), "the others stay");
assert!(ctx.input(|i| i.events.contains(&Event::Copy)), "and so does what isn't a key");
});
}
#[test]
fn the_clipboard_is_the_pages_only_with_nothing_over_them() {
frame(vec![Event::Copy, Event::Paste("text".into())], |ctx| {
assert_eq!(PageKeys::new(true).clipboard(ctx), Clipboard::default());
assert_eq!(PageKeys::new(false).clipboard(ctx), Clipboard { copy: true, cut: false, paste: true });
});
frame(vec![Event::Cut], |ctx| {
assert_eq!(PageKeys::new(true).clipboard(ctx), Clipboard::default());
assert_eq!(PageKeys::new(false).clipboard(ctx), Clipboard { copy: false, cut: true, paste: false });
});
}
#[test]
fn the_pages_peek_at_a_key_or_take_it() {
frame(vec![press(Key::Delete)], |ctx| {
let keys = PageKeys::new(false);
assert!(keys.pressed(ctx, Key::Delete));
assert!(keys.pressed(ctx, Key::Delete), "a peek leaves it");
assert!(keys.take(ctx, Key::Delete));
assert!(!keys.pressed(ctx, Key::Delete), "a take doesn't");
assert!(!keys.take(ctx, Key::Escape));
});
}
}
@@ -797,3 +797,120 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() {
let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec();
assert_eq!(aliased_hit_tests(source), aliases);
}
/// egui's raw key state: a key pressed, held, let go or taken, and the key, text and clipboard
/// events themselves, however they are imported.
const RAW_KEY_READS: [&str; 17] = [
"Event::Copy",
"Event::Cut",
"Event::Paste",
"Event::Text",
"Event::{",
"Event::*",
"Event as ",
"InputState::",
"key_pressed(",
"key_released(",
"key_down(",
"keys_down",
"num_presses(",
"consume_key(",
"consume_shortcut(",
"count_and_consume_key(",
"Event::Key",
];
/// Functions in the files the key guard scans ([`key_files`]) that read egui's keys themselves:
/// a window's or a field's own keys, which `PageKeys` would hide, and the pages' reads that
/// `PageKeys` can't make (chords that wait for the menu bar instead, Escape ending what the pages
/// hold, any key stopping a glide, the clipboard's hand-off to text fields): (file, function, why).
const NOT_PAGE_KEYS: [(&str, &str, &str); 17] = [
(
"app/clipboard.rs",
"clipboard_keys",
"the pages' clipboard: Copy, Cut and Paste (events and chords), which act only when `page_keys.over()` is false, \
and the pastes it hands on to text fields while something is over the pages",
),
(
"app/keys.rs",
"select_all_keys",
"Select all, a chord that waits for a menu of the menu bar as the registered commands' do, and isn't called while something covers the pages",
),
("canvas/area.rs", "glide_interrupted", "any key stops the touchpad's glide"),
("canvas/autoscroll.rs", "update_latched", "Escape ends the middle-button scroll, which holds the whole view"),
("canvas/autoscroll.rs", "escape", "Escape ends the middle-button scroll, which holds the whole view"),
("canvas/find.rs", "find_bar", "the Find field's own keys, while it has the focus"),
(
"canvas/navigation.rs",
"view_chords",
"the view's chords, which wait for a menu of the menu bar as the registered commands' do, and for whatever covers the pages",
),
("canvas/page_input.rs", "cancel_on_escape", "Escape cancels a press the pages hold, which goes on under a dialog opened during it"),
("tools/calc_order_ui.rs", "panel", "the Calculation order panel's own Alt+arrows"),
("tools/comments/composer.rs", "submit_key", "the comment field's own Enter, while it has the focus (the Comments panel's too)"),
("tools/field_table_ui.rs", "show", "the Text field table window's own Escape"),
("tools/field_table_ui.rs", "column_dividers", "the Text field table window's own arrows"),
("tools/fill_sign.rs", "signature_pad", "the signature dialog's own ⌘Z"),
("tools/forms_ui.rs", "combo_editor", "the combo box's own Enter, while its field has the focus"),
("tools/prepare/properties.rs", "body", "the field properties' own Enter, while one of their fields has the focus"),
("tools/redact_ui.rs", "search_body", "the redaction search field's own Enter, while it has the focus"),
("tools/sign_ui/dialog.rs", "sign_as", "the signing dialog's own Enter"),
];
/// The files whose key reads the key guard checks, relative to `src`: every file under `canvas/`
/// and `tools/` and the canvas's root, whether or not it is a page module yet, the app's own keys
/// and clipboard, and the Pages panel's thumbnails; not their tests, and not `PageKeys` itself. A
/// helper in another file that page code hands egui's input to isn't seen.
fn key_files() -> Vec<String> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = ["canvas.rs", "app/keys.rs", "app/clipboard.rs", "panels/pages.rs"].map(str::to_owned).to_vec();
let mut dirs = vec![src.join("canvas"), src.join("tools")];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(dir).expect("source directory") {
let path = entry.expect("source entry").path();
let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
if stem == "tests" || stem.ends_with("_tests") {
continue;
}
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let file =
path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");
if file != "canvas/page_keys.rs" {
files.push(file);
}
}
}
}
files.sort();
files
}
/// Page code reads keys only through `PageKeys`. The pages are drawn before the dialogs and the
/// palette and read egui's keys first, so a raw read acts on what is selected or typed behind
/// whatever is over them: Delete under a dialog deleted the comment selected behind it, and the
/// Escape that closed one threw away the paragraph being typed.
#[test]
fn page_modules_read_keys_only_through_page_keys() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let files = key_files();
let mut found = Vec::new();
for file in &files {
let source = std::fs::read_to_string(src.join(file)).expect("source file").replace("\r\n", "\n");
for (n, function, text) in code_lines(&source) {
for name in RAW_KEY_READS.iter().filter(|name| uses(&text, name)) {
found.push((file.as_str(), function.map(str::to_owned), n, (*name).to_owned()));
}
}
}
let problems = misuses_in(
found,
&NOT_PAGE_KEYS,
"reads egui's keys in page code. Read them through `PageKeys` (src/canvas/page_keys.rs) instead: it has none \
while a dialog, the palette or a menu is over the pages, which read keys before those are drawn. Not the \
pages' keys? Add the function to NOT_PAGE_KEYS in src/canvas/page_pointer/tests.rs, with why.",
"reads no keys of its own any more: remove it from NOT_PAGE_KEYS.",
);
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
+1 -1
View File
@@ -763,7 +763,7 @@ fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() {
ctx.input_mut(|i| {
i.events.push(egui::Event::Key { key: egui::Key::G, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
});
shortcuts(&mut v, &ctx, Default::default());
shortcuts(&mut v, &ctx, Default::default(), true, Default::default());
assert_eq!(v.current, 2, "the keyboard step chose the next match");
assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice");
+31
View File
@@ -23,6 +23,35 @@ pub(crate) struct Hotbar {
search: String,
#[serde(skip)]
picker_open: bool,
/// The pass the Hotbar tools window was last drawn on.
#[serde(skip)]
picker_shown: Option<u64>,
}
impl Hotbar {
/// The Hotbar tools window is over the pages: open, and drawn on the last pass. Read mode, full
/// screen, Organize and a document that hides the toolbars don't draw the hotbar, and a window
/// they hide holds no keys.
pub(crate) fn picker_over_pages(&self, ctx: &egui::Context) -> bool {
self.picker_open && self.picker_shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr())
}
/// Closes the Hotbar tools window when the hotbar is `hidden` on this pass (Read mode or full
/// screen, which a native menu's click may have turned on just now), or the last pass didn't
/// draw it (Organize, or a document that hides the toolbars). Left open, it would hold the keys of the pass that hides it, and come
/// back over the pages on the pass that shows the hotbar again, after that pass's page keys
/// had been read without it.
pub(crate) fn close_hidden_picker(&mut self, ctx: &egui::Context, hidden: bool) {
if self.picker_open && (hidden || !self.picker_over_pages(ctx)) {
self.close_picker();
}
}
/// Closes the Hotbar tools window.
pub(crate) fn close_picker(&mut self) {
self.picker_open = false;
self.picker_shown = None;
}
}
impl Default for Hotbar {
@@ -38,6 +67,7 @@ impl Default for Hotbar {
placed: false,
search: String::new(),
picker_open: false,
picker_shown: None,
}
}
}
@@ -871,6 +901,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option<f32>, ui
data.insert_temp(egui::Id::unique("custom-hotbar"), shown.response.rect.size());
});
bar.picker_open = picker_open;
bar.picker_shown = picker_open.then(|| ctx.cumulative_pass_nr());
app.hotbar = bar;
if let Some(id) = run {
app.execute(id);
+8 -1
View File
@@ -104,6 +104,7 @@ use canvas::crop;
use canvas::fling;
use canvas::image_pick;
use canvas::page_cursor;
use canvas::page_keys;
use canvas::page_pointer;
use canvas::page_router;
pub use canvas::scroll_speed;
@@ -731,8 +732,14 @@ pub struct PdfCraftApp {
deferred_commands: Vec<(&'static str, Option<DocId>)>,
/// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise.
pub(crate) native_menu: Option<craft_menu::NativeMenu>,
/// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`.
/// Input a menu click stands for (⌘C, Delete…), waiting to join a pass's input (see
/// `release_menu_input`).
pub(crate) menu_input: Vec<egui::Event>,
/// Edit ▸ Paste clicks waiting to ask the platform for the clipboard (see
/// `release_menu_input`).
pub(crate) menu_pastes: usize,
/// This frame's keys as the pages see them: none while something is over them.
pub(crate) page_keys: page_keys::PageKeys,
/// The dialog seen at the last check, and a counter bumped whenever it changes (see
/// [`Self::dialog_epoch`]).
dialog_seen: Option<Dialog>,
+1 -1
View File
@@ -552,7 +552,7 @@ impl PdfCraftApp {
}
"edit.copy" => self.menu_input.push(egui::Event::Copy),
CUT => self.menu_input.push(egui::Event::Cut),
PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
PASTE => self.menu_pastes = self.menu_pastes.saturating_add(1),
SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)),
DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)),
PANEL => self.choose_right_panel(panel(command)),
@@ -611,6 +611,11 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
let menu_id = ui.scope_id().with(("card-menu", key));
if open_menu {
egui::Popup::open_id(ui.ctx(), menu_id);
} else if more_rect.is_none() {
// The button is drawn on the selected card alone. Without it (Undo deselected the card
// with its menu open), egui would keep the menu open with nowhere to show it, and an
// open popup holds every page key (`PageKeys`).
egui::Popup::close_id(ui.ctx(), menu_id);
}
// Hang the menu from the "…" button (a pointer anchor would follow the mouse to the items).
let anchor = more_rect.map_or(egui::PopupAnchor::PointerFixed, egui::PopupAnchor::ParentRect);
+15 -7
View File
@@ -87,7 +87,17 @@ pub(super) fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::Do
/// (the selection page commands and Print act on) without moving the document. Right-click
/// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging
/// moves the selection, or the page grabbed, to the gap it is dropped on.
pub(super) fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, editable: bool, nav: &mut Option<Nav>) {
#[allow(clippy::too_many_arguments)]
pub(super) fn pages(
ui: &mut egui::Ui,
t: &Tokens,
info: &DocInfo,
view: &mut crate::DocView,
modal: bool,
keys: crate::page_keys::PageKeys,
editable: bool,
nav: &mut Option<Nav>,
) {
let focus_id = ui.make_persistent_id(("pages-keyboard", view.id));
view.pages_focus_id = Some(focus_id);
let keyboard = ui.interact(ui.clip_rect(), focus_id, Sense::focusable_noninteractive());
@@ -96,20 +106,18 @@ pub(super) fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut cr
// Escape remains with the panel after the pointer moves away.
ui.memory_mut(|m| m.set_focus_lock_filter(focus_id, egui::EventFilter { escape: true, ..Default::default() }));
// Escape drops the selection while the pointer is over the panel and nothing else wants the key.
if !modal
&& !view.selected.is_empty()
if !view.selected.is_empty()
&& (owns_keyboard || !ui.ctx().egui_wants_keyboard_input())
&& (owns_keyboard || ui.rect_contains_pointer(ui.clip_rect()))
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
&& keys.take(ui.ctx(), egui::Key::Escape)
{
view.clear_page_selection(Some(view.current));
}
// Delete (or Backspace) deletes the selection, or the current page, likewise.
if !modal
&& editable
if editable
&& (owns_keyboard || !ui.ctx().egui_wants_keyboard_input())
&& (owns_keyboard || ui.rect_contains_pointer(ui.clip_rect()))
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
&& (keys.take(ui.ctx(), egui::Key::Delete) || keys.take(ui.ctx(), egui::Key::Backspace))
{
delete_pages(view, info.pages.len());
}
+3 -1
View File
@@ -27,6 +27,8 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let bm_can_paste = app.bookmark_clipboard.is_some();
// Prepare a form is open: the Fields panel orders tabs.
let preparing = app.is_preparing();
// The Pages panel's Escape and Delete act on the pages: not while something is over them.
let keys = app.page_keys.also(app.covers_pages());
{
let Some(doc) = app.session.get(id) else { return };
let info = &doc.info;
@@ -218,7 +220,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.label(tl!("Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth."));
}
}
RightPanel::Pages => pages(ui, &t, info, view, modal, bm_editable, &mut nav),
RightPanel::Pages => pages(ui, &t, info, view, modal, keys, bm_editable, &mut nav),
RightPanel::Fields => {
field_properties = fields(
ui,
+16
View File
@@ -35,6 +35,22 @@ pub(crate) struct Updates {
close_queued: bool,
}
impl Updates {
/// The prompt that offers a newer release, after the check at start or Help ▸ Check for
/// updates, is open over the whole window. It isn't the Updates dialog (`open`), and it leaves
/// the menus on.
pub(crate) fn prompt_open(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
self.updater.prompt_open()
}
#[cfg(target_arch = "wasm32")]
{
false
}
}
}
impl Default for Updates {
fn default() -> Self {
Self {
@@ -5,9 +5,10 @@ use super::{
Color32, CommentPrefs, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NoteIcon, PagePointer, QuickTool, Shape, Tokens, pos2,
theme,
};
use crate::page_keys::PageKeys;
/// The floating composer card (new note, new text box, edit). Returns the edit to apply.
pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option<Edit> {
pub(crate) fn composer(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option<Edit> {
let c = view.comments.composer.as_ref()?;
let page = c.page;
let Some(xf) = view.page_xform(page) else {
@@ -27,6 +28,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
allowed: true,
hidden: false,
redact_mark: ([0.89, 0.13, 0.13], 1.0),
keys,
};
let anchor = cx.to_screen(c.at);
let viewport = view.viewport_rect();
@@ -79,7 +81,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
c.focus = false;
}
let enter = submit_key(ui, &edit, false);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if keys.pressed(ui.ctx(), egui::Key::Escape) {
cancel = true;
}
ui.add_space(8.0);
+27 -29
View File
@@ -7,6 +7,7 @@ use super::popups::has_popup;
use super::state::{Composer, ComposerKind, Gesture, PageCx};
use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
use crate::page_cursor::{Layer, Subject};
use crate::page_keys::PageKeys;
use crate::page_router::{CommentPart, Hit, Owner, PagePress};
/// Comment input on one page, before text selection runs. `press` is what this page has to do with
@@ -28,7 +29,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
};
let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None };
let cv = &mut view.comments;
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
if cv.gesture.is_some() && cx.keys.take(ui.ctx(), egui::Key::Escape) {
cv.gesture = None;
return true;
}
@@ -554,13 +555,12 @@ impl Gestures<'_> {
false
}
};
let finish =
resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) || (drawing_here && std::mem::take(&mut cv.finish_request));
let finish = resp.double_clicked() || cx.keys.pressed(ui.ctx(), egui::Key::Enter) || (drawing_here && std::mem::take(&mut cv.finish_request));
// Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too.
// Only while a drawing is in progress: otherwise Delete belongs to the selected comment.
if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page)
&& !ui.ctx().egui_wants_keyboard_input()
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete))
&& (cx.keys.take(ui.ctx(), egui::Key::Backspace) || cx.keys.take(ui.ctx(), egui::Key::Delete))
{
cv.undo_point();
return true;
@@ -739,13 +739,14 @@ impl Gestures<'_> {
}
}
/// Delete / Escape handling for comments (only while no text field has focus).
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, tool: &mut QuickTool, allowed: bool) {
/// Delete / Escape handling for comments (only while no text field has focus, and nothing is over
/// the pages).
pub(crate) fn keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, info: &DocInfo, tool: &mut QuickTool, allowed: bool) {
if ctx.egui_wants_keyboard_input() {
return;
}
let cv = &mut view.comments;
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if keys.pressed(ctx, egui::Key::Escape) {
// One step back per press: cancel the gesture, then deselect, then drop the tool (a
// comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every
// click with no way out by the keyboard).
@@ -756,7 +757,7 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, tool
*tool = QuickTool::Select;
}
}
if allowed && cv.selected.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
if allowed && cv.selected.is_some() && (keys.pressed(ctx, egui::Key::Delete) || keys.pressed(ctx, egui::Key::Backspace)) {
view.pending_edit = delete_selected(cv, info);
}
}
@@ -786,7 +787,7 @@ pub(crate) fn delete_selected(cv: &mut CommentView, info: &DocInfo) -> Option<Ed
/// on screen whatever the zoom, the page's rotation and the view's; the keys are taken before
/// the view scrolls or turns the page with them. Each press is one undo step. Text markup stays
/// with its text, and a locked comment where it is.
pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) {
pub(crate) fn nudge_keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, info: &DocInfo) {
if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() {
return;
}
@@ -802,26 +803,23 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo
}
use egui::Key;
let mut step = egui::Vec2::ZERO;
ctx.input_mut(|i| {
// Only an arrow pressed alone or with ⌘/Ctrl or Shift, judged by each press's own
// modifiers. Alt+← / Alt+→ go back and forward through the view history in
// `canvas::shortcuts` (#3632), and egui's key patterns would let an extra Alt through.
// A Ctrl that isn't the command key (macOS) is left alone, as the patterns left it.
i.events.retain(|e| {
let egui::Event::Key { key, pressed: true, modifiers, .. } = e else { return true };
if modifiers.alt || (modifiers.ctrl && !modifiers.command) {
return true;
}
let direction = match key {
Key::ArrowLeft => vec2(-1.0, 0.0),
Key::ArrowRight => vec2(1.0, 0.0),
Key::ArrowUp => vec2(0.0, -1.0),
Key::ArrowDown => vec2(0.0, 1.0),
_ => return true,
};
step += direction * if modifiers.command || modifiers.shift { 10.0 } else { 1.0 };
false
});
// Only an arrow pressed alone or with ⌘/Ctrl or Shift, judged by each press's own
// modifiers. Alt+← / Alt+→ go back and forward through the view history in
// `canvas::shortcuts` (#3632), and egui's key patterns would let an extra Alt through.
// A Ctrl that isn't the command key (macOS) is left alone, as the patterns left it.
keys.take_presses(ctx, |key, modifiers| {
if modifiers.alt || (modifiers.ctrl && !modifiers.command) {
return false;
}
let direction = match key {
Key::ArrowLeft => vec2(-1.0, 0.0),
Key::ArrowRight => vec2(1.0, 0.0),
Key::ArrowUp => vec2(0.0, -1.0),
Key::ArrowDown => vec2(0.0, 1.0),
_ => return false,
};
step += direction * if modifiers.command || modifiers.shift { 10.0 } else { 1.0 };
true
});
if step == egui::Vec2::ZERO {
return;
+3 -1
View File
@@ -1,5 +1,6 @@
use super::state::PageCx;
use super::{Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, pos2, theme};
use crate::page_keys::PageKeys;
/// A comment with a pop-up window of its own: not a text box, a Fill & Sign mark, a reply or a
/// status (replies are found by `/IRT` reference, [`Annotation::is_comment`]).
@@ -367,7 +368,7 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr
.map_or(Color32::from_rgb(0xFF, 0xD6, 0x5C), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8));
let pal = palette(color);
// A screen rectangle on this page in user space, to save as the pop-up's `/Rect`. Only
// converts: the pointer and the redaction look are unused.
// converts: the pointer, the keys (none) and the redaction look are unused.
let to_user = |r: Rect| {
let cx = PageCx {
page,
@@ -379,6 +380,7 @@ pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, pr
allowed,
hidden,
redact_mark: ([0.0; 3], 1.0),
keys: PageKeys::new(true),
};
cx.rect_to_user(r)
};
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use super::geometry::{corner_ratio, frame, is_markup, resized};
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect};
use crate::page_keys::PageKeys;
/// The user's login name, which Acrobat uses as the default comment author.
pub(super) fn login_name() -> String {
@@ -570,6 +571,8 @@ pub(crate) struct PageCx<'a> {
pub allowed: bool,
/// Comments are hidden (Hide all comments): nothing to select.
pub hidden: bool,
/// This frame's keys as the pages see them.
pub keys: PageKeys,
}
impl PageCx<'_> {
+3 -1
View File
@@ -106,6 +106,7 @@ fn an_upright_note_on_a_turned_page_is_in_view_where_it_is_drawn() {
allowed: true,
hidden: false,
redact_mark: ([0.0; 3], 1.0),
keys: Default::default(),
};
assert_eq!(edited_in_view(&cx, 0, viewport), in_view, "no_rotate: {no_rotate}");
}
@@ -633,7 +634,7 @@ fn stale_comment_state_is_cleared_and_delete_never_hits_non_comment_slots() {
});
let mut out = ctx.run_ui(input, |ui| {
let mut tool = QuickTool::Select;
keys(ui.ctx(), &mut view, &shifted, &mut tool, true);
keys(ui.ctx(), crate::page_keys::PageKeys::new(false), &mut view, &shifted, &mut tool, true);
});
out.textures_delta.clear();
assert_eq!(view.comments.selected, None);
@@ -749,6 +750,7 @@ fn the_hover_outline_goes_round_what_a_press_would_take() {
allowed: true,
hidden: false,
redact_mark: ([0.0; 3], 1.0),
keys: Default::default(),
};
let on = |owner, layer| Hit::new(owner, layer, egui::CursorIcon::PointingHand);
let comment = on(Owner::Comment { index: 0, part: CommentPart::Body }, Layer::Comment);
+8 -5
View File
@@ -11,6 +11,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::page_router::{Hit, Owner, PagePress};
use crate::theme::Tokens;
use crate::widgets;
@@ -632,8 +633,9 @@ pub(crate) fn release_image_selection(view: &mut DocView, added: &[Added]) {
}
}
/// Delete removes the selected item; Escape clears the selection.
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_active: bool) {
/// Delete removes the selected item; Escape clears the selection. Not while something is over the
/// pages.
pub(crate) fn keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, image_controller_active: bool) {
if image_controller_active && view.image_selection.is_some()
|| matches!(view.pending_edit.as_ref(), Some(Edit::EditPageImage { .. }))
|| view.content.selected.is_none()
@@ -642,7 +644,8 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_act
{
return;
}
let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape)));
let del = keys.pressed(ctx, egui::Key::Delete) || keys.pressed(ctx, egui::Key::Backspace);
let esc = keys.pressed(ctx, egui::Key::Escape);
// Not while a button is down: the press held would then name another item.
if del
&& !view.content.held
@@ -671,7 +674,7 @@ pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) {
/// The in-place text editor, with Done and Discard beside it. Returns the edit once the text is
/// committed (a click away, or Done), and whether Done finished adding text. Discard or Escape
/// throws the draft away.
pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option<Edit>, bool) {
pub(crate) fn editor(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option<Edit>, bool) {
let doc_id = view.id;
let Some(page) = view.content.draft.as_ref().map(|d| d.page) else { return (None, false) };
let Some(xf) = view.page_xform(page) else { return (None, false) };
@@ -811,7 +814,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// that is not a click away.
let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p))
|| crate::edit_text_ui::focus_pos(ui.ctx()).is_some_and(|p| buttons.contains(p));
cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
cancel |= keys.pressed(ui.ctx(), egui::Key::Escape);
commit = done || resp.lost_focus() && !on_buttons && !outside && !view.objects.hold_editor;
});
// Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
+4 -3
View File
@@ -13,6 +13,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::page_pointer::PagePointer;
use crate::page_router::{Hit, Owner, PagePress, ParagraphPart};
@@ -865,7 +866,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
}
/// The inline editor; returns the edit once the text is applied.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
pub(crate) fn overlay(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
let view_id = view.id;
// Clicks outside the document (the Format text panel) keep the paragraph open, as does the
// keyboard moving there. Where the click landed is `interact_pos`: `latest_pos` is gone on the
@@ -1046,8 +1047,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
ctx.request_repaint();
}
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);
let esc = keys.pressed(ctx, egui::Key::Escape);
let apply = keys.pressed(ctx, egui::Key::Enter) && ui.input(|i| i.modifiers.command);
if esc {
done = Some(false);
} else if apply || (lost && !outside && !hold) {
@@ -2,6 +2,7 @@ use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES,
use crate::canvas::{ImageObb, PainterExt, image_screen_rect};
use crate::image_engine::{CROP_ACCENT, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds};
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::page_router::{CropPart, Hit, Owner, PagePress, PicturePart};
/// What the pointer and the page are, for the image tools on one page.
@@ -488,13 +489,13 @@ fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) {
/// Enter applies the crop and Escape leaves it, though not while a button is down (`held`): Escape
/// then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode stays.
/// Nothing while a text field has the keyboard.
pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], held: bool) {
pub(crate) fn picture_keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], held: bool) {
if ctx.egui_wants_keyboard_input() || view.page_screen_rect(page).is_none() {
return;
}
let Some(s) = view.image_selection.as_ref().filter(|s| s.page == page) else { return };
let index = s.index;
let key = |k: egui::Key| ctx.input(|i| i.key_pressed(k));
let key = |k: egui::Key| keys.pressed(ctx, k);
let image = images.get(index);
if s.crop.is_some() {
if key(egui::Key::Enter) {
@@ -509,10 +510,8 @@ pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, page: usize,
}
} else if !held && !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image.is_some() {
// The key is the picture's: added content's own Delete runs later this frame.
ctx.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, egui::Key::Delete);
i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace);
});
keys.take(ctx, egui::Key::Delete);
keys.take(ctx, egui::Key::Backspace);
view.image_selection = None;
// Added content's selection follows the picture's (`content_ui::sync_image_selection`), and
// goes with it: once the picture is gone it would be the next added item's.
+3 -2
View File
@@ -11,6 +11,7 @@ use pdfcraft_render::{DocInfo, PageInfo};
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::theme::Tokens;
mod scan;
@@ -602,7 +603,7 @@ pub enum FillAction {
}
/// The in-place editor for typed text. Returns the edit once committed.
pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, author: &str) -> Option<Edit> {
pub(crate) fn type_box(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, info: &DocInfo, author: &str) -> Option<Edit> {
let tb = view.fill_text.clone()?;
let xf = view.page_xform(tb.page)?;
let v = info.pages.get(tb.page)?.user_to_view(tb.at[0] as f32, tb.at[1] as f32);
@@ -678,7 +679,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
t.focus = false;
}
// Taken here: the same press must not also leave the tool (one step back per press).
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
if keys.take(ui.ctx(), egui::Key::Escape) {
cancel = true;
} else if r.lost_focus() {
commit = true;
+34 -31
View File
@@ -16,6 +16,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform, SignTab};
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::page_pointer::PagePointer;
use crate::page_router::{Hit, Owner, PagePress};
@@ -1112,11 +1113,26 @@ fn step_radio(view: &mut DocView, f: &FormField, widget: usize, forward: bool) -
edit
}
/// A focused radio button's arrow keys: → or ↓ steps to the next button in its group (`true`), ← or
/// ↑ to the one before.
fn arrow_step(ctx: &egui::Context, keys: PageKeys) -> Option<bool> {
let pressed = |a, b| keys.pressed(ctx, a) || keys.pressed(ctx, b);
if pressed(egui::Key::ArrowRight, egui::Key::ArrowDown) {
Some(true)
} else if pressed(egui::Key::ArrowLeft, egui::Key::ArrowUp) {
Some(false)
} else {
None
}
}
/// The in-place editor or option list for the focused field. Returns an edit to apply.
/// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor
/// then takes no more than fits.
#[allow(clippy::too_many_arguments)]
pub(crate) fn overlay(
ctx: &egui::Context,
keys: PageKeys,
view: &mut DocView,
info: &DocInfo,
form: &[FormField],
@@ -1213,9 +1229,9 @@ pub(crate) fn overlay(
let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), frame);
turned_caret_and_paint(ui, &r, rect, frame, laid_out.as_ref(), caret_placed, font);
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)
});
let (enter, esc, tab) =
(keys.pressed(ctx, egui::Key::Enter), keys.pressed(ctx, egui::Key::Escape), keys.pressed(ctx, egui::Key::Tab));
let shift = ui.input(|i| i.modifiers.shift);
if esc {
close = true;
} else if tab {
@@ -1256,7 +1272,7 @@ pub(crate) fn overlay(
);
}
let mut st = view.forms.focus.as_ref().map(|x| x.list).unwrap_or_default();
let list = DropDownList { view: view.view_key(), field: f, widget: w, rect, zoom, keys: !editable_combo(f) };
let list = DropDownList { view: view.view_key(), field: f, widget: w, rect, zoom, keys: !editable_combo(f), page_keys: keys };
let (chosen, resp) = choice_list(ctx, list, &mut st);
if let Some(fx) = view.forms.focus.as_mut() {
fx.list = st;
@@ -1266,8 +1282,8 @@ pub(crate) fn overlay(
close = true;
}
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)) {
let (tab, shift) = (keys.pressed(ctx, egui::Key::Tab), ctx.input(|i| i.modifiers.shift));
if keys.pressed(ctx, egui::Key::Escape) {
close = true;
} else if tab {
next = Some(!shift);
@@ -1277,16 +1293,10 @@ pub(crate) fn overlay(
}
}
FormFieldKind::List => {
let (up, down, esc, tab, shift) = ctx.input_mut(|i| {
(
// Consumed, so the arrows move the selection without also scrolling the page.
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
i.key_pressed(egui::Key::Escape),
i.key_pressed(egui::Key::Tab),
i.modifiers.shift,
)
});
// Taken, so the arrows move the selection without also scrolling the page.
let (up, down) = (keys.take(ctx, egui::Key::ArrowUp), keys.take(ctx, egui::Key::ArrowDown));
let (esc, tab) = (keys.pressed(ctx, egui::Key::Escape), keys.pressed(ctx, egui::Key::Tab));
let shift = ctx.input(|i| i.modifiers.shift);
if esc {
close = true;
} else if tab {
@@ -1323,20 +1333,11 @@ pub(crate) fn overlay(
has_focus = r.has_focus();
});
view.forms.keys = Some((id, f.kind == FormFieldKind::Radio));
let (esc, tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift));
let space = has_focus && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Space));
let (esc, tab) = (keys.pressed(ctx, egui::Key::Escape), keys.pressed(ctx, egui::Key::Tab));
let shift = ctx.input(|i| i.modifiers.shift);
let space = has_focus && keys.take(ctx, egui::Key::Space);
let arrow = has_focus && f.kind == FormFieldKind::Radio;
let step = ctx.input(|i| {
if !arrow {
None
} else if i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::ArrowDown) {
Some(true)
} else if i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowUp) {
Some(false)
} else {
None
}
});
let step = if arrow { arrow_step(ctx, keys) } else { None };
if esc || clicked_outside() {
close = true;
} else if tab {
@@ -1376,6 +1377,8 @@ struct DropDownList<'a> {
/// Whether the list takes the arrow keys and Enter: not for an editable combo box, whose
/// text box has them.
keys: bool,
/// The pages' keys, which the list's are: none while a dialog or a menu is over the pages.
page_keys: PageKeys,
}
/// Items a drop-down's list shows before it scrolls.
@@ -1388,7 +1391,7 @@ const LIST_ROWS: usize = 9;
/// and the list's response. With `keys` off (an editable combo box, whose text box has the keys)
/// only the pointer moves through and picks from it.
fn choice_list(ctx: &egui::Context, list: DropDownList<'_>, st: &mut ChoiceList) -> (Option<usize>, egui::InnerResponse<()>) {
let DropDownList { view, field: f, widget: w, rect, zoom, keys } = list;
let DropDownList { view, field: f, widget: w, rect, zoom, keys, page_keys } = list;
let n = f.options.len();
let shown = n.min(LIST_ROWS);
let max_first = n.saturating_sub(shown);
@@ -1405,7 +1408,7 @@ fn choice_list(ctx: &egui::Context, list: DropDownList<'_>, st: &mut ChoiceList)
st.placed = true;
}
// Consumed, so the keys move through the list rather than the page.
let key = |k| keys && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k));
let key = |k| keys && page_keys.take(ctx, k);
let moved = if n == 0 {
None
} else if key(egui::Key::ArrowUp) {
+6 -3
View File
@@ -8,6 +8,7 @@ use pdfcraft_render::{DocInfo, LinkTarget};
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::page_router::{Hit, Owner};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, QuickTool, widgets};
@@ -215,12 +216,14 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info:
}
}
/// Delete removes the selected link; Escape clears the selection.
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) {
/// Delete removes the selected link; Escape clears the selection. Not while something is over the
/// pages.
pub(crate) fn keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView) {
if view.links.selected.is_none() || ctx.egui_wants_keyboard_input() {
return;
}
let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape)));
let del = keys.pressed(ctx, egui::Key::Delete) || keys.pressed(ctx, egui::Key::Backspace);
let esc = keys.pressed(ctx, egui::Key::Escape);
if del && let Some((page, index)) = view.links.selected.take() {
view.pending_edit = Some(Edit::DeleteLink { page, index });
} else if esc {
+3 -3
View File
@@ -331,7 +331,7 @@ pub(crate) fn page_input(
}
// Keys belong to a focused text field (the label, unit or viewport name) when there is one.
let typing = ui.ctx().egui_wants_keyboard_input();
if !typing && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if !typing && cx.keys.pressed(ui.ctx(), egui::Key::Escape) {
state.cancel();
}
let screen = |p: Point| {
@@ -391,10 +391,10 @@ pub(crate) fn page_input(
}
if state.page == Some(page) && !state.points.is_empty() {
let double = clicks.double && takes_clicks(state);
if (double || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) {
if (double || !typing && cx.keys.pressed(ui.ctx(), egui::Key::Enter)) && matches!(tool, Tool::Perimeter | Tool::Area) {
finish(state, &mut view.pending_edit, doc, page, tool, author, units);
}
if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) {
if !typing && cx.keys.pressed(ui.ctx(), egui::Key::Backspace) {
state.points.pop();
}
let mut points = state.points.clone();
+30 -29
View File
@@ -9,6 +9,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::page_router::{Hit, ObjectsPart, Owner, PagePress};
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
@@ -116,7 +117,7 @@ impl ObjectSelection {
self.select_paragraph = Some((page, block));
}
pub fn prepare(&mut self, doc: &Document, enabled: bool, ctx: &egui::Context) {
pub fn prepare(&mut self, doc: &Document, enabled: bool, ctx: &egui::Context, keys: PageKeys) {
self.hold_editor = false;
if !enabled {
self.clear();
@@ -155,7 +156,7 @@ impl ObjectSelection {
// A refused edit keeps the generation and the original selection.
self.pending = None;
}
if self.page.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
if self.page.is_some() && keys.take(ctx, egui::Key::Escape) {
self.clear();
}
// After the Escape check: the Esc that closed the editor must not clear what it selected.
@@ -337,8 +338,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut D
&& view.pending_edit.is_none()
&& !ui.ctx().egui_wants_keyboard_input()
{
let deleted =
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace));
let deleted = cx.keys.take(ui.ctx(), egui::Key::Delete) || cx.keys.take(ui.ctx(), egui::Key::Backspace);
if deleted {
view.pending_edit = Edit::delete_objects(page, view.objects.selected.iter().map(|o| o.target).collect());
return true;
@@ -487,7 +487,7 @@ fn on_frame(r: Rect, p: Pos2) -> bool {
/// The arrow keys move the selected image or objects a point, Shift and an arrow ten points,
/// as in Acrobat. They are taken before the view turns the page with them.
pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document) {
pub(crate) fn nudge_keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, doc: &Document) {
if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() {
return;
}
@@ -497,26 +497,23 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document
let Some(xf) = view.page_xform(page) else { return };
use egui::Key;
let mut step = egui::Vec2::ZERO;
ctx.input_mut(|i| {
// Only an arrow pressed alone or with Shift, judged by each press's own modifiers (the
// frame's can differ when a key is let go in the same frame). Alt+← / Alt+→ go back and
// forward through the view history (#3632) and Ctrl/⌘+← / → turn the page, both in
// `canvas::shortcuts`.
i.events.retain(|e| {
let egui::Event::Key { key, pressed: true, modifiers, .. } = e else { return true };
if modifiers.alt || modifiers.ctrl || modifiers.command || modifiers.mac_cmd {
return true;
}
let direction = match key {
Key::ArrowLeft => egui::vec2(-1.0, 0.0),
Key::ArrowRight => egui::vec2(1.0, 0.0),
Key::ArrowUp => egui::vec2(0.0, -1.0),
Key::ArrowDown => egui::vec2(0.0, 1.0),
_ => return true,
};
step += direction * if modifiers.shift { 10.0 } else { 1.0 };
false
});
// Only an arrow pressed alone or with Shift, judged by each press's own modifiers (the
// frame's can differ when a key is let go in the same frame). Alt+← / Alt+→ go back and
// forward through the view history (#3632) and Ctrl/⌘+← / → turn the page, both in
// `canvas::shortcuts`.
keys.take_presses(ctx, |key, modifiers| {
if modifiers.alt || modifiers.ctrl || modifiers.command || modifiers.mac_cmd {
return false;
}
let direction = match key {
Key::ArrowLeft => egui::vec2(-1.0, 0.0),
Key::ArrowRight => egui::vec2(1.0, 0.0),
Key::ArrowUp => egui::vec2(0.0, -1.0),
Key::ArrowDown => egui::vec2(0.0, 1.0),
_ => return false,
};
step += direction * if modifiers.shift { 10.0 } else { 1.0 };
true
});
if step == egui::Vec2::ZERO {
return;
@@ -544,13 +541,13 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document
/// Delete (or Backspace) removes the selected objects, as in Acrobat: a paragraph selected by its
/// frame, or everything an area selection took, goes at once instead of one typed character at a
/// time.
pub(crate) fn delete_keys(ctx: &egui::Context, view: &mut DocView) {
pub(crate) fn delete_keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView) {
if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() || view.objects.gesture.is_some() {
return;
}
let Some(page) = view.objects.page.filter(|_| !view.objects.selected.is_empty()) else { return };
use egui::{Key, Modifiers};
if !ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)) {
use egui::Key;
if !(keys.take(ctx, Key::Delete) || keys.take(ctx, Key::Backspace)) {
return;
}
// The selection stays until the edit applies: `prepare` clears it when the document changes,
@@ -659,7 +656,11 @@ mod tests {
},
|ui| {
let resp = ui.interact(ui.available_rect_before_wrap(), egui::Id::unique("page"), egui::Sense::click());
page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, PagePress::Free, &mut view);
page_input(
PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info, keys: Default::default() },
PagePress::Free,
&mut view,
);
},
);
output.textures_delta.clear();
+26 -26
View File
@@ -13,6 +13,7 @@ use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageToolContext, PageXform};
use crate::comments;
use crate::page_cursor::Layer;
use crate::page_keys::PageKeys;
use crate::page_router::{Hit, Owner, PagePress};
use crate::theme;
use crate::widgets;
@@ -368,12 +369,12 @@ pub(crate) fn page_input(
press: PagePress,
view: &mut DocView,
) -> Outcome {
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
let PageToolContext { ui, resp, xf, ptr, page, info, keys, .. } = cx;
let mut out = Outcome { consumed: false, properties: false, placed: false, table: None };
let pointer = ptr.pos;
let prep = &mut view.prepare;
if prep.marquee_before.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
if prep.marquee_before.is_some() && keys.take(ui.ctx(), egui::Key::Escape) {
cancel_marquee(prep);
out.consumed = true;
return out;
@@ -753,7 +754,7 @@ pub(crate) fn delete_selected(view: &mut DocView) -> Option<Edit> {
/// The arrow keys move the selected field a point, Shift and an arrow ten points, as in Acrobat,
/// as the object nudge does (`object_ui::nudge_keys`) and as dragging it does (#2608). The keys
/// are consumed before the view turns the page with them; one press is one undo step.
pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document) {
pub(crate) fn nudge_keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView, doc: &Document) {
if ctx.egui_wants_keyboard_input() || view.prepare.grab.is_some() {
return;
}
@@ -763,25 +764,22 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document
let Some(xf) = view.page_xform(page) else { return };
use egui::Key;
let mut step = egui::Vec2::ZERO;
ctx.input_mut(|i| {
// Only an arrow pressed alone or with Shift, judged by each press's own modifiers (the
// frame's can differ when a key is let go in the same frame). Alt+← / Alt+→ go back and
// forward through the view history (#3632) and Alt+↑ / ↓ is Calculation order's move.
i.events.retain(|e| {
let egui::Event::Key { key, pressed: true, modifiers, .. } = e else { return true };
if modifiers.alt || modifiers.ctrl || modifiers.command || modifiers.mac_cmd {
return true;
}
let direction = match key {
Key::ArrowLeft => egui::vec2(-1.0, 0.0),
Key::ArrowRight => egui::vec2(1.0, 0.0),
Key::ArrowUp => egui::vec2(0.0, -1.0),
Key::ArrowDown => egui::vec2(0.0, 1.0),
_ => return true,
};
step += direction * if modifiers.shift { 10.0 } else { 1.0 };
false
});
// Only an arrow pressed alone or with Shift, judged by each press's own modifiers (the
// frame's can differ when a key is let go in the same frame). Alt+← / Alt+→ go back and
// forward through the view history (#3632) and Alt+↑ / ↓ is Calculation order's move.
keys.take_presses(ctx, |key, modifiers| {
if modifiers.alt || modifiers.ctrl || modifiers.command || modifiers.mac_cmd {
return false;
}
let direction = match key {
Key::ArrowLeft => egui::vec2(-1.0, 0.0),
Key::ArrowRight => egui::vec2(1.0, 0.0),
Key::ArrowUp => egui::vec2(0.0, -1.0),
Key::ArrowDown => egui::vec2(0.0, 1.0),
_ => return false,
};
step += direction * if modifiers.shift { 10.0 } else { 1.0 };
true
});
if step == egui::Vec2::ZERO {
return;
@@ -819,21 +817,23 @@ fn cancel_marquee(prep: &mut PrepareView) {
}
}
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) {
/// Delete removes the selected fields and Escape deselects them, while no text field has focus and
/// nothing is over the pages.
pub(crate) fn keys(ctx: &egui::Context, keys: PageKeys, view: &mut DocView) {
if ctx.egui_wants_keyboard_input() {
return;
}
if view.prepare.marquee_before.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
if view.prepare.marquee_before.is_some() && keys.take(ctx, egui::Key::Escape) {
cancel_marquee(&mut view.prepare);
return;
}
if view.prepare.selected.is_none() {
return;
}
let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape)));
let del = keys.pressed(ctx, egui::Key::Delete) || keys.pressed(ctx, egui::Key::Backspace);
if del {
view.pending_edit = delete_selected(view);
} else if esc {
} else if keys.pressed(ctx, egui::Key::Escape) {
view.prepare.selected = None;
view.prepare.also.clear();
}
+369
View File
@@ -5322,6 +5322,375 @@ fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() {
assert_eq!(h.state().quick_tool, QuickTool::Select);
}
/// A square drawn and selected with the Select tool.
fn selected_square() -> Harness<'static, PdfCraftApp> {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (250.0, 20.0));
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
h
}
/// Delete or Backspace with a dialog open is the dialog's: the comment selected behind it stays.
/// Both deleted it.
#[test]
fn delete_under_a_dialog_leaves_the_selected_comment() {
use pdfcraft_ui_egui::Dialog;
for (dialog, key) in [(Dialog::Shortcuts, egui::Key::Delete), (Dialog::About, egui::Key::Backspace)] {
let mut h = selected_square();
h.state_mut().dialog = Some(dialog);
h.run_steps(3);
h.key_press(key);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "{key:?} under a dialog deletes nothing");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "and the comment stays selected");
}
}
/// Escape with a dialog open closes the dialog and nothing else: the comment stays selected. It
/// was deselected too.
#[test]
fn escape_under_a_dialog_closes_it_and_keeps_the_selected_comment() {
let mut h = selected_square();
h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::Shortcuts);
h.run_steps(3);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "and nothing else");
}
/// With a menu of the menu bar open, Delete is the menu's: the comment stays. It was deleted.
#[test]
fn delete_with_a_menu_open_leaves_the_selected_comment() {
let mut h = selected_square();
crate::support::open_menu(&mut h, "View");
assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open");
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment was still selected for the Delete");
}
/// Edit ▸ Delete removes the selected comment: the key a menu click stands for is not under the
/// menu it came from.
#[test]
fn edit_delete_removes_the_selected_comment() {
let mut h = selected_square();
crate::support::open_menu(&mut h, "Edit");
crate::support::menu_row(&h, "Delete").click();
h.run_steps(4);
assert!(comments(&h).is_empty());
}
/// The Escape that closes a dialog opened from a menu while a comment is being typed is the
/// dialog's: the typing stays, in the composer or posted. It was thrown away.
#[test]
fn escape_closing_a_dialog_over_a_comment_being_typed_keeps_the_typing() {
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
click_pt(&mut h, (250.0, 180.0));
field(&h, "Add a comment").click();
h.run_steps(2);
h.event(egui::Event::Text("First line".into()));
h.run_steps(2);
crate::support::open_shortcuts_from_the_help_menu(&mut h);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
let open = h.state().views[0].comments.composer.as_ref().is_some_and(|c| c.text.contains("First line"));
let posted = comments(&h).iter().any(|c| c.contents.as_deref().is_some_and(|t| t.contains("First line")));
assert!(open || posted, "the typing stays");
}
/// With a menu of the menu bar open, the arrows are the menu's: the selected comment stays put.
#[test]
fn an_arrow_with_a_menu_open_leaves_the_selected_comment_where_it_is() {
let mut h = selected_square();
let before = comments(&h)[0].rect;
crate::support::open_menu(&mut h, "View");
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_eq!(comments(&h)[0].rect, before, "not moved");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment was still selected for the arrow");
}
/// Edit ▸ Delete from a native menu with the comment's own menu still open removes the comment:
/// the click never reaches the window, so Edit ▸ Delete closes the popup, as a click anywhere else
/// would, before its key joins the pages'. The popup held the Delete.
#[test]
fn edit_delete_with_a_popup_open_removes_the_selected_comment() {
let mut h = selected_square();
let p = at(&h, 90.0, 70.0);
h.hover_at(p);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the comment's menu is open");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert!(comments(&h).is_empty(), "Edit ▸ Delete removed the comment");
}
/// A native menu's click never reaches the window, so Edit ▸ Delete closes the Hotbar tools window,
/// as it closes egui's popups, and removes the selected comment. The window held the Delete.
#[test]
fn edit_delete_from_a_native_menu_with_the_hotbar_window_open_removes_the_comment() {
let mut h = selected_square();
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "the Hotbar tools window is open");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is still selected");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert!(h.query_by_label("Close menu").is_none(), "Edit ▸ Delete closed the Hotbar tools window");
assert!(comments(&h).is_empty(), "Edit ▸ Delete removed the comment");
}
/// Edit ▸ Delete goes to a text field that has the keyboard, as the user's Delete does: with the
/// Hotbar tools window's search field focused, a native Edit ▸ Delete leaves the window open and
/// deletes in the field. Closed, the window took the field with it, and the Delete removed the
/// selected comment. (A native click on Edit ▸ Copy, Cut, Paste or Select all reaches a focused
/// field as craft-menu's tap of the shortcut, not as a menu command.)
#[test]
fn edit_delete_from_a_native_menu_into_the_hotbar_window_search_keeps_the_window() {
let mut h = selected_square();
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "the Hotbar tools window is open");
let search = |h: &Harness<'static, PdfCraftApp>, text: &str| h.query_all_by_role(Role::TextInput).any(|n| n.value().as_deref() == Some(text));
assert!(search(&h, ""), "the search field is empty");
h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("")).expect("the search field").click();
h.run_steps(2);
h.event(egui::Event::Text("pen".into()));
h.run_steps(2);
h.key_press(egui::Key::ArrowLeft);
h.run_steps(2);
assert!(search(&h, "pen"), "typed into the search field");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "Edit ▸ Delete closed the Hotbar tools window");
assert!(search(&h, "pe"), "Edit ▸ Delete deleted in the search field");
assert_eq!(comments(&h).len(), 1, "Edit ▸ Delete removed the comment");
}
/// Full screen hides the hotbar and closes its tools window, which doesn't come back with the
/// hotbar: back from full screen, a Delete on the pass that shows the hotbar again removes the
/// selected comment with nothing over it. The window came back, drawn after that pass's keys.
#[test]
fn full_screen_closes_the_hotbar_tools_window() {
let mut h = selected_square();
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "the Hotbar tools window is open");
assert!(h.state_mut().execute("view.full_screen"));
h.run_steps(4);
assert!(h.state().full_screen, "full screen");
// Back, as a native menu's click is, between passes.
assert!(h.state_mut().execute("view.full_screen"));
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert!(!h.state().full_screen, "back from full screen");
assert!(h.query_by_label("Close menu").is_none(), "the Hotbar tools window came back");
assert!(comments(&h).is_empty(), "Delete removed the comment");
}
/// A square drawn and selected, then the update prompt a check at start shows when it finds a
/// newer release (the check waits until the square is selected).
fn selected_square_under_the_update_prompt() -> Harness<'static, PdfCraftApp> {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let found = Arc::new(AtomicBool::new(false));
let answer = found.clone();
let mut h = harness(move |app| {
app.set_startup_update_check(true);
app.set_update_source(Arc::new(move || {
// Not forever: a test that fails before the square is selected leaves no thread behind.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !answer.load(Ordering::SeqCst) {
if std::time::Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
Ok(Some(craft_update::Release {
tag: "v99.0.0".into(),
version: craft_update::parse_version("v99.0.0"),
html_url: "https://example.invalid/release".into(),
..Default::default()
}))
}));
app.set_option("quick", "square").unwrap();
});
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (250.0, 20.0));
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
found.store(true, Ordering::SeqCst);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while h.query_by_label_contains("is available").is_none() {
h.run_steps(2);
assert!(std::time::Instant::now() < deadline, "timed out waiting for the update prompt");
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.run_steps(2);
h
}
/// The update prompt is over the window, in full screen too: Delete under it leaves the selected
/// comment, and the Escape that closes it (Later) leaves it selected. Both acted on the comment.
#[test]
fn keys_under_the_update_prompt_leave_the_selected_comment() {
for full_screen in [false, true] {
let mut h = selected_square_under_the_update_prompt();
if full_screen {
assert!(h.state_mut().execute("view.full_screen"));
h.run_steps(4);
assert!(h.state().full_screen && h.query_by_label_contains("is available").is_some(), "full screen, the prompt over it");
}
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "Delete under the update prompt deleted the comment (full screen: {full_screen})");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.query_by_label_contains("is available").is_none(), "Escape closed the prompt");
assert_eq!(h.state().full_screen, full_screen, "the Escape that closed the prompt is the prompt's alone");
let selected = h.state().views[0].comments.selected;
assert_eq!(selected, Some((0, 0)), "Escape under the update prompt deselected the comment (full screen: {full_screen})");
}
}
/// The update prompt leaves the menus on, but covers the pages as a dialog does: Edit ▸ Delete from
/// a native menu under it leaves the selected comment, which the prompt hides.
#[test]
fn edit_delete_from_a_native_menu_under_the_update_prompt_leaves_the_comment() {
let mut h = selected_square_under_the_update_prompt();
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert_eq!(comments(&h).len(), 1, "Edit ▸ Delete under the update prompt deleted the comment");
assert!(h.query_by_label_contains("is available").is_some(), "the prompt is still open");
}
/// "Open in Full Screen mode?", asked for a document that wants it, leaves the menus on as the
/// update prompt does, and covers the pages as it does, in full screen too: neither the user's
/// Delete nor Edit ▸ Delete from a native menu removes the comment selected in another document
/// under it, and the Escape that answers the prompt is the prompt's alone.
#[test]
fn edit_delete_from_a_native_menu_under_the_full_screen_prompt_leaves_the_comment() {
for full_screen in [false, true] {
let mut h = selected_square();
if full_screen {
assert!(h.state_mut().execute("view.full_screen"));
h.run_steps(4);
assert!(h.state().full_screen, "full screen");
}
let deck = String::from_utf8(TEXT_FIXTURE.to_vec()).unwrap().replace("/Type /Catalog", "/Type /Catalog /PageMode /FullScreen").into_bytes();
h.state_mut().open_bytes("deck.pdf", None, deck).expect("the deck opens");
h.run_steps(3);
assert!(h.state().pending_full_screen.is_some(), "the full-screen prompt is open");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::with_params("window.document", serde_json::json!({ "index": 0 })));
h.run_steps(3);
assert_eq!(h.state().active, Some(0), "Window ▸ the first document switched to it");
assert!(h.state().pending_full_screen.is_some(), "the full-screen prompt is still open");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is still selected");
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "Delete under the full-screen prompt deleted the comment (full screen: {full_screen})");
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert_eq!(comments(&h).len(), 1, "Edit ▸ Delete under the full-screen prompt deleted the comment (full screen: {full_screen})");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().pending_full_screen.is_none(), "Escape answered the prompt");
assert_eq!(h.state().full_screen, full_screen, "the Escape that answered the prompt is the prompt's alone");
}
}
/// The OCR Verify screen takes the pages' place, and the pages' own chords don't reach them under
/// it: ⌘A selects none of the hidden page's text, and ⌘= leaves its zoom. Both acted on the
/// hidden page, and stayed after the screen closed.
#[test]
fn chords_on_the_ocr_verify_screen_leave_the_hidden_page() {
let mut h = harness(|_| {});
h.state_mut().ocr_verify.open = true;
h.run_steps(3);
assert!(h.state().views[0].selected_text().is_none(), "nothing is selected");
let zoom = h.state().views[0].zoom;
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(3);
assert!(h.state().views[0].selected_text().is_none(), "⌘A on the OCR Verify screen selected the hidden page's text");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Equals);
h.run_steps(3);
assert_eq!(h.state().views[0].zoom, zoom, "⌘= on the OCR Verify screen zoomed the hidden page");
h.state_mut().ocr_verify.open = false;
h.run_steps(3);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(3);
assert!(h.state().views[0].selected_text().is_some(), "with the screen closed, ⌘A selects the page's text");
}
/// The comment card's "…" menu is drawn from the button on the selected card. Undo with the menu
/// open deselects the card, and egui kept the menu open with nowhere to show it: an open popup,
/// which held every page key, H included.
#[test]
fn undo_with_a_card_menu_open_leaves_the_page_keys() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
drag_pt(&mut h, (170.0, 180.0), (270.0, 120.0));
assert_eq!(comments(&h).len(), 2);
h.state_mut().set_option("quick", "select").unwrap();
h.state_mut().right = Some(pdfcraft_ui_egui::RightPanel::Comments);
h.run_steps(3);
click_pt(&mut h, (250.0, 20.0));
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
h.get_by_label("More").click();
h.run_steps(2);
assert!(h.query_by_label("Set status").is_some(), "the card's menu is shown");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "Undo took the second square back");
assert_eq!(h.state().views[0].comments.selected, None, "and deselected the first");
assert!(!egui::Popup::is_any_open(&h.ctx), "the menu closed with its card's button");
h.key_press(egui::Key::H);
h.run_steps(2);
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
}
/// ⌘C with a menu of the menu bar open is the menu's: the selected comment isn't copied.
#[test]
fn copy_with_a_menu_open_copies_no_comment() {
let mut h = selected_square();
support::open_menu(&mut h, "View");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::C);
h.run_steps(3);
assert!(h.state().comment_clipboard.is_none(), "⌘C with a menu open copied the comment");
}
/// The OCR Verify screen takes the window: an arrow there leaves the comment selected on the
/// hidden page where it is. It moved it.
#[test]
fn an_arrow_on_the_ocr_verify_screen_leaves_the_selected_comment() {
let mut h = selected_square();
let before = comments(&h)[0].rect;
h.state_mut().ocr_verify.open = true;
h.run_steps(3);
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is still selected");
assert_eq!(comments(&h)[0].rect, before, "the hidden comment moved");
}
/// #5767: Make Properties Default on a text box or callout keeps its font size too, so the next
/// one (also after a restart) takes it, along with the colour.
#[test]
+52
View File
@@ -1718,3 +1718,55 @@ fn rail_page_and_zoom_fields_match_and_fit_the_rail() {
assert!((r.center().x - rail.center().x).abs() < 1.0, "{r:?} is off the rail's centre ({rail:?})");
}
}
/// Added text, typed and selected with Done.
fn selected_added_text() -> Harness<'static, PdfCraftApp> {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 40.0, 300.0);
click(&mut h, p);
h.event(egui::Event::Text("Reviewed".into()));
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(5);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)));
h
}
/// Delete and Escape with a dialog open are the dialog's: the added text selected behind it stays
/// and stays selected. Delete removed it, and Escape deselected it as it closed the dialog.
#[test]
fn delete_and_escape_under_a_dialog_leave_the_selected_added_text() {
for key in [egui::Key::Delete, egui::Key::Escape] {
let mut h = selected_added_text();
h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::Shortcuts);
h.run_steps(3);
h.key_press(key);
h.run_steps(3);
assert_eq!(added(&h).len(), 1, "{key:?} under a dialog deletes nothing");
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "{key:?}: still selected");
assert_eq!(h.state().dialog.is_none(), key == egui::Key::Escape, "{key:?}: only Escape closes it");
}
}
/// The Escape that closes a dialog opened from a menu while added text is being typed is the
/// dialog's: the typing stays, in the editor or added to the page. It was thrown away.
#[test]
fn escape_closing_a_dialog_over_added_text_being_typed_keeps_the_typing() {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 40.0, 300.0);
click(&mut h, p);
h.event(egui::Event::Text("Reviewed".into()));
h.run_steps(2);
assert!(h.state().views[0].content.draft.is_some(), "the editor is open");
crate::support::open_shortcuts_from_the_help_menu(&mut h);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
let open = h.state().views[0].content.draft.as_ref().is_some_and(|d| d.text.contains("Reviewed"));
let added = added(&h).iter().any(|a| matches!(&a.content, pdfcraft_engine::AddedContent::Text(t) if t.text.contains("Reviewed")));
assert!(open || added, "the typing stays");
}
+166
View File
@@ -11492,6 +11492,172 @@ fn affine_review_cropped_rotation_preview_matches_saved_corners() {
}
}
/// The Escape that closes a dialog opened from a menu while a paragraph is being typed is the
/// dialog's: the typing stays, in the open paragraph or applied to the page. It was thrown away.
#[test]
fn escape_closing_a_dialog_over_a_paragraph_being_typed_keeps_the_typing() {
let mut h = open_source_font_fixture();
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
let serif = egui::pos2(page.left() + 25.0 / 300.0 * page.width(), page.top() + (300.0 - 228.0) / 300.0 * page.height());
h.hover_at(serif);
h.run_steps(1);
h.drag_at(serif);
h.run_steps(1);
h.drop_at(serif);
h.run_steps(3);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open");
h.event(egui::Event::Text("typed ".into()));
h.run_steps(2);
crate::support::open_shortcuts_from_the_help_menu(&mut h);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
let s = h.state();
let open = s.views[0].line_editor.as_ref().is_some_and(|e| e.text.contains("typed"));
let applied = s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().any(|b| b.text.contains("typed"));
assert!(open || applied, "the typing stays");
}
/// Edit ▸ Select all from the in-window menu bar selects every page in Organize. The bar reads
/// as open on the pass after its click, and its ⌘A waits for the bar to let go of the keys, which
/// the chords leave to it: nothing was selected.
#[test]
fn edit_select_all_from_the_menu_bar_selects_every_page() {
let mut h = organize(3);
h.run_steps(2);
support::open_menu(&mut h, "Edit");
support::menu_row(&h, "Select all").click();
h.run_steps(4);
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2], "Edit ▸ Select all selected every page");
}
/// Edit ▸ Cut from the in-window menu bar cuts the selected page in Organize: its Cut waits for
/// the bar, which reads as open on the pass after its click, to let go of the keys.
#[test]
fn edit_cut_from_the_menu_bar_cuts_the_page() {
let mut h = organize(3);
h.run_steps(2);
h.get_by_label("Page 2").click();
h.run_steps(2);
support::open_menu(&mut h, "Edit");
support::menu_row(&h, "Cut").click();
h.run_steps(4);
assert!(h.state().page_clipboard.is_some(), "Edit ▸ Cut put the page on the clipboard");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.pages.len(), 2, "Edit ▸ Cut took the page out");
}
/// A native menu's Edit ▸ Paste with a page's context menu open closes the menu, which the click
/// never reached, and the platform's answer pastes the page, also when it comes late (on Wayland
/// egui-winit reads the clipboard asynchronously, within 3 seconds).
#[test]
fn a_late_paste_for_a_native_edit_paste_with_a_context_menu_open_pastes_the_page() {
let mut h = organize(3);
h.run_steps(2);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.event(egui::Event::Copy);
h.run_steps(3);
assert!(h.state().page_clipboard.is_some(), "the page is copied");
let pages = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().info.pages.len();
h.get_by_label("Page 2").click_button(egui::PointerButton::Secondary);
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the page's context menu is open");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.paste"));
h.step();
assert!(support::asked_for_the_clipboard(&h), "Edit ▸ Paste asked the platform for the clipboard");
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx), "Edit ▸ Paste closed the context menu");
h.event(egui::Event::Paste("clipboard text".into()));
h.run_steps(3);
assert_eq!(pages(&h), 4, "the late paste pasted the page");
}
/// Two native Edit ▸ Pastes with a page's context menu open each ask for the clipboard, and each
/// answer pastes the page (on Wayland they come one by one).
#[test]
fn two_native_edit_pastes_with_a_context_menu_open_paste_twice() {
let mut h = organize(3);
h.run_steps(2);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.event(egui::Event::Copy);
h.run_steps(3);
assert!(h.state().page_clipboard.is_some(), "the page is copied");
let pages = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().info.pages.len();
h.get_by_label("Page 2").click_button(egui::PointerButton::Secondary);
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the page's context menu is open");
let ctx = h.ctx.clone();
for _ in 0..2 {
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.paste"));
h.step();
assert!(support::asked_for_the_clipboard(&h), "Edit ▸ Paste asked the platform for the clipboard");
}
for pasted in [4, 5] {
h.event(egui::Event::Paste("clipboard text".into()));
h.run_steps(3);
assert_eq!(pages(&h), pasted, "each answer pastes the page");
}
}
/// A paste behind a page's context menu stays there, also one that answers an Edit ▸ Paste asked
/// for before the user opened the menu (egui-winit answers it as it answers ⌘V): it pastes no page.
#[test]
fn a_paste_behind_a_context_menu_opened_after_edit_paste_pastes_nothing() {
let mut h = organize(3);
h.run_steps(2);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.event(egui::Event::Copy);
h.run_steps(3);
assert!(h.state().page_clipboard.is_some(), "the page is copied");
let pages = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().info.pages.len();
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.paste"));
h.run_steps(2);
h.get_by_label("Page 2").click_button(egui::PointerButton::Secondary);
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the page's context menu is open");
h.event(egui::Event::Paste("clipboard text".into()));
h.run_steps(3);
assert_eq!(pages(&h), 3, "the paste behind the context menu pasted the page");
}
/// Edit ▸ Paste from the in-window menu bar pastes the copied page in Organize. The bar reads as
/// open on the pass after its click, so Edit ▸ Paste asks the platform for the clipboard once it
/// has let go of the keys, and the answer (kittest gives none, so the test does) comes to the
/// pages. An answer to a request sent with the click would come while the bar still held the keys.
#[test]
fn edit_paste_from_the_menu_bar_pastes_the_copied_page() {
let mut h = organize(3);
h.run_steps(2);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.event(egui::Event::Copy);
h.run_steps(3);
assert!(h.state().page_clipboard.is_some(), "the page is copied");
let pages = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().info.pages.len();
assert_eq!(pages(&h), 3);
support::open_menu(&mut h, "Edit");
support::menu_row(&h, "Paste").click();
h.step();
assert!(craft_menu::bar::is_open(&h.ctx), "the bar reads as open on the pass after the click");
assert!(!support::asked_for_the_clipboard(&h), "Edit ▸ Paste asked while the bar held the keys");
let mut asked = false;
for _ in 0..4 {
h.step();
asked |= support::asked_for_the_clipboard(&h);
}
assert!(asked, "Edit ▸ Paste asked the platform for the clipboard");
h.event(egui::Event::Paste("clipboard text".into()));
h.run_steps(4);
assert_eq!(pages(&h), 4, "Edit ▸ Paste pasted the page");
}
#[test]
fn a_modified_click_away_from_a_cropped_picture_applies_its_crop() {
// Shift-, Ctrl- or Command-clicking away is still a click away: it applies the crop and lets
@@ -615,3 +615,34 @@ fn a_newer_copy_elsewhere_wins_over_the_copied_objects() {
h.run_steps(4);
assert_eq!(can_undo(&h, 0).as_deref(), Some("Paste"));
}
/// A picture the platform answers a native Edit ▸ Paste with lands on the page, as a text answer
/// does, the context menu that was open closed by Edit ▸ Paste: here a picture copied since the
/// objects were.
#[test]
fn a_picture_for_a_native_edit_paste_with_a_context_menu_open_lands_on_the_page() {
let mut h = harness();
h.state_mut().system_clipboard = false;
keys(&mut h, Modifiers::COMMAND, Key::A);
keys(&mut h, Modifiers::COMMAND, Key::C);
assert!(h.state().object_clipboard.is_some(), "the objects are copied, so Edit ▸ Paste is on");
let p = at(&h, 55.0, 70.0);
h.hover_at(p);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the context menu is open");
let before = can_undo(&h, 0);
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.paste"));
h.step();
assert!(crate::support::asked_for_the_clipboard(&h), "Edit ▸ Paste asked the platform for the clipboard");
assert!(!egui::Popup::is_any_open(&h.ctx), "Edit ▸ Paste closed the context menu");
h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY))));
h.run_steps(3);
assert_ne!(can_undo(&h, 0), before, "the picture was dropped");
assert_eq!(can_undo(&h, 0).as_deref(), Some("Add image"), "the picture landed on the page");
}
+185
View File
@@ -2240,3 +2240,188 @@ fn a_camera_click_released_off_its_page_moves_nothing() {
h.run_steps(2);
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0));
}
/// Delete and Escape with a dialog open are the dialog's: the link selected behind it with the Link
/// tool stays and stays selected. Delete removed it, and Escape deselected it as it closed the
/// dialog.
#[test]
fn delete_and_escape_under_a_dialog_leave_the_selected_link() {
for key in [egui::Key::Delete, egui::Key::Escape] {
let mut h = open(link_pdf(), "150", false);
link_tool(&mut h);
let on_link = at(&h, 150.0, 325.0);
mouse_click(&mut h, on_link);
assert_eq!(h.state().views[0].links.selected, Some((0, 0)));
h.state_mut().dialog = Some(Dialog::Shortcuts);
h.run_steps(3);
h.key_press(key);
h.run_steps(3);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().links().len(), 1, "{key:?} under a dialog deletes nothing");
assert_eq!(s.views[0].links.selected, Some((0, 0)), "{key:?}: still selected");
assert_eq!(s.dialog.is_none(), key == egui::Key::Escape, "{key:?}: only Escape closes it");
}
}
/// Escape with a dialog open over Edit text objects that an area selected closes the dialog, and
/// the objects stay selected: Delete then removes them. Escape went to the objects instead, and the
/// dialog stayed open.
#[test]
fn escape_under_a_dialog_closes_it_over_selected_edit_text_objects() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
mouse_click(&mut h, egui::pos2(one.min.x + 60.0, one.max.y - 8.0));
mouse_drag(&mut h, egui::pos2(one.min.x + 60.0, two.min.y + 3.0), egui::vec2(200.0, 30.0));
let blocks = |h: &Harness<'_, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(1).len()
};
assert_eq!(blocks(&h), 1, "page 2's line is there");
h.state_mut().dialog = Some(Dialog::Shortcuts);
h.run_steps(3);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert_eq!(blocks(&h), 0, "the objects stayed selected: Delete removes them");
}
/// With a menu of the menu bar open, V and H are the menu's: the tool stays. V picked Select.
#[test]
fn the_tool_letters_with_a_menu_open_leave_the_tool() {
for letter in [egui::Key::V, egui::Key::H] {
let mut h = open(link_pdf(), "150", false);
link_tool(&mut h);
crate::support::open_menu(&mut h, "Edit");
h.key_press(letter);
h.run_steps(3);
assert_eq!(h.state().quick_tool, QuickTool::Link, "{letter:?} with a menu open");
}
}
/// With a menu of the menu bar open, Delete leaves Edit text objects that an area selected: the key
/// is the menu's. It deleted them.
#[test]
fn delete_with_a_menu_open_leaves_selected_edit_text_objects() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
mouse_click(&mut h, egui::pos2(one.min.x + 60.0, one.max.y - 8.0));
mouse_drag(&mut h, egui::pos2(one.min.x + 60.0, two.min.y + 3.0), egui::vec2(200.0, 30.0));
let blocks = |h: &Harness<'_, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(1).len()
};
assert_eq!(blocks(&h), 1, "page 2's line is there");
crate::support::open_menu(&mut h, "View");
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert_eq!(blocks(&h), 1, "Delete with a menu open deletes nothing");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx), "Escape closed the menu");
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert_eq!(blocks(&h), 0, "the objects stayed selected, and Delete with the menu closed removes them");
}
/// With a menu of the menu bar open, End is the menu's: the view stays on its page. It went to the
/// last page.
#[test]
fn end_with_a_menu_open_turns_no_page() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
let current = h.state().views[0].current;
crate::support::open_menu(&mut h, "View");
h.key_press(egui::Key::End);
h.run_steps(4);
assert_eq!(h.state().views[0].current, current, "End with a menu open turns no page");
}
/// With a menu of the menu bar open, Delete leaves the picture selected with Edit text: the key is
/// the menu's. It deleted the picture.
#[test]
fn delete_with_a_menu_open_leaves_the_selected_picture() {
let mut h = open(linked_picture_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
h.run_steps(3);
crate::support::open_menu(&mut h, "View");
h.key_press(egui::Key::Delete);
h.run_steps(3);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "nothing was deleted");
assert!(s.views[0].image_selection.is_some(), "and the picture is still selected");
}
/// The Hotbar tools window, left open when full screen hides it, holds no keys there: the arrows
/// still turn the page. It held every page key until the window was drawn again.
#[test]
fn a_hotbar_window_hidden_by_full_screen_holds_no_keys() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.state_mut().execute("view.full_screen"));
h.run_steps(4);
assert!(h.state().full_screen, "full screen");
let before = h.state().views[0].current;
h.key_press(egui::Key::ArrowRight);
h.run_steps(4);
assert_eq!(h.state().views[0].current, before + 1, "→ turns the page");
}
/// Full screen entered as a native menu's click is, between passes, closes the Hotbar tools window
/// on the pass it hides it: → on that pass turns the page. The window, drawn on the pass before,
/// held it.
#[test]
fn full_screen_from_a_native_menu_with_the_hotbar_window_open_leaves_the_keys_to_the_page() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "the Hotbar tools window is open");
let before = h.state().views[0].current;
assert!(h.state_mut().execute("view.full_screen"));
h.key_press(egui::Key::ArrowRight);
h.run_steps(4);
assert!(h.state().full_screen, "full screen");
assert_eq!(h.state().views[0].current, before + 1, "→ turns the page");
}
/// With a menu of the menu bar open, the view's chords wait, as the registered commands' do: ⌘=
/// zooms nothing. It zoomed the view behind the menu.
#[test]
fn a_view_chord_with_a_menu_open_waits() {
// (A chord on the OCR Verify screen: comments::chords_on_the_ocr_verify_screen_leave_the_hidden_page.)
let mut h = open(bottom_line_pages_pdf(), "100", false);
let before = h.state().views[0].zoom;
crate::support::open_menu(&mut h, "View");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Equals);
h.run_steps(3);
assert_eq!(h.state().views[0].zoom, before, "⌘= with a menu open zooms nothing");
}
/// In Organize Pages, a Cut with a menu of the menu bar open is the menu's: the pages stay. It cut
/// the current page.
#[test]
fn a_cut_with_a_menu_open_leaves_organize_pages_alone() {
let mut h = open(bottom_line_pages_pdf(), "100", false);
h.state_mut().views[0].organize = true;
h.run_steps(4);
crate::support::open_menu(&mut h, "View");
h.event(egui::Event::Cut);
h.run_steps(4);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().info.pages.len(), 2, "no page was cut");
}
+23
View File
@@ -2079,3 +2079,26 @@ fn with_prepare_open_a_double_click_on_a_field_leaves_the_polygon_unfinished() {
assert_eq!(polygons, 0, "the polygon is not finished");
assert!(matches!(s.views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { .. })), "it is still being clicked out");
}
/// Delete and Escape with a dialog open are the dialog's: the field selected behind it stays and
/// stays selected. Delete removed it, and Escape deselected it as it closed the dialog.
#[test]
fn delete_and_escape_under_a_dialog_leave_the_selected_field() {
for key in [egui::Key::Delete, egui::Key::Escape] {
let mut h = harness();
let before = names(&h).len();
assert!(h.state_mut().execute("form.add.text"));
h.run_steps(2);
let (a, b) = (at(&h, 40.0, 300.0), at(&h, 200.0, 322.0));
drag(&mut h, a, b);
h.run_steps(2);
assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|s| s.0.as_str()), Some("Text1"));
h.state_mut().dialog = Some(Dialog::Shortcuts);
h.run_steps(3);
h.key_press(key);
h.run_steps(3);
assert_eq!(names(&h).len(), before + 1, "{key:?} under a dialog deletes nothing");
assert!(h.state().views[0].prepare.selected.is_some(), "{key:?}: still selected");
assert_eq!(h.state().dialog.is_none(), key == egui::Key::Escape, "{key:?}: only Escape closes it");
}
}
+15
View File
@@ -59,6 +59,11 @@ pub fn open_menu(h: &mut Harness<'static, PdfCraftApp>, title: &str) {
h.run_steps(2);
}
/// Whether the last frame asked the platform for the clipboard (`RequestPaste`, Edit ▸ Paste's).
pub fn asked_for_the_clipboard(h: &Harness<'static, PdfCraftApp>) -> bool {
h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::RequestPaste))
}
/// The menu row labelled `label` in an open menu.
pub fn menu_row<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> {
use egui_kittest::kittest::Queryable;
@@ -113,3 +118,13 @@ pub fn skip_without_catalogs(name: &str) -> bool {
}
skip
}
/// Open Help ▸ Keyboard shortcuts with a click, as a user would, so whatever had the keyboard loses
/// it as it does then.
pub fn open_shortcuts_from_the_help_menu(h: &mut Harness<'static, PdfCraftApp>) {
use egui_kittest::kittest::Queryable;
open_menu(h, "Help");
h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with("Keyboard shortcuts"))).click();
h.run_steps(4);
assert!(h.state().dialog.is_some(), "Keyboard shortcuts is open");
}
+60
View File
@@ -2631,6 +2631,66 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() {
assert!(h.state().views[0].selected.is_empty(), "Ctrl+A now belongs to the viewer, not page thumbnails");
}
/// Delete over the Pages panel with a menu of the menu bar open is the menu's, and the Escape that
/// closes a thumbnail's context menu the menu's: no page is deleted, and the page selection stays.
/// Delete deleted the selected pages, and the panel took the Escape: it dropped the selection and
/// left the menu open. (The in-window bar takes an Escape with a menu open before the panel is
/// drawn, and a dialog takes the panel's keyboard.)
#[test]
fn delete_with_a_menu_open_and_escape_closing_a_thumbnail_menu_leave_the_pages_panel() {
let mut h = harness(&[("panel", "pages")]);
h.run_steps(2);
// The first ⌘-click on another page picks it and the current page.
h.get_by_label("Page 2").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
let selected = h.state().views[0].selected.clone();
assert_eq!(selected.iter().copied().collect::<Vec<_>>(), [0, 1], "⌘-click picked pages 1 and 2");
let thumb = h.get_by_label("Page 2").rect().center();
crate::support::open_menu(&mut h, "View");
h.hover_at(thumb);
h.run_steps(1);
assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open");
h.key_press(Key::Delete);
h.run_steps(3);
let pages = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().info.pages.len();
assert_eq!(pages(&h), 3, "Delete with a menu open deleted pages");
assert_eq!(h.state().views[0].selected, selected, "Delete with a menu open dropped the page selection");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx), "Escape closed the menu");
// The thumbnail's own context menu, the panel keeping the keyboard: Escape closes the menu.
h.hover_at(thumb);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: thumb, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the thumbnail's menu is open");
assert_eq!(h.state().views[0].selected, selected, "the right-click kept the page selection");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx), "Escape closed the thumbnail's menu");
assert_eq!(h.state().views[0].selected, selected, "the Escape that closed the thumbnail's menu dropped the page selection");
assert_eq!(pages(&h), 3);
}
/// Full screen entered over the OCR Verify screen draws the page alone, and its keys are the
/// pages': Escape leaves it. The hidden screen still counted as over the pages, so only ⌘L did.
#[test]
fn escape_leaves_full_screen_entered_over_the_ocr_verify_screen() {
let mut h = harness(&[]);
h.state_mut().ocr_verify.open = true;
h.run_steps(3);
h.key_press_modifiers(Modifiers::COMMAND, Key::L);
h.run_steps(4);
assert!(h.state().full_screen, "⌘L entered full screen");
h.key_press(Key::Escape);
h.run_steps(4);
assert!(!h.state().full_screen, "Escape left full screen");
assert!(h.state().ocr_verify.open, "the OCR Verify screen is back");
}
/// Three 300×400 pt pages; the second comes in with a one-second Wipe and stays one second
/// (`/Trans`, `/Dur`), the others have neither.
const PRESENTATION: &[u8] = b"%PDF-1.7