From bfa6db7bd39975fade5180404e5639dea47b9fa0 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 05:53:41 -0500 Subject: [PATCH] M4: a cut too deep to copy leaves the bookmarks in place (#3994) A cut copies the subtree, then deletes it. When the copy is truncated (bookmarks nested past the listing depth), paste cannot bring back what was left out, so the cut now keeps the copy, deletes nothing and says why. Test: cutting_a_subtree_too_deep_to_copy_keeps_it (40-level synthetic outline); fails when the truncated copy is allowed to delete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/editing.rs | 24 ++++++++++-------- crates/ui-egui/tests/it/bookmarks.rs | 37 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f2b20f39ed..879025b01d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -981,12 +981,15 @@ impl PdfCraftApp { self.copy_bookmark(id, &path); return; } - A::Cut(path) => { - if !self.copy_bookmark(id, &path) { + A::Cut(path) => match self.copy_bookmark(id, &path) { + Some(false) => Edit::DeleteBookmark { path }, + // Paste could not bring back what the copy left out, so the bookmarks stay. + Some(true) => { + self.notify_tr("Bookmarks nested too deep to list could not be copied, so nothing was cut."); return; } - Edit::DeleteBookmark { path } - } + None => return, + }, A::Paste { parent, index } => { self.paste_bookmarks(id, parent, index); return; @@ -1006,22 +1009,23 @@ impl PdfCraftApp { } impl PdfCraftApp { - /// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could - /// not be read (the reason is shown). - fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool { + /// Put the bookmark at `path` and its subtree on the bookmark clipboard. `Some(true)` when + /// bookmarks nested too deep to list were left out; `None` if it could not be read (the reason + /// is shown). + fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> Option { match self.session.outline_tree(id, path) { Some(Ok(tree)) => { if tree.truncated { self.notify_tr("Bookmarks nested too deep to list were left out of the copy."); } self.bookmark_clipboard = Some(tree.nodes); - true + Some(tree.truncated) } Some(Err(e)) => { self.notify_error(e); - false + None } - None => false, + None => None, } } diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 5251871401..668227e8e7 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -531,3 +531,40 @@ fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() { assert!(active_outline(&h).is_empty()); assert_eq!(outline(&h), tree, "the source is untouched"); } + +/// A one-page document whose single bookmark "Top" heads a chain `depth` bookmarks deep. +fn deep_outline_pdf(depth: usize) -> Vec { + let mut pdf = String::from( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >> endobj +", + ); + for k in 0..depth { + let id = 10 + k; + let parent = if k == 0 { 4 } else { id - 1 }; + let title = if k == 0 { "Top".to_string() } else { format!("L{k}") }; + let kids = if k + 1 < depth { format!("/First {0} 0 R /Last {0} 0 R /Count 1", id + 1) } else { String::new() }; + pdf.push_str(&format!("{id} 0 obj << /Title ({title}) /Parent {parent} 0 R {kids} /Dest [3 0 R /Fit] >> endobj\n")); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + pdf.into_bytes() +} + +/// Cutting a subtree nested deeper than a copy can hold would lose what the copy left out, so +/// the cut copies and says why it deleted nothing. +#[test] +fn cutting_a_subtree_too_deep_to_copy_keeps_it() { + let mut h = harness(); + h.state_mut().open_bytes("deep.pdf", None, deep_outline_pdf(40)).unwrap(); + h.run_steps(3); + let before = active_outline(&h); + assert_eq!(before.first().map(|b| b.0.as_str()), Some("Top")); + menu(&mut h, "Top", "Cut"); + assert_eq!(active_outline(&h), before, "nothing was cut"); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("so nothing was cut"), "{toast}"); + assert!(h.state().bookmark_clipboard.is_some(), "the copy is still on the bookmark clipboard"); +}