Merge PR #3828: Add thumbnail size controls to the Pages inspector

Conflicts:
- crates/ui-egui/src/i18n/*.tsv: union of both sides' new keys
- ATTRIBUTION.toml: ours; hashes recomputed at the end of the track
- ROADMAP.md, docs/roadmap.md: both progress-log entries, newest first
- parity/acrobat-features.toml (organize page grid): main's evidence and remembered-size note, with the PR's Pages inspector sentence
This commit is contained in:
aiongg
2026-10-22 01:45:41 +00:00
34 changed files with 300 additions and 6 deletions
+2
View File
@@ -140,6 +140,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`.
<table>
<tr>
<td width="50%"><img src="docs/images/pdfcraft-find.png" alt="The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading"></td>
+3
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
@@ -172,6 +174,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
+23
View File
@@ -565,6 +565,8 @@ pub struct DocView {
/// The current page the Pages panel last followed: it scrolls to the current page's
/// thumbnail only when the page changes.
pub(crate) panel_followed: Option<usize>,
/// Scale of the thumbnails in the Pages inspector, independent of document and grid zoom.
thumbnail_zoom: f32,
/// 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
@@ -603,6 +605,8 @@ pub struct DocView {
/// 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;
/// Thumbnail size in the Pages inspector, relative to its usual size.
pub const THUMBNAIL_ZOOM_RANGE: std::ops::RangeInclusive<f32> = 0.5..=1.5;
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.
@@ -785,6 +789,7 @@ impl DocView {
pages_panel: None,
panel_gap: None,
panel_followed: None,
thumbnail_zoom: 1.0,
grid_zoom: 1.0,
grid_zoom_request: None,
grid_anchor: None,
@@ -862,6 +867,7 @@ impl DocView {
fresh.current = self.current;
fresh.organize = self.organize;
fresh.grid_zoom = self.grid_zoom;
fresh.thumbnail_zoom = self.thumbnail_zoom;
// Same document id, so the scroll offset egui keeps for it is still this document's.
fresh.shown = self.shown;
fresh.highlight_fields = self.highlight_fields;
@@ -1498,6 +1504,23 @@ impl DocView {
self.grid_zoom
}
/// Scale of the thumbnails in the Pages inspector (1.0 is the usual size).
pub fn thumbnail_zoom(&self) -> f32 {
self.thumbnail_zoom
}
/// Resize the inspector thumbnails, keeping the current page in view after layout.
/// Out-of-range values are clamped; non-finite values are ignored.
pub fn set_thumbnail_zoom(&mut self, zoom: f32) {
if zoom.is_finite() {
let zoom = zoom.clamp(*THUMBNAIL_ZOOM_RANGE.start(), *THUMBNAIL_ZOOM_RANGE.end());
if zoom != self.thumbnail_zoom {
self.thumbnail_zoom = zoom;
self.panel_followed = None;
}
}
}
/// 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() {
+1
View File
@@ -680,6 +680,7 @@ impl Host for crate::PdfCraftApp {
"layout": format!("{:?}", v.layout),
"organize": v.organize,
"grid_zoom": v.grid_zoom(),
"thumbnail_zoom": v.thumbnail_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(),
+3
View File
@@ -2757,3 +2757,6 @@ certificate Version الإصدار
Paragraph spacing تباعد الفقرات
Less أقل
Other options خيارات أخرى
Thumbnail size حجم الصور المصغرة
Smaller thumbnails تصغير الصور المصغرة
Larger thumbnails تكبير الصور المصغرة
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2444,3 +2444,6 @@ animation effects Off Изключени
Paragraph spacing Разстояние между абзаците
Less По-малко
Other options Други опции
Thumbnail size Размер на миниатюрите
Smaller thumbnails По-малки миниатюри
Larger thumbnails По-големи миниатюри
Can't render this file because it contains an unexpected character in line 8 and column 16.
+3
View File
@@ -2555,3 +2555,6 @@ animation effects Off Vypnuto
Paragraph spacing Mezera mezi odstavci
Less Méně
Other options Další možnosti
Thumbnail size Velikost miniatur
Smaller thumbnails Menší miniatury
Larger thumbnails Větší miniatury
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2482,3 +2482,6 @@ animation effects Off Aus
Paragraph spacing Absatzabstand
Less Weniger
Other options Weitere Optionen
Thumbnail size Miniaturgröße
Smaller thumbnails Kleinere Miniaturen
Larger thumbnails Größere Miniaturen
Can't render this file because it contains an unexpected character in line 6 and column 51.
+3
View File
@@ -2487,3 +2487,6 @@ animation effects Off Ανενεργά
Paragraph spacing Απόσταση παραγράφων
Less Λιγότερα
Other options Άλλες επιλογές
Thumbnail size Μέγεθος μικρογραφιών
Smaller thumbnails Μικρότερες μικρογραφίες
Larger thumbnails Μεγαλύτερες μικρογραφίες
Can't render this file because it contains an unexpected character in line 477 and column 9.
+3
View File
@@ -2621,3 +2621,6 @@ animation effects Off Desactivados
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
Thumbnail size Tamaño de las miniaturas
Smaller thumbnails Miniaturas más pequeñas
Larger thumbnails Miniaturas más grandes
Can't render this file because it contains an unexpected character in line 6 and column 60.
+3
View File
@@ -2481,3 +2481,6 @@ animation effects Off Désactivés
Paragraph spacing Espacement des paragraphes
Less Moins
Other options Autres options
Thumbnail size Taille des vignettes
Smaller thumbnails Réduire les vignettes
Larger thumbnails Agrandir les vignettes
Can't render this file because it contains an unexpected character in line 6 and column 39.
+3
View File
@@ -2480,3 +2480,6 @@ animation effects Off Ki
Paragraph spacing Bekezdéstávolság
Less Kevesebb
Other options Egyéb beállítások
Thumbnail size Bélyegképek mérete
Smaller thumbnails Kisebb bélyegképek
Larger thumbnails Nagyobb bélyegképek
Can't render this file because it contains an unexpected character in line 7 and column 44.
+3
View File
@@ -2693,3 +2693,6 @@ certificate SHA-256 digest Impronta SHA-256
Paragraph spacing Spaziatura paragrafi
Less Meno
Other options Altre opzioni
Thumbnail size Dimensione miniature
Smaller thumbnails Miniature più piccole
Larger thumbnails Miniature più grandi
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2481,3 +2481,6 @@ animation effects Off オフ
Paragraph spacing 段落間隔
Less 折りたたむ
Other options その他のオプション
Thumbnail size サムネイルのサイズ
Smaller thumbnails サムネイルを小さく
Larger thumbnails サムネイルを大きく
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2479,3 +2479,6 @@ animation effects Off Өшірулі
Paragraph spacing Абзац аралығы
Less Азырақ
Other options Басқа параметрлер
Thumbnail size Нобай өлшемі
Smaller thumbnails Кішірек нобайлар
Larger thumbnails Үлкенірек нобайлар
Can't render this file because it contains an unexpected character in line 856 and column 9.
+3
View File
@@ -2731,3 +2731,6 @@ theme Appearance Išvaizda
Paragraph spacing Tarpas tarp pastraipų
Less Mažiau
Other options Kitos parinktys
Thumbnail size Miniatiūrų dydis
Smaller thumbnails Mažesnės miniatiūros
Larger thumbnails Didesnės miniatiūros
Can't render this file because it contains an unexpected character in line 404 and column 9.
+3
View File
@@ -2457,3 +2457,6 @@ animation effects Off Wyłączone
Paragraph spacing Odstęp między akapitami
Less Mniej
Other options Inne opcje
Thumbnail size Rozmiar miniatur
Smaller thumbnails Mniejsze miniatury
Larger thumbnails Większe miniatury
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2495,3 +2495,6 @@ animation effects Off Desativados
Paragraph spacing Espaçamento entre parágrafos
Less Menos
Other options Outras opções
Thumbnail size Tamanho das miniaturas
Smaller thumbnails Miniaturas menores
Larger thumbnails Miniaturas maiores
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -2394,3 +2394,6 @@ animation effects Off Выкл
Paragraph spacing Интервал между абзацами
Less Меньше
Other options Другие параметры
Thumbnail size Размер миниатюр
Smaller thumbnails Уменьшить миниатюры
Larger thumbnails Увеличить миниатюры
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -2486,3 +2486,6 @@ animation effects Off Izklopljeni
Paragraph spacing Razmik med odstavki
Less Manj
Other options Druge možnosti
Thumbnail size Velikost sličic
Smaller thumbnails Manjše sličice
Larger thumbnails Večje sličice
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -2390,3 +2390,6 @@ animation effects Off ఆఫ్
Paragraph spacing పేరా అంతరం
Less తక్కువ
Other options ఇతర ఎంపికలు
Thumbnail size థంబ్‌నెయిల్ పరిమాణం
Smaller thumbnails చిన్న థంబ్‌నెయిల్‌లు
Larger thumbnails పెద్ద థంబ్‌నెయిల్‌లు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+3
View File
@@ -2477,3 +2477,6 @@ animation effects Off Kapalı
Paragraph spacing Paragraf aralığı
Less Daha az
Other options Diğer seçenekler
Thumbnail size Küçük resim boyutu
Smaller thumbnails Küçük resimleri küçült
Larger thumbnails Küçük resimleri büyüt
Can't render this file because it contains an unexpected character in line 855 and column 9.
+3
View File
@@ -2503,3 +2503,6 @@ animation effects Off Вимкнено
Paragraph spacing Інтервал між абзацами
Less Менше
Other options Інші параметри
Thumbnail size Розмір мініатюр
Smaller thumbnails Зменшити мініатюри
Larger thumbnails Збільшити мініатюри
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2733,3 +2733,6 @@ key Space Space
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
Thumbnail size Kích thước hình thu nhỏ
Smaller thumbnails Giảm kích thước hình thu nhỏ
Larger thumbnails Tăng kích thước hình thu nhỏ
Can't render this file because it contains an unexpected character in line 491 and column 12.
+3
View File
@@ -2445,3 +2445,6 @@ animation effects Off 关
Paragraph spacing 段落间距
Less 收起
Other options 其他选项
Thumbnail size 缩略图大小
Smaller thumbnails 缩小缩略图
Larger thumbnails 放大缩略图
Can't render this file because it contains an unexpected character in line 9 and column 16.
+3
View File
@@ -2431,3 +2431,6 @@ animation effects Off 關
Paragraph spacing 段落間距
Less 收起
Other options 其他選項
Thumbnail size 縮圖大小
Smaller thumbnails 縮小縮圖
Larger thumbnails 放大縮圖
Can't render this file because it contains an unexpected character in line 9 and column 16.
+9
View File
@@ -2649,6 +2649,14 @@ impl PdfCraftApp {
v.open_organize();
}
}
// Inspector thumbnail size, in percent, independent of document and grid zoom.
("thumbnail-zoom", Some(v)) => {
let percent = value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())?;
if !percent.is_finite() || !canvas::THUMBNAIL_ZOOM_RANGE.contains(&(percent / 100.0)) {
return Err("thumbnail-zoom must be between 50 and 150".into());
}
v.set_thumbnail_zoom(percent / 100.0);
}
// `--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())?;
@@ -2790,6 +2798,7 @@ impl PdfCraftApp {
"cover",
"organize",
"grid-zoom",
"thumbnail-zoom",
"fields",
"find",
"rotate",
+58 -1
View File
@@ -786,6 +786,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ancestor.pop();
}
}
if panel == RightPanel::Pages {
let enabled = !modal && view.panel_drag.is_none();
thumbnail_size(ui, &t, view, enabled);
}
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel {
RightPanel::Comments => {
if let Some(e) = crate::comments_panel::show(ui, &t, info, view, prefs, comment_allowed, &mut nav) {
@@ -1187,6 +1191,57 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
}
}
/// Size controls stay above the scroll area, so they remain reachable on long documents.
fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, enabled: bool) {
let before = view.thumbnail_zoom();
let mut zoom = before;
let label = ui
.horizontal(|ui| {
let label = ui.label(egui::RichText::new(tl!("Thumbnail size")).small().color(t.text_muted));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(egui::RichText::new(format!("{:.0}%", before * 100.0)).small().color(t.text_muted));
});
label
})
.inner;
ui.add_enabled_ui(enabled, |ui| {
ui.spacing_mut().slider_width = (ui.available_width() - 52.0 - 2.0 * ui.spacing().item_spacing.x).max(1.0);
ui.horizontal(|ui| {
if ui
.add_enabled_ui(zoom > *crate::canvas::THUMBNAIL_ZOOM_RANGE.start(), |ui| {
icons::button(ui, "zoom-out", 26.0, false, tl!("Smaller thumbnails"))
})
.inner
.clicked()
{
zoom -= 0.25;
}
let mut percent = zoom * 100.0;
let range = (*crate::canvas::THUMBNAIL_ZOOM_RANGE.start() * 100.0)..=(*crate::canvas::THUMBNAIL_ZOOM_RANGE.end() * 100.0);
ui.scope(|ui| {
// The regular field fill blends into this panel; keep the slider's full rail visible.
ui.visuals_mut().widgets.inactive.bg_fill = t.border;
let slider = ui.add(egui::Slider::new(&mut percent, range).step_by(1.0).show_value(false).trailing_fill(true)).labelled_by(label.id);
slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(percent), tl!("Thumbnail size")));
});
zoom = percent / 100.0;
if ui
.add_enabled_ui(zoom < *crate::canvas::THUMBNAIL_ZOOM_RANGE.end(), |ui| {
icons::button(ui, "zoom-in", 26.0, false, tl!("Larger thumbnails"))
})
.inner
.clicked()
{
zoom += 0.25;
}
});
});
if zoom != before {
view.set_thumbnail_zoom(zoom);
}
ui.add_space(6.0);
}
/// The page thumbnails. A click goes to the page; ⌘/Ctrl-click and ⇧-click pick several pages
/// (the selection page commands and Print act on) without moving the document. Right-click
/// offers Extract, Cut, Copy and Paste on the selection, or on the page clicked; dragging
@@ -1215,7 +1270,9 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
// scroll bar dragged by the user already wins over a scroll to a place in egui.
let follow = view.panel_followed != Some(view.current) && view.panel_drag.is_none();
view.panel_followed = Some(view.current);
let w = (ui.available_width() - 40.0).min(150.0);
let available = (ui.available_width() - 16.0).max(1.0);
let usual = (ui.available_width() - 40.0).clamp(1.0, 150.0);
let w = (usual * view.thumbnail_zoom()).min(available);
let count = info.pages.len();
let mut rows: Vec<Rect> = Vec::with_capacity(info.pages.len());
let mut dropped = false;
+25
View File
@@ -86,6 +86,31 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa
call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}"))
}
#[test]
fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "pages"}));
ok(&mut h, &c, "ui.set", json!({"key": "thumbnail-zoom", "value": "50"}));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["active"]["thumbnail_zoom"], 0.5);
assert_eq!(state["active"]["grid_zoom"], 1.0);
assert_eq!(state["documents"][0]["dirty"], false);
let controls = ok(&mut h, &c, "ui.inspect", json!({"query": "thumbnails"}));
let widgets = controls["widgets"].as_array().unwrap();
assert!(widgets.iter().any(|w| w["label"] == "Larger thumbnails"));
assert!(widgets.iter().any(|w| w["label"] == "Smaller thumbnails" && w["enabled"] == false));
assert!(call(&mut h, &c, "ui.set", json!({"key": "thumbnail-zoom", "value": "NaN"})).is_err());
ok(&mut h, &c, "ui.click", json!({"label": "Larger thumbnails"}));
h.run_steps(3);
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"], 0.75);
let sliders = ok(&mut h, &c, "ui.inspect", json!({"query": "Thumbnail size", "role": "Slider"}));
let slider = sliders["widgets"].as_array().unwrap().first().expect("the thumbnail size slider is labelled");
ok(&mut h, &c, "ui.focus", json!({"id": slider["id"]}));
ok(&mut h, &c, "ui.key", json!({"key": "ArrowRight"}));
h.run_steps(3);
assert!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"].as_f64().unwrap() > 0.75);
}
#[test]
fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() {
let (mut h, c) = harness();
+22
View File
@@ -3854,6 +3854,28 @@ fn dragging_pages_panel_thumbnails_reorders_pages() {
assert!(view.panel_drag.is_none());
}
#[test]
fn resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo() {
let mut h = harness(4, |app| {
app.right = Some(pdfcraft_ui_egui::RightPanel::Pages);
app.set_option("thumbnail-zoom", "50").unwrap();
});
let before = page_texts(h.state());
h.state_mut().views[0].select_pages(&[1, 2]);
h.run_steps(2);
let from = panel_thumb_rect(&h, "Page 2").center();
let to = panel_thumb_rect(&h, "Page 1").center_top() + egui::vec2(0.0, 4.0);
drag(&mut h, from, to);
h.run_steps(4);
assert_eq!(page_texts(h.state()), vec![before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()]);
assert_eq!(h.state().views[0].target_pages(), vec![0, 1]);
assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
assert!(h.state_mut().execute("edit.undo"));
h.run_steps(4);
assert_eq!(page_texts(h.state()), before);
assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
}
#[test]
fn a_thumbnail_drag_that_ends_off_the_panel_or_outlives_the_pages_is_dropped() {
let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages));
+80
View File
@@ -1408,6 +1408,86 @@ fn in_panel_view(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> bool {
h.state().views[0].pages_panel.expect("the Pages panel is shown").contains_rect(r)
}
#[test]
fn pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection() {
let mut h = pages_panel_harness(&[(200, 300); 30]);
h.state_mut().views[0].select_pages(&[0, 1]);
h.run_steps(2);
let usual = thumbnail_rect(&h, 1).width();
let zoom = h.state().views[0].zoom;
let visible = |h: &Harness<'static, PdfCraftApp>| (1..=30).filter(|n| in_panel_view(h, thumbnail_rect(h, *n))).count();
let before = visible(&h);
h.get_by_label("Smaller thumbnails").click();
h.run_steps(4);
assert!(thumbnail_rect(&h, 1).width() < usual);
assert!(visible(&h) > before, "smaller thumbnails show more complete pages");
assert_eq!(h.state().views[0].target_pages(), vec![0, 1]);
assert_eq!(h.state().views[0].current, 0);
assert_eq!(h.state().views[0].zoom, zoom, "only the thumbnails are resized");
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
if let Ok(dir) = std::env::var("PDFCRAFT_THUMBNAIL_SHOTS") {
h.render().unwrap().save(format!("{dir}/small.png")).unwrap();
}
h.get_by_label("Larger thumbnails").click();
h.run_steps(4);
assert!((thumbnail_rect(&h, 1).width() - usual).abs() < 0.1);
h.get_by_label("Larger thumbnails").click();
h.run_steps(4);
assert!(thumbnail_rect(&h, 1).width() > usual);
assert_eq!(h.state().views[0].target_pages(), vec![0, 1]);
if let Ok(dir) = std::env::var("PDFCRAFT_THUMBNAIL_SHOTS") {
h.render().unwrap().save(format!("{dir}/large.png")).unwrap();
h.state_mut().set_option("theme", "dark").unwrap();
h.run_steps(4);
h.render().unwrap().save(format!("{dir}/large-dark.png")).unwrap();
}
}
#[test]
fn pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload() {
let mut h = pages_panel_harness(&[(200, 300); 6]);
let usual = thumbnail_rect(&h, 1).width();
let zoom = h.state().views[0].zoom;
h.state_mut().set_option("grid-zoom", "200").unwrap();
h.state_mut().set_option("thumbnail-zoom", "50%").unwrap();
h.run_steps(4);
let small = thumbnail_rect(&h, 1).width();
assert!(small < usual);
for invalid in ["0", "49", "151", "NaN", "inf", "nope"] {
assert!(h.state_mut().set_option("thumbnail-zoom", invalid).is_err(), "reject {invalid}");
}
h.run_steps(3);
assert_eq!(thumbnail_rect(&h, 1).width(), small);
assert_eq!(h.state().views[0].grid_zoom(), 2.0);
assert_eq!(h.state().views[0].zoom, zoom);
let info = h.state().session.get(h.state().views[0].id).unwrap().info.clone();
h.state_mut().views[0].document_replaced(&info);
h.run_steps(4);
assert_eq!(thumbnail_rect(&h, 1).width(), small, "file reload keeps the chosen thumbnail size");
h.state_mut().views[0].set_thumbnail_zoom(f32::NAN);
assert_eq!(h.state().views[0].thumbnail_zoom(), 0.5);
h.state_mut().views[0].set_thumbnail_zoom(f32::MAX);
assert_eq!(h.state().views[0].thumbnail_zoom(), 1.5);
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
let mut empty = PdfCraftApp::new();
assert!(empty.set_option("thumbnail-zoom", "100").is_err());
}
#[test]
fn pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible() {
let mut h = pages_panel_harness(&[(200, 300); 30]);
h.state_mut().views[0].go_to_page(24);
h.run_steps(4);
for size in ["50", "150", "80", "100"] {
h.state_mut().set_option("thumbnail-zoom", size).unwrap();
h.run_steps(4);
assert!(in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25 stays visible at {size}%");
assert_eq!(h.state().views[0].current, 24);
h.get_by_label("Smaller thumbnails");
h.get_by_label("Larger thumbnails");
}
}
/// #2781: as in Acrobat, going to a page whose thumbnail is out of sight scrolls the Pages
/// panel to show it.
#[test]
+4 -1
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -116,7 +118,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
@@ -202,6 +204,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. |
| 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. |
| 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. |
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
+4 -1
View File
@@ -1,5 +1,7 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (column select #740, multi-object move #844, Shift-⌘-G #810 fixed) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the
@@ -33,7 +35,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Split window, spreadsheet split | yes | no | planned |
| Read mode, full screen | yes | yes | shipped; presentation options planned |
| Light, dark, system theme | yes | yes | shipped |
| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704) | shipped |
| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped |
| Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned |
| Message bars (restricted, signed, PDF/A, forms) | yes | restricted and signed; no PDF/A view mode | partial |
@@ -81,5 +83,6 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. |
| 2026-10-11 | minor | Column select shipped (#740, PR #765); several objects move together on one page (#844, PR #907); Shift-⌘-G goes back (#810, PR #835) |
| 2026-10-10 | major | Created from the command registry, automation tools, the View area of the checklist and open UI issues |
+3 -3
View File
@@ -1262,8 +1262,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"]
notes = "The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it."
evidence = ["crates/ui-egui/tests/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"]
notes = "Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it."
[[feature]]
id = "view.thumbnail-context-ops"
@@ -2885,7 +2885,7 @@ status = "shipped"
commands = ["page.organize"]
tools = []
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", "crates/ui-egui/tests/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"]
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. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing: a slider as in Acrobat; the Pages side panel has no size control."
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. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing in Organize: a slider as in Acrobat. The Pages inspector has its own 50–150% slider and buttons (#3808)."
[[feature]]
id = "organize.split-every-n"