mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
1 parent
6062cd6a44
commit
bf409db55c
7 files changed
+118
-21
No files matched your search
@@ -256,6 +256,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view.
|
||||
- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
@@ -2136,8 +2136,7 @@ fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> (
|
||||
if !view.continuous {
|
||||
// The row's bounding box on screen (one page, or a two-page spread).
|
||||
let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union);
|
||||
// Within a point, so layout rounding can't stop a fitting page from turning.
|
||||
let fits = row_rect.is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0);
|
||||
let fits = row_rect.is_some_and(|r| fits_height(r, avail.height(), ui.ctx().pixels_per_point()));
|
||||
let can_turn = !middle_gesture && unobstructed && ui.rect_contains_pointer(avail);
|
||||
single_paging = can_turn && fits;
|
||||
// Every wheel event goes to the pager, so it follows each trackpad touch to its end
|
||||
@@ -2936,6 +2935,28 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry:
|
||||
scroll
|
||||
}
|
||||
|
||||
/// Where the `shown` pages sit in the scroll content, as how far the content starts into the
|
||||
/// layout and how tall it is. Scrolling continuously every page scrolls; otherwise only the
|
||||
/// current row does, the content being its bounding box plus margins. Pages that fit a view
|
||||
/// `view_height` tall sit in its middle, the spare height shared above and below (#5708): the
|
||||
/// content fills the view, so there is nothing to scroll, and starts on a whole physical pixel
|
||||
/// as the layout's pages do, or their edges would blur and the seams between them open.
|
||||
fn vertical_placement(continuous: bool, rects: &[Rect], shown: &[usize], view_height: f32, pixels_per_point: f32) -> (f32, f32) {
|
||||
let Some(r) = shown.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) else { return (0.0, 0.0) };
|
||||
if fits_height(r, view_height, pixels_per_point) {
|
||||
return (((r.center().y - view_height / 2.0) * pixels_per_point).round() / pixels_per_point, view_height);
|
||||
}
|
||||
if continuous { (0.0, r.bottom() + MARGIN) } else { (r.top() - MARGIN, r.height() + 2.0 * MARGIN) }
|
||||
}
|
||||
|
||||
/// Whether the pages in `r` show whole in a view `view_height` tall, without their margins.
|
||||
/// Within half a physical pixel, which is all that snapping the pages to pixels can add: a
|
||||
/// fitting page is centred and has nothing to scroll, so any more would be cut off unseen.
|
||||
fn fits_height(r: Rect, view_height: f32, pixels_per_point: f32) -> bool {
|
||||
let slack = if pixels_per_point.is_finite() && pixels_per_point > 0.0 { 0.5 / pixels_per_point } else { 0.0 };
|
||||
r.height() <= view_height + slack
|
||||
}
|
||||
|
||||
/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a
|
||||
/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests.
|
||||
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
@@ -3057,15 +3078,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// row, the jump to the new page scrolled both out of view, a blank frame on every turn forward.
|
||||
let row = shown_row(view, &rects);
|
||||
let visible_pages: Vec<usize> = if view.continuous { (0..rects.len()).collect() } else { row.clone() };
|
||||
let (y_shift, content_h) = if view.continuous {
|
||||
(0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0))
|
||||
} else {
|
||||
// Only the current row scrolls into view; the content is its bounding box plus margins.
|
||||
match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) {
|
||||
Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN),
|
||||
None => (0.0, 0.0),
|
||||
}
|
||||
};
|
||||
let (y_shift, content_h) = vertical_placement(view.continuous, &rects, &visible_pages, avail.height(), ui.ctx().pixels_per_point());
|
||||
|
||||
let mut scroll = page_scroll_area(
|
||||
ui,
|
||||
|
||||
@@ -2560,3 +2560,23 @@ fn enhancing_a_scan_re_renders_the_page_shown() {
|
||||
assert!(app.scan_clean_run.is_none());
|
||||
assert_eq!(app.views[i].pages.get(&0).unwrap().tag, STALE_TAG, "the old raster is re-rendered");
|
||||
}
|
||||
|
||||
/// #5708 review: a page that fits is centred with nothing to scroll, so only pixel snapping may
|
||||
/// pass for fitting. A page half a pixel or more too tall scrolls, or its ends would be cut off.
|
||||
#[test]
|
||||
fn only_pixel_snapping_passes_for_fitting_the_view() {
|
||||
let page = |height: f32| [Rect::from_min_size(pos2(0.0, MARGIN), vec2(300.0, height))];
|
||||
for ppp in [1.0, 2.0] {
|
||||
let slack = 0.5 / ppp;
|
||||
let (_, content) = vertical_placement(false, &page(600.0 + slack), &[0], 600.0, ppp);
|
||||
assert_eq!(content, 600.0, "at {ppp}x, snapping's half pixel still fits");
|
||||
for over in [slack + 0.1, 1.0] {
|
||||
let (shift, content) = vertical_placement(false, &page(600.0 + over), &[0], 600.0, ppp);
|
||||
assert_eq!((shift, content), (0.0, 600.0 + over + 2.0 * MARGIN), "at {ppp}x, {over} pt too tall scrolls");
|
||||
}
|
||||
}
|
||||
// A scale that isn't one has no slack and never panics.
|
||||
for ppp in [0.0, f32::NAN, f32::INFINITY] {
|
||||
assert!(!fits_height(Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 600.5)), 600.0, ppp));
|
||||
}
|
||||
}
|
||||
@@ -236,25 +236,25 @@ fn page_hover_cursors_stay_off_while_a_dialog_is_open() {
|
||||
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();
|
||||
// Small enough that the check box sits above the dialog card.
|
||||
app.set_option("zoom", "100").unwrap();
|
||||
// Large enough that the drop-down sits below the dialog card.
|
||||
app.set_option("zoom", "300").unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..30 {
|
||||
h.run_steps(2);
|
||||
}
|
||||
// The middle of the "agree" check box (/Rect [20 310 36 326] on a 300 × 400 page).
|
||||
// The middle of the "country" drop-down (/Rect [20 220 200 240] on a 300 × 400 page).
|
||||
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let k = page.width() / 300.0;
|
||||
let check = page.min + egui::vec2(28.0 * k, (400.0 - 318.0) * k);
|
||||
h.hover_at(check);
|
||||
let field = page.min + egui::vec2(110.0 * k, (400.0 - 230.0) * k);
|
||||
h.hover_at(field);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the check box takes clicks");
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the drop-down takes clicks");
|
||||
h.state_mut().set_option("dialog", "security").unwrap();
|
||||
h.run_steps(4);
|
||||
let card = card(&h, "dialog");
|
||||
assert!(!card.contains(check), "the check box {check:?} is beside the dialog {card:?}, under its backdrop");
|
||||
h.hover_at(check + egui::vec2(1.0, 0.0));
|
||||
assert!(!card.contains(field), "the drop-down {field:?} is beside the dialog {card:?}, under its backdrop");
|
||||
h.hover_at(field + egui::vec2(1.0, 0.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "nothing under the dialog's backdrop can be clicked");
|
||||
}
|
||||
@@ -1345,6 +1345,68 @@ fn single_page_fit_page_fits_the_shown_page() {
|
||||
assert_ne!(h.state().views[0].zoom, z1, "the wider page refits instead of keeping page 1's zoom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_shorter_than_the_window_sit_in_its_middle() {
|
||||
let centred = |h: &Harness<'static, PdfCraftApp>, r: egui::Rect| {
|
||||
let vp = h.state().views[0].viewport_rect();
|
||||
assert!(r.height() < vp.height(), "{r:?} is shorter than {vp:?}");
|
||||
assert!((r.center().y - vp.center().y).abs() < 1.0, "{r:?} is in the middle of {vp:?}");
|
||||
};
|
||||
let mut h = harness(&[("layout", "single"), ("continuous", "off"), ("zoom", "50")]);
|
||||
h.run_steps(4);
|
||||
centred(&h, rect(&h, 0).expect("page 1"));
|
||||
assert_eq!(h.state().views[0].current, 0);
|
||||
|
||||
// The page drawn there is the page a click finds: its link still goes to page 3.
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0));
|
||||
h.hover_at(at);
|
||||
h.run_steps(2);
|
||||
h.drag_at(at); // press
|
||||
h.drop_at(at); // release
|
||||
h.run_steps(8);
|
||||
assert_eq!(h.state().views[0].current, 2, "the link is where the page is drawn");
|
||||
|
||||
// Scrolling continuously, the column of pages as a whole.
|
||||
let mut h = harness(&[("layout", "single"), ("continuous", "on"), ("zoom", "25")]);
|
||||
h.run_steps(4);
|
||||
let column = rect(&h, 0).expect("page 1").union(rect(&h, 2).expect("page 3"));
|
||||
centred(&h, column);
|
||||
|
||||
// A page taller than the view starts at its top, as before.
|
||||
let mut h = harness(&[("layout", "single"), ("continuous", "off"), ("zoom", "250")]);
|
||||
h.run_steps(4);
|
||||
let (r, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect());
|
||||
assert!(r.height() > vp.height() && (r.min.y - vp.min.y).abs() < 40.0, "{r:?} in {vp:?}");
|
||||
}
|
||||
|
||||
/// A page too tall for its margins still fits the window: it shows whole, in the middle.
|
||||
#[test]
|
||||
fn a_page_that_fits_without_its_margins_shows_whole() {
|
||||
let mut h = harness(&[("layout", "single"), ("continuous", "off"), ("zoom", "50")]);
|
||||
h.run_steps(4);
|
||||
let (half, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect());
|
||||
let spare = 20.0;
|
||||
let zoom = (50.0 * (vp.height() - spare) / half.height()).floor();
|
||||
h.state_mut().set_option("zoom", &zoom.to_string()).unwrap();
|
||||
h.run_steps(4);
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
assert!(r.height() < vp.height() && r.height() + 2.0 * 28.0 > vp.height(), "{r:?} fits {vp:?} only without its margins");
|
||||
assert!(r.min.y >= vp.min.y && r.max.y <= vp.max.y, "{r:?} shows whole in {vp:?}");
|
||||
assert!((r.center().y - vp.center().y).abs() < 1.0, "{r:?} is in the middle of {vp:?}");
|
||||
}
|
||||
|
||||
/// Two pages side by side are placed as the row they make: the taller decides, not the first.
|
||||
#[test]
|
||||
fn a_row_too_tall_for_the_window_starts_at_its_top_whichever_page_is_shorter() {
|
||||
let h = mixed_harness(&[("layout", "two-page"), ("zoom", "180")]);
|
||||
let vp = h.state().views[0].viewport_rect();
|
||||
let (first, second) = (rect(&h, 0).expect("page 1"), rect(&h, 1).expect("page 2"));
|
||||
assert!(first.height() > vp.height() && second.height() < vp.height(), "{first:?}, {second:?} in {vp:?}");
|
||||
let row = first.union(second);
|
||||
assert!((row.min.y - vp.min.y).abs() < 40.0, "{row:?} starts in {vp:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_link_goes_to_its_destination() {
|
||||
// Default fit-width zoom: pages are taller than the window, so the target can scroll to the top.
|
||||
|
||||
@@ -324,6 +324,7 @@ Estimates unchanged.
|
||||
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
| 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer |
|
||||
| 2026-11-02 | patch | M3 #5708: pages shorter than the window sit in its middle, on a whole physical pixel |
|
||||
| 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. |
|
||||
|
||||
@@ -878,8 +878,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["view.layout.single"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/it/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/it/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/it/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/it/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/it/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/it/view.rs::view_menu_page_display_uses_the_same_commands"]
|
||||
notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette"
|
||||
evidence = ["crates/ui-egui/tests/it/view.rs::pages_shorter_than_the_window_sit_in_its_middle", "crates/ui-egui/tests/it/view.rs::a_page_that_fits_without_its_margins_shows_whole", "crates/ui-egui/tests/it/view.rs::a_row_too_tall_for_the_window_starts_at_its_top_whichever_page_is_shorter", "crates/ui-egui/tests/it/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/it/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/it/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/it/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/it/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/it/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/it/view.rs::view_menu_page_display_uses_the_same_commands"]
|
||||
notes = "a page or row shorter than the window sits in its middle, the spare height shared above and below, continuous scrolling included when every page fits (#5708); when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette"
|
||||
|
||||
[[feature]]
|
||||
id = "view.layout-two-up"
|
||||
|
||||
Reference in new issue
Block a user