Cancel stale Organize page drags after document changes (#4039)

* M4: Cancel stale Organize page drags after document changes

* M4: Record Organize drag cancellation coverage
This commit is contained in:
Ahmad Mu
2026-10-23 18:43:05 +04:00
committed by GitHub
parent e6b9792539
commit 44e8eeb289
5 changed files with 200 additions and 2 deletions
+3
View File
@@ -190,6 +190,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
@@ -638,6 +640,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
+2 -1
View File
@@ -876,8 +876,9 @@ impl DocView {
self.goto_point = None;
self.zoom_anchor = None;
self.flash = None;
// A thumbnail drag holds page indexes from before the change.
// Thumbnail drags hold page indexes from before the change.
self.panel_drag = None;
self.org_drag = None;
self.reset_coarse_state();
}
+1
View File
@@ -49,6 +49,7 @@ mod links;
mod marks;
mod object_selection;
mod ocr;
mod organize_drag;
mod pageboxes;
mod panel_snapshots;
mod pdf_links;
+192
View File
@@ -0,0 +1,192 @@
//! A document edit cancels an Organize drag before old page indices can be used.
use egui::{Modifiers, PointerButton, Pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::{AddedText, Edit, Session};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
use pdfcraft_ui_egui::PdfCraftApp;
fn document(prefix: &str) -> std::sync::Arc<Vec<u8>> {
// Use the engine's synthetic document builder rather than another PDF serializer.
let mut session = Session::new();
let bytes = session.create_blank(200.0, 300.0, 4).unwrap();
let id = session.open("pages.pdf", None, bytes, None).unwrap();
for page in 0..4 {
session
.apply(
id,
Edit::AddText {
page,
text: AddedText { text: format!("{prefix} {}", page + 1), rect: [20.0, 120.0, 180.0, 150.0], ..Default::default() },
},
)
.unwrap();
}
session.save_bytes(id).unwrap()
}
fn organize() -> Harness<'static, PdfCraftApp> {
let bytes = document("Page");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("pages.pdf", None, bytes.as_ref().clone()).unwrap();
app.set_option("organize", "on").unwrap();
app
});
h.run_steps(4);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 4"]);
h
}
fn page_texts(app: &PdfCraftApp) -> Vec<String> {
let doc = app.session.get(app.views[0].id).unwrap();
let mut renderer = PageRenderer::new(doc.bytes.clone(), Default::default());
(0..renderer.page_count())
.map(|page| {
renderer
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
.text
.unwrap()
.plain_text()
.trim()
.to_string()
})
.collect()
}
fn copy_modifier(h: &Harness<'static, PdfCraftApp>, copying: bool) -> Modifiers {
if !copying {
Modifiers::NONE
} else if h.ctx.os() == egui::os::OperatingSystem::Mac {
Modifiers::ALT
} else {
Modifiers::CTRL
}
}
fn start_drag(h: &mut Harness<'static, PdfCraftApp>, copying: bool) -> (Pos2, Modifiers) {
let from = h.get_by_label("Page 1").rect().center();
let to = h.get_by_label("Page 3").rect().right_center() - egui::vec2(10.0, 0.0);
let modifiers = copy_modifier(h, copying);
h.event(egui::Event::ModifiersChanged(modifiers));
h.hover_at(from);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers });
h.run_steps(1);
for step in 1..=5 {
h.hover_at(from + (to - from) * (step as f32 / 5.0));
h.run_steps(1);
}
(to, modifiers)
}
fn release(h: &mut Harness<'static, PdfCraftApp>, to: Pos2, modifiers: Modifiers) {
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
h.run_steps(4);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
fn assert_saved_order(h: &Harness<'static, PdfCraftApp>, expected: &[&str]) {
let app = h.state();
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
let mut reopened = PdfCraftApp::new();
reopened.open_bytes("saved.pdf", None, bytes.as_ref().clone()).unwrap();
assert_eq!(page_texts(&reopened), expected, "save/reopen preserves the post-edit order");
}
fn check_undo_cancels_drag(copying: bool) {
let mut h = organize();
assert!(h.state_mut().apply_edit(Edit::MovePages { pages: vec![0], to: 3 }));
h.run_steps(2);
assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]);
let (to, modifiers) = start_drag(&mut h, copying);
// The source index now belongs to Page 2. Undo changes it back to Page 1.
h.state_mut().undo();
h.run_steps(2);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 4"]);
h.hover_at(to - egui::vec2(1.0, 0.0));
h.run_steps(2);
release(&mut h, to, modifiers);
let expected = ["Page 1", "Page 2", "Page 3", "Page 4"];
assert_eq!(page_texts(h.state()), expected, "releasing the old drag must not move or copy a different page");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Move page"), "the cancelled gesture leaves Redo available");
assert_saved_order(&h, &expected);
h.state_mut().redo();
h.run_steps(2);
assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"], "the preserved Redo still applies the original edit");
h.state_mut().undo();
h.run_steps(2);
assert_eq!(page_texts(h.state()), expected);
// Cancellation must not disable the next ordinary drag or its history.
let (to, modifiers) = start_drag(&mut h, copying);
release(&mut h, to, modifiers);
let changed = if copying { vec!["Page 1", "Page 2", "Page 3", "Page 1", "Page 4"] } else { vec!["Page 2", "Page 3", "Page 1", "Page 4"] };
assert_eq!(page_texts(h.state()), changed);
h.state_mut().undo();
h.run_steps(2);
assert_eq!(page_texts(h.state()), expected);
h.state_mut().redo();
h.run_steps(2);
assert_eq!(page_texts(h.state()), changed);
assert_saved_order(&h, &changed);
}
#[test]
fn undo_cancels_an_organize_move_before_pointer_release() {
check_undo_cancels_drag(false);
}
#[test]
fn undo_cancels_an_organize_copy_before_pointer_release() {
check_undo_cancels_drag(true);
}
#[test]
fn redo_cancels_an_organize_drag_before_pointer_release() {
let mut h = organize();
assert!(h.state_mut().apply_edit(Edit::MovePages { pages: vec![0], to: 3 }));
h.state_mut().undo();
h.run_steps(2);
let (to, modifiers) = start_drag(&mut h, false);
h.state_mut().redo();
h.run_steps(2);
release(&mut h, to, modifiers);
let expected = ["Page 2", "Page 3", "Page 4", "Page 1"];
assert_eq!(page_texts(h.state()), expected, "Redo cancels the old drag even though its source index remains valid");
assert_saved_order(&h, &expected);
}
#[test]
fn deleting_a_page_cancels_an_organize_drag_before_pointer_release() {
let mut h = organize();
let (to, modifiers) = start_drag(&mut h, false);
assert!(h.state_mut().apply_edit(Edit::DeletePages { pages: vec![0] }));
h.run_steps(2);
release(&mut h, to, modifiers);
let expected = ["Page 2", "Page 3", "Page 4"];
assert_eq!(page_texts(h.state()), expected, "a page edit cancels the old drag before indices shift");
assert_saved_order(&h, &expected);
}
/// Replacement already starts a fresh view; valid old indices must not survive its reset.
#[test]
fn replacing_a_document_cancels_an_organize_drag_before_pointer_release() {
let mut h = organize();
let (to, modifiers) = start_drag(&mut h, false);
let bytes = document("Replacement");
let app = h.state_mut();
let id = app.views[0].id;
app.session.reload(id, bytes).unwrap();
let info = &app.session.get(id).unwrap().info;
app.views[0].document_replaced(info);
let expected = ["Replacement 1", "Replacement 2", "Replacement 3", "Replacement 4"];
assert_eq!(page_texts(h.state()), expected, "the new content replaces all four pages before release");
h.run_steps(2);
release(&mut h, to, modifiers);
assert_eq!(page_texts(h.state()), expected, "releasing the old drag must not reorder replacement pages");
assert_saved_order(&h, &expected);
}
+2 -1
View File
@@ -162,7 +162,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Organize drags cancel after document edits and Undo/Redo (#3916 partial). Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
@@ -239,6 +239,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
| 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. |
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
| 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. |