M4/M1: Drag page thumbnails to reorder (selection or the grabbed page, insertion bar, one undo step); damaged files say they were repaired, and Document Properties ▸ Advanced lists the repairs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 03:22:58 -04:00
co-authored by Claude Opus 5.5
parent 9fcece8afb
commit 09c1404d95
5 changed files with 140 additions and 10 deletions
+73 -2
View File
@@ -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<Vec<usize>>,
/// Fill & Sign text being typed.
pub fill_text: Option<crate::fill_sign::TypeBox>,
/// 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<usize> {
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<usize> = 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<RenderRequest> = (0..info.pages.len())
+12
View File
@@ -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();
}
}
})
});
+24
View File
@@ -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::<Vec<_>>(), [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<usize> = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![2], "the moved page stays selected");
}
+23
View File
@@ -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");
}
+8 -8
View File
@@ -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"