Issue #870: keys leave the document under a dialog alone (#898)

* Issue #870: keys leave the document under a dialog alone

With Preferences (or any other dialog) open, the registered shortcuts still
ran on the document underneath it: ⌘W / Ctrl+W closed the file, ⌘Z undid an
edit, ⌘F opened Find. In Organize Pages the grid's own keys did too, so Delete
or Backspace deleted the selected pages behind the dialog, and the grid took
the Escape meant to close it.

Every modal now owns the keyboard, as "Save changes?" already did:
PdfCraftApp::modal_open covers the dialog, the save, password and link
prompts and the update check. Shortcuts stop there, and the organize grid's
keys and autoscroll wait until the page is unobstructed.

* Contributors: credit jay-tau by their GitHub profile name
This commit is contained in:
Joel Tony 2026-10-11 02:34:14 +05:30 committed by GitHub
parent 603cefe213
commit f45fd7507e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 65 additions and 9 deletions

View File

@ -71,3 +71,6 @@ sync_github_name = true
[people.Hathor875]
real_name = "Krzysztof Cieślik"
sync_github_name = true
[people.jay-tau]
sync_github_name = true

View File

@ -1772,7 +1772,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
None => {}
}
// No dialog, close prompt or palette over the page: only then does page input count.
let unobstructed = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open;
let unobstructed = !app.modal_open() && !app.palette_open;
let view = &mut app.views[index];
if view.organize {
organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t);
return;
@ -3273,7 +3274,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
}
/// 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, ui: &mut egui::Ui, t: &Tokens) {
fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, 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));
@ -3379,8 +3380,9 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b
});
});
});
// Keys act on the selection unless a text field has focus.
if editable && !ui.ctx().egui_wants_keyboard_input() {
// 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| {
(
@ -3431,16 +3433,16 @@ fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: Strin
resp.on_hover_text(label).clicked()
}
fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, auto_scroll_enabled: bool, ui: &mut egui::Ui, t: &Tokens) {
fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) {
let ppp = ui.ctx().pixels_per_point();
// The page image scales with the zoom; the padding and the page number don't.
let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 56.0);
let anchor = view.grid_anchor.take();
let mut open_page = None;
organize_toolbar(view, info, editable, dirty, ui, t);
organize_toolbar(view, info, editable, dirty, unobstructed, ui, t);
let viewport = ui.available_rect_before_wrap();
view.viewport_screen = viewport;
let auto_delta = if auto_scroll_enabled {
let auto_delta = if unobstructed {
view.auto_scroll.update(ui, viewport, true)
} else {
view.auto_scroll.cancel();

View File

@ -1194,6 +1194,12 @@ impl PdfCraftApp {
self.active.and_then(|i| self.views.get(i).map(|v| (i, v.id)))
}
/// 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 {
self.dialog.is_some() || self.close_request.is_some() || self.password_prompt.is_some() || self.pending_link.is_some() || self.updates.open
}
/// Enable the UI control channel on `ctx` (opt-in; see [`control`]). Returns a client that
/// sends requests to this app; [`control::serve`] exposes it on loopback.
pub fn attach_control(&mut self, ctx: &egui::Context) -> control::ControlClient {
@ -1769,6 +1775,11 @@ impl PdfCraftApp {
}
return;
}
// The other dialogs and prompts are modal too (⌘W closed the file behind Preferences,
// #870).
if self.modal_open() {
return;
}
if let Some(view) = self.active.and_then(|i| self.views.get_mut(i))
&& view.auto_scroll.escape(ctx)
{
@ -1958,7 +1969,7 @@ impl eframe::App for PdfCraftApp {
self.shortcuts(ctx);
// Scrolling is transient: never resume after changing tabs, opening a modal/palette,
// or returning to a window that lost focus.
let blocked = self.dialog.is_some() || self.close_request.is_some() || self.palette_open || !ctx.input(|i| i.focused);
let blocked = self.modal_open() || self.palette_open || !ctx.input(|i| i.focused);
for (index, view) in self.views.iter_mut().enumerate() {
if blocked || self.active != Some(index) {
view.auto_scroll.cancel();

View File

@ -6,7 +6,7 @@ use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
use pdfcraft_ui_egui::{CloseRequest, PdfCraftApp};
use pdfcraft_ui_egui::{CloseRequest, Dialog, PdfCraftApp};
/// An `n`-page document with a proper xref table; page `i` shows "Page i+1".
fn fixture(n: usize) -> Vec<u8> {
@ -356,6 +356,46 @@ fn the_save_prompt_answers_to_the_keyboard() {
let _ = std::fs::remove_file(out);
}
#[test]
fn keys_leave_the_document_under_a_dialog_alone() {
// Issue #870: ⌘W with Preferences open closed the file underneath it, and in the page grid
// Delete deleted the selected page. While a dialog is open its keys are its own.
let mut h = organize(3);
h.get_by_label("Page 2").click();
h.run_steps(2);
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
assert!(h.state_mut().execute("app.preferences"));
h.run_steps(3);
let shift = Modifiers::COMMAND | Modifiers::SHIFT;
for (m, key) in [
(Modifiers::COMMAND, Key::W),
(shift, Key::W),
(Modifiers::COMMAND, Key::Z),
(Modifiers::COMMAND, Key::D),
(Modifiers::COMMAND, Key::F),
(Modifiers::NONE, Key::Delete),
(Modifiers::NONE, Key::Backspace),
] {
h.key_press_modifiers(m, key);
h.run_steps(3);
let app = h.state();
assert_eq!(app.views.len(), 1, "{m:?}+{key:?} closed the file");
assert!(app.close_request.is_none(), "{m:?}+{key:?} asked to close the file");
assert!(app.session.get(app.views[0].id).unwrap().can_undo().is_some(), "{m:?}+{key:?} undid the rotation");
assert!(app.views[0].find.is_none(), "{m:?}+{key:?} opened Find");
assert_eq!(app.dialog, Some(Dialog::Preferences), "{m:?}+{key:?} replaced Preferences");
}
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3"], "a page was deleted under the dialog");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes Preferences");
assert_eq!(h.state().views[0].target_pages(), [1], "Escape went to the dialog, not the page selection");
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
assert!(h.state().close_request.is_some(), "with the dialog closed, ⌘W closes the file again");
}
#[test]
fn clean_tabs_close_without_asking() {
let mut h = harness(1, |_| {});