M3: Marquee zoom (drag to fill the window with an area, click to zoom in) and Take a Snapshot (copies an area at the current zoom to the clipboard as an image)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 04:00:12 -04:00
parent 7515170103
commit e61f75d69a
8 changed files with 197 additions and 10 deletions

View File

@ -165,6 +165,8 @@ pub const COMMANDS: &[CommandSpec] = &[
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"),
c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"),
c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
c("view.theme", "Switch light / dark theme", VIEW, None, Nothing, "moon"),

View File

@ -154,6 +154,9 @@ pub struct DocView {
pub sign: crate::sign_ui::SignView,
/// Organize: the pages being dragged to a new place.
pub org_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>,
/// Fill & Sign text being typed.
pub fill_text: Option<crate::fill_sign::TypeBox>,
/// A non-edit action requested by the organize toolbar, handled by the app.
@ -242,6 +245,8 @@ impl DocView {
crop_drag: None,
sign: Default::default(),
org_drag: None,
marquee: None,
marquee_done: None,
fill_text: None,
}
}
@ -517,6 +522,16 @@ impl DocView {
if self.rotation % 180 == 90 { (p.height, p.width) } else { (p.width, p.height) }
}
/// Marquee Zoom: zoom so `r` (on screen) fills the window, centred.
pub fn zoom_to_rect(&mut self, r: Rect) {
let Some((page, pr)) = self.screen_rects.iter().find(|(_, pr)| pr.contains(r.center())).copied() else { return };
let k = (self.viewport_screen.width() / r.width().max(1.0)).min(self.viewport_screen.height() / r.height().max(1.0));
let f = (r.center() - pr.min) / pr.size();
self.zoom = (self.zoom * k).clamp(0.08, 64.0);
self.fit = Fit::None;
self.zoom_anchor = Some((page, f.x.clamp(0.0, 1.0), f.y.clamp(0.0, 1.0), self.viewport_screen.center() - self.viewport_screen.min));
}
/// Zoom keeping the centre of the view still.
pub fn set_zoom(&mut self, zoom: f32) {
let centre = self.viewport_screen.center();
@ -914,7 +929,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
| QuickTool::AddText
| QuickTool::Stamp(_)
| QuickTool::Link
| QuickTool::SignArea { .. } => false,
| QuickTool::SignArea { .. }
| QuickTool::MarqueeZoom
| QuickTool::Snapshot => false,
};
let prefs = &app.comment_prefs;
let allowed = doc.allows_annotation();
@ -1104,6 +1121,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
if matches!(tool, QuickTool::SignArea { .. }) {
crate::sign_ui::page_input(ui, &resp, &xf, i, info, view);
}
if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) {
crate::zoom_snap::page_input(ui, &resp, &xf, i, view);
}
if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) {
view.current = i;
open_boxes = true;
@ -1403,6 +1423,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
if tool == QuickTool::Crop && cropped {
tool = QuickTool::Select;
}
if let Some(done) = app.views[index].marquee_done.take() {
app.finish_marquee(index, done);
}
// A signature rectangle was drawn, or an empty signature field clicked.
let drawn = app.views[index].sign.drawn.take();
if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) {

View File

@ -308,6 +308,11 @@ impl PrintCraftApp {
}
"sign.panel" => self.right = Some(RightPanel::Signatures),
"optimize.advanced" => self.dialog = Some(Dialog::Optimize),
"view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom,
"edit.snapshot" => {
self.quick_tool = crate::QuickTool::Snapshot;
self.notify("Drag a rectangle around the area to copy");
}
"page.copy" => self.copy_pages(false),
"page.cut" => self.copy_pages(true),
"page.paste" => self.paste_pages(),

View File

@ -543,6 +543,8 @@ impl Host for crate::PrintCraftApp {
crate::QuickTool::Link => "link".to_string(),
crate::QuickTool::SignArea { certify: false } => "sign".to_string(),
crate::QuickTool::SignArea { certify: true } => "certify".to_string(),
crate::QuickTool::MarqueeZoom => "marquee-zoom".to_string(),
crate::QuickTool::Snapshot => "snapshot".to_string(),
crate::QuickTool::Stamp(k) => format!("stamp-{}", k.name().trim_start_matches("PC").to_ascii_lowercase()),
crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")),
crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")),

View File

@ -18,6 +18,7 @@ mod export_ui;
mod marks_ui;
mod optimize_ui;
mod sign_ui;
mod zoom_snap;
/// Header & footer / watermark / background dialog types (tests and automation).
pub mod marks {
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
@ -111,6 +112,10 @@ pub enum QuickTool {
SignArea {
certify: bool,
},
/// View ▸ Zoom ▸ Marquee Zoom.
MarqueeZoom,
/// Edit ▸ Take a Snapshot.
Snapshot,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -255,6 +260,10 @@ pub struct PrintCraftApp {
pub optimize_draft: OptimizeDraft,
/// Pages copied or cut in Organize Pages, ready to paste (into any document).
pub page_clipboard: Option<PageClip>,
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
/// system clipboard (tests turn that off).
pub last_snapshot: Option<(u32, u32, Vec<u8>)>,
pub system_clipboard: bool,
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
pub recovery: Option<RecoveryStore>,
/// Entries left by a previous session, offered in the Recovery dialog.
@ -360,6 +369,8 @@ impl PrintCraftApp {
sig_expanded: Vec::new(),
optimize_draft: OptimizeDraft::default(),
page_clipboard: None,
last_snapshot: None,
system_clipboard: true,
recovery: None,
recoverable: Vec::new(),
recovery_keys: Default::default(),
@ -826,6 +837,8 @@ impl PrintCraftApp {
"add-text" => QuickTool::AddText,
"link" => QuickTool::Link,
"sign" => QuickTool::SignArea { certify: false },
"marquee-zoom" => QuickTool::MarqueeZoom,
"snapshot" => QuickTool::Snapshot,
"certify" => QuickTool::SignArea { certify: true },
stamp if stamp.starts_with("stamp-") => QuickTool::Stamp(
printcraft_engine::StampKind::ALL

View File

@ -0,0 +1,106 @@
//! 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 printcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile};
use crate::canvas::{DocView, PageXform};
use crate::{PrintCraftApp, 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, page: usize, view: &mut DocView) {
let pointer = ui.input(|i| i.pointer.hover_pos());
let origin = ui.input(|i| i.pointer.press_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 PrintCraftApp {
/// 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;
}
match self.snapshot(index, page, view_rect) {
Ok((w, h)) => self.notify(format!("The selected area has been copied ({w} × {h} pixels)")),
Err(e) => self.notify(format!("Couldn't take the snapshot: {e}")),
}
}
_ => {}
}
}
/// 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 = printcraft_render::PageRenderer::new(doc.bytes.clone(), config);
let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0 });
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))
}
}

View File

@ -225,3 +225,39 @@ fn initial_view_is_edited_and_honoured_on_open() {
let view = &app.views[0];
assert_eq!((view.current, view.cover, app.right), (1, true, Some(printcraft_ui_egui::RightPanel::Bookmarks)));
}
fn drag(h: &mut Harness<'static, PrintCraftApp>, a: egui::Pos2, b: egui::Pos2) {
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(b);
h.run_steps(3);
}
#[test]
fn marquee_zoom_and_snapshot() {
let mut h = harness();
let i = h.state().active.unwrap();
// Text "Page 2 pages PAGE" sits at y 150 (of 300) from x 20.
let r = h.state().views[i].page_screen_rect(0).expect("on screen");
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height());
// Snapshot of the text line.
h.state_mut().system_clipboard = false;
assert!(h.state_mut().execute("edit.snapshot"));
drag(&mut h, at(15.0, 175.0), at(180.0, 140.0));
let (w, hgt, px) = h.state().last_snapshot.clone().expect("a snapshot");
assert!(w > 50 && hgt > 10, "{w} × {hgt}");
assert!(px.chunks_exact(4).any(|p| p[0] < 100), "the text is in it");
// Marquee zoom on a small area zooms in, centred on it.
let before = h.state().views[i].zoom;
h.state_mut().set_option("quick", "marquee-zoom").unwrap();
drag(&mut h, at(20.0, 160.0), at(60.0, 140.0));
h.run_steps(4);
let after = h.state().views[i].zoom;
assert!(after > before * 2.0, "{before} → {after}");
}

View File

@ -983,11 +983,11 @@ title = "Marquee zoom tool"
area = "B"
tier = "P1"
milestone = "M3"
status = "planned"
commands = []
status = "shipped"
commands = ["view.marquee_zoom"]
tools = []
evidence = []
notes = ""
evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"]
notes = "drag to fill the window with an area; click to zoom in"
[[feature]]
id = "view.dynamic-zoom"
@ -1511,11 +1511,11 @@ title = "Snapshot tool (copy a region as an image)"
area = "B"
tier = "P1"
milestone = "M3"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["edit.snapshot"]
tools = ["page_render"]
evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"]
notes = "copies the area at the current zoom to the clipboard as an image"
[[feature]]
id = "view.rulers"