mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M4: zoom the Organize page grid (#468)
* M4: zoom the Organize page grid The page grid draws its pages at 50% to 300%: toolbar buttons with a percentage that resets to 100%, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0 (which no longer change the hidden page view's zoom while the grid shows). Thumbnails are re-rendered sharper in a few steps as the grid grows, capped so one document's thumbnails stay within 256 MB. The page under the pointer (or the first one in view) stays where it is across a zoom. Co-Authored-By: Claude Opus 5.5 <[email protected]> * M4: zoomed grid pages are rendered at the size drawn A zoomed page grid stretched 132 pt thumbnails, and the stepped detail that was meant to help was capped by a memory budget, so a long document stayed blurry at any zoom. Pages drawn larger than their thumbnails now get a render at exactly the size drawn while they are in view; the thumbnail stands in until it arrives. Only pages in view keep one, so memory no longer depends on the page count, and thumbnails go back to their one fixed size. Co-Authored-By: Claude Opus 5.5 <[email protected]> * View: the middle button pans instead of drawing with markup tools egui's drag responses accept every pointer button, so on Windows and macOS a middle-button drag reached the page tools: with Draw (ink) selected it drew a stroke, and other tools cropped, selected or moved. Linux was unaffected because its middle-click auto-scroll already blocks page input. Outside Linux a middle-button drag that starts in the viewport (document or Organize Pages) now pans 1:1 while the button is held, with a Grabbing cursor, and page input is blocked from the press through the release. Linux keeps its latched auto-scroll. Co-Authored-By: Claude Opus 5.5 <[email protected]> * M3: a short page gone to from the Pages panel stays current The current page was whichever page showed most of itself. Going to a short page (a cheque) above a tall one scrolls it to the top, where the tall page below shows more, so the current page and the Pages panel's highlight moved to the tall page instead of the one clicked. The page navigated to now stays current while it is wholly on screen. Co-Authored-By: Claude Opus 5.5 <[email protected]> * M4: page operations from the Pages panel: context menu and drag to reorder Right-clicking a thumbnail offers Extract pages, Cut, Copy and Paste after, on the selection or on the page clicked (which becomes the selection), as the organize grid does. Dragging thumbnails moves the selection, or the page grabbed, to the gap it is dropped on: a bar marks the gap, the page count follows the pointer, and the panel scrolls near its edges. The move is one undoable step; the moved pages stay selected and the current page follows its page. Documents that forbid assembly keep click-only thumbnails. Parity: view.thumbnail-context-ops is shipped, with what it still lacks. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Pages panel: only the primary button drags thumbnails; a drag left over after the panel closed or the document changed is dropped --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Cody Vogt <[email protected]> Co-authored-by: William Max & Co. <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
17 files changed
+334
-30
No files matched your search
+10
-10
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2d56f8464f3c25efe9737b204db34cef012ea3716f4d78209d9abeaf0ea9393b"
|
||||
sha256 = "844da6ed41f9baf939e1a99042dec5904447ca82fb5662c4361a9a809adb19f8"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "b0ca648560c45e554447aae8dba37c1c9c636c86b277d00fceeb2a21b474c8f7"
|
||||
sha256 = "e049f7df877d07137465cbe7ead240c9cd60daaaeb298f4877eb58974c728004"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05"
|
||||
sha256 = "f47c19dcf7272068dc8b46a37eb692948f5571737f6c1c82e8282f1b7d64458d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0351382a91d2df51da3d45e25a09af87a081bc88e3496c3512ad89f5d4460a3a"
|
||||
sha256 = "8fd69fd5e769259b99b059adb3a9d2fb90489bf561d62113ff8d25227495e122"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "57f52e3671a322938a160ac11b73b1f55fb6311140f20d89ae12c9a91a0500d6"
|
||||
sha256 = "8b380ba241bc25a05289717a3bfa4ea580be1c78b43c91d54c72de9c024b06eb"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "e505c00bcc8b8af6aec27abf84bebac7a1925c632130411000a6c57241816193"
|
||||
sha256 = "ab089d2dab457a727158c3414dbe28b4edc3231cd30b5092feeeed3d8feca8a5"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "c198f1de831d7f9c853baa6977894ccc34d94e6b36c772c2e53fc5b0e0fe708b"
|
||||
sha256 = "59ec12395bc45ed15a0de3b2d0878d2ea250bfd9e9372103d8869b142bcc675f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "9682b2c3c28449dc63637cb5962b309df3ad7db7e8d96bf8bb0f0bc386c9c611"
|
||||
sha256 = "ca7c441f30dc116349f0aa523e8a6cfc8e019bf7ff73f377f0264f9dcda9f133"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "319b032d2c50097ec14e2c7eab92e8696d33e198aeac3585ff46e98c61dec90f"
|
||||
sha256 = "cba0c2ca8989a28aa41056b98eb1c8019e3a13e882ead246ea1d69f82c44e087"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f3a03fc466c1f956cbd44a5cad101c53db5ffc80b3b7551a273ac41f43263dd5"
|
||||
sha256 = "bdb59f15d5659bd54fb4316b37f3e50f7c352640aeef1860be87700682d3bb48"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
||||
@@ -140,6 +140,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
- **2026-10-09 (M2, bounded page labels):** Inspector number trees have shared work, depth, and cycle limits; prefixes are bounded before decoding and complete custom labels have per-label and aggregate UTF-8 byte caps. A refused tree leaves physical page numbers intact and records one warning, with custom labels applied only after every range succeeds. Pure COS Roman and repeated-letter formatters are shared with Organize; label queries and edits return errors for unsupported output before changing the stored tree, while ordinary labels and prefix-only ranges remain supported. Synthetic tests cover safe just-over-limit cases, large first numbers, aliases, cycles, wide/deep trees, UTF-16/UTF-8 boundaries, aggregate output, and unchanged refused edits. Milestone and overall effort estimates unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
|
||||
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
|
||||
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
+160
-15
@@ -23,6 +23,8 @@ const MARGIN: f32 = 28.0;
|
||||
const SIDE: f32 = 70.0;
|
||||
const THUMB_TAG: u64 = 1 << 63;
|
||||
const TEXT_TAG: u64 = 1 << 62;
|
||||
/// A page rendered for the organize grid at the size it is drawn there.
|
||||
const GRID_TAG: u64 = 1 << 61;
|
||||
/// The tag of a raster that is out of date (shown until its replacement arrives).
|
||||
const STALE_TAG: u64 = u64::MAX;
|
||||
/// Pages whose raster would exceed this many device pixels on a side are drawn in tiles.
|
||||
@@ -303,6 +305,29 @@ pub struct DocView {
|
||||
/// The page-grid gap under the pointer this frame (where dropped files go), when it can
|
||||
/// take pages.
|
||||
pub grid_gap: Option<usize>,
|
||||
/// How large the page grid draws its pages (1.0 = the usual thumbnails).
|
||||
grid_zoom: f32,
|
||||
/// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid
|
||||
/// out, so it can keep the same pages in view.
|
||||
grid_zoom_request: Option<f32>,
|
||||
/// The page to keep in place after a grid zoom, and how far below the top of the grid its
|
||||
/// cell was.
|
||||
grid_anchor: Option<(usize, f32)>,
|
||||
/// Sharp renders of the pages the grid shows larger than a thumbnail: only those in view,
|
||||
/// at the size drawn (device pixels wide).
|
||||
grid_pages: HashMap<usize, (u32, TextureHandle)>,
|
||||
}
|
||||
|
||||
/// The page grid's zoom range and the step of its buttons and keys.
|
||||
pub const GRID_ZOOM_RANGE: std::ops::RangeInclusive<f32> = 0.5..=3.0;
|
||||
const GRID_ZOOM_STEP: f32 = 1.25;
|
||||
/// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes
|
||||
/// the size on every frame, and a slightly soft or slightly large image is fine meanwhile.
|
||||
const GRID_SHARP: std::ops::RangeInclusive<f32> = 0.9..=1.6;
|
||||
|
||||
/// Whether an image `have` device pixels wide is good enough to draw `want` pixels wide.
|
||||
fn sharp_enough(have: u32, want: f32) -> bool {
|
||||
want > 0.0 && GRID_SHARP.contains(&(have as f32 / want))
|
||||
}
|
||||
|
||||
/// Organize-toolbar actions that need the app (file pickers, new tabs, dialogs).
|
||||
@@ -408,6 +433,10 @@ impl DocView {
|
||||
pending_action: None,
|
||||
insert_at: None,
|
||||
grid_gap: None,
|
||||
grid_zoom: 1.0,
|
||||
grid_zoom_request: None,
|
||||
grid_anchor: None,
|
||||
grid_pages: HashMap::new(),
|
||||
comments: Default::default(),
|
||||
measure: Default::default(),
|
||||
forms: Default::default(),
|
||||
@@ -509,6 +538,8 @@ impl DocView {
|
||||
p.tag = STALE_TAG;
|
||||
}
|
||||
self.stale_thumbs.extend(self.thumbs.keys().copied());
|
||||
// Pages may have moved: a sharp render of another page would be worse than a soft one.
|
||||
self.grid_pages.clear();
|
||||
self.tiles.clear();
|
||||
self.texts.clear();
|
||||
self.text_failed.clear();
|
||||
@@ -901,6 +932,23 @@ impl DocView {
|
||||
true
|
||||
}
|
||||
|
||||
/// How large the page grid draws its pages (1.0 = the usual thumbnails).
|
||||
pub fn grid_zoom(&self) -> f32 {
|
||||
self.grid_zoom
|
||||
}
|
||||
|
||||
/// Zoom the page grid; out-of-range values are clamped and nonsense is ignored.
|
||||
pub fn set_grid_zoom(&mut self, zoom: f32) {
|
||||
if zoom.is_finite() {
|
||||
self.grid_zoom = zoom.clamp(*GRID_ZOOM_RANGE.start(), *GRID_ZOOM_RANGE.end());
|
||||
}
|
||||
}
|
||||
|
||||
/// How many device pixels wide the grid's sharp render of `page` is, if it has one.
|
||||
pub fn grid_page_pixels(&self, page: usize) -> Option<u32> {
|
||||
self.grid_pages.get(&page).map(|(w, _)| *w)
|
||||
}
|
||||
|
||||
/// Zoom keeping the centre of the view still.
|
||||
pub fn set_zoom(&mut self, zoom: f32) {
|
||||
let centre = self.viewport_screen.center();
|
||||
@@ -968,7 +1016,10 @@ impl DocView {
|
||||
self.tiles.insert((page, r.request.tag, t.x / TILE, t.y / TILE), tex);
|
||||
continue;
|
||||
}
|
||||
if r.request.tag & THUMB_TAG != 0 {
|
||||
if r.request.tag & GRID_TAG != 0 {
|
||||
let tex = ctx.load_texture(format!("grid-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
|
||||
self.grid_pages.insert(page, (r.width, tex));
|
||||
} else if r.request.tag & THUMB_TAG != 0 {
|
||||
let tex = ctx.load_texture(format!("thumb-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
|
||||
self.thumbs.insert(page, tex);
|
||||
self.stale_thumbs.remove(&page);
|
||||
@@ -1205,15 +1256,29 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) {
|
||||
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) {
|
||||
view.rotate_view(false);
|
||||
}
|
||||
if pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)) {
|
||||
view.zoom_step(true);
|
||||
}
|
||||
if pressed(cmd(Key::Minus)) {
|
||||
view.zoom_step(false);
|
||||
}
|
||||
if pressed(cmd(Key::Num0)) {
|
||||
view.fit = Fit::Page;
|
||||
view.goto = Some((view.current, 0.0));
|
||||
// In the page grid ⌘+ / ⌘− / ⌘0 size its pages instead (looking is always allowed).
|
||||
let (zoom_in, zoom_out, zoom_reset) = (pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)), pressed(cmd(Key::Minus)), pressed(cmd(Key::Num0)));
|
||||
if view.organize {
|
||||
if zoom_in {
|
||||
view.grid_zoom_request = Some(view.grid_zoom * GRID_ZOOM_STEP);
|
||||
}
|
||||
if zoom_out {
|
||||
view.grid_zoom_request = Some(view.grid_zoom / GRID_ZOOM_STEP);
|
||||
}
|
||||
if zoom_reset {
|
||||
view.grid_zoom_request = Some(1.0);
|
||||
}
|
||||
} else {
|
||||
if zoom_in {
|
||||
view.zoom_step(true);
|
||||
}
|
||||
if zoom_out {
|
||||
view.zoom_step(false);
|
||||
}
|
||||
if zoom_reset {
|
||||
view.fit = Fit::Page;
|
||||
view.goto = Some((view.current, 0.0));
|
||||
}
|
||||
}
|
||||
if pressed(cmd(Key::Num1)) {
|
||||
view.set_zoom(1.0);
|
||||
@@ -2834,6 +2899,30 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b
|
||||
if save.on_hover_text(tl!("Save these pages as one PDF")).on_disabled_hover_text(tl!("No changes to save")).clicked() {
|
||||
view.pending_action = Some(ViewAction::Save);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Page size in the grid (right to left: in, the percentage, out).
|
||||
let zoom = view.grid_zoom;
|
||||
if ui
|
||||
.add_enabled_ui(zoom < *GRID_ZOOM_RANGE.end(), |ui| icons::button(ui, "zoom-in", 30.0, false, tl!("Larger pages")))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
view.grid_zoom_request = Some(zoom * GRID_ZOOM_STEP);
|
||||
}
|
||||
let percent = egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted))
|
||||
.frame(false);
|
||||
let percent = ui.add_sized([44.0, 30.0], percent);
|
||||
percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size")));
|
||||
if percent.on_hover_text(tl!("Reset page size")).clicked() {
|
||||
view.grid_zoom_request = Some(1.0);
|
||||
}
|
||||
if ui
|
||||
.add_enabled_ui(zoom > *GRID_ZOOM_RANGE.start(), |ui| icons::button(ui, "zoom-out", 30.0, false, tl!("Smaller pages")))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
view.grid_zoom_request = Some(zoom / GRID_ZOOM_STEP);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2901,7 +2990,9 @@ fn organize_grid(
|
||||
t: &Tokens,
|
||||
) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let cell = vec2(190.0, 250.0);
|
||||
// The page image scales with the zoom; the padding and the page number don't.
|
||||
let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 56.0);
|
||||
let anchor = view.grid_anchor.take();
|
||||
let mut open_page = None;
|
||||
organize_toolbar(view, info, editable, dirty, ui, t);
|
||||
let viewport = ui.available_rect_before_wrap();
|
||||
@@ -2914,6 +3005,9 @@ fn organize_grid(
|
||||
};
|
||||
let middle_gesture = view.auto_scroll.blocks_input();
|
||||
let mut cells: Vec<(usize, Rect)> = Vec::with_capacity(info.pages.len());
|
||||
// The pages in view that are drawn larger than their thumbnails, and those of them that
|
||||
// need a sharper render than they have.
|
||||
let (mut in_view, mut sharper): (Vec<usize>, Vec<RenderRequest>) = (Vec::new(), Vec::new());
|
||||
let mut drop = false;
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if middle_gesture {
|
||||
@@ -2936,6 +3030,9 @@ fn organize_grid(
|
||||
let c = Rect::from_min_size(pos2(row_rect.left() + left + col as f32 * cell.x, row_rect.top()), cell);
|
||||
let resp = ui.interact(c, ui.id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() });
|
||||
cells.push((i, c));
|
||||
if let Some((_, above)) = anchor.filter(|(page, _)| *page == i) {
|
||||
ui.scroll_to_rect_animation(c.translate(vec2(0.0, -above)), Some(egui::Align::Min), egui::style::ScrollAnimation::none());
|
||||
}
|
||||
// Drag pages to move them (the selection, or the page grabbed).
|
||||
if resp.drag_started() {
|
||||
if !view.selected.contains(&i) {
|
||||
@@ -2963,7 +3060,19 @@ fn organize_grid(
|
||||
}
|
||||
ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow);
|
||||
ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE);
|
||||
if let Some(tex) = view.thumbs.get(&i) {
|
||||
// A page drawn larger than its thumbnail gets a render at the size drawn, while
|
||||
// it is in view; until that arrives the thumbnail stands in.
|
||||
let want = size.x * ppp;
|
||||
let thumb = view.thumbs.get(&i);
|
||||
let thumb_ok = thumb.is_some_and(|t| t.size()[0] as f32 >= want * GRID_SHARP.start());
|
||||
let sharp = view.grid_pages.get(&i).filter(|_| !thumb_ok);
|
||||
if !thumb_ok && ui.is_rect_visible(c) {
|
||||
in_view.push(i);
|
||||
if !sharp.is_some_and(|(w, _)| sharp_enough(*w, want)) && !view.errors.contains_key(&i) {
|
||||
sharper.push(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: want / p.width.max(1.0), tag: GRID_TAG });
|
||||
}
|
||||
}
|
||||
if let Some(tex) = sharp.map(|(_, t)| t).or(thumb) {
|
||||
ui.painter().image(tex.id(), pr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().rect_stroke(pr, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||||
@@ -3072,11 +3181,30 @@ fn organize_grid(
|
||||
}
|
||||
}
|
||||
view.auto_scroll.paint(ui, viewport);
|
||||
// Pinch, or Ctrl/⌘ with the wheel, zooms the grid about the page under the pointer.
|
||||
let pinch = ui.input(|i| i.zoom_delta());
|
||||
if (pinch - 1.0).abs() > 0.001 && pointer.is_some() && !middle_gesture {
|
||||
view.grid_zoom_request = Some(view.grid_zoom * pinch);
|
||||
}
|
||||
if let Some(zoom) = view.grid_zoom_request.take() {
|
||||
let before = view.grid_zoom;
|
||||
view.set_grid_zoom(zoom);
|
||||
if view.grid_zoom != before {
|
||||
// Keep the page under the pointer (or else the first one in view) where it is.
|
||||
let under = pointer.and_then(|p| cells.iter().find(|(_, r)| r.contains(p)));
|
||||
let anchor = under.or_else(|| cells.iter().find(|(_, r)| r.bottom() > viewport.top()));
|
||||
view.grid_anchor = anchor.map(|(i, r)| (*i, r.top() - viewport.top()));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
// Sharp renders are kept only for the pages in view, so their memory stays small however
|
||||
// long the document is. They come first; the thumbnails of the other pages follow.
|
||||
view.grid_pages.retain(|page, _| in_view.contains(page));
|
||||
let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max);
|
||||
let queue: Vec<RenderRequest> = (0..info.pages.len())
|
||||
let thumbs = (0..info.pages.len())
|
||||
.filter(|p| (!view.thumbs.contains_key(p) || view.stale_thumbs.contains(p)) && !view.errors.contains_key(p))
|
||||
.map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG })
|
||||
.collect();
|
||||
.map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG });
|
||||
let queue: Vec<RenderRequest> = sharper.into_iter().chain(thumbs).collect();
|
||||
if queue != view.last_queue {
|
||||
pool.set_queue(queue.clone());
|
||||
view.last_queue = queue;
|
||||
@@ -3123,6 +3251,23 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(img.pixels.as_ptr().cast::<u8>(), at, "the renderer's own buffer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_zoom_is_clamped_and_sharp_renders_tolerate_a_pinch() {
|
||||
assert!(sharp_enough(900, 876.0) && sharp_enough(800, 876.0) && sharp_enough(1300, 876.0));
|
||||
assert!(!sharp_enough(264, 876.0), "a thumbnail stretched over a zoomed page");
|
||||
assert!(!sharp_enough(876, 200.0), "far larger than drawn: wasteful");
|
||||
assert!(!sharp_enough(100, 0.0) && !sharp_enough(100, f32::NAN));
|
||||
let mut v = view(3, PageLayout::Continuous);
|
||||
for odd in [f32::NAN, f32::INFINITY] {
|
||||
v.set_grid_zoom(odd);
|
||||
assert_eq!(v.grid_zoom(), 1.0);
|
||||
}
|
||||
v.set_grid_zoom(99.0);
|
||||
assert_eq!(v.grid_zoom(), 3.0);
|
||||
v.set_grid_zoom(-4.0);
|
||||
assert_eq!(v.grid_zoom(), 0.5);
|
||||
}
|
||||
|
||||
fn view(pages: usize, layout: PageLayout) -> DocView {
|
||||
let info = pdfcraft_render::DocInfo {
|
||||
pages: (0..pages)
|
||||
|
||||
@@ -553,6 +553,7 @@ impl Host for crate::PdfCraftApp {
|
||||
"fit": format!("{:?}", v.fit),
|
||||
"layout": format!("{:?}", v.layout),
|
||||
"organize": v.organize,
|
||||
"grid_zoom": v.grid_zoom(),
|
||||
// Pages picked in the organize grid or the Pages panel (empty: the current page).
|
||||
"selected_pages": v.selected.iter().map(|p| p + 1).collect::<Vec<_>>(),
|
||||
"auto_scrolling": v.auto_scrolling(),
|
||||
|
||||
@@ -2049,3 +2049,6 @@ action wizard Start Старт
|
||||
Remove from Recent Премахни от последно отваряните
|
||||
Remove {name} from Recent Премахни {name} от последно отваряните
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартните шрифтове не могат да покажат „{c}“ ({code}). Засега като текст могат да се добавят само западноевропейски символи.
|
||||
Larger pages По-големи страници
|
||||
Smaller pages По-малки страници
|
||||
Reset page size Възстанови размера на страниците
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2087,3 +2087,6 @@ action wizard Start Starten
|
||||
Remove from Recent Aus „Zuletzt geöffnet“ entfernen
|
||||
Remove {name} from Recent {name} aus „Zuletzt geöffnet“ entfernen
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Die Standardschriften können „{c}“ ({code}) nicht darstellen. Derzeit lassen sich nur westeuropäische Zeichen als Text hinzufügen.
|
||||
Larger pages Größere Seiten
|
||||
Smaller pages Kleinere Seiten
|
||||
Reset page size Seitengröße zurücksetzen
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2005,3 +2005,6 @@
|
||||
Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e}
|
||||
Go to page… Ir a la página…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Las fuentes estándar no pueden mostrar «{c}» ({code}). Por ahora solo se pueden añadir como texto caracteres de Europa occidental.
|
||||
Larger pages Páginas más grandes
|
||||
Smaller pages Páginas más pequeñas
|
||||
Reset page size Restablecer el tamaño de las páginas
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -2040,3 +2040,6 @@
|
||||
No recent files Aucun fichier récent
|
||||
Go to page… Aller à la page…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Les polices standard ne peuvent pas afficher « {c} » ({code}). Pour l'instant, seuls les caractères d'Europe occidentale peuvent être ajoutés comme texte.
|
||||
Larger pages Pages plus grandes
|
||||
Smaller pages Pages plus petites
|
||||
Reset page size Rétablir la taille des pages
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2085,3 +2085,6 @@ action wizard Start Indítás
|
||||
Rotation: Elforgatás:
|
||||
Line ending Vonalvég
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. A szabványos betűtípusok nem tudják megjeleníteni ezt: „{c}” ({code}). Egyelőre csak nyugat-európai karakterek adhatók hozzá szövegként.
|
||||
Larger pages Nagyobb oldalak
|
||||
Smaller pages Kisebb oldalak
|
||||
Reset page size Oldalméret visszaállítása
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -1981,3 +1981,6 @@
|
||||
Go to page… ページへ移動…
|
||||
Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準フォントでは「{c}」({code})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。
|
||||
Larger pages ページを大きく表示
|
||||
Smaller pages ページを小さく表示
|
||||
Reset page size ページサイズをリセット
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2001,3 +2001,6 @@
|
||||
Created a PDF from the first {n} files PDF создан из первых файлов: {n}
|
||||
Go to page… Перейти на страницу…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартные шрифты не могут отобразить «{c}» ({code}). Пока в виде текста можно добавлять только западноевропейские символы.
|
||||
Larger pages Крупнее страницы
|
||||
Smaller pages Мельче страницы
|
||||
Reset page size Сбросить размер страниц
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2107,3 +2107,6 @@ calendar date December грудня
|
||||
Rotation: Поворот:
|
||||
Line ending Закінчення лінії
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартні шрифти не можуть відобразити «{c}» ({code}). Поки що як текст можна додавати лише західноєвропейські символи.
|
||||
Larger pages Більші сторінки
|
||||
Smaller pages Менші сторінки
|
||||
Reset page size Скинути розмір сторінок
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2051,3 +2051,6 @@
|
||||
Couldn't import the signature image: {e} 无法导入签名图像:{e}
|
||||
Go to page… 转到页面…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 标准字体无法显示“{c}”({code})。目前只能添加西欧字符作为文本。
|
||||
Larger pages 放大页面
|
||||
Smaller pages 缩小页面
|
||||
Reset page size 重置页面大小
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1974,3 +1974,6 @@
|
||||
Go to page… 轉到頁面…
|
||||
Reopen the files that were open when PdfCraft last closed 重新開啟上次關閉 PdfCraft 時開啟的檔案
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準字型無法顯示「{c}」({code})。目前只能新增西歐字元作為文字。
|
||||
Larger pages 放大頁面
|
||||
Smaller pages 縮小頁面
|
||||
Reset page size 重設頁面大小
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1420,6 +1420,14 @@ impl PdfCraftApp {
|
||||
self.view_defaults.with_zoom(value).ok_or("default-zoom must be fit-width, fit-page or a percentage from 8 to 6400")?;
|
||||
}
|
||||
("organize", Some(v)) => v.organize = value != "off",
|
||||
// `--grid-zoom 150`: the size of the pages in the organize grid, in percent.
|
||||
("grid-zoom", Some(v)) => {
|
||||
let percent = value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())?;
|
||||
if !percent.is_finite() || !canvas::GRID_ZOOM_RANGE.contains(&(percent / 100.0)) {
|
||||
return Err("grid-zoom must be between 50 and 300".into());
|
||||
}
|
||||
v.set_grid_zoom(percent / 100.0);
|
||||
}
|
||||
("rotate", Some(v)) => {
|
||||
let deg: u16 = value.parse().map_err(|_| "rotate: 0, 90, 180 or 270")?;
|
||||
if !deg.is_multiple_of(90) {
|
||||
@@ -1506,7 +1514,10 @@ impl PdfCraftApp {
|
||||
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.reveal = true;
|
||||
}
|
||||
(k, None) if ["page", "zoom", "layout", "cover", "organize", "fields", "find", "rotate", "select", "notice", "comment"].contains(&k) => {
|
||||
(k, None)
|
||||
if ["page", "zoom", "layout", "cover", "organize", "grid-zoom", "fields", "find", "rotate", "select", "notice", "comment"]
|
||||
.contains(&k) =>
|
||||
{
|
||||
return Err(format!("`{k}` needs an open document"));
|
||||
}
|
||||
(other, _) => return Err(format!("unknown option {other}")),
|
||||
|
||||
@@ -1227,6 +1227,117 @@ fn files_dropped_outside_the_page_grid_still_open_as_documents() {
|
||||
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
|
||||
}
|
||||
|
||||
fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 {
|
||||
h.state().views[0].grid_zoom()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_grid_zooms_with_its_buttons_keys_and_pinch() {
|
||||
let mut h = organize(12);
|
||||
let width = |h: &Harness<'static, PdfCraftApp>| h.get_by_label("Page 2").rect().left() - h.get_by_label("Page 1").rect().left();
|
||||
let usual = width(&h);
|
||||
h.get_by_label("Larger pages").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(grid_zoom(&h), 1.25);
|
||||
assert!(width(&h) > usual + 20.0, "the pages are drawn larger");
|
||||
h.get_by_label("Reset page size").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!((grid_zoom(&h), width(&h)), (1.0, usual));
|
||||
h.get_by_label("Smaller pages").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(grid_zoom(&h), 0.8);
|
||||
assert!(width(&h) < usual - 20.0);
|
||||
// Keys.
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Num0);
|
||||
h.run_steps(2);
|
||||
assert_eq!(grid_zoom(&h), 1.0);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Equals);
|
||||
h.run_steps(2);
|
||||
assert_eq!(grid_zoom(&h), 1.25);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Minus);
|
||||
h.run_steps(2);
|
||||
assert_eq!(grid_zoom(&h), 1.0);
|
||||
// Pinch (or Ctrl/⌘ + wheel) over the grid; elsewhere it leaves the grid alone.
|
||||
let page = h.get_by_label("Page 1").rect().center();
|
||||
h.hover_at(page);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::Zoom(1.5));
|
||||
h.run_steps(2);
|
||||
assert_eq!(grid_zoom(&h), 1.5);
|
||||
h.hover_at(h.get_by_label("Larger pages").rect().center());
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::Zoom(1.5));
|
||||
h.run_steps(2);
|
||||
assert_eq!(grid_zoom(&h), 1.5, "the pointer was on the toolbar");
|
||||
// It stops at its limits, where the buttons switch off.
|
||||
for _ in 0..12 {
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Equals);
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(grid_zoom(&h), 3.0);
|
||||
h.get_by_label("Larger pages").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(grid_zoom(&h), 3.0);
|
||||
for _ in 0..20 {
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Minus);
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(grid_zoom(&h), 0.5);
|
||||
assert!(!dirty(&h), "zooming is not an edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomed_pages_in_view_are_rendered_at_the_size_drawn() {
|
||||
// A long document: sharpness must not depend on how many pages there are.
|
||||
let mut h = harness(400, |app| {
|
||||
app.set_option("organize", "on").unwrap();
|
||||
app.set_option("grid-zoom", "300").unwrap();
|
||||
});
|
||||
let drawn = 146.0 * 3.0 * h.ctx.pixels_per_point();
|
||||
let sharp = |h: &Harness<'static, PdfCraftApp>, page| h.state().views[0].grid_page_pixels(page);
|
||||
for _ in 0..200 {
|
||||
if sharp(&h, 0).is_some_and(|w| w as f32 >= drawn * 0.9) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
h.run_steps(1);
|
||||
}
|
||||
let width = sharp(&h, 0).expect("the first page has a sharp render");
|
||||
assert!((drawn * 0.9..=drawn * 1.6).contains(&(width as f32)), "{width} for {drawn}");
|
||||
assert!(sharp(&h, 399).is_none(), "pages out of view have none");
|
||||
// Zoomed out again, thumbnails are enough and the sharp renders are dropped.
|
||||
h.state_mut().set_option("grid-zoom", "50").unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(sharp(&h, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap() {
|
||||
let dir = temp_path("zoomed-grid");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let note = dir.join("note.txt");
|
||||
std::fs::write(¬e, "a note").unwrap();
|
||||
let mut h = harness(3, |app| {
|
||||
app.set_option("organize", "on").unwrap();
|
||||
app.set_option("grid-zoom", "200").unwrap();
|
||||
assert!(
|
||||
app.set_option("grid-zoom", "20").is_err() && app.set_option("grid-zoom", "NaN").is_err() && app.set_option("grid-zoom", "big").is_err()
|
||||
);
|
||||
});
|
||||
assert_eq!(grid_zoom(&h), 2.0);
|
||||
h.state_mut().pick_override = Some(vec![note.to_string_lossy().into_owned()]);
|
||||
h.get_by_label("Insert a file before page 2").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(texts_of(h.state(), 0), ["Page 1", "a note", "Page 2", "Page 3"]);
|
||||
// Files dropped over a gap go there too.
|
||||
let gap = h.get_by_label("Insert a file before page 2").rect().center();
|
||||
h.hover_at(gap + egui::vec2(0.0, 60.0));
|
||||
h.run_steps(2);
|
||||
drop_files(&mut h, vec![Dropped("more.txt", b"more".to_vec())]);
|
||||
h.run_steps(4);
|
||||
assert_eq!(texts_of(h.state(), 0), ["Page 1", "more", "a note", "Page 2", "Page 3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_pages_writes_what_the_grid_shows() {
|
||||
let path = temp_path("grid-save.pdf");
|
||||
@@ -1351,6 +1462,10 @@ fn restricted_documents_show_a_notice_and_block_page_changes() {
|
||||
h.run_steps(3);
|
||||
assert_eq!(texts_of(h.state(), 0).len(), 2, "page changes are blocked");
|
||||
assert_eq!(h.query_all_by_label_contains("Insert a file").count(), 0, "nothing to insert into");
|
||||
// Looking closer is not a page change.
|
||||
h.get_by_label("Larger pages").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(grid_zoom(&h), 1.25);
|
||||
assert!(!dirty(&h));
|
||||
h.get_by_label("Security settings").click();
|
||||
h.run_steps(3);
|
||||
|
||||
@@ -2815,11 +2815,11 @@ title = "Thumbnail size slider"
|
||||
area = "D"
|
||||
tier = "P2"
|
||||
milestone = "M4"
|
||||
status = "planned"
|
||||
commands = []
|
||||
status = "shipped"
|
||||
commands = ["page.organize"]
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn"]
|
||||
notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. Missing: a slider as in Acrobat; the size is per document view and not remembered between sessions; the Pages side panel has no size control."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.split-every-n"
|
||||
|
||||
Reference in new issue
Block a user