Files
pdfcraft/crates/ui-egui/src/zoom_snap.rs
T
alehelandClaude Opus 5.5 cc4726f6e6 fix(ui): one page pointer per frame, so taps work and nothing over a page lets it through
The page view had no single reading of the pointer. About twenty page modules each
read egui's raw state (hover_pos, latest_pos, interact_pos, press_origin) and tested it
against an unclipped page rectangle. That state knows nothing of what is drawn over the
pages, and hover_pos is None on the frame a finger or a Windows pen lifts: egui-winit
sends PointerGone with every touch release, and winit reports Windows pen input as
touch. So a tap placed no stamp, followed no link and ticked no check box. A touch drag
of a comment never committed, and the next drag anywhere on the page moved the comment.
The Stamp crosshair showed on the command palette, on the quick bar and on the chrome
over a page scrolled under it, a link's tip floated over the Unlock prompt, and a
double-click on a dialog's backdrop selected the word under it.

canvas::document_area now reads one PagePointer per frame from the pages' content
widget (step 1 of the page pointer router in the cursor-handling review):
- pos: interact_pos, which keeps the position through a touch release, only while egui
  has the pages under the pointer with nothing over them (contains_pointer, and the top
  layer there is the pages', as the closed #2626 did) or while the pages hold a press.
- origin: where the held press began, also on the frame that brings both the press and
  its release. This replaces the text layer's raw-event fallback (#2100).
- edges: the frame's primary presses and releases on the pages, in order.

It reaches the page modules through comments::PageCx and as an argument: the stamp
blocks, text layer, page press and link and comment hover in canvas.rs; comments and the
signature drag preview; forms and Prepare a form; Fill & Sign and the Sign area; crop,
marquee zoom, snapshot, camera and measure; redact and the Link tool; added content,
Edit text, images and objects. page_covered, a hand-kept list of what covers the pages
that missed the Unlock prompt, is gone. The pages' hover tip is click-through, as a
tooltip is, so a quick move onto it does not take the pointer off the link under it.

Tests: tests/it/support/input.rs sends pointer input in the frames the platform delivers
it in (a touch tap and drag, a click in one frame, a double-click). kittest otherwise
runs one frame per event, so the suite never saw a touch release. tests/it/pointer.rs
holds the review's probes and #2626's layer tests. With the fix reverted, the
tap (stamp, link, check box), touch drag, palette, quick bar, chrome, Unlock tip and
dialog double-click tests fail, and the mouse-click and one-frame-click controls pass.
The tip test fails with the tip interactable, and each page_pointer unit test fails
under a mutation of the part it pins. The probes for later steps stay ignored: the
link hand and the I-beam over other tools' targets (step 2), paragraph edges (step 3)
and Escape mid-drag (step 4). touch_tap_ticks_a_check_box is ignored until the
check box focus fix that follows: since #2223 a tap on a check box counts as a click away
from its focus.

tests/it/comments.rs: the multiline markup hover test (#3541) hovered a point below the
window on a page rotated 270 degrees, and its tip showed only because the hover was
tested against the page's whole rectangle. Its harness now zooms to 100 %, so every
rotation shows the whole page, and the hover helper asserts the point is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:54:28 +02:00

184 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! View ▸ Zoom ▸ Marquee Zoom (drag a rectangle to fill the window with it; click to zoom in)
//! and Edit ▸ Take a Snapshot (drag a rectangle to copy that area as an image).
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile};
use crate::canvas::{DocView, PageXform};
use crate::page_pointer::PagePointer;
use crate::{PdfCraftApp, QuickTool};
/// A finished gesture: (page, the rectangle on screen, the same in page view points
/// [x0, y0, x1, y1]); a plain click has an empty rectangle.
pub type Marquee = (usize, Rect, [f32; 4]);
/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot).
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) {
let (pointer, origin) = (ptr.pos, ptr.origin);
if pointer.is_some_and(|p| xf.rect.contains(p)) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
if resp.drag_started()
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
{
view.marquee = Some((page, o));
}
if let Some((p, start)) = view.marquee
&& p == page
&& let Some(end) = pointer
{
let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom()));
let r = Rect::from_two_pos(start, end);
ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle);
if resp.drag_stopped() {
view.marquee = None;
let (a, b) = (xf.screen_to_view(r.left_top()), xf.screen_to_view(r.right_bottom()));
view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]));
}
}
if resp.clicked()
&& let Some(p) = pointer.filter(|p| xf.rect.contains(*p))
{
let v = xf.screen_to_view(p);
view.marquee_done = Some((page, Rect::from_min_size(p, egui::Vec2::ZERO), [v.0, v.1, v.0, v.1]));
}
}
impl PdfCraftApp {
/// Finish a marquee gesture for the current tool.
pub(crate) fn finish_marquee(&mut self, index: usize, done: Marquee) {
let (page, rect, view_rect) = done;
match self.quick_tool {
QuickTool::MarqueeZoom => {
let view = &mut self.views[index];
if rect.width() < 6.0 || rect.height() < 6.0 {
// A click zooms in one step around the point.
let z = (view.zoom * 1.5).min(64.0);
view.zoom_at(z, rect.center());
} else {
view.zoom_to_rect(rect);
}
}
QuickTool::Snapshot => {
if view_rect[2] - view_rect[0] < 2.0 || view_rect[3] - view_rect[1] < 2.0 {
return;
}
// The tool outlives the tab it was chosen on.
if self.views.get(index).map(|v| v.id).is_some_and(|id| !self.may_copy(id)) {
return;
}
match self.snapshot(index, page, view_rect) {
Ok((w, h)) => {
self.notify_fmt("The selected area has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())])
}
Err(e) => self.notify_fmt("Couldn't take the snapshot: {e}", &[("e", &e.to_string())]),
}
}
_ => {}
}
}
/// View ▸ Zoom ▸ Fit Visible on the current page: find where the page has ink (a quick
/// low-resolution render) and zoom so that spans the window's width. A blank page fits the
/// width.
pub fn fit_visible(&mut self, index: usize) -> Result<(), String> {
let view = &self.views[index];
let page = view.current;
let doc = self.session.get(view.id).ok_or("no document")?;
let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
// About 800 px along the longer side is plenty to find the margins.
let size = doc.info.pages.get(page).map_or(792.0, |p| p.width.max(p.height));
let out =
r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0, preview: false });
if let Some(e) = out.error {
return Err(e);
}
let rot = view.rotation;
let view = &mut self.views[index];
match ink_bounds(out.width as usize, out.height as usize, &out.rgba) {
Some([u0, v0, u1, v1]) => {
// View space → the rotated view on screen.
let rotate = |u: f32, v: f32| match rot {
90 => (1.0 - v, u),
180 => (1.0 - u, 1.0 - v),
270 => (v, 1.0 - u),
_ => (u, v),
};
let (a, b) = (rotate(u0, v0), rotate(u1, v1));
if !view.fit_content(page, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]) {
view.fit = crate::canvas::Fit::Width;
}
}
None => view.fit = crate::canvas::Fit::Width,
}
Ok(())
}
/// Render `view_rect` (page view points) of `page` at the current zoom and copy it to the
/// clipboard (and keep it in `last_snapshot`).
pub fn snapshot(&mut self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32), String> {
let view = &self.views[index];
let doc = self.session.get(view.id).ok_or("no document")?;
let ppp = self.ctx.as_ref().map_or(2.0, |c| c.pixels_per_point());
let scale = (view.zoom * ppp).clamp(0.5, 8.0);
let tile = Tile {
x: (view_rect[0] * scale).floor().max(0.0) as u32,
y: (view_rect[1] * scale).floor().max(0.0) as u32,
w: ((view_rect[2] - view_rect[0]) * scale).ceil().max(1.0) as u32,
h: ((view_rect[3] - view_rect[1]) * scale).ceil().max(1.0) as u32,
};
if (tile.w as u64) * (tile.h as u64) > 64_000_000 {
return Err("the area is too large at this zoom".into());
}
let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0, preview: false });
if let Some(e) = out.error {
return Err(e);
}
let (w, h) = (out.width, out.height);
#[cfg(not(target_arch = "wasm32"))]
if self.system_clipboard {
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
}
self.last_snapshot = Some((w, h, out.rgba));
Ok((w, h))
}
}
/// The normalised bounds [u0, v0, u1, v1] of the pixels that aren't (near) white, if any.
pub(crate) fn ink_bounds(w: usize, h: usize, rgba: &[u8]) -> Option<[f32; 4]> {
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return None;
}
let ink = |p: &[u8]| p[3] > 16 && (p[0] < 240 || p[1] < 240 || p[2] < 240);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (y, row) in rgba.chunks_exact(w * 4).take(h).enumerate() {
for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
if ink(p) {
x0 = x0.min(x);
x1 = x1.max(x + 1);
y0 = y0.min(y);
y1 = y1.max(y + 1);
}
}
}
(x1 > x0 && y1 > y0).then(|| [x0 as f32 / w as f32, y0 as f32 / h as f32, x1 as f32 / w as f32, y1 as f32 / h as f32])
}
#[cfg(test)]
mod tests {
#[test]
fn ink_bounds_finds_the_marked_area() {
let (w, h) = (10, 20);
let mut px = vec![255u8; w * h * 4];
for (x, y) in [(2, 5), (7, 15)] {
px[(y * w + x) * 4] = 0;
}
assert_eq!(super::ink_bounds(w, h, &px), Some([0.2, 0.25, 0.8, 0.8]));
assert_eq!(super::ink_bounds(w, h, &vec![255u8; w * h * 4]), None);
}
}