mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:04:03 +00:00
fix(ui): keys under a dialog or a menu leave the pages alone (keyboard routing, part 1) (#6608)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user