Merge PR #3864: M7: keep a moved or resized page image in place until its page is drawn again

This commit is contained in:
aiongg
2026-10-22 01:44:48 +00:00
3 changed files with 292 additions and 18 deletions
+128
View File
@@ -1049,6 +1049,18 @@ impl DocView {
self.pages.get(&page).map(|p| p.tex.id())
}
/// Whether `page` is still drawn, somewhere, as it was before its last change: its whole-page
/// raster (a tiled page's backdrop too) or any of its tiles waits for a replacement.
/// Whether `page`'s whole-page raster (a tiled page's backdrop too) is still the one from
/// before its last change, whatever its tiles are.
pub(crate) fn page_raster_out_of_date(&self, page: usize) -> bool {
self.pages.get(&page).is_some_and(|p| p.tag == STALE_TAG)
}
pub(crate) fn page_out_of_date(&self, page: usize) -> bool {
self.pages.get(&page).is_some_and(|p| p.tag == STALE_TAG) || self.stale_tiles.iter().any(|k| k.0 == page)
}
/// The print preview's picture of `page`: the sheet raster when it has arrived, otherwise the
/// thumbnail.
pub(crate) fn page_preview(&self, page: usize) -> Option<egui::TextureId> {
@@ -6500,4 +6512,120 @@ trailer << /Root 1 0 R >>
assert_eq!(v.find.as_ref().unwrap().matches, vec![(1, 5..9)]);
assert!(v.goto.is_none());
}
/// A page whose picture moved can get its whole-page raster back before its last stale tile.
/// From then on the raster has the picture where it went: it is drawn from there, not from
/// where it was (which the raster now shows empty), and another drag starts from there too.
#[test]
fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() {
let content = b"q 60 0 0 60 40 260 cm /Im0 Do Q";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content.as_slice(), b"\nendstream"].concat(),
[
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >>\nstream\n".as_slice(),
&[200, 30, 30],
b"\nendstream",
]
.concat(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(o);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.pdf", None, bytes.clone()).unwrap();
app.set_option("zoom", "100").unwrap();
app.quick_tool = QuickTool::EditText;
app
});
let settle = |h: &mut egui_kittest::Harness<'static, PdfCraftApp>| {
for _ in 0..400 {
h.step();
if !h.state().render_pending() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
};
h.run_steps(4);
settle(&mut h);
let page = h.state().views[0].page_screen_rect(0).unwrap();
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 300.0 * page.width(), page.top() + (400.0 - y) / 400.0 * page.height());
let red = |pixels: &image::RgbaImage, r: Rect| {
let (w, hh) = (pixels.width(), pixels.height());
(r.top().max(0.0) as u32..(r.bottom() as u32).min(hh))
.flat_map(|y| (r.left().max(0.0) as u32..(r.right() as u32).min(w)).map(move |x| (x, y)))
.filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 150 && g < 90 && b < 90))
.count()
};
let (old, new) = (Rect::from_two_pos(at(42.0, 262.0), at(98.0, 318.0)), Rect::from_two_pos(at(182.0, 82.0), at(238.0, 138.0)));
// Select the picture and drag it, without overlap, from (40, 260) to (180, 80).
let drag = |h: &mut egui_kittest::Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2, release: bool| {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
if release {
h.drop_at(to);
h.step();
}
};
let (grab, put) = (at(70.0, 290.0), at(210.0, 110.0));
h.hover_at(grab);
h.run_steps(1);
h.drag_at(grab);
h.run_steps(1);
h.drop_at(grab);
h.run_steps(3);
assert!(h.state().views[0].image_selection.is_some(), "the picture is selected");
h.state_mut().views[0].hold_rasters = true;
drag(&mut h, grab, put, true);
// One tile stays stale, as a tile result arriving after the whole-page raster would; then
// the whole-page raster is let through.
let tile = h.ctx.load_texture("stale-tile", egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), TextureOptions::LINEAR);
h.state_mut().views[0].tiles.insert((0, 1, 0, 0), tile);
h.state_mut().views[0].stale_tiles.insert((0, 1, 0, 0));
h.state_mut().views[0].hold_rasters = false;
for _ in 0..400 {
h.step();
if !h.state().views[0].page_raster_out_of_date(0) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().views[0].page_raster_out_of_date(0), "the whole-page raster is fresh");
assert!(h.state().views[0].page_out_of_date(0), "a tile is still stale");
h.run_steps(2);
let pixels = h.render().unwrap();
assert!(red(&pixels, new) > 100, "the picture shows where it went");
assert_eq!(red(&pixels, old), 0, "and not where it was");
// Another drag in that interval takes its pixels from where the raster has them.
let further = at(210.0, 200.0);
drag(&mut h, put, further, false);
let pixels = h.render().unwrap();
let moving = Rect::from_two_pos(at(182.0, 172.0), at(238.0, 228.0));
assert!(red(&pixels, moving) > 100, "the dragged picture is drawn from the fresh raster");
assert_eq!(red(&pixels, old), 0, "nothing shows where it first was");
h.state_mut().views[0].stale_tiles.clear();
h.state_mut().views[0].tiles.clear();
}
}
+60 -18
View File
@@ -369,6 +369,9 @@ pub struct ImageSelection {
drag: Option<(Pos2, Option<Pos2>)>,
/// Crop mode (Acrobat's crop handles): the box kept so far.
pub crop: Option<CropMode>,
/// A move or resize applied on release: the frame it had and the one it got, in the page's
/// 0..1 coordinates, while the page raster still has the picture where it was.
settled: Option<(Rect, Rect)>,
}
/// Cropping a page image: the handles trim instead of resizing, the part that will go is
@@ -544,6 +547,28 @@ impl ImageSelection {
}
}
/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where
/// the screen may still show the picture).
fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) {
let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
for r in blank {
clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE);
}
let mut mesh = egui::Mesh::with_texture(tex);
for (a, b) in [
(from.left_top(), to.left_top()),
(from.right_top(), to.right_top()),
(from.right_bottom(), to.right_bottom()),
(from.left_bottom(), to.left_bottom()),
] {
let (u, v) = xf.screen_to_norm(a);
mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
clipped.add(egui::Shape::mesh(mesh));
}
/// Once a selected corner is grabbed, text under it cannot take over the gesture. The same
/// applies after the pointer leaves the image/page while dragging.
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool {
@@ -666,14 +691,17 @@ pub(crate) fn image_input(
// hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images.
// Fully clipped placements (empty visible frames) get no outline.
let faint_all = boxes.len() <= MAX_OUTLINES;
// The picture being dragged has its frame at the pointer; its outline where it was would
// trail behind until the release.
let moving = selected.filter(|_| dragging);
if faint_all {
for b in boxes.iter().filter(|b| b.is_positive()) {
for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) {
painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
}
let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p)));
if !faint_all {
for i in selected.into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) {
for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) {
if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) {
painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
@@ -708,6 +736,26 @@ pub(crate) fn image_input(
);
}
};
// Until the page is rendered again its raster has an applied move or resize's picture
// where it was: it stays blanked there and drawn where it went.
// The whole-page raster and its tiles are replaced separately: the old frame stays blanked
// while anything still shows the picture there, but its pixels come from the old frame
// only while the whole-page raster itself is the old one. Once that is fresh it has the
// picture where it went, and that is where they come from.
let (out_of_date, raster_old, raster) = (view.page_out_of_date(page), view.page_raster_out_of_date(page), view.page_raster(page));
let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y));
// Where the screen may still show the picture from before an applied move, while the page
// isn't drawn again yet. Another drag keeps it blanked too.
let left_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from);
if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) {
match (s.settled, raster) {
(Some((from, to)), Some(tex)) if out_of_date => {
let source = if raster_old { from } else { to };
stretched_picture(painter, xf, tex, &[screen(from)], screen(source), screen(to));
}
_ => s.settled = None,
}
}
if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) {
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
handles(b);
@@ -765,21 +813,10 @@ pub(crate) fn image_input(
if preview != b
&& let Some(tex) = view.page_raster(page)
{
let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
clipped.rect_filled(b, CornerRadius::ZERO, Color32::WHITE);
let mut mesh = egui::Mesh::with_texture(tex);
for (from, to) in [
(b.left_top(), preview.left_top()),
(b.right_top(), preview.right_top()),
(b.right_bottom(), preview.right_bottom()),
(b.left_bottom(), preview.left_bottom()),
] {
let (u, v) = xf.screen_to_norm(from);
mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
clipped.add(egui::Shape::mesh(mesh));
// Its pixels: where the whole-page raster has it.
let source = left_at.filter(|_| raster_old).map_or(b, screen);
let blank: Vec<Rect> = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect();
stretched_picture(painter, xf, tex, &blank, source, preview);
}
painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
@@ -788,6 +825,11 @@ pub(crate) fn image_input(
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
if let Some(s) = view.image_selection.as_mut() {
s.drag = None;
let norm = |r: Rect| {
let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max));
Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1))
};
s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview)));
}
if preview != b {
view.pending_edit =
@@ -843,7 +885,7 @@ pub(crate) fn image_input(
_ => egui::CursorIcon::PointingHand,
});
if resp.clicked() || resp.secondary_clicked() {
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None });
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, settled: None });
}
if resp.secondary_clicked() {
ui.ctx().data_mut(|d| d.insert_temp(menu_key, true));
+104
View File
@@ -4549,6 +4549,110 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() {
}
}
/// Red pixels in `r`: the test picture.
fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize {
let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32);
(y0..y1.min(pixels.height()))
.flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y)))
.filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 150 && g < 90 && b < 90))
.count()
}
/// A resized picture is applied when the corner is let go, but the page raster still has it at
/// its old size until the page is rendered again. Until then it stays drawn at its new size and
/// blanked at its old one; the old one used to show again for those frames.
#[test]
fn a_resized_image_keeps_its_new_size_until_the_page_is_redrawn() {
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
let mut image = Dict::new();
image.set(b"Type".to_vec(), Object::name("XObject"));
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
let image = doc.add(Object::Stream(Stream::flate(image, &[200, 30, 30])));
let page = pdfcraft_model::pages(&doc).remove(0);
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 80 0 0 80 60 40 cm /Im0 Do Q")));
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut xo = Dict::new();
xo.set(b"Im0".to_vec(), Object::Ref(image));
resources.set(b"XObject".to_vec(), Object::Dict(xo));
doc.update_dict(page.obj, |d| {
d.set(b"Resources".to_vec(), Object::Dict(resources));
d.set(b"Contents".to_vec(), Object::Ref(contents));
})
.unwrap();
let bytes = write_incremental(&doc, &Default::default()).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("picture.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.state_mut().views[0].set_zoom(1.2);
h.run_steps(4);
support::wait_until(&mut h, "the page raster", |app| !app.render_pending());
let page = h.state().views[0].page_screen_rect(0).unwrap();
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
click_at(&mut h, at(100.0, 80.0));
assert!(h.state().views[0].image_selection.is_some(), "the picture is selected");
support::wait_until(&mut h, "the page raster", |app| !app.render_pending());
// From 60..140 × 40..120 to half its size, keeping its lower left corner.
let (grip, to) = (at(140.0, 120.0), at(100.0, 80.0));
let kept = egui::Rect::from_two_pos(at(62.0, 42.0), at(98.0, 78.0));
let gone = egui::Rect::from_two_pos(at(104.0, 84.0), at(138.0, 118.0));
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip + (to - grip) * (k as f32 / 4.0));
h.run_steps(1);
}
// While dragged, no outline stays where the picture was.
let old = egui::Rect::from_two_pos(at(60.0, 40.0), at(140.0, 120.0));
let trailing = h.output().shapes.iter().any(|shape| {
matches!(&shape.shape, egui::Shape::Rect(r) if r.stroke.width == 0.75 && (r.rect.min - old.min).length() < 2.0 && (r.rect.max - old.max).length() < 2.0)
});
assert!(!trailing, "the dragged picture's outline trails behind it");
h.state_mut().views[0].hold_rasters = true;
h.drop_at(to);
for frame in 0..4 {
h.step();
let pixels = h.render().unwrap();
assert!(red(&pixels, kept) > 100, "frame {frame} after the release: the picture is not at its new size");
assert_eq!(red(&pixels, gone), 0, "frame {frame} after the release: the picture shows at its old size");
}
// Resized again before the page is drawn: the raster still has the first size, so that is
// what stays blanked while the corner moves.
let (grip, to) = (to, at(80.0, 60.0));
let again = egui::Rect::from_two_pos(at(62.0, 42.0), at(78.0, 58.0));
h.hover_at(grip);
h.run_steps(1);
h.drag_at(grip);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(grip + (to - grip) * (k as f32 / 4.0));
h.run_steps(1);
}
let pixels = h.render().unwrap();
assert!(red(&pixels, again) > 20, "the second drag draws the picture at its new size");
assert_eq!(red(&pixels, gone), 0, "the second drag shows the picture at its first size");
h.drop_at(to);
h.run_steps(2);
let pixels = h.render().unwrap();
assert_eq!(red(&pixels, gone), 0, "after the second release");
h.state_mut().views[0].hold_rasters = false;
support::wait_until(&mut h, "the page drawn with the resized picture", |app| !app.render_pending());
h.run_steps(2);
let pixels = h.render().unwrap();
assert!(red(&pixels, again) > 20 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
}
/// Exercise actual pointer gestures on each cropped placement, without capturing private pixels.
#[test]
#[ignore = "requires an explicitly supplied local PDF with cropped artwork"]