Pages panel: keep a short page current; context menu and drag to reorder (#573)

* View: the middle button pans instead of drawing with markup tools

egui's drag responses accept every pointer button, so on Windows and macOS a
middle-button drag reached the page tools: with Draw (ink) selected it drew a
stroke, and other tools cropped, selected or moved. Linux was unaffected
because its middle-click auto-scroll already blocks page input.

Outside Linux a middle-button drag that starts in the viewport (document or
Organize Pages) now pans 1:1 while the button is held, with a Grabbing
cursor, and page input is blocked from the press through the release. Linux
keeps its latched auto-scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: a short page gone to from the Pages panel stays current

The current page was whichever page showed most of itself. Going to a short
page (a cheque) above a tall one scrolls it to the top, where the tall page
below shows more, so the current page and the Pages panel's highlight moved
to the tall page instead of the one clicked. The page navigated to now stays
current while it is wholly on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: page operations from the Pages panel: context menu and drag to reorder

Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on
the selection or on the page clicked (which becomes the selection), as the
organize grid does. Dragging thumbnails moves the selection, or the page
grabbed, to the gap it is dropped on: a bar marks the gap, the page count
follows the pointer, and the panel scrolls near its edges. The move is one
undoable step; the moved pages stay selected and the current page follows
its page. Documents that forbid assembly keep click-only thumbnails.

Parity: view.thumbnail-context-ops is shipped, with what it still lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped

---------

Co-authored-by: Cody Vogt <Cody.Vogt@caltechsurveys.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: William Max & Co. <info@williammax.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Max Abramowitz
2026-10-09 14:24:32 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Cody Vogt William Max & Co. Brandon Thomas
parent a48e1a3ffe
commit d1b7e98698
5 changed files with 248 additions and 8 deletions
+11 -1
View File
@@ -285,6 +285,8 @@ pub struct DocView {
pub sign: crate::sign_ui::SignView,
/// Organize: the pages being dragged to a new place.
pub org_drag: Option<Vec<usize>>,
/// Pages panel: the pages being dragged to a new place.
pub panel_drag: Option<Vec<usize>>,
/// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one.
pub marquee: Option<(usize, Pos2)>,
pub marquee_done: Option<crate::zoom_snap::Marquee>,
@@ -417,6 +419,7 @@ impl DocView {
crop_drag: None,
sign: Default::default(),
org_drag: None,
panel_drag: None,
marquee: None,
marquee_done: None,
fill_text: None,
@@ -446,6 +449,8 @@ impl DocView {
self.goto_point = None;
self.zoom_anchor = None;
self.flash = None;
// A thumbnail drag holds page indexes from before the change.
self.panel_drag = None;
}
/// Pages an organize command acts on: the selection, or the current page.
@@ -1544,6 +1549,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let mut current = view.current;
let mut best_overlap = -1.0f32;
let mut current_overlap = -1.0f32;
let mut current_height = f32::INFINITY;
let pointer = ui.input(|i| i.pointer.hover_pos());
for &i in &visible_pages {
let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp);
@@ -1559,6 +1565,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let overlap = r.intersect(visible).height();
if i == view.current {
current_overlap = overlap;
current_height = r.height();
}
// Pages shown equally (two rows wholly on screen) differ only by rounding: the
// topmost one counts.
@@ -2006,7 +2013,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
// The page navigated to stays current while no page shows more of itself, so the next
// page step starts from it rather than from a page further down the screen (#188).
if current_overlap >= best_overlap - TIE {
// It also stays current while it is wholly on screen: a short page (a cheque) gone to
// from the Pages panel shows less of itself than the tall page below it, and must not
// hand the highlight to that page.
if current_overlap >= best_overlap - TIE || current_overlap >= current_height - TIE {
current = view.current;
}
if view.layout != PageLayout::Single {
+138 -4
View File
@@ -546,7 +546,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx);
}
}
RightPanel::Pages => pages(ui, &t, info, view, modal, &mut nav),
RightPanel::Pages => pages(ui, &t, info, view, modal, bm_editable, &mut nav),
RightPanel::Fields => fields(ui, &t, info, &doc.form, preparing, &mut nav, &mut panel_edit),
RightPanel::Layers => {
if info.layers.is_empty() {
@@ -895,8 +895,10 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
}
/// The page thumbnails. A click goes to the page; ⌘/Ctrl-click and ⇧-click pick several pages
/// (the selection page commands and Print act on) without moving the document.
fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, nav: &mut Option<Nav>) {
/// (the selection page commands and Print act on) without moving the document. Right-click
/// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging
/// moves the selection, or the page grabbed, to the gap it is dropped on.
fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocView, modal: bool, editable: bool, nav: &mut Option<Nav>) {
// Escape drops the selection while the pointer is over the panel and nothing else wants the key.
if !modal
&& !view.selected.is_empty()
@@ -907,10 +909,24 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
view.clear_page_selection(Some(view.current));
}
let w = (ui.available_width() - 40.0).min(150.0);
let mut rows: Vec<Rect> = Vec::with_capacity(info.pages.len());
let mut dropped = false;
for (i, p) in info.pages.iter().enumerate() {
ui.vertical_centered(|ui| {
let h = w * p.height / p.width.max(1.0);
let (rect, resp) = ui.allocate_exact_size(vec2(w + 16.0, h + 16.0), Sense::click());
let sense = if editable { Sense::click_and_drag() } else { Sense::click() };
let (rect, resp) = ui.allocate_exact_size(vec2(w + 16.0, h + 16.0), sense);
rows.push(rect);
// Only the primary button drags pages (the middle button scrolls, the secondary one opens the menu).
if resp.drag_started_by(egui::PointerButton::Primary) {
if !view.target_pages().contains(&i) {
view.click_page(i, egui::Modifiers::NONE, true);
}
view.panel_drag = Some(view.target_pages());
}
if resp.drag_stopped() {
dropped = true;
}
let info = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]);
let picked = view.selected.contains(&i);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, picked, info.clone()));
@@ -945,9 +961,98 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
*nav = Some(Nav::Page(i));
}
}
resp.context_menu(|ui| {
// A page outside the selection becomes the selection, as in the organize grid.
if !view.target_pages().contains(&i) {
view.click_page(i, egui::Modifiers::NONE, true);
}
if ui.button(tl!("Extract pages")).clicked() {
view.pending_action = Some(crate::canvas::ViewAction::Extract);
ui.close();
}
ui.separator();
if ui.add_enabled(editable, egui::Button::new(tl!("Cut"))).clicked() {
view.pending_action = Some(crate::canvas::ViewAction::CopyPages { cut: true });
ui.close();
}
if ui.button(tl!("Copy")).clicked() {
view.pending_action = Some(crate::canvas::ViewAction::CopyPages { cut: false });
ui.close();
}
if ui.add_enabled(editable, egui::Button::new(tl!("Paste after"))).clicked() {
view.pending_action = Some(crate::canvas::ViewAction::PastePages);
ui.close();
}
});
});
ui.add_space(4.0);
}
page_drag(ui, t, view, &rows, dropped);
}
/// 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> {
let (i, r) = rows.iter().enumerate().min_by(|(_, a), (_, b)| (a.center().y - y).abs().total_cmp(&(b.center().y - y).abs()))?;
Some(if y < r.center().y { i } else { i + 1 })
}
/// Where page `p` goes when `moving` (sorted) move to `to` (counted without them).
fn moved_index(p: usize, moving: &[usize], to: usize) -> usize {
match moving.iter().position(|m| *m == p) {
Some(k) => to + k,
None => {
let rest = p.saturating_sub(moving.iter().filter(|m| **m < p).count());
if rest >= to { rest + moving.len() } else { rest }
}
}
}
/// While thumbnails are dragged: a bar on the gap they would go to, the page count by the
/// pointer, and scrolling near the panel's edges. On release, queue the move.
fn page_drag(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, rows: &[Rect], dropped: bool) {
let Some(mut moving) = view.panel_drag.clone() else { return };
// A drag that ended while the panel was closed (or elsewhere) leaves nothing to drop.
if !dropped && !ui.input(|i| i.pointer.primary_down()) {
view.panel_drag = None;
return;
}
let pointer = ui.input(|i| i.pointer.interact_pos());
let gap = pointer.and_then(|p| panel_gap(rows, p.y));
if let (Some(gap), Some(first), Some(last)) = (gap, rows.first(), rows.last()) {
let y = rows.get(gap).map_or(last.bottom() + 20.0, |r| r.top() - 3.0);
ui.painter().line_segment([pos2(first.left(), y), pos2(first.right(), y)], Stroke::new(3.0, t.accent));
}
if let Some(p) = pointer {
let n = moving.len();
let what = if n == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &n.to_string())]) };
ui.painter().text(p + vec2(14.0, 14.0), Align2::LEFT_TOP, what, theme::medium(12.0), t.accent_text);
let clip = ui.clip_rect();
let edge = 40.0;
if p.y < clip.top() + edge {
ui.scroll_with_delta(vec2(0.0, 10.0));
} else if p.y > clip.bottom() - edge {
ui.scroll_with_delta(vec2(0.0, -10.0));
}
ui.ctx().request_repaint();
}
if !dropped {
return;
}
view.panel_drag = None;
let Some(gap) = gap else { return };
moving.sort_unstable();
moving.dedup();
let Some(&first) = moving.first() else { return };
// `to` counts positions without the moving pages.
let to = gap.saturating_sub(moving.iter().filter(|x| **x < gap).count());
let contiguous = moving.windows(2).all(|w| w[1] == w[0] + 1);
if contiguous && to == first {
return;
}
view.current = moved_index(view.current, &moving, to);
view.selected = (to..to + moving.len()).collect();
view.pending_edit = Some(pdfcraft_engine::Edit::MovePages { pages: moving, to });
}
/// The Fields panel: fields by page, in tab order. While preparing a form, each field can move
@@ -1043,6 +1148,35 @@ pub fn human_size(n: usize) -> String {
}
}
#[cfg(test)]
mod page_moves {
use super::{moved_index, panel_gap};
use egui::{Rect, pos2};
/// Above a thumbnail's middle is the gap before it; below is the gap after it.
#[test]
fn the_gap_follows_the_nearest_thumbnail() {
let rows: Vec<Rect> = (0..3).map(|i| Rect::from_min_max(pos2(0.0, i as f32 * 100.0), pos2(50.0, i as f32 * 100.0 + 90.0))).collect();
assert_eq!(panel_gap(&rows, -20.0), Some(0));
assert_eq!(panel_gap(&rows, 30.0), Some(0));
assert_eq!(panel_gap(&rows, 60.0), Some(1));
assert_eq!(panel_gap(&rows, 260.0), Some(3));
assert_eq!(panel_gap(&rows, 900.0), Some(3));
assert_eq!(panel_gap(&[], 10.0), None);
}
/// The current page follows a move, whether it moved or others moved around it.
#[test]
fn pages_keep_their_identity_across_a_move() {
// [0 1 2 3 4], move [1 2] to the end: [0 3 4 1 2].
let order: Vec<usize> = (0..5).map(|p| moved_index(p, &[1, 2], 3)).collect();
assert_eq!(order, [0, 3, 4, 1, 2]);
// Move [3] to the front: [3 0 1 2 4].
let order: Vec<usize> = (0..5).map(|p| moved_index(p, &[3], 0)).collect();
assert_eq!(order, [1, 2, 3, 0, 4]);
}
}
#[cfg(test)]
mod label_column {
use super::ellipsized_prefix;
+37
View File
@@ -1898,3 +1898,40 @@ fn print_shortcut_offers_the_pages_picked_in_the_pages_panel() {
h.get_by_label("Selected pages (2)");
h.get_by_label("Sheet 1 of 2");
}
#[test]
fn dragging_pages_panel_thumbnails_reorders_pages() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
let before = page_texts(h.state());
// The Pages panel is on the right; the document view may label its pages too.
let thumb = |h: &Harness<'static, PdfCraftApp>, label: &str| {
h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).expect("the thumbnail")
};
// Grab page 3 (page 4 is below the window) and drop it above page 1.
let (from, to) = (thumb(&h, "Page 3").center(), thumb(&h, "Page 1").center_top() + egui::vec2(0.0, 4.0));
drag(&mut h, from, to);
h.run_steps(4);
let after = page_texts(h.state());
assert_eq!(after, vec![before[2].clone(), before[0].clone(), before[1].clone(), before[3].clone()], "{after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page"));
let view = &h.state().views[0];
assert_eq!(view.selected.iter().copied().collect::<Vec<_>>(), vec![0], "the moved page stays selected");
assert!(view.panel_drag.is_none());
}
#[test]
fn a_thumbnail_drag_that_ends_off_the_panel_or_outlives_the_pages_is_dropped() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
let before = page_texts(h.state());
// A drag left over (the panel closed mid-drag, the button released elsewhere) moves nothing.
h.state_mut().views[0].panel_drag = Some(vec![2]);
h.run_steps(2);
assert!(h.state().views[0].panel_drag.is_none());
assert_eq!(page_texts(h.state()), before);
// A change to the document drops page indexes taken before it.
h.state_mut().views[0].panel_drag = Some(vec![3]);
let id = h.state().views[0].id;
let info = h.state().session.get(id).unwrap().info.clone();
h.state_mut().views[0].document_changed(&info);
assert!(h.state().views[0].panel_drag.is_none());
}
+59
View File
@@ -676,3 +676,62 @@ fn page_down_steps_every_spread_when_several_fit_on_screen() {
assert_eq!(up, [9, 7, 5, 3, 1, 0], "{fit:?}");
}
}
/// Open `sizes` in Fit width, continuous, with the Pages panel showing.
fn pages_panel_harness(sizes: &[(u32, u32)]) -> Harness<'static, PdfCraftApp> {
let bytes = sized_fixture(sizes);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("cheques.pdf", None, bytes).unwrap();
app.right = Some(pdfcraft_ui_egui::RightPanel::Pages);
app
});
h.run_steps(6);
h.state_mut().active = Some(0);
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
h.run_steps(6);
h
}
/// A thumbnail in the Pages panel (the panel lists pages as "Page n" buttons).
fn thumbnail(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 {
h.get_all_by_label(label).find(|n| n.rect().left() > 1000.0).expect("the Pages panel thumbnail").rect().center()
}
/// Press and release `button` at `at`.
fn press(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2, button: egui::PointerButton) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: Default::default() });
}
#[test]
fn a_short_page_gone_to_from_the_pages_panel_stays_current() {
// A short page (a cheque) above a tall one: going to it scrolls it to the top, where the tall
// page below shows more of itself. The current page, and the Pages panel's highlight, moved
// to the tall page.
let mut h = pages_panel_harness(&[(600, 250), (600, 1800), (600, 250), (600, 1800)]);
for page in [1, 0, 2] {
let at = thumbnail(&h, &format!("Page {}", page + 1));
press(&mut h, at, egui::PointerButton::Primary);
h.run_steps(6);
assert_eq!(h.state().views[0].current, page, "clicked page {}", page + 1);
}
}
#[test]
fn pages_panel_thumbnails_have_a_context_menu() {
let mut h = pages_panel_harness(&[(200, 300); 4]);
let at = thumbnail(&h, "Page 3");
press(&mut h, at, egui::PointerButton::Secondary);
h.run_steps(2);
// The page right-clicked becomes the selection the menu acts on.
assert_eq!(h.state().views[0].target_pages(), vec![2]);
for item in ["Extract pages", "Cut", "Paste after"] {
h.get_by_label(item);
}
h.get_by_label("Copy").click();
h.run_steps(3);
assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![2]));
}
+3 -3
View File
@@ -1235,11 +1235,11 @@ title = "Page operations from the thumbnails panel (drag reorder, context menu)"
area = "B"
tier = "P1"
milestone = "M4"
status = "planned"
status = "shipped"
commands = []
tools = []
evidence = []
notes = ""
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."
[[feature]]
id = "view.bookmarks-panel"