M4: delete pages from the Pages panel (#607)

Right-clicking a thumbnail offers Delete pages, and Delete or Backspace with the
pointer over the panel deletes too, as in the organize grid: the selection, or
the current page. A document keeps at least one page, so with every page
picked the item is off and the keys do nothing. The selection goes and the
current page moves to the page after the deleted ones; one undoable step.

Parity: view.thumbnail-context-ops notes and evidence include Delete pages.

Co-authored-by: William Max & Co. <info@williammax.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Max Abramowitz 2026-10-09 20:29:26 -04:00 committed by GitHub
parent 6e13a5fc54
commit c110c579a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 95 additions and 2 deletions

View File

@ -908,7 +908,17 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
{
view.clear_page_selection(Some(view.current));
}
// Delete (or Backspace) deletes the selection, or the current page, likewise.
if !modal
&& editable
&& !ui.ctx().egui_wants_keyboard_input()
&& 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))
{
delete_pages(view, info.pages.len());
}
let w = (ui.available_width() - 40.0).min(150.0);
let count = info.pages.len();
let mut rows: Vec<Rect> = Vec::with_capacity(info.pages.len());
let mut dropped = false;
for (i, p) in info.pages.iter().enumerate() {
@ -986,6 +996,13 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
view.pending_action = Some(crate::canvas::ViewAction::PastePages);
ui.close();
}
ui.separator();
let some_left = view.target_pages().len() < count;
// Never every page: a document keeps at least one.
if ui.add_enabled(editable && some_left, egui::Button::new(tl!("Delete pages"))).clicked() {
delete_pages(view, count);
ui.close();
}
});
});
ui.add_space(4.0);
@ -993,6 +1010,24 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
page_drag(ui, t, view, &rows, dropped);
}
/// Queue deleting the selection (or the current page) of a document of `n` pages, unless that
/// would leave no page. The selection goes; the current page stays on the page it showed, or on
/// the page that follows the deleted ones.
fn delete_pages(view: &mut crate::DocView, n: usize) {
let mut pages = view.target_pages();
pages.sort_unstable();
pages.dedup();
if pages.is_empty() || pages.len() >= n {
return;
}
// Deleted pages before it shift it up; if it was deleted itself, the page after the deleted
// run lands where it was (or the last page, when the deletion ran to the end).
let before = pages.iter().filter(|p| **p < view.current).count();
view.current = view.current.saturating_sub(before).min(n.saturating_sub(pages.len() + 1));
view.clear_page_selection(None);
view.pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages });
}
/// The gap (0 = before the first page, n = after the last) the pointer points at in the Pages
/// panel, whose thumbnails are `rows`, top to bottom.
fn panel_gap(rows: &[Rect], y: f32) -> Option<usize> {

View File

@ -2077,3 +2077,61 @@ fn a_thumbnail_drag_that_ends_off_the_panel_or_outlives_the_pages_is_dropped() {
h.state_mut().views[0].document_changed(&info);
assert!(h.state().views[0].panel_drag.is_none());
}
/// A Pages panel thumbnail (the rightmost node with that label).
fn panel_thumb(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 {
h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).expect("the thumbnail").center()
}
fn right_click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
#[test]
fn deleting_pages_from_the_pages_panel_menu() {
let mut h = pages_panel(4);
let at = panel_thumb(&h, "Page 2");
right_click_at(&mut h, at);
h.get_by_label("Delete pages").click();
h.run_steps(4);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3", "Page 4"]);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete page"));
assert!(h.state().views[0].selected.is_empty(), "the deleted pages leave the selection");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 4"]);
}
#[test]
fn the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page() {
let mut h = pages_panel(3);
// ⌘-click picks pages 2 and 3 (and the current page, 1: the first ⌘-click keeps it).
h.get_by_label("Page 2").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
let (p1, p3) = (panel_thumb(&h, "Page 1"), panel_thumb(&h, "Page 3"));
h.hover_at(p3);
h.run_steps(1);
assert_eq!(h.state().views[0].selected.iter().copied().collect::<Vec<_>>(), vec![0, 1, 2]);
// Every page picked: Delete does nothing, and the menu item is off.
h.key_press(Key::Delete);
h.run_steps(3);
assert_eq!(page_texts(h.state()).len(), 3, "a document keeps at least one page");
right_click_at(&mut h, p3);
assert!(h.query_by(|n| n.label().as_deref() == Some("Delete pages") && n.is_disabled()).is_some());
h.key_press(Key::Escape);
h.run_steps(2);
// Pages 2 and 3 picked (page 2 current): Delete deletes them, and page 1 becomes current.
h.state_mut().views[0].select_pages(&[1, 2]);
h.hover_at(p1);
h.run_steps(1);
h.key_press(Key::Delete);
h.run_steps(4);
assert_eq!(page_texts(h.state()), ["Page 1"]);
assert_eq!(h.state().views[0].current, 0);
}

View File

@ -1238,8 +1238,8 @@ milestone = "M4"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnails_have_a_context_menu"]
notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy and Paste after. Not yet: Rotate, Delete, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
evidence = ["crates/ui-egui/tests/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"]
notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
[[feature]]
id = "view.bookmarks-panel"