fix(ui): dragged added text follows the pointer before release (#1508)

* fix(ui): dragged added text follows the pointer before release (fixes #1488)

* fix(ui): keep a moved item in place until the page is redrawn (fixes #1488)
This commit is contained in:
Marco Arnulfo authored and GitHub committed 2026-10-13 00:05:43 -04:00
1 parent 55f76ab767
commit 801a2f1488
5 files changed
+277 -4

No files matched your search

+12
View File
@@ -63,4 +63,16 @@ impl Document {
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
/// The page beneath an added item while it is dragged: `index` counts the page's added
/// items, as `Edit::DeleteContent` does. Read-only, like [`Self::text_edit_preview`].
pub fn added_content_preview(&self, page: usize, index: usize) -> Result<TextEditPreview, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let mut cos = editor.cos.clone();
pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
})?
}
}
+10 -1
View File
@@ -394,6 +394,7 @@ pub struct DocView {
pub(crate) edit_lines: HashMap<usize, (u64, Vec<pdfcraft_engine::TextBlock>)>,
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
pub(crate) text_background: crate::edit_text_ui::TextBackground,
pub(crate) move_background: crate::content_ui::MoveBackground,
/// Edit text & images: the images per page (by document generation), and the selected one.
pub(crate) edit_images: HashMap<usize, (u64, Vec<pdfcraft_engine::PageImage>)>,
pub(crate) objects: crate::object_ui::ObjectSelection,
@@ -635,6 +636,7 @@ impl DocView {
edit_lines: HashMap::new(),
line_editor: None,
text_background: Default::default(),
move_background: Default::default(),
edit_images: HashMap::new(),
objects: Default::default(),
image_selection: None,
@@ -780,7 +782,7 @@ impl DocView {
}
pub fn render_pending(&self) -> bool {
!self.last_queue.is_empty() || self.text_background.pending()
!self.last_queue.is_empty() || self.text_background.pending() || self.move_background.pending()
}
/// Re-render every page and text layer (the document's appearance changed, e.g. a layer
@@ -1343,6 +1345,7 @@ impl DocView {
// Signature previews own a separate renderer and image/background textures.
self.signature_drag = Default::default();
self.text_background = Default::default();
self.move_background = Default::default();
if !self.last_queue.is_empty() {
pool.set_queue(Vec::new());
self.last_queue.clear();
@@ -1614,6 +1617,7 @@ impl DocView {
let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.pages.insert(page, PageTex { tag: r.request.tag, tex });
self.signature_drag.page_received(page);
self.move_background.page_received(page);
self.waiting_since.remove(&page);
if !self.stale_tiles.iter().any(|k| k.0 == page) {
self.forms.unbaked.retain(|(_, p)| *p != page);
@@ -2347,6 +2351,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
} else {
view.content.selected = None;
}
view.move_background.prepare(ui.ctx(), doc, &view.content, scale);
// A destination's position (bookmarks, links) refines `goto`: its point at the window's
// top-left, or the centre of a /FitR rectangle at the window's centre.
@@ -2546,6 +2551,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
view.text_background.paint(painter, &xf, i, view.line_editor.as_ref());
if editing_content {
let page_tex = view.pages.get(&i).map(|p| p.tex.id());
view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex);
}
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside);
// Comments: tools, selection, moving and resizing come before text selection.
+122
View File
@@ -105,6 +105,128 @@ impl ContentView {
}
}
/// While an added item is moved, the page without it, rendered once per gesture: the item's own
/// pixels follow the pointer over it instead of staying put until release (#1488).
#[derive(Default)]
pub(crate) struct MoveBackground {
key: Option<(u64, usize, usize)>,
renderer: Option<pdfcraft_render::RenderPool>,
texture: Option<egui::TextureHandle>,
tag: Option<u64>,
/// The item's display-space box when the gesture started.
from: Option<[f64; 4]>,
/// The applied move's box. The page raster on screen still has the item where it was until
/// its replacement arrives, so the preview stays up until then.
settling: Option<[f64; 4]>,
}
impl MoveBackground {
pub fn pending(&self) -> bool {
self.renderer.is_some() && self.texture.is_none()
}
pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) {
let key = match (cv.grab, cv.selected) {
(Some((gp, Grab::Move, _)), Some((sp, si))) if gp == sp => Some((doc.edit_generation(), sp, si)),
_ => None,
};
if !(key.is_none() && self.settling.is_some()) && self.key != key {
*self = Self { key, ..Default::default() };
if let Some((_, page, index)) = key {
self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer());
self.from = on_page(&doc.added, page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect());
}
}
if self.settling.is_some() {
return;
}
let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return };
let Some(p) = doc.info.pages.get(page) else { return };
let max_side = ctx.input(|i| i.max_texture_side) as f32;
let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0));
let tag = (scale * 1000.0) as u64;
if self.tag != Some(tag) {
renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
self.tag = Some(tag);
}
if let Some(out) = renderer.try_recv() {
if out.error.is_some() {
// Without a background the item stays put while dragged, as before.
self.renderer = None;
return;
}
if out.request.tag == tag {
self.texture = Some(ctx.load_texture(
"added-content-move-background",
crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba),
egui::TextureOptions::LINEAR,
));
}
}
if self.pending() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// The move was applied: keep the preview, now at the new generation, until the page is
/// rendered again. Any other edit ends it.
pub fn committed(&mut self, edit: &Edit, generation: u64) {
match edit {
Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => {
self.key = Some((generation, *page, *index));
self.settling = Some(content.rect());
}
_ => *self = Self::default(),
}
}
pub fn page_received(&mut self, page: usize) {
if self.settling.is_some() && self.key.is_some_and(|(_, p, _)| p == page) {
*self = Self::default();
}
}
/// The page without the moving item, then the item's pixels taken from `page_tex` (the page
/// as rendered, item included where it was) at the dragged or applied position.
#[allow(clippy::too_many_arguments)]
pub fn paint(
&self,
ui: &egui::Ui,
painter: &egui::Painter,
xf: &PageXform,
page: usize,
info: &DocInfo,
cv: &ContentView,
page_tex: Option<egui::TextureId>,
) {
let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return };
if kp != page {
return;
}
let r = screen_rect(xf, info, page, from);
let d = if let Some((_, _, start)) = cv.grab {
let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return };
p - start
} else if let Some(to) = self.settling {
// A moved text box keeps its top edge (its height follows its lines).
screen_rect(xf, info, page, to).left_top() - r.left_top()
} else {
return;
};
let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
let r = r.expand(1.0);
let mut mesh = egui::Mesh::with_texture(page_tex);
for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] {
let (u, v) = xf.screen_to_norm(c);
mesh.vertices.push(egui::epaint::Vertex { pos: c + d, uv: Pos2::new(u, v), color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
}
/// Text style for new text (the panel's Format controls set it).
pub fn default_style() -> AddedText {
AddedText { size: 12.0, ..AddedText::default() }
+1
View File
@@ -43,6 +43,7 @@ impl PdfCraftApp {
let info = &doc.info;
let view = &mut self.views[i];
view.signature_drag.committed(&edit, doc.edit_generation());
view.move_background.committed(&edit, doc.edit_generation());
if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) {
// Comment and content edits change one page: keep every other raster.
view.page_changed(page);
+132 -3
View File
@@ -1,16 +1,20 @@
//! Edit a PDF ▸ Add content in the real shell (egui_kittest): type text onto a page, move it,
//! restyle it from the panel, add an image, delete with the keyboard.
use egui::Pos2;
use egui::{Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
harness_bytes(include_bytes!("data/form.pdf").to_vec())
}
fn harness_bytes(bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.open_bytes("form.pdf", None, bytes.clone()).unwrap();
app.set_option("zoom", "150").unwrap();
app
});
@@ -231,3 +235,128 @@ fn typing_thai_text_keeps_marks_and_commits_to_page() {
click(&mut h, p);
assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]);
}
/// The screen rectangle of an added item's display-space box (y up, from the page's bottom).
fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect {
let s = h.state();
let ph = s.session.get(s.views[0].id).unwrap().info.pages[0].height;
let page = s.views[0].page_screen_rect(0).expect("on screen");
let k = page.width() / 300.0;
let p = |x: f64, y: f64| page.min + egui::vec2(x as f32 * k, (ph - y as f32) * k);
egui::Rect::from_two_pos(p(r[0], r[1]), p(r[2], r[3]))
}
/// Dark pixels in `r`: glyph ink (the selection outline is blue, the page white).
fn ink(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)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90))
.count()
}
/// Only the left part of the glyphs: kittest paints its cursor down and right of the pointer.
fn probe(r: egui::Rect) -> egui::Rect {
egui::Rect::from_min_size(r.min, egui::vec2(r.width() * 0.6, r.height()))
}
/// Ink in the probes of the text's first and dragged places.
fn ink_at(h: &mut Harness<'static, PdfCraftApp>, from: egui::Rect, to: egui::Rect) -> (usize, usize) {
let pixels = h.render().unwrap();
(ink(&pixels, probe(from)), ink(&pixels, probe(to)))
}
/// Types text, finishes it, and drags it up without releasing, until the drag preview shows.
/// Returns the text's box, its screen rectangles before and after, and the pointer.
fn drag_added_text(h: &mut Harness<'static, PdfCraftApp>) -> ([f64; 4], egui::Rect, egui::Rect, Pos2) {
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(h, 40.0, 300.0);
click(h, p);
h.event(egui::Event::Text("MMMMMMMM".into()));
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(3);
let rect = added(h)[0].content.rect();
let from = item_on_screen(h, rect);
let mut shown = 0;
for _ in 0..100 {
h.run_steps(2);
shown = ink(&h.render().unwrap(), probe(from));
if shown > 20 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(shown > 20, "the added text is on the page ({shown} dark pixels)");
let grab = pos2(from.right() - 12.0, from.center().y);
let shift = egui::vec2(0.0, -150.0);
h.hover_at(grab);
h.run_steps(1);
h.drag_at(grab);
h.run_steps(1);
h.hover_at(grab + shift);
let to = from.translate(shift);
for _ in 0..100 {
h.run_steps(2);
let (old, new) = ink_at(h, from, to);
if old == 0 && new > 20 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
(rect, from, to, grab + shift)
}
/// #1488: while a finished text box is dragged, its text follows the box instead of staying put
/// until the button is released.
#[test]
fn dragged_text_follows_the_pointer_before_release() {
let mut h = harness();
let (rect, from, to, end) = drag_added_text(&mut h);
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
h.render().unwrap().save(format!("{dir}/added-text-drag.png")).unwrap();
}
let (old, new) = ink_at(&mut h, from, to);
assert!(new > 20, "the text is drawn where the box was dragged ({new} dark pixels)");
assert_eq!(old, 0, "and no longer where it was");
assert_eq!(added(&h)[0].content.rect(), rect, "nothing is applied before release");
h.drop_at(end);
h.run_steps(3);
assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it");
}
/// After release the text stays in its new place while the page is re-rendered with the move,
/// instead of jumping back to where it was for those frames.
#[test]
fn released_text_does_not_jump_back_before_the_page_is_redrawn() {
// A page slow enough to render that the frames before its new raster can be looked at: many
// pale squares, too light to count as ink.
let mut content = String::from("0.9 g\n");
for i in 0..60_000 {
content.push_str(&format!("{} {} 1 1 re f\n", i % 290, (i / 290) % 390));
}
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
content.len()
);
let mut h = harness_bytes(pdf.into_bytes());
let (rect, from, to, end) = drag_added_text(&mut h);
h.drop_at(end);
// Frame by frame until the page has been re-rendered. The one frame that swaps in the new
// raster reads back blank here (kittest renders it after its old texture is freed).
let mut shown = Vec::new();
while h.state().render_pending() || shown.is_empty() {
h.run_steps(1);
assert!(shown.len() < 500, "the page is re-rendered");
let (old, new) = ink_at(&mut h, from, to);
assert_eq!(old, 0, "frame {} after release: the text is back where it was", shown.len() + 1);
shown.push(new);
}
assert!(shown.len() > 2, "frames before the new raster: {shown:?}");
assert!(shown.iter().filter(|n| **n <= 20).count() <= 1, "the text stays where it was released: {shown:?}");
assert_ne!(added(&h)[0].content.rect(), rect, "the move is applied");
h.run_steps(2);
let (old, new) = ink_at(&mut h, from, to);
assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)");
}