From 09c1404d95860d15ec8a91cc430ce42aafc00142 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 03:22:58 -0400 Subject: [PATCH] =?UTF-8?q?M4/M1:=20Drag=20page=20thumbnails=20to=20reorde?= =?UTF-8?q?r=20(selection=20or=20the=20grabbed=20page,=20insertion=20bar,?= =?UTF-8?q?=20one=20undo=20step);=20damaged=20files=20say=20they=20were=20?= =?UTF-8?q?repaired,=20and=20Document=20Properties=20=E2=96=B8=20Advanced?= =?UTF-8?q?=20lists=20the=20repairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 75 ++++++++++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 12 ++++++ crates/ui-egui/tests/editing.rs | 24 +++++++++++ crates/ui-egui/tests/viewer.rs | 23 ++++++++++ parity/acrobat-features.toml | 16 +++---- 5 files changed, 140 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba622f94d..989595e59 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -152,6 +152,8 @@ pub struct DocView { pub crop_drag: crate::crop::CropDrag, /// Use a certificate: a signature rectangle being drawn, or an empty signature field clicked. pub sign: crate::sign_ui::SignView, + /// Organize: the pages being dragged to a new place. + pub org_drag: Option>, /// Fill & Sign text being typed. pub fill_text: Option, /// A non-edit action requested by the organize toolbar, handled by the app. @@ -233,6 +235,7 @@ impl DocView { pending_redaction: None, crop_drag: None, sign: Default::default(), + org_drag: None, fill_text: None, } } @@ -829,7 +832,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let view = &mut app.views[index]; // Opened without the owner password and something is restricted. let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); - match notices(view, info, secured, crate::sign_ui::banner(&doc.signatures), ui, &t) { + let repaired = !doc.repair_log().is_empty(); + match notices(view, info, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { + Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), None => {} @@ -1564,6 +1569,7 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: enum Notice { Security, Signatures, + Repairs, } /// The notice bar above the pages: the signature status first (Acrobat's signature bar), then @@ -1572,6 +1578,7 @@ fn notices( view: &mut DocView, info: &DocInfo, secured: bool, + repaired: bool, signed: Option<(&str, Color32, String)>, ui: &mut egui::Ui, t: &Tokens, @@ -1595,10 +1602,13 @@ fn notices( return None; } let mut open_security = false; + let mut open_repairs = false; let msg = if secured { Some(("lock", "This document is secured. Some changes are restricted by its security settings.".to_string(), false)) } else if !info.fields.is_empty() { Some(("text-cursor-input", format!("This document contains {} interactive form fields.", info.fields.len()), true)) + } else if repaired { + Some(("bandage", "This file was damaged and has been repaired. Saving keeps the repaired version.".to_string(), false)) } else if !info.warnings.is_empty() { Some(("triangle-alert", info.warnings[0].clone(), false)) } else { @@ -1622,9 +1632,15 @@ fn notices( if secured && crate::widgets::pill_button(ui, "Security settings", false).clicked() { open_security = true; } + if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, "Details", false).clicked() { + open_repairs = true; + } }); }); }); + if open_repairs { + return Some(Notice::Repairs); + } open_security.then_some(Notice::Security) } @@ -1870,11 +1886,19 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut /// Organize pages: a thumbnail grid (Acrobat's Organize Pages view). Click selects, ⌘-click /// toggles, ⇧-click extends; double-click opens the page. +/// The gap (0 = before the first page, n = after the last) the pointer points at in the grid. +fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option { + let (i, r) = cells.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(p).total_cmp(&b.distance_sq_to_pos(p)))?; + Some(if p.x < r.center().x { *i } else { i + 1 }) +} + fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable: bool, ui: &mut egui::Ui, t: &Tokens) { let ppp = ui.ctx().pixels_per_point(); let cell = vec2(190.0, 250.0); let mut open_page = None; organize_toolbar(view, info, editable, ui, t); + let mut cells: Vec<(usize, Rect)> = Vec::with_capacity(info.pages.len()); + let mut drop = false; egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.add_space(20.0); let cols = ((ui.available_width() - 40.0) / cell.x).floor().max(1.0) as usize; @@ -1886,7 +1910,21 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable let i = row * cols + col; let Some(p) = info.pages.get(i) else { break }; let c = Rect::from_min_size(pos2(row_rect.left() + left + col as f32 * cell.x, row_rect.top()), cell); - let resp = ui.interact(c, ui.id().with(("org", i)), Sense::click()); + let resp = ui.interact(c, ui.id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() }); + cells.push((i, c)); + // Drag pages to move them (the selection, or the page grabbed). + if resp.drag_started() { + if !view.selected.contains(&i) { + view.selected = [i].into(); + view.select_anchor = Some(i); + } + let mut pages: Vec = view.selected.iter().copied().collect(); + pages.sort_unstable(); + view.org_drag = Some(pages); + } + if resp.drag_stopped() { + drop = true; + } resp.widget_info(|| { egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), format!("Page {}", p.label)) }); @@ -1928,6 +1966,39 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable } } } + // While dragging: the gap the pages would go to, drawn as a bar. + if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) { + if let Some(gap) = drop_gap(&cells, p) { + let x = match cells.iter().find(|(i, _)| *i == gap) { + Some((_, r)) => r.left() + 3.0, + None => cells.last().map_or(0.0, |(_, r)| r.right() - 3.0), + }; + let row = cells.iter().find(|(i, _)| *i == gap).or(cells.last()).map(|(_, r)| r.y_range()).unwrap_or(egui::Rangef::new(0.0, 0.0)); + ui.painter().line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent)); + ui.painter().text( + p + vec2(14.0, 14.0), + Align2::LEFT_TOP, + format!("{} page{}", pages.len(), if pages.len() == 1 { "" } else { "s" }), + theme::medium(12.0), + t.accent_text, + ); + } + if drop { + view.org_drag = None; + if let Some(gap) = drop_gap(&cells, p) { + // `to` counts positions without the moving pages. + let to = gap - pages.iter().filter(|x| **x < gap).count(); + let first = pages[0]; + let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1); + if !(contiguous && to == first) { + view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to }); + view.selected = (to..to + pages.len()).collect(); + } + } + } + } else if drop { + view.org_drag = None; + } }); let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max); let queue: Vec = (0..info.pages.len()) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 682dcda3d..52e7ee8fb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -202,6 +202,18 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { row(ui, "Layers", i.layers.len().to_string()); row(ui, "Attachments", i.attachments.len().to_string()); row(ui, "JavaScript", yes(i.has_javascript)); + // What was repaired while reading a damaged file (fidelity: never silent). + let repairs = doc.repair_log(); + row(ui, "Repairs", if repairs.is_empty() { "None".to_string() } else { repairs.len().to_string() }); + if !repairs.is_empty() { + ui.label(""); + egui::CollapsingHeader::new("Repair log").show(ui, |ui| { + for r in &repairs { + ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); + } + }); + ui.end_row(); + } } }) }); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 416a70d08..026f79d1b 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -517,3 +517,27 @@ fn extract_options_and_rotate_pages_dialog() { let d = s.session.get(s.views[0].id).unwrap(); assert_eq!(d.info.pages.iter().map(|p| p.rotation).collect::>(), [90, 0]); } + +#[test] +fn dragging_thumbnails_reorders_pages() { + let mut h = organize(4); + let before = page_texts(h.state()); + let grab = |h: &Harness<'static, PrintCraftApp>, label: &str| h.get_by_label(label).rect(); + let (from, to) = (grab(&h, "Page 1").center(), grab(&h, "Page 3").right_center() - egui::vec2(10.0, 0.0)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(from + (to - from) * (k as f32 / 5.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(4); + // Page 1 now sits after page 3. + let after = page_texts(h.state()); + assert_eq!(after, vec![before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "{after:?}"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page")); + let sel: Vec = h.state().views[0].selected.iter().copied().collect(); + assert_eq!(sel, vec![2], "the moved page stays selected"); +} diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index f6c8f8b47..acc9aedb5 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -171,3 +171,26 @@ fn layouts_fit_height_labels_and_system_theme() { h.state_mut().set_option("theme", "system").unwrap(); assert!(h.state().follow_system_theme); } + +#[test] +fn damaged_files_say_they_were_repaired() { + // No cross-reference table: the file is reconstructed. + let damaged = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("damaged.pdf", None, damaged.clone()).unwrap(); + app + }); + h.run_steps(4); + h.get_by_label_contains("This file was damaged and has been repaired."); + h.get_by_label("Details").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Properties(printcraft_ui_egui::PropsTab::Advanced))); + h.get_by_label("Repair log"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 747e82a9d..e1033a274 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -55,11 +55,11 @@ title = "Record repairs and show them to the user" area = "A" tier = "P0" milestone = "M1" -status = "partial" +status = "shipped" commands = [] tools = ["doc_info"] -evidence = [] -notes = "doc_info reports repair notes; no repair log in the UI yet" +evidence = ["crates/ui-egui/tests/viewer.rs::damaged_files_say_they_were_repaired"] +notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them" [[feature]] id = "core.xref-streams" @@ -2587,11 +2587,11 @@ title = "Reorder pages by drag and drop" area = "D" tier = "P0" milestone = "M4" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "move-earlier/later buttons exist; drag in the grid is M4.7" +evidence = ["crates/ui-egui/tests/editing.rs::dragging_thumbnails_reorders_pages"] +notes = "drag thumbnails (or the selection) in the page grid; an insertion bar shows where they go; move earlier/later buttons too" [[feature]] id = "organize.insert-blank" @@ -3446,8 +3446,8 @@ milestone = "M5" status = "partial" commands = [] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text"] -notes = "appearances for every type PrintCraft creates (highlight multiplies); clouds, callouts, stamps and rich text are not drawn yet" +evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn"] +notes = "appearances for every type PrintCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets); rich text not yet" [[feature]] id = "comment.sticky-note"