diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index c3b12f87af..fda6104099 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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 = "90c0df152b440991ba6654812961dd885674e9b9e93c37d66a3d241df71bd28a" +sha256 = "5d982ffe0c946c4dc9930901a23e0a6221cff8ecd8481f704b06003719cd6fe0" [[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 = "2d4027cb7577efab49f523879873fefda78efb743e091a178fa1f92df37203cc" +sha256 = "eb612b49df91777bbb89625eb0257707a389720349b018fc954afe7ebfb02970" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bd2d867653f3b5089941e52c1964028d91c164138e7142728b0d81e4b8dd295c" +sha256 = "9b6ef3dd77e51982a8e23eb4e4182c16c590553b060ab141283e851e93ee2041" [[asset]] path = "crates/ui-egui/src/i18n/es.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 = "38f53f6e2413fddb747c362a1354d6627ded42f9bfc1e718cc61b90eeec5b980" +sha256 = "7708e9b9a12d1896261e3e010addd61b4313c5126959f5b9d613d67ee469cc33" [[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 = "4f58ca9ef332e525277bdf6f9a8259d34b38f2c1607b1b5952fcc5e341c990ab" +sha256 = "36bf3703711e7730a2b6995e6949f120bf0a4ff7ef6836c129d396d66ee61cac" [[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 = "8f004e5abf2d5977771991b4f9e7296367b7e3911e5460e599550ce45c5a603e" +sha256 = "89e5a0f086d176a92addd6dfa327aa7e9ee619e6cab640a3353c07390c45575c" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2454a4c6c6825b53ed259927bfc5ad914b0f7b88bb9418ccbb6c60053f4f58d0" +sha256 = "1e24555846cacfc8e84ed6900e2a495ecf994bf7bd96bc01733b48507518d2a3" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8c9e0815bb64a06f3cd3536649560183ba17a02928d6aed834ba895b8a51c0fc" +sha256 = "2772eb00b4ccdf378bf311ca82510202a4b9b6c948706378e3c319c8aa0713a2" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -118,7 +118,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 = "0c794ec4c554485a4f306010550516ae1e4d05ddf9a4f707f01a023839e516f0" +sha256 = "e273af2513c3bc8db051b7f80fedeaae36aaba53486e54f608fc227497d43a7f" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -129,7 +129,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 = "667849f4f8082ab6622c8d644fdc29f6db0cc05035c5b93faab2ee0e5b0b1bf6" +sha256 = "4c044110ea4feef81e8561b510bd4bcf89beabc3e32a42b94265cf3d46692b72" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -140,7 +140,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 = "ad3ce6eb44c7b5b10180c574f6c91ef6e56eac833a1b385afae873ad794612cc" +sha256 = "d20714c060877abcea56c7d0f638a5bf1e31f698d606086d741985c03b10ff7a" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -151,7 +151,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 = "895ec4f4814f74bcadc3c949735fa3cf4907255e9807367fe2c7a5e2875e5764" +sha256 = "361e015a2ebfb507d4c59f5b2450c9112fe0a7e3d4ad5ba77e88f33f473bdd75" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -162,7 +162,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 = "89fd25329037709bac9dafbdf63e08ee22d4a5d3d99c70671614e5e5e0ac65a5" +sha256 = "48114bed18d577a6aa00dab59241ad26a22d369b41cc4f60cecd716e7921dbc4" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fad8447c713a75691b29b4395d55368a6154df568b5bb67a26eaee00bdfb6459" +sha256 = "e073dfb9aa269b5174b5b9e997629ffd988cc310afb461d6fa3185a9da050776" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index cdebc1ed72..87f02ca76c 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -7,7 +7,8 @@ //! `--page N --zoom 150 --layout continuous|two-up|single --panel comments|bookmarks|pages|fields|layers|attachments|none //! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed //! --organize on --fields on --dialog properties|shortcuts|about --palette --home on -//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|` +//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page| +//! --combine-view grid|list --combine-zoom 60..200` //! //! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files //! hands them to the PdfCraft already running, where they open as tabs (`single_instance`). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b8aed65702..23d4036097 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -20,8 +20,8 @@ pub use inspect::{ }; pub use pixels::Pixels; pub use raster::{ - MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Tile, - device_pixels, effective_scale, + MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Retiring, + Tile, device_pixels, effective_scale, }; pub use text::{PageText, TextGlyph}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 18d19e151d..e6b635ef11 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -883,6 +883,48 @@ impl RenderPool { } } +/// A pool's threads after [`RenderPool::retire`]. Idle ones exit at once; one still rendering is +/// told to stop at its next drawing operator and exits when its render returns, which a single +/// long operator (such as decoding a huge image) can delay. +pub struct Retiring(Vec>); + +impl Retiring { + /// The threads that haven't exited yet. + pub fn running(&mut self) -> usize { + self.0.retain(|h| !h.is_finished()); + self.0.len() + } + + /// Whether every thread has exited. + pub fn exited(&self) -> bool { + self.0.iter().all(JoinHandle::is_finished) + } +} + +impl RenderPool { + /// No replacement threads: when the watchdog gives up on a render, the request (and any + /// later one) is answered with an error instead of starting another thread. For a caller that + /// bounds how many render threads exist at once: the stuck thread keeps running until its + /// operator returns, and a replacement would run beside it. + pub fn without_replacements(self) -> Self { + *lock(&self.replacements_left) = 0; + self + } + + /// This pool's threads that haven't exited (workers and replacements; 0 when rendering inline). + pub fn threads(&self) -> usize { + lock(&self._workers).iter().filter(|h| !h.is_finished()).count() + } + + /// Drop the pool but keep its threads' handles, for a caller that bounds how many render + /// threads run at once: a render the watchdog gave up on can outlive its pool. + pub fn retire(self) -> Retiring { + let handles = std::mem::take(&mut *lock(&self._workers)); + drop(self); + Retiring(handles) + } +} + impl Drop for RenderPool { fn drop(&mut self) { #[cfg(not(target_arch = "wasm32"))] @@ -1294,6 +1336,58 @@ mod tests { assert!(initializer.join().unwrap().unwrap().is_some()); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_retired_pool_counts_its_threads_until_they_exit() { + // Idle: its thread leaves as soon as the pool is gone. + let mut idle = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).retire(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while idle.running() > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(idle.running(), 0); + // Busy in a step that can't be interrupted: still counted after the pool is dropped. + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + *lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2))); + pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); + while pool.shared.render_started.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let mut busy = pool.retire(); + assert_eq!(busy.running(), 1, "the render is still under way"); + while busy.running() > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(busy.running(), 0, "and the thread exits when it returns"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_pool_without_replacements_never_runs_a_second_thread() { + // The watchdog gives up on a render stuck in one long step; with no replacement, the + // request is answered with an error and the stuck thread is the only one. + let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).without_replacements(); + pool.set_stuck_after(std::time::Duration::from_millis(50)); + *lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2))); + pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let answer = loop { + assert!(pool.threads() <= 1, "{} render threads", pool.threads()); + if let Some(page) = pool.try_recv() { + break page; + } + assert!(std::time::Instant::now() < deadline, "the stuck render is answered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert!(answer.error.is_some(), "answered with an error, not a second attempt"); + assert!(pool.threads() <= 1); + let mut retired = pool.retire(); + while retired.running() > 0 { + assert!(std::time::Instant::now() < deadline, "the stuck thread exits when its step ends"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn page_panic_retires_shared_generation_and_healthy_pages_use_private_parsers() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 016e07fbc1..6438c9030b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -60,7 +60,7 @@ fn texture_bytes(tex: &TextureHandle) -> usize { /// Visible grid rows, plus one adjacent row in either direction. Work is independent of /// document length, including after a large scroll jump. -fn thumbnail_rows(top: f32, bottom: f32, row_height: f32, rows: usize) -> Range { +pub(crate) fn thumbnail_rows(top: f32, bottom: f32, row_height: f32, rows: usize) -> Range { let first = (top.max(0.0) / row_height).floor() as usize; let end = (bottom.max(0.0) / row_height).ceil() as usize; first.saturating_sub(1).min(rows)..end.saturating_add(1).min(rows) @@ -3411,7 +3411,7 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b /// Organize pages: a thumbnail grid (Acrobat's Organize Pages view). Click selects, ⌘-click /// toggles, ⇧-click extends; double-click opens the page. /// The gap (0 = before the first page, n = after the last) the pointer points at in the grid. -fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option { +pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option { let (i, r) = cells.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(p).total_cmp(&b.distance_sq_to_pos(p)))?; Some(if p.x < r.center().x { *i } else { i + 1 }) } @@ -3662,7 +3662,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool /// A rendered raster as texture data. Its words are premultiplied RGBA bytes, exactly /// `Color32`s, so the renderer's buffer becomes the image without a copy on the UI thread. -fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage { +pub(crate) fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage { match bytemuck::allocation::try_cast_vec::(pixels.into_words()) { Ok(px) if px.len() == size[0].saturating_mul(size[1]) => egui::ColorImage::new(size, px), Ok(px) => egui::ColorImage::from_rgba_premultiplied(size, bytemuck::cast_slice(&px)), diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs new file mode 100644 index 0000000000..49c70a6ba4 --- /dev/null +++ b/crates/ui-egui/src/combine_grid.rs @@ -0,0 +1,1307 @@ +//! Combine files as a grid: a thumbnail of the first page each file contributes, drawn as a +//! stack when it contributes more than one, with its name below. Cards are selected and moved +//! with the same [`RowAction`]s as the table's rows, so both views share the selection, the +//! order and the undo history. +//! +//! Thumbnails render off the UI thread through [`RenderPool`] (its panic guard, size caps and +//! watchdog), one small pool per thumbnail. Only missing ones are asked for, those in view first. +//! At most [`MAX_THREADS`] render threads exist at once, counted as threads, not pools: the pools +//! start no watchdog replacements ([`RenderPool::without_replacements`]), and a pool's threads +//! count until they have actually exited ([`RenderPool::retire`]), so a render the watchdog gave +//! up on, or one whose file left the list, holds its place until its thread is gone. A thread +//! stuck for good in one step that can't be interrupted keeps its place for good: threads can't +//! be killed safely. On the web the pool renders on the UI thread inside `try_recv`, with no +//! watchdog: there, at most one thumbnail per frame, only one waits at a time (its pool holds the +//! parsed document), and a very slow page still stalls that frame (as in the document view). + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, TextureHandle, Vec2, pos2, vec2}; +use pdfcraft_render::{RenderConfig, RenderPool, RenderRequest, Retiring}; + +use crate::PdfCraftApp; +use crate::combine_ui::{CombineFile, ERROR, Lock, RowAction, WARNING}; +use crate::icons; +use crate::theme::{self, Tokens}; + +/// Render threads for thumbnails at once, counting those of retired pools until they exit. +const MAX_THREADS: usize = 3; +/// Results taken per frame, and the bytes they may upload. +const UPLOADS_PER_FRAME: usize = 4; +const UPLOAD_BYTES_PER_FRAME: usize = 8 << 20; +/// Thumbnail textures kept, in bytes. Those out of view go first, longest unseen first; those in +/// view are kept even over budget (at most a screenful of thumbnails of at most [`MAX_SIDE`]). +const TEXTURE_BUDGET: usize = 32 << 20; +/// The longest side of a thumbnail, in pixels. +/// (Room for a Letter page at 200% on a screen of 1.5 pixels per point; sharper screens upscale a +/// little at the largest sizes.) +const MAX_SIDE: f32 = 768.0; +/// Thumbnail sizes round up to this many pixels, so a slightly different layout reuses them. +const SIZE_STEP: u32 = 16; +/// The box a page is fitted in at 100%, and the room the card adds around it (for the hover bar +/// and the stack above it, the name and a note below), which doesn't scale. +const PAPER: Vec2 = vec2(132.0, 172.0); +const AROUND: Vec2 = vec2(52.0, 90.0); +/// From the top of a card to its page box: room for the hover bar, which sits mostly above the +/// page. +const ABOVE: f32 = 32.0; +/// The hover bar's height; a quarter of it overlaps the page. +const BAR_H: f32 = 34.0; +/// The grid's zoom range, and the sizes its buttons and keys step through (the slider, the wheel +/// and a pinch go anywhere between). +pub(crate) const ZOOM_RANGE: std::ops::RangeInclusive = 0.6..=2.0; +const ZOOM_STEPS: [f32; 7] = [0.6, 0.8, 1.0, 1.25, 1.5, 1.75, 2.0]; + +/// The next size up (or down) from `zoom`; the end of the range stays put. +pub(crate) fn zoom_step(zoom: f32, larger: bool) -> f32 { + let next = + if larger { ZOOM_STEPS.iter().copied().find(|s| *s > zoom + 0.001) } else { ZOOM_STEPS.iter().rev().copied().find(|s| *s < zoom - 0.001) }; + next.unwrap_or(if larger { *ZOOM_RANGE.end() } else { *ZOOM_RANGE.start() }) +} +/// Space above the first row of cards. +const TOP: f32 = 12.0; +/// The page shown large (a card's magnifier) is kept beside the thumbnails under this key, so it +/// shares their threads, their limits and their checks. +const PREVIEW: u64 = u64::MAX; +/// The longest side of the page shown large, in pixels. +const PREVIEW_SIDE: f32 = 2048.0; +/// The shape drawn for a page whose size is unknown (US Letter); nothing is rendered for it. +const FALLBACK_PAGE: (f32, f32) = (612.0, 792.0); +/// Icons drawn on the (always white) paper, in either theme: the light theme's muted text, which +/// keeps its contrast on white where the dark theme's doesn't. +const ON_PAPER: Color32 = Color32::from_rgb(0x5E, 0x5E, 0x66); + +/// What the grid has for a file (tests and the control channel). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ThumbState { + /// Nothing yet: not in view, or nothing to draw (locked, unreadable, a bad page range, a page + /// whose size is unknown). + None, + /// Being rendered. + Pending, + /// A thumbnail of page `page` (0-based), `width` × `height` pixels. + Ready { page: usize, width: u32, height: u32 }, + /// The page couldn't be drawn. + Failed(String), +} + +/// A card being dragged: which file, and the list as it was (see [`RowAction::DropAt`]). +#[derive(Clone, Copy)] +struct GridDrag { + file: u64, + revision: u64, +} + +/// Everything a thumbnail depends on: the file's bytes, how it was read (its password), the page +/// and the size. A cached thumbnail is used only while all of them still match. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Key { + /// The address of the file's shared bytes: the same file, even after undo brings it back. + source: usize, + auth: u64, + page: usize, + px: [u32; 2], +} + +impl Key { + fn same_page(&self, other: &Key) -> bool { + (self.source, self.auth, self.page) == (other.source, other.auth, other.page) + } +} + +struct Entry { + key: Key, + tex: Option, + error: Option, + /// The frame the card last showed it. + seen: u64, + bytes: usize, +} + +struct Job { + file: u64, + key: Key, + pool: RenderPool, + tag: u64, +} + +/// A thumbnail a card needs this frame. +struct Want { + file: u64, + key: Key, + bytes: Arc>, + password: Option>, + scale: f32, + visible: bool, +} + +/// The grid's thumbnails and the renders under way. +#[derive(Default)] +pub(crate) struct Thumbs { + entries: HashMap, + jobs: Vec, + /// Threads of pools let go of, until they exit. + retiring: Vec, + frame: u64, + next_tag: u64, + wants: Vec, + /// Render on the UI thread, as the web build does (tests). + #[cfg(test)] + inline: bool, +} + +/// What a card can draw now. +enum Look { + Ready(egui::TextureId), + Failed(String), + /// Not ready; an older thumbnail of the same page may stand in. + Pending(Option), +} + +impl Thumbs { + /// Forget every thumbnail (the tab closed). Renders under way are let go of: told to stop at + /// their next drawing operator, and counted until their threads exit. + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.wants.clear(); + for job in self.jobs.drain(..) { + self.retiring.push(job.pool.retire()); + } + } + + /// Render threads that exist now (real threads, not pools): those of pools at work and of + /// pools let go of. + fn threads(&mut self) -> usize { + self.retiring.retain_mut(|r| r.running() > 0); + let retiring: usize = self.retiring.iter_mut().map(Retiring::running).sum(); + let working: usize = self.jobs.iter().map(|j| j.pool.threads()).sum(); + working.saturating_add(retiring) + } + + fn look(&mut self, file: u64, key: Key) -> Look { + let frame = self.frame; + match self.entries.get_mut(&file) { + Some(e) if e.key == key => { + e.seen = frame; + match (&e.tex, &e.error) { + (Some(tex), _) => Look::Ready(tex.id()), + (None, Some(err)) => Look::Failed(err.clone()), + (None, None) => Look::Pending(None), + } + } + // Another size of the same page stands in until this one arrives. A page that failed + // isn't tried again at another size: it would fail (or hang) the same way, each zoom. + Some(e) if e.key.same_page(&key) => { + e.seen = frame; + match &e.error { + Some(err) if e.tex.is_none() => Look::Failed(err.clone()), + _ => Look::Pending(e.tex.as_ref().map(TextureHandle::id)), + } + } + _ => Look::Pending(None), + } + } + + fn state(&self, file: u64) -> ThumbState { + if let Some(e) = self.entries.get(&file) { + if let Some(tex) = &e.tex { + let [w, h] = tex.size(); + return ThumbState::Ready { + page: e.key.page, + width: u32::try_from(w).unwrap_or(u32::MAX), + height: u32::try_from(h).unwrap_or(u32::MAX), + }; + } + if let Some(err) = &e.error { + return ThumbState::Failed(err.clone()); + } + } + if self.jobs.iter().any(|j| j.file == file) { ThumbState::Pending } else { ThumbState::None } + } + + /// Once per frame, whatever shows: drop what no longer matches the list, take finished + /// renders, start those the grid asked for and keep the textures within budget. + fn finish(&mut self, ctx: &egui::Context, current: &HashMap) { + // Reconcile with the list as it is now (undo, redo, unlock, a new range, removal): a + // thumbnail of another page, password or file is never shown again. + self.entries.retain(|file, e| current.get(file).is_some_and(|k| k.same_page(&e.key))); + // Renders nobody wants any more are let go of now (on the web, before they would render). + let (keep, gone): (Vec, Vec) = self.jobs.drain(..).partition(|job| current.get(&job.file).is_some_and(|k| k.same_page(&job.key))); + self.jobs = keep; + self.retiring.extend(gone.into_iter().map(|job| job.pool.retire())); + + // Finished renders, a few per frame. A pool without threads (the web) renders inside + // `try_recv`: at most one of those per frame. + let gpu_side = ctx.input(|i| i.max_texture_side); + let max_side = gpu_side.min(2 * MAX_SIDE as usize); + let (mut taken, mut uploaded, mut inline_used) = (0usize, 0usize, false); + let mut i = 0; + while i < self.jobs.len() { + if taken >= UPLOADS_PER_FRAME || uploaded >= UPLOAD_BYTES_PER_FRAME { + break; + } + let Some(job) = self.jobs.get(i) else { break }; + if job.pool.is_inline() { + if inline_used { + i += 1; + continue; + } + inline_used = true; + } + // Not done yet, or not this job's answer (cannot happen with one request per pool): + // keep waiting. A pool without threads answers on the call that renders, so there no + // answer is one that never comes: the thumbnail fails rather than hold up the rest. + let answer = job.pool.try_recv().filter(|out| out.request.tag == job.tag); + if answer.is_none() && !job.pool.is_inline() { + i += 1; + continue; + } + let job = self.jobs.swap_remove(i); + taken += 1; + let failed = |e: String| Entry { key: job.key, tex: None, error: Some(e), seen: self.frame, bytes: 0 }; + let entry = match answer { + None => failed("the page wasn't drawn".to_owned()), + Some(out) => match out.error { + Some(e) => failed(e), + None => match texture( + ctx, + job.file, + [out.width, out.height], + out.rgba, + if job.file == PREVIEW { gpu_side.min(PREVIEW_SIDE as usize) } else { max_side }, + ) { + Ok((tex, bytes)) => { + uploaded = uploaded.saturating_add(bytes); + Entry { key: job.key, tex: Some(tex), error: None, seen: self.frame, bytes } + } + Err(e) => failed(e), + }, + }, + }; + self.entries.insert(job.file, entry); + // Its thread (idle now, or still finishing a render the watchdog gave up on) counts + // until it exits. + self.retiring.push(job.pool.retire()); + } + + // New renders: the cards in view first, in grid order; one per file at a time. + let mut wants = std::mem::take(&mut self.wants); + wants.sort_by_key(|w| (w.file != PREVIEW, !w.visible)); + // A pool without threads (the web) parses its document when it is made and keeps it until + // it renders, one per frame: while one waits, nothing else starts. + // (Nor in the frame that rendered one: one heavy step per frame on the UI thread.) + let mut threads = if inline_used || self.jobs.iter().any(|j| j.pool.is_inline()) { MAX_THREADS } else { self.threads() }; + let mut waiting = false; + for w in wants { + // Wanted for the list as it is now (not changed by this frame's action); one render + // per file at a time; and a want made earlier this frame may have just been answered. + if current.get(&w.file).is_none_or(|k| !k.same_page(&w.key)) + || self.jobs.iter().any(|j| j.file == w.file) + || self.entries.get(&w.file).is_some_and(|e| e.key == w.key) + { + continue; + } + if threads >= MAX_THREADS { + waiting = true; + break; + } + let config = RenderConfig { password: w.password, ..Default::default() }; + #[cfg(test)] + let pool = if self.inline { RenderPool::new_inline(w.bytes, config) } else { RenderPool::new(w.bytes, 1, config).without_replacements() }; + #[cfg(not(test))] + let pool = RenderPool::new(w.bytes, 1, config).without_replacements(); + self.next_tag = self.next_tag.wrapping_add(1); + let tag = self.next_tag; + // The request stays in the pool's queue (it is never replaced) until its answer. + pool.set_queue(vec![RenderRequest { page: w.key.page, scale: w.scale, tag, ..Default::default() }]); + threads = if pool.is_inline() { MAX_THREADS } else { threads.saturating_add(1) }; + self.jobs.push(Job { file: w.file, key: w.key, pool, tag }); + } + + // Within budget: drop the thumbnails out of view longest unseen first. + // (The page shown large has its own: one texture, gone when it closes.) + let mut total: usize = self.entries.iter().filter(|(f, _)| **f != PREVIEW).map(|(_, e)| e.bytes).sum(); + if total > TEXTURE_BUDGET { + let mut old: Vec<(u64, u64, usize)> = self + .entries + .iter() + .filter(|(f, e)| **f != PREVIEW && e.seen != self.frame && e.bytes > 0) + .map(|(f, e)| (e.seen, *f, e.bytes)) + .collect(); + old.sort_unstable(); + for (_, file, bytes) in old { + if total <= TEXTURE_BUDGET { + break; + } + self.entries.remove(&file); + total = total.saturating_sub(bytes); + } + } + + // The cards were drawn before these results arrived: draw them again. Keep polling while + // renders are under way, and while a thumbnail waits for a thread to exit (not when + // nothing waits: a thread exiting changes nothing on screen). + if taken > 0 || self.jobs.iter().any(|j| j.pool.is_inline()) { + ctx.request_repaint(); + } else if !self.jobs.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } else if waiting { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + self.frame = self.frame.wrapping_add(1); + } +} + +/// A rendered thumbnail as a texture, and its size in bytes. A raster that doesn't add up, or is +/// larger than a thumbnail can be (or than the GPU accepts), is refused, never uploaded. +fn texture( + ctx: &egui::Context, + file: u64, + size: [u32; 2], + pixels: pdfcraft_render::Pixels, + max_side: usize, +) -> Result<(TextureHandle, usize), String> { + let [w, h] = size.map(|v| v as usize); + if w == 0 || h == 0 || w > max_side || h > max_side { + return Err(format!("the thumbnail came back {w}×{h} pixels")); + } + let bytes = w.checked_mul(h).and_then(|p| p.checked_mul(4)).ok_or("the thumbnail is too large")?; + if pixels.len() != bytes { + return Err("the thumbnail came back incomplete".to_owned()); + } + let image = crate::canvas::texture_image([w, h], pixels); + Ok((ctx.load_texture(format!("combine-thumb-{file}"), image, egui::TextureOptions::LINEAR), bytes)) +} + +/// `name` shortened in the middle to what `fits`, keeping its extension ("Pilot Boat Ad….pdf"). +pub(crate) fn fit_name(name: &str, fits: impl Fn(&str) -> bool) -> String { + if fits(name) { + return name.to_owned(); + } + // A short extension after the last dot (a dot is one byte, so these are char boundaries). + let split = name.rfind('.').filter(|p| *p > 0 && name.len() - p <= 6); + let (stem, ext) = match split { + Some(p) => (name.get(..p).unwrap_or(name), name.get(p..).unwrap_or("")), + None => (name, ""), + }; + let chars: Vec = stem.chars().collect(); + let with = |n: usize| format!("{}…{ext}", chars.iter().take(n).collect::()); + // The longest start of the stem that fits, found by halving. + let (mut lo, mut hi) = (0usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + with(lo) +} + +/// The box a page is fitted in at `zoom` (clamped to [`ZOOM_RANGE`]). +fn paper_box(zoom: f32) -> Vec2 { + let zoom = if zoom.is_finite() { zoom.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end()) } else { 1.0 }; + PAPER * zoom +} + +/// The size to draw a page of `w` × `h` points in `fit`; `None` for a size that isn't one. +fn paper_size(w: f32, h: f32, fit: Vec2) -> Option { + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return None; + } + let s = (fit.x / w).min(fit.y / h); + s.is_finite().then(|| vec2(w * s, h * s)) +} + +/// How many cards of `cell` fit across `width` (at least one). +fn columns_in(width: f32, cell: Vec2) -> usize { + ((width - 16.0) / cell.x).floor().max(1.0) as usize +} + +/// The grid of files, in the card that holds the list. Returns what the user did. +pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result], selected: &[bool]) -> Option { + let ctx = ui.ctx().clone(); + let ppp = ctx.pixels_per_point(); + let mut action = None; + let revision = app.combine_tab.revision; + let drag = egui::DragAndDrop::payload::(&ctx).map(|d| *d); + // The card dragged may scroll out of the rows drawn, so its release is watched here. + let released = drag.is_some() && ctx.input(|i| i.pointer.any_released()); + let n = app.combine_draft.len(); + // The same file twice is allowed (e.g. a cover sheet), but probably a mistake. + let mut copies: HashMap<(&str, usize), usize> = HashMap::new(); + for f in &app.combine_draft { + *copies.entry((f.name.as_str(), f.bytes.len())).or_default() += 1; + } + let twice: Vec = app.combine_draft.iter().map(|f| copies.get(&(f.name.as_str(), f.bytes.len())).is_some_and(|n| *n > 1)).collect(); + let reveal = app.combine_tab.reveal.take(); + let fit = paper_box(app.combine_zoom); + let cell = fit + AROUND; + let mut cells: Vec<(usize, Rect)> = Vec::new(); + let viewport = ui.available_rect_before_wrap(); + let pointer = ctx.pointer_latest_pos(); + let files = &mut app.combine_draft; + let thumbs = &mut app.combine_thumbs; + let columns = &mut app.combine_tab.grid_columns; + + // Over the grid itself, not over a menu or dialog above it. + let over_grid = ui.rect_contains_pointer(viewport); + let mut width = viewport.width(); + let mut focused_card = None; + let output = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]).show_viewport(ui, |ui, clip| { + // Dragging near the top or bottom edge scrolls. + if drag.is_some() + && let Some(p) = pointer + && viewport.contains(p) + { + let edge = 40.0; + let delta = if p.y < viewport.top() + edge { + 12.0 + } else if p.y > viewport.bottom() - edge { + -12.0 + } else { + 0.0 + }; + if delta != 0.0 { + ui.scroll_with_delta(vec2(0.0, delta)); + ctx.request_repaint(); + } + } + ui.add_space(TOP); + width = ui.available_width(); + let cols = columns_in(width, cell); + *columns = cols; + let rows = n.div_ceil(cols); + let left = ((width - cols as f32 * cell.x) / 2.0).max(0.0); + let (area, _) = ui.allocate_exact_size(vec2(width, rows as f32 * cell.y), Sense::hover()); + let cell_rect = |i: usize| { + let (row, col) = (i / cols, i % cols); + Rect::from_min_size(pos2(area.left() + left + col as f32 * cell.x, area.top() + row as f32 * cell.y), cell) + }; + if let Some(i) = reveal.and_then(|id| files.iter().position(|f| f.id == id)) { + ui.scroll_to_rect(cell_rect(i), None); + } + for row in crate::canvas::thumbnail_rows(clip.top() - TOP, clip.bottom() - TOP, cell.y, rows) { + for col in 0..cols { + let i = row * cols + col; + let Some(f) = files.get_mut(i) else { break }; + let c = cell_rect(i); + cells.push((i, c)); + let card = Card { + i, + rect: c, + check: checks.get(i).cloned().unwrap_or(Ok(f.pages)), + selected: selected.get(i).copied().unwrap_or(false), + twice: twice.get(i).copied().unwrap_or(false), + revision, + ppp, + fit, + }; + let card_id = ui.id().with(("combine-card", f.id)); + if let Some(a) = card.show(ui, t, f, thumbs) { + action = Some(a); + } + if ui.memory(|m| m.has_focus(card_id)) { + focused_card = Some(card_id); + } + } + } + // While a card is dragged: the gap it would go to, drawn as a bar, and how many go. + if let Some(d) = drag + && let Some(p) = pointer + { + let gap = crate::canvas::drop_gap(&cells, p); + // Drawn at the near edge of the card nearest the pointer (as `drop_gap` decides), so a + // gap at the end of a row shows on that row, not at the start of the next. + let nearest = cells.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(p).total_cmp(&b.distance_sq_to_pos(p))); + if let Some((_, r)) = nearest { + let x = if p.x < r.center().x { r.left() + 2.0 } else { r.right() - 2.0 }; + ui.painter().line_segment([pos2(x, r.top() + 10.0), pos2(x, r.bottom() - 30.0)], Stroke::new(3.0, t.accent)); + } + let moving = files + .iter() + .position(|f| f.id == d.file) + .map_or(1, |i| if selected.get(i).copied().unwrap_or(false) { selected.iter().filter(|s| **s).count() } else { 1 }); + let label = if moving == 1 { tl!("1 file").to_string() } else { crate::i18n::fmt(tl!("{n} files"), &[("n", &moving.to_string())]) }; + ui.painter().text(p + vec2(14.0, 14.0), Align2::LEFT_TOP, label, theme::medium(12.0), t.accent_text); + // Dropped in the grid: there. Anywhere else: nothing moves. + if released + && viewport.contains(p) + && let Some(gap) = gap + { + action = Some(RowAction::DropAt { file: d.file, revision: d.revision, gap }); + } + } + }); + + app.combine_tab.card_focus = focused_card; + + // Pinch, or Ctrl/⌘ with the wheel, over the grid zooms it. + let pinch = ctx.input(|i| i.zoom_delta()); + if (pinch - 1.0).abs() > 0.001 && over_grid { + app.combine_tab.zoom_request = Some(app.combine_zoom * pinch); + } + // A zoom asked for this frame (toolbar, keys, pinch) applies now the grid has been laid out: + // the card under the pointer (or else the first in view) keeps its place on screen. Not while + // a card is dragged: the grid under it would change. + if let Some(zoom) = app.combine_tab.zoom_request.take() + && drag.is_none() + { + let before = app.combine_zoom; + app.set_combine_zoom(zoom); + if app.combine_zoom != before { + // The same card all through one gesture (a slider drag, a turn of the wheel): the + // first in view changes as the cards reflow, and following it would drift. + let now = ctx.input(|i| i.time); + let gesture = app + .combine_tab + .zoom_anchor + .filter(|a| now - a.2 < 0.5) + .and_then(|(id, above, _)| app.combine_draft.iter().position(|f| f.id == id).map(|i| (i, above))); + // Cards just outside the view are drawn too (under the toolbar, say): the pointer + // counts only over the grid. + let under = pointer.filter(|_| over_grid).and_then(|p| cells.iter().find(|(_, r)| r.contains(p))); + let anchor = gesture + .or_else(|| under.or_else(|| cells.iter().find(|(_, r)| r.bottom() > viewport.top())).map(|(i, r)| (*i, r.top() - viewport.top()))); + app.combine_tab.zoom_anchor = anchor.and_then(|(i, above)| app.combine_draft.get(i).map(|f| (f.id, above, now))); + if let Some((i, above)) = anchor { + // Where it will be: its row at the new size, as far below the top as it is now; + // within what can be scrolled to, so zooming out at the bottom shows no gap. + let cell = paper_box(app.combine_zoom) + AROUND; + let cols = columns_in(width, cell); + let content = TOP + n.div_ceil(cols) as f32 * cell.y; + let y = (TOP + (i / cols) as f32 * cell.y - above).min(content - viewport.height()).max(0.0); + // Set as the scroll position itself, before the next frame draws anything: no + // jump, and no scroll still animating (to a card revealed by the keys, say) can + // carry it elsewhere. + let mut state = egui::scroll_area::State::default(); + state.offset = vec2(0.0, y); + state.store(&ctx, output.id); + } + ctx.request_repaint(); + } + } + + action +} + +/// One card's place and what it shows. +struct Card { + i: usize, + rect: Rect, + check: Result, + selected: bool, + twice: bool, + revision: u64, + ppp: f32, + /// The box the page is fitted in (the zoom). + fit: Vec2, +} + +impl Card { + fn show(&self, ui: &mut egui::Ui, t: &Tokens, f: &mut CombineFile, thumbs: &mut Thumbs) -> Option { + let mut action = None; + let c = self.rect; + let id = ui.id().with(("combine-card", f.id)); + let resp = ui.interact(c.shrink(4.0), id, Sense::click_and_drag()); + let name = f.name.clone(); + // As the organize grid's pages: a check box that is on when selected; and, for screen + // readers, selected as items in a list are. + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &name)); + let pages = if f.pages == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &f.pages.to_string())]) }; + let painter = ui.painter_at(c); + + // Selected and hovered cards. + if self.selected || resp.hovered() { + painter.rect_filled(c.shrink(6.0), CornerRadius::same(8), if self.selected { t.accent_soft } else { t.hover }); + } + if self.selected { + painter.rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + + // The page, fitted in the paper box (top-aligned, room above for the stack). + let page = f.first_page(); + // The page's own size, when it is known and is one. + let known = page.as_ref().ok().and_then(|p| f.sizes.get(*p).copied()).filter(|(w, h)| paper_size(*w, *h, self.fit).is_some()); + let size_pt = known.unwrap_or(FALLBACK_PAGE); + let size = paper_size(size_pt.0, size_pt.1, self.fit).unwrap_or(self.fit); + let top = c.top() + ABOVE; + let paper = Rect::from_min_size(pos2(c.center().x - size.x / 2.0 - 3.0, top + (self.fit.y - size.y)), size); + // More than one page: sheets behind it. + let takes = self.check.as_ref().map_or(f.pages, |k| *k); + if takes > 1 { + for d in [6.0, 3.0] { + let sheet = paper.translate(vec2(d, -d)); + painter.rect_filled(sheet, CornerRadius::ZERO, Color32::WHITE); + painter.rect_stroke(sheet, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + } + } + painter.rect_filled(paper.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); + + // What goes on the paper: the thumbnail, or why there isn't one. + let mut why: Option<(&str, Color32, String)> = None; + if let Some(lock) = f.lock { + let text = match lock { + Lock::Open => tl!("Password-protected"), + Lock::Permissions => tl!("Its security settings don't allow copying pages"), + }; + why = Some(("lock", ON_PAPER, text.to_owned())); + } else if let Some(p) = &f.problem { + why = Some(("circle-x", ERROR, p.clone())); + } else { + match page { + Err(e) => why = Some(("circle-x", ERROR, e)), + // The pages couldn't be counted, or the page's size is unknown (some passwords + // open a file for combining but not for reading it here): nothing is drawn (the + // engine decides at Combine), and the card says so rather than look blank. + Ok(p) if p >= f.pages || known.is_none() => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), + Ok(p) => { + // Pixels per point: the size the paper is drawn at, on both sides, and never a + // side longer than a thumbnail's (a long, thin page fits by its long side). + let (w, h) = size_pt; + let scale = (size.x * self.ppp / w).min(size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); + let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); + let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; + let look = thumbs.look(f.id, key); + let mut want = false; + match look { + Look::Ready(tex) => { + painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), + Look::Pending(stand_in) => { + if let Some(tex) = stand_in { + painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + want = true; + } + } + if want && scale.is_finite() && scale > 0.0 { + thumbs.wants.push(Want { + file: f.id, + key, + bytes: f.bytes.clone(), + password: f.password().map(Arc::from), + scale, + visible: ui.is_rect_visible(c), + }); + } + } + } + } + if let Some((icon, colour, _)) = &why { + icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(28.0)), icon, 28.0, *colour); + } + // For screen readers: the pages, and why no page is shown (otherwise only in the tooltip). + let description = match &why { + Some((_, _, reason)) => format!("{pages}. {reason}"), + None => pages.clone(), + }; + ui.ctx().accesskit_node_builder(id, |node| { + node.set_selected(self.selected); + node.set_description(description); + }); + painter.rect_stroke(paper, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + + // A warning that doesn't stop it being combined: a badge on the corner. + let warned = !f.notes.is_empty() || self.twice; + if warned && why.as_ref().is_none_or(|(icon, ..)| *icon == "eye-off") { + let r = Rect::from_center_size(paper.right_top() + vec2(-2.0, 2.0), Vec2::splat(18.0)); + painter.circle_filled(r.center(), 10.0, t.card); + icons::paint(ui, r, "triangle-alert", 15.0, WARNING); + } + + // The name, shortened in the middle to fit, and the pages taken when a range is set. + let font = theme::medium(12.5); + let max_w = c.width() - 20.0; + let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), t.text).size().x <= max_w; + let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned(); + let name_y = top + self.fit.y + 16.0; + painter.text(pos2(c.center().x, name_y), Align2::CENTER_CENTER, shown, font, t.text); + let note = if !f.range.trim().is_empty() { + match &self.check { + Ok(k) => Some((crate::i18n::fmt(tl!("Pages: {k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]), t.text_muted)), + Err(_) => Some((tl!("Check the pages").to_owned(), ERROR)), + } + } else { + None + }; + if let Some((text, colour)) = note { + painter.text(pos2(c.center().x, name_y + 17.0), Align2::CENTER_CENTER, text, theme::regular(11.5), colour); + } + + // On hover, the card's own actions along the top of the page (not while a card is + // dragged): remove this file only, and show it large when there is a page to show. Drawn + // over the card, so a click on one doesn't select or move it. + let previewable = why.is_none(); + // Nothing of the card shows over a dialog (the preview, the password box). + let covered = ui.ctx().memory(|m| m.top_modal_layer().is_some()); + let mut over_bar = false; + if !covered && ui.rect_contains_pointer(c.shrink(4.0)) && !egui::DragAndDrop::has_any_payload(ui.ctx()) { + let count = if previewable { 2.0 } else { 1.0 }; + // Mostly above the page (a quarter over its top edge), so it hides little of it, and + // never the name below. + let y = paper.top() + BAR_H / 4.0 - BAR_H / 2.0; + let bar = Rect::from_center_size(pos2(paper.center().x, y), vec2(count * 26.0 + (count - 1.0) * 2.0 + 8.0, BAR_H)); + over_bar = ui.rect_contains_pointer(bar); + painter.rect(bar, CornerRadius::same(8), t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink(4.0)).layout(egui::Layout::left_to_right(egui::Align::Center))); + bar_ui.spacing_mut().item_spacing.x = 2.0; + let remove = crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]); + if icons::button(&mut bar_ui, "trash-2", 26.0, false, &remove).clicked() { + action = Some(RowAction::RemoveFile(f.id)); + } + if previewable && icons::button(&mut bar_ui, "search", 26.0, false, tl!("Preview")).clicked() { + action = Some(RowAction::Preview(f.id)); + } + } + + // The full name and everything known about the file, on hover. + let mut tip = format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())); + for line in why.iter().map(|(_, _, s)| s).chain(&f.notes) { + tip.push('\n'); + tip.push_str(line); + } + if self.twice { + tip.push('\n'); + tip.push_str(tl!("Added more than once")); + } + // Page ranges are typed in the list. + if self.check.is_err() { + tip.push('\n'); + tip.push_str(tl!("Switch to List view to change the pages it takes.")); + } + // (Over the bar, its buttons say what they do instead.) + let resp = if covered || over_bar { resp } else { resp.on_hover_text(tip) }; + + // The same actions on a right click (and Unlock… for a locked file). + let at = resp.rect.left_bottom(); + let (file, locked) = (f.id, f.lock.is_some()); + resp.context_menu(|ui| { + let item = |ui: &mut egui::Ui, icon: &str, label: &str| { + ui.add(egui::Button::image_and_text(icons::image(icon, 15.0, ui.visuals().text_color()), label)).clicked() + }; + if previewable && item(ui, "search", tl!("Preview")) { + action = Some(RowAction::Preview(file)); + ui.close(); + } + // This file only, as its trash button, and named so. + if item(ui, "trash-2", &crate::i18n::fmt(tl!("Remove {name}"), &[("name", &name)])) { + action = Some(RowAction::RemoveFile(file)); + ui.close(); + } + if locked && item(ui, "lock-open", tl!("Unlock…")) { + action = Some(RowAction::Unlock(Some(self.i), at)); + ui.close(); + } + }); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), GridDrag { file: f.id, revision: self.revision }); + } + if resp.clicked() { + action = Some(RowAction::Click(self.i, resp.ctx.input(|i| i.modifiers))); + } + action + } +} + +/// The file a card's magnifier opened, shown large over the page: one of the pages it adds at a +/// time, flipped with the buttons or the Left and Right keys; Esc, the close button or a click +/// outside closes it. It closes by itself when the file leaves the list or has nothing to show. +pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { + // The command palette goes over everything: the preview waits under it (drawn again after). + if app.palette_open { + return; + } + let Some(p) = app.combine_tab.preview.as_mut() else { return }; + let Some(f) = app.combine_draft.iter().find(|f| f.id == p.file).filter(|f| f.lock.is_none() && f.problem.is_none()) else { + app.combine_tab.preview = None; + return; + }; + // The pages it adds, worked out again only when its range changes. + let pages = match &p.pages { + Some(((range, count), pages)) if *range == f.range && *count == f.pages => pages.clone(), + _ => match f.pages_taken() { + Ok(pages) => { + let pages: Arc<[usize]> = Arc::from(pages); + p.pages = Some(((f.range.clone(), f.pages), pages.clone())); + pages + } + Err(_) => { + app.combine_tab.preview = None; + return; + } + }, + }; + let n = pages.len(); + p.at = p.at.min(n.saturating_sub(1)); + let Some(&page) = pages.get(p.at) else { + app.combine_tab.preview = None; + return; + }; + p.page = Some(page); + let at = p.at; + // Flipped with the keys (the grid's arrows wait while this shows), unless a text field has + // them. + let typing = ctx.text_edit_focused(); + let (back, on) = ctx.input_mut(|i| { + for k in [egui::Key::Plus, egui::Key::Equals, egui::Key::Minus, egui::Key::Num0] { + i.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, k)); + } + if typing { + return (false, false); + } + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) | i.consume_key(egui::Modifiers::NONE, egui::Key::PageUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) | i.consume_key(egui::Modifiers::NONE, egui::Key::PageDown), + ) + }); + let (mut step, mut close) = (i64::from(on) - i64::from(back), false); + let screen = ctx.content_rect(); + let width = (screen.width() * 0.86).max(320.0); + let body_h = (screen.height() * 0.86 - 110.0).max(200.0); + let ppp = ctx.pixels_per_point(); + // No larger than the GPU takes, or the render would be refused. + let longest = ctx.input(|i| i.max_texture_side as f32).min(PREVIEW_SIDE); + let thumbs = &mut app.combine_thumbs; + let modal = egui::Modal::new(egui::Id::new("combine-preview")).show(ctx, |ui| { + ui.set_width(width); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::bidi::visual(&f.name)).font(theme::semibold(15.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "x", 30.0, false, tl!("Close")).clicked() { + close = true; + } + let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &(at + 1).to_string()), ("n", &n.to_string())]); + ui.label(egui::RichText::new(of).color(t.text_muted)); + }); + }); + ui.add_space(8.0); + // The page, as large as fits. + let (area, _) = ui.allocate_exact_size(vec2(width, body_h), Sense::hover()); + let known = f.sizes.get(page).copied().filter(|(w, h)| paper_size(*w, *h, area.size()).is_some()); + let (w, h) = known.unwrap_or(FALLBACK_PAGE); + let size = paper_size(w, h, area.size() - vec2(8.0, 8.0)).unwrap_or(area.size()); + let paper = Rect::from_center_size(area.center(), size); + let painter = ui.painter_at(area); + painter.rect_filled(paper.translate(vec2(0.0, 2.0)), CornerRadius::same(1), t.page_shadow); + painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + if known.is_some() { + let scale = (size.x * ppp / w).min(size.y * ppp / h).min(longest / w.max(h)); + let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); + let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px }; + match thumbs.look(PREVIEW, key) { + Look::Ready(tex) => { + painter.image(tex, paper, uv, Color32::WHITE); + } + Look::Failed(e) => { + let text = crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]); + painter.text(paper.center(), Align2::CENTER_CENTER, text, theme::regular(13.0), ON_PAPER); + } + Look::Pending(stand_in) => { + // A smaller render of this page stands in: the last size shown, or its card's. + let card = thumbs.entries.get(&f.id).filter(|e| e.key.page == page && e.key.auth == f.auth).and_then(|e| e.tex.as_ref()); + if let Some(tex) = stand_in.or(card.map(TextureHandle::id)) { + painter.image(tex, paper, uv, Color32::WHITE); + } + if scale.is_finite() && scale > 0.0 { + thumbs.wants.push(Want { + file: PREVIEW, + key, + bytes: f.bytes.clone(), + password: f.password().map(Arc::from), + scale, + visible: true, + }); + } + } + } + } else { + icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(36.0)), "eye-off", 36.0, ON_PAPER); + } + painter.rect_stroke(paper, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + ui.add_space(8.0); + // Previous and next, centred below. + ui.horizontal(|ui| { + ui.add_space((width - 2.0 * 30.0 - 6.0) / 2.0); + if ui.add_enabled_ui(at > 0, |ui| icons::button(ui, "chevron-left", 30.0, false, tl!("Previous page"))).inner.clicked() { + step = -1; + } + if ui.add_enabled_ui(at + 1 < n, |ui| icons::button(ui, "chevron-right", 30.0, false, tl!("Next page"))).inner.clicked() { + step = 1; + } + }); + }); + if close || modal.should_close() { + app.combine_tab.preview = None; + } else if let Some(p) = app.combine_tab.preview.as_mut() { + p.at = match step { + s if s < 0 => p.at.saturating_sub(1), + s if s > 0 => (p.at + 1).min(n.saturating_sub(1)), + _ => p.at, + }; + } +} + +impl PdfCraftApp { + /// The file shown large, the page on screen (0-based, in the file) and its render, while it + /// shows (tests and the control channel). + pub fn combine_preview(&self) -> Option<(String, usize, ThumbState)> { + let p = self.combine_tab.preview.as_ref()?; + let f = self.combine_draft.iter().find(|f| f.id == p.file)?; + Some((f.name.clone(), p.page?, self.combine_thumbs.state(PREVIEW))) + } + + /// Size the Combine grid's cards (1.0 = the usual); out-of-range values are clamped and + /// nonsense is ignored. + pub fn set_combine_zoom(&mut self, zoom: f32) { + if zoom.is_finite() { + self.combine_zoom = zoom.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end()); + } + } + + /// Every frame, whatever shows: take finished thumbnails, start those the grid asked for (only + /// while it is drawn), and let go of what no longer matches the list, so renders under way + /// finish and release their threads in the list view, on Home or in a document tab too. + pub(crate) fn combine_thumbs_frame(&mut self, ctx: &egui::Context) { + // What each file's thumbnail must match now (cards out of view included). + let current: HashMap = self + .combine_draft + .iter_mut() + .filter_map(|f| { + let page = f.first_page().ok()?; + Some((f.id, Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px: [0, 0] })) + }) + .collect(); + // The page shown large, while it shows: leaving the tab closes it. + if !self.combine_showing() { + self.combine_tab.preview = None; + } + let mut current = current; + if let Some(p) = &self.combine_tab.preview + && let Some(page) = p.page + && let Some(f) = self.combine_draft.iter().find(|f| f.id == p.file) + { + current.insert(PREVIEW, Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px: [0, 0] }); + } + self.combine_thumbs.finish(ctx, ¤t); + } + + /// Each listed file's grid thumbnail, in list order (tests and the control channel). + pub fn combine_thumbnails(&self) -> Vec { + self.combine_draft.iter().map(|f| self.combine_thumbs.state(f.id)).collect() + } + + /// Thumbnail renders under way (tests: never more than a few). + pub fn combine_thumbnails_rendering(&self) -> usize { + self.combine_thumbs.jobs.len() + } + + /// Render threads for thumbnails that exist now (counted from the threads themselves), + /// including those of finished or abandoned renders that haven't exited yet (tests: never + /// more than three). + pub fn combine_thumbnail_threads(&mut self) -> usize { + self.combine_thumbs.threads() + } + + /// Whether no thumbnail render is under way and every render thread has exited (tests). + pub fn combine_thumbnail_threads_exited(&self) -> bool { + self.combine_thumbs.jobs.is_empty() && self.combine_thumbs.retiring.iter().all(Retiring::exited) + } + + /// Thumbnail textures held (tests: none once the tab is closed). + pub fn combine_thumbnail_textures(&self) -> usize { + self.combine_thumbs.entries.values().filter(|e| e.tex.is_some()).count() + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + use crate::combine_ui::{moved_order, step_to}; + + #[test] + fn long_names_keep_their_extension_and_whole_characters() { + let fits = |n: usize| move |s: &str| s.chars().count() <= n; + assert_eq!(fit_name("short.pdf", fits(20)), "short.pdf"); + assert_eq!(fit_name("# 227 Pilot Boat Advance.pdf", fits(18)), "# 227 Pilot B….pdf"); + // Multi-byte characters are never cut in half. + assert_eq!(fit_name("Überweisungsbestätigung.pdf", fits(12)), "Überwei….pdf"); + assert_eq!(fit_name("日本語のファイル名です.pdf", fits(8)), "日本語….pdf"); + // No extension, or a dot that isn't one. + assert_eq!(fit_name("README-without-extension", fits(10)), "README-wi…"); + assert_eq!(fit_name(".hidden-file-name", fits(8)), ".hidden…"); + // Nothing fits: the ellipsis and the extension. + assert_eq!(fit_name("abcdef.pdf", fits(0)), "….pdf"); + } + + #[test] + fn paper_sizes_fit_the_box_and_bad_sizes_are_refused() { + // The box follows the zoom, within its range; nonsense is 100%. + assert_eq!(paper_box(1.0), PAPER); + assert_eq!(paper_box(2.0), PAPER * 2.0); + assert_eq!(paper_box(99.0), PAPER * 2.0); + assert_eq!(paper_box(-1.0), PAPER * 0.6); + assert_eq!(paper_box(f32::NAN), PAPER); + for zoom in [0.6, 1.0, 2.0] { + let fit = paper_box(zoom); + // Letter is a little wider than the box: it fills the width. A tall page fills the height. + let letter = paper_size(612.0, 792.0, fit).unwrap(); + assert!((letter.x - fit.x).abs() < 0.01 && letter.y <= fit.y + 0.01, "{letter:?}"); + let tall = paper_size(612.0, 1008.0, fit).unwrap(); + assert!((tall.y - fit.y).abs() < 0.01 && tall.x <= fit.x + 0.01, "{tall:?}"); + let landscape = paper_size(792.0, 612.0, fit).unwrap(); + assert!((landscape.x - fit.x).abs() < 0.01 && landscape.y < fit.y, "{landscape:?}"); + for (w, h) in [(0.0, 792.0), (612.0, -1.0), (f32::NAN, 1.0), (f32::INFINITY, 1.0), (1e-30, 1e30)] { + let s = paper_size(w, h, fit); + assert!(s.is_none_or(|s| s.x.is_finite() && s.y.is_finite() && s.x <= fit.x + 0.01 && s.y <= fit.y + 0.01), "{w}×{h}: {s:?}"); + } + } + } + + #[test] + fn zoom_buttons_step_through_round_sizes() { + let mut z = 0.6; + let mut up = vec![z]; + while z < 2.0 { + z = zoom_step(z, true); + up.push(z); + } + assert_eq!(up, ZOOM_STEPS); + assert_eq!(zoom_step(2.0, true), 2.0, "the largest stays put"); + assert_eq!(zoom_step(0.6, false), 0.6, "the smallest stays put"); + // From anywhere the slider left it, to the next round size. + assert_eq!(zoom_step(1.1, true), 1.25); + assert_eq!(zoom_step(1.1, false), 1.0); + assert_eq!(zoom_step(1.953, true), 2.0); + assert_eq!(zoom_step(1.0000001, true), 1.25, "a hair over a size counts as that size"); + } + + #[test] + fn arrow_keys_step_by_file_and_by_row() { + // The list: one at a time, staying put at both ends. + assert_eq!(step_to(0, 5, 1, false), 0); + assert_eq!(step_to(4, 5, 1, true), 4); + assert_eq!(step_to(2, 5, 1, true), 3); + // A grid of 3 per row and 8 files: rows [0 1 2] [3 4 5] [6 7]. + assert_eq!(step_to(1, 8, 3, true), 4, "down one row, same column"); + assert_eq!(step_to(5, 8, 3, true), 7, "a short last row is reached at its last file"); + assert_eq!(step_to(6, 8, 3, true), 6, "the last row goes no further down"); + assert_eq!(step_to(2, 8, 3, false), 2, "the first row goes no further up"); + assert_eq!(step_to(7, 8, 3, false), 4); + // Out-of-range starts and empty lists don't panic. + assert_eq!(step_to(99, 8, 3, false), 4); + assert_eq!(step_to(0, 0, 3, true), 0); + assert_eq!(step_to(0, 1, 0, true), 0); + } + + #[test] + fn moves_to_a_gap_keep_order_and_report_no_ops() { + let ids = [10, 11, 12, 13, 14]; + let set = |v: &[u64]| v.iter().copied().collect::>(); + // One file to the start, to the end, and between others. + assert_eq!(moved_order(&ids, &set(&[13]), 0), Some(vec![13, 10, 11, 12, 14])); + assert_eq!(moved_order(&ids, &set(&[10]), 5), Some(vec![11, 12, 13, 14, 10])); + assert_eq!(moved_order(&ids, &set(&[10]), 3), Some(vec![11, 12, 10, 13, 14])); + // A discontiguous selection gathers at the gap, in list order. + assert_eq!(moved_order(&ids, &set(&[10, 12, 14]), 2), Some(vec![11, 10, 12, 14, 13])); + assert_eq!(moved_order(&ids, &set(&[11, 13]), 5), Some(vec![10, 12, 14, 11, 13])); + // Dropping a file next to itself, or a block inside itself, changes nothing. + assert_eq!(moved_order(&ids, &set(&[12]), 2), None); + assert_eq!(moved_order(&ids, &set(&[12]), 3), None); + assert_eq!(moved_order(&ids, &set(&[11, 12, 13]), 2), None); + // Nothing to move, unknown files and gaps past the end are harmless. + assert_eq!(moved_order(&ids, &set(&[]), 1), None); + assert_eq!(moved_order(&ids, &set(&[99]), 1), None); + assert_eq!(moved_order(&ids, &set(&[10]), 99), Some(vec![11, 12, 13, 14, 10])); + } + + /// A PDF of `pages` pages of `w` × `h` points. + fn pdf(pages: usize, w: u32, h: u32) -> Arc> { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 {w} {h}] >>", kids.join(" "))); + for i in 0..pages { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>", 4 + 2 * i)); + let body = "0.2 0.4 0.8 rg 0 0 10 10 re f"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Arc::new(out) + } + + fn key_of(bytes: &Arc>) -> Key { + Key { source: Arc::as_ptr(bytes) as usize, auth: 1, page: 0, px: [32, 48] } + } + + fn want(file: u64, bytes: &Arc>) -> Want { + Want { file, key: key_of(bytes), bytes: bytes.clone(), password: None, scale: 0.15, visible: true } + } + + fn listing(files: &[(u64, &Arc>)]) -> HashMap { + files.iter().map(|(f, b)| (*f, key_of(b))).collect() + } + + /// One frame of `finish`, as the app runs it; how soon it asks to be drawn again. + fn frame(ctx: &egui::Context, thumbs: &mut Thumbs, current: &HashMap) -> std::time::Duration { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| thumbs.finish(ui.ctx(), current)); + // No GPU here: the texture uploads are taken as done. + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map_or(std::time::Duration::MAX, |v| v.repaint_delay) + } + + #[test] + fn a_thumbnail_that_arrives_is_drawn_without_waiting_for_input() { + // Regression: the cards are drawn before results are taken; the last result used to + // leave its card blank until the next mouse move. + let ctx = egui::Context::default(); + let mut thumbs = Thumbs::default(); + let bytes = pdf(1, 200, 300); + let current = listing(&[(1, &bytes)]); + thumbs.wants.push(want(1, &bytes)); + frame(&ctx, &mut thumbs, ¤t); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let delay = frame(&ctx, &mut thumbs, ¤t); + if thumbs.entries.get(&1).is_some_and(|e| e.tex.is_some()) { + assert_eq!(delay, std::time::Duration::ZERO, "the frame that took the result asks for another"); + break; + } + assert!(std::time::Instant::now() < deadline, "the thumbnail arrives"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + #[test] + fn a_result_for_a_file_that_changed_is_never_shown() { + let ctx = egui::Context::default(); + let mut thumbs = Thumbs::default(); + let bytes = pdf(1, 200, 300); + thumbs.wants.push(want(1, &bytes)); + frame(&ctx, &mut thumbs, &listing(&[(1, &bytes)])); + assert_eq!(thumbs.jobs.len(), 1, "the render started"); + // The file leaves the list (or is unlocked differently) while it renders. + let other = pdf(1, 200, 300); + let changed = listing(&[(1, &other)]); + frame(&ctx, &mut thumbs, &changed); + assert!(thumbs.jobs.is_empty(), "let go of at once"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while thumbs.threads() > 0 { + assert!(std::time::Instant::now() < deadline, "its thread exits"); + frame(&ctx, &mut thumbs, &changed); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(thumbs.entries.is_empty(), "its result is never kept"); + } + + #[test] + fn textures_over_budget_go_longest_unseen_first_and_those_in_view_stay() { + let ctx = egui::Context::default(); + let mut thumbs = Thumbs::default(); + let tex = |n: &str| ctx.load_texture(n, egui::ColorImage::new([1, 1], vec![Color32::WHITE]), egui::TextureOptions::LINEAR); + let files: Vec>> = (0..4).map(|_| pdf(1, 200, 300)).collect(); + thumbs.frame = 10; + // Seen at frames 1, 5 and 9 (out of view now), and 10 (in view), 20 MB each as counted. + for (i, seen) in [1u64, 5, 9, 10].into_iter().enumerate() { + let key = key_of(&files[i]); + thumbs.entries.insert(i as u64, Entry { key, tex: Some(tex(&format!("t{i}"))), error: None, seen, bytes: 20 << 20 }); + } + let current: HashMap = files.iter().enumerate().map(|(i, b)| (i as u64, key_of(b))).collect(); + frame(&ctx, &mut thumbs, ¤t); + let mut kept: Vec = thumbs.entries.keys().copied().collect(); + kept.sort_unstable(); + assert_eq!(kept, [3], "80 MB: the three out of view go, oldest first; the one in view stays even over budget"); + } + + #[test] + fn render_threads_never_exceed_three_while_files_come_and_go() { + let ctx = egui::Context::default(); + let mut thumbs = Thumbs::default(); + let files: Vec>> = (0..12).map(|i| pdf(1 + i % 3, 200, 300)).collect(); + let mut most = 0; + for round in 0..120u64 { + // A different handful of files every few frames: renders are abandoned all the time. + let start = (round / 3 % 4 * 3) as usize; + let shown: Vec<(u64, &Arc>)> = (start..start + 4).map(|i| (i as u64, &files[i % files.len()])).collect(); + for (file, bytes) in &shown { + thumbs.wants.push(want(*file, bytes)); + } + frame(&ctx, &mut thumbs, &listing(&shown)); + let threads = thumbs.threads(); + most = most.max(threads); + assert!(threads <= MAX_THREADS, "round {round}: {threads} render threads"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(most > 0, "renders did run"); + } + + #[test] + fn on_the_web_one_thumbnail_waits_at_a_time() { + // Regression: pools that render on the UI thread have no threads to count, so three + // started every frame while one finished, each holding its parsed document. + let ctx = egui::Context::default(); + let mut thumbs = Thumbs { inline: true, ..Default::default() }; + let files: Vec>> = (0..6).map(|_| pdf(1, 200, 300)).collect(); + let shown: Vec<(u64, &Arc>)> = files.iter().enumerate().map(|(i, b)| (i as u64, b)).collect(); + // One heavy step a frame on the UI thread: a parse, then the next frame its render. + for round in 0..14 { + for (file, bytes) in &shown { + thumbs.wants.push(want(*file, bytes)); + } + frame(&ctx, &mut thumbs, &listing(&shown)); + assert!(thumbs.jobs.len() <= 1, "round {round}: {} renders waiting", thumbs.jobs.len()); + assert!(thumbs.jobs.iter().all(|j| j.pool.is_inline())); + } + assert!(thumbs.entries.values().filter(|e| e.tex.is_some()).count() == 6, "all six drawn, one heavy step per frame"); + } + + #[test] + fn a_page_that_failed_is_not_tried_again_at_another_size() { + // Regression: each zoom step asked for a new render of a page that had failed (or hung + // for the watchdog's 20 s), filling the render threads. + // (A card asks for a render only when it looks Pending.) + let mut thumbs = Thumbs::default(); + let bytes = pdf(1, 200, 300); + let key = key_of(&bytes); + thumbs.entries.insert(1, Entry { key, tex: None, error: Some("broken".into()), seen: 0, bytes: 0 }); + let larger = Key { px: [key.px[0] + 64, key.px[1] + 64], ..key }; + assert!(matches!(thumbs.look(1, larger), Look::Failed(e) if e == "broken"), "the failure stands at another size"); + // Another read of the file (a new password) is a new page: tried again. + let reread = Key { auth: 2, ..key }; + assert!(matches!(thumbs.look(1, reread), Look::Pending(None))); + } + + #[test] + fn on_the_web_a_render_that_never_answers_fails_and_lets_the_rest_through() { + // A pool without threads answers on the call that renders: no answer then never comes. + // It used to wait for good, so nothing else started and every frame asked for another. + let ctx = egui::Context::default(); + let mut thumbs = Thumbs { inline: true, ..Default::default() }; + let (silent, next) = (pdf(1, 200, 300), pdf(1, 200, 300)); + let pool = RenderPool::new_inline(silent.clone(), RenderConfig::default()); + // Nothing queued: this pool has nothing to answer with. + thumbs.jobs.push(Job { file: 1, key: key_of(&silent), pool, tag: 7 }); + let current = listing(&[(1, &silent), (2, &next)]); + for _ in 0..4 { + thumbs.wants.push(want(2, &next)); + frame(&ctx, &mut thumbs, ¤t); + } + assert!(thumbs.entries.get(&1).is_some_and(|e| e.tex.is_none() && e.error.is_some()), "the silent one failed"); + assert!(thumbs.entries.get(&2).is_some_and(|e| e.tex.is_some()), "the next one was drawn"); + assert!(thumbs.jobs.is_empty()); + assert!(frame(&ctx, &mut thumbs, ¤t) > std::time::Duration::ZERO, "no more frames asked for at once"); + } +} diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 0fd087cd8b..2588b63a2c 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -17,8 +17,8 @@ use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; /// Same reds and ambers as the signature status (`sign_ui`). -const ERROR: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F); -const WARNING: Color32 = Color32::from_rgb(0xE6, 0x86, 0x19); +pub(crate) const ERROR: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F); +pub(crate) const WARNING: Color32 = Color32::from_rgb(0xE6, 0x86, 0x19); /// Undo steps kept for the list (each holds the list itself; the files' bytes are shared). const HISTORY: usize = 100; @@ -113,6 +113,32 @@ impl Columns { } } +/// How the files are shown: a thumbnail of each (the default, to see what goes where) or the +/// table (kept in the settings). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CombineView { + #[default] + Grid, + List, +} + +impl CombineView { + pub fn as_str(self) -> &'static str { + match self { + CombineView::Grid => "grid", + CombineView::List => "list", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "grid" => Some(CombineView::Grid), + "list" => Some(CombineView::List), + _ => None, + } + } +} + /// A column heading being dragged to a new place. #[derive(Clone, Copy)] struct HeadingDrag(SortKey); @@ -152,6 +178,39 @@ pub struct CombineTab { fitted: f32, /// The password box of Unlock…, while it shows. unlock: Option, + /// Bumped on every change to the list, so a grid drag that began before one is dropped. + pub(crate) revision: u64, + /// The file the keyboard moved to, for the grid to scroll into view. + pub(crate) reveal: Option, + /// Files per grid row as last drawn (Up/Down move a row in the grid). + pub(crate) grid_columns: usize, + /// A grid zoom asked for this frame (toolbar, keys, pinch); the grid applies it once it has + /// laid out, so it can keep the same cards in view. + pub(crate) zoom_request: Option, + /// The card size slider, which takes the arrow keys while it has the keyboard. + pub(crate) zoom_slider: Option, + /// The card kept in place through a zoom gesture: its file, how far below the top of the grid + /// it was, and when the grid last zoomed (seconds). + pub(crate) zoom_anchor: Option<(u64, f32, f64)>, + /// A file shown large (a card's magnifier), while it shows. + pub(crate) preview: Option, + /// The card that had the keyboard last frame: Space shows its file large; on any other focused + /// widget Space is that widget's. + pub(crate) card_focus: Option, +} + +/// The pages a range takes, and the range and page count they were worked out for. +pub(crate) type PagesTaken = ((String, usize), Arc<[usize]>); + +/// A file shown large: which, the place among the pages it adds, and those pages (worked out +/// again only when its page range or page count changes). +#[derive(Clone, Debug)] +pub(crate) struct Preview { + pub(crate) file: u64, + pub(crate) at: usize, + /// The page (0-based, in the file) on screen, for the thumbnails to render. + pub(crate) page: Option, + pub(crate) pages: Option, } /// A password: kept in memory only, never printed (debug output ends up in logs). @@ -188,6 +247,8 @@ struct UnlockPrompt { /// What can be known about a file up front, opened with a password or not. struct Assessed { pages: usize, + /// Each page's displayed size in points (empty when the inspector couldn't read it). + sizes: Arc<[(f32, f32)]>, problem: Option, lock: Option, notes: Vec, @@ -216,6 +277,10 @@ fn assess(bytes: &Arc>, password: Option<&str>) -> Result i.pages.len(), None => pdfcraft_engine::source_page_count(bytes, password).unwrap_or(0), }; + let sizes: Arc<[(f32, f32)]> = match &info { + Some(i) => i.pages.iter().map(|p| (p.width, p.height)).collect(), + None => Arc::from(Vec::new()), + }; let encrypted = password.is_some() || info.as_ref().is_some_and(|i| i.encrypted); let warnings = info.map(|i| i.warnings).unwrap_or_default(); let (problem, lock) = match check { @@ -239,7 +304,7 @@ fn assess(bytes: &Arc>, password: Option<&str>) -> Result, } +/// A new value for [`CombineFile::auth`]: counted for the whole run, outside the undo history. +fn next_auth() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +/// A page range checked: the pages it takes and the first of them (0-based), or why it's wrong. +type RangeCheck = Result<(usize, usize), String>; + #[derive(Clone, Debug)] pub struct CombineFile { /// Stable while the file is in the list (selection, undo). @@ -265,6 +339,11 @@ pub struct CombineFile { pub name: String, pub bytes: Arc>, pub pages: usize, + /// Each page's displayed size in points; shared, so undo snapshots don't copy it. + pub(crate) sizes: Arc<[(f32, f32)]>, + /// How the file was read (with which password): a new value every time it is read, never + /// reused, not even after undo, so a thumbnail made another way is never taken for this one. + pub(crate) auth: u64, /// The pages to take ("" = all). pub range: String, /// When the file was last modified (desktop picks and drops only). @@ -279,34 +358,68 @@ pub struct CombineFile { pub notes: Vec, /// Where it came from, shown among the warnings (e.g. an open document's unsaved changes). origin_note: Option, - /// The range last checked and the result: the number of pages it takes, or why it is wrong. - checked: Option<(String, Result)>, + /// The range last checked and the result: the number of pages it takes and the first of + /// them (0-based), or why it is wrong. + checked: Option<(String, RangeCheck)>, } impl CombineFile { fn take(&mut self, a: Assessed, password: Option) { self.pages = a.pages; + self.sizes = a.sizes; self.problem = a.problem; self.lock = a.lock; self.password = password; + self.auth = next_auth(); self.notes = self.origin_note.iter().cloned().chain(a.notes).collect(); self.checked = None; } + /// The password the file was unlocked with, if any. + pub(crate) fn password(&self) -> Option<&str> { + self.password.as_ref().map(|p| p.0.as_str()) + } + /// The number of pages `range` takes, or why it can't be used. Checked again only when the /// range changes. pub fn selection(&mut self) -> Result { + self.checked_range().map(|(count, _)| count) + } + + /// The first page (0-based) the range takes, in its order ("3, 1" starts with page 3): the + /// page the grid shows for the file. + pub fn first_page(&mut self) -> Result { + self.checked_range().map(|(_, first)| first) + } + + /// Every page (0-based) the range takes, in its order: what the preview flips through. + pub(crate) fn pages_taken(&self) -> Result, String> { + if self.range.trim().is_empty() { + return Ok((0..self.pages).collect()); + } + match pdfcraft_engine::print::select_pages(self.pages, Some(&self.range), &[], pdfcraft_engine::print::Subset::All, false) { + Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()), + Ok(p) => Ok(p), + Err(e) => Err(e.to_string()), + } + } + + fn checked_range(&mut self) -> RangeCheck { if let Some((range, result)) = &self.checked && *range == self.range { return result.clone(); } let result = if self.range.trim().is_empty() { - Ok(self.pages) + // (A file whose pages couldn't be counted is left to the engine, as before; the grid + // checks the first page against the page count.) + Ok((self.pages, 0)) } else { match pdfcraft_engine::print::select_pages(self.pages, Some(&self.range), &[], pdfcraft_engine::print::Subset::All, false) { - Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()), - Ok(p) => Ok(p.len()), + Ok(p) => match p.first() { + Some(first) => Ok((p.len(), *first)), + None => Err(tl!("No pages selected").to_string()), + }, Err(e) => Err(e.to_string()), } }; @@ -326,23 +439,39 @@ impl CombineFile { } } -enum RowAction { +pub(crate) enum RowAction { /// A click on row `i` (Ctrl/⌘ toggles it, Shift selects up to it). Click(usize, Modifiers), SelectAll, - /// Up/Down arrow; with Shift the selection grows. + /// An arrow key: `by` files on (1 in the list, or along a grid row; a grid row's length for + /// Up/Down in the grid). With Shift the selection grows. Step { down: bool, + by: usize, extend: bool, }, MoveUp, MoveDown, Remove, + /// One file's card trash: that file only, whatever else is selected. + RemoveFile(u64), + /// One file's magnifier: show it large. + Preview(u64), + /// Space: show the file the keyboard is on (or the first selected) large. + PreviewSelected, /// Row `from` (and the rest of the selection, if it is selected) dropped on row `to`. Drop { from: usize, to: usize, }, + /// A grid card (and the rest of the selection, if it is selected) dropped at `gap` + /// (0 = before the first file, n = after the last), if the list hasn't changed since the + /// drag began. + DropAt { + file: u64, + revision: u64, + gap: usize, + }, Sort(SortKey), /// Unlock… on row `i` (and the rest of the selection, if it is selected), or on the /// selection (`None`); the password box opens at the position. @@ -369,7 +498,25 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let selected: Vec = ids.iter().map(|id| app.combine_tab.selected.contains(id)).collect(); let count = selected.iter().filter(|s| **s).count(); let blocker = blocker(&app.combine_draft, &checks); - let mut events = TableEvents { action: keyboard(ui, n, count > 0), ..Default::default() }; + let grid = app.combine_view == CombineView::Grid; + let columns = grid.then_some(app.combine_tab.grid_columns); + // The keyboard is the list's unless a text field, the password box or the size slider has it. + let slider_focused = grid && app.combine_tab.zoom_slider.is_some_and(|id| ui.memory(|m| m.has_focus(id))); + // (Nor while a file is shown large: its window may only open or close this frame.) + let covered = app.combine_tab.unlock.is_some() || app.combine_tab.preview.is_some(); + let focus = ui.memory(|m| m.focused()); + let space = focus.is_none() || focus == app.combine_tab.card_focus; + let keys = if covered || slider_focused { None } else { keyboard(ui, n, count > 0, columns, space) }; + let mut events = TableEvents { action: keys, ..Default::default() }; + // ⌘+ / ⌘− / ⌘0 size the grid's cards (instead of the whole window's text). + if grid && n > 0 && !covered { + app.combine_tab.zoom_request = zoom_keys(ui, app.combine_zoom).or(app.combine_tab.zoom_request); + } + // An arrow pressed or a zoom asked for in the list has nothing for the grid to do later. + if !grid { + app.combine_tab.reveal = None; + app.combine_tab.zoom_request = None; + } let mut combine = false; egui::Frame::NONE.inner_margin(egui::Margin::symmetric(28, 20)).show(ui, |ui| { @@ -386,12 +533,19 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } }); }); - ui.label(egui::RichText::new(tl!("Files are combined from top to bottom. Leave Pages empty to take every page.")).color(t.text_faint)); + let hint = if grid { + tl!("Files are combined in the order shown. Drag them to change it.") + } else { + tl!("Files are combined from top to bottom. Leave Pages empty to take every page.") + }; + ui.label(egui::RichText::new(hint).color(t.text_faint)); ui.add_space(12.0); - // Toolbar. - ui.horizontal(|ui| { + // Toolbar: on two lines when the window is narrow. + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 6.0; + // Where the line ends (an overflowing line would move it). + let edge = ui.max_rect().right(); add_menu(app, ui); let open_docs: Vec<(usize, String)> = app.views.iter().enumerate().filter_map(|(i, v)| app.session.get(v.id).map(|d| (i, d.display_name()))).collect(); @@ -409,7 +563,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } }); - separator(ui, &t); + group(ui, &t, edge, 4.0 * 30.0 + 3.0 * 6.0); // Moves need a selected row with room to move; the buttons stay put either way. let can_up = selected.iter().skip_while(|s| **s).any(|s| *s); let can_down = selected.iter().rev().skip_while(|s| **s).any(|s| *s); @@ -436,7 +590,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if unlock.clicked() { events.action = Some(RowAction::Unlock(None, unlock.rect.left_bottom() + vec2(0.0, 6.0))); } - separator(ui, &t); + group(ui, &t, edge, 2.0 * 30.0 + 6.0); let (undo, redo) = (!app.combine_tab.undo.is_empty(), !app.combine_tab.redo.is_empty()); if tool(ui, undo, "undo-2", tl!("Undo"), tl!("Nothing to undo")).clicked() { app.execute("edit.undo"); @@ -444,10 +598,20 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if tool(ui, redo, "redo-2", tl!("Redo"), tl!("Nothing to redo")).clicked() { app.execute("edit.redo"); } + group(ui, &t, edge, 2.0 * 30.0 + 6.0); + // Thumbnails or the table; the choice is kept in the settings. + for (view, icon, label) in [(CombineView::Grid, "layout-grid", tl!("Grid view")), (CombineView::List, "list", tl!("List view"))] { + if icons::button(ui, icon, 30.0, app.combine_view == view, label).clicked() { + app.combine_view = view; + } + } if count > 1 { ui.add_space(6.0); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{n} selected"), &[("n", &count.to_string())])).color(t.text_muted)); } + if grid && n > 0 { + zoom_controls(app, ui, &t, edge); + } }); ui.add_space(10.0); @@ -459,9 +623,19 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui, |ui| { ui.set_min_height(card_h - 12.0); + // The grid scrolls inside the card, leaving room for the totals below it. + if grid { + ui.set_max_height(card_h - 12.0); + } ui.set_width(ui.available_width()); if n == 0 { empty_state(app, ui, &t, files_hovering); + // The view as the toolbar left it: a click on List view this frame starts no + // thumbnail renders. + } else if app.combine_view == CombineView::Grid { + if let Some(action) = crate::combine_grid::grid(app, ui, &t, &checks, &selected) { + events.action = Some(action); + } } else { let table_events = table(app, ui, &t, &checks, &selected); events.range_focused |= table_events.range_focused; @@ -473,6 +647,9 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }, ); + // A file shown large, over everything. + crate::combine_grid::preview(app, ui.ctx(), &t); + // Totals. ui.add_space(8.0); if n > 0 { @@ -597,10 +774,22 @@ fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { fn separator(ui: &mut egui::Ui, t: &Tokens) { ui.add_space(4.0); - ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(4.0), Stroke::new(1.0, t.divider)); + // As tall as the buttons on this line (the toolbar can take two). + let top = ui.cursor().top(); + ui.painter().vline(ui.cursor().left(), (top + 4.0)..=(top + 26.0), Stroke::new(1.0, t.divider)); ui.add_space(6.0); } +/// Before a group of toolbar buttons `width` wide: a divider, or a new line when the group +/// doesn't fit before `edge` (the toolbar wraps between groups, never inside one). +fn group(ui: &mut egui::Ui, t: &Tokens, edge: f32, width: f32) { + if edge - ui.cursor().min.x < width + 10.0 { + ui.end_row(); + } else { + separator(ui, t); + } +} + /// A toolbar icon button, disabled with a reason. fn tool(ui: &mut egui::Ui, enabled: bool, icon: &str, label: &str, disabled_why: &str) -> egui::Response { ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, label)).inner.on_disabled_hover_text(disabled_why) @@ -623,9 +812,13 @@ fn blocker(files: &[CombineFile], checks: &[Result]) -> Option Option { - if n == 0 || ui.ctx().memory(|m| m.focused().is_some()) { +/// selection, Delete removes it, Ctrl/⌘+A selects every file. In the grid (`columns` per row) +/// Left/Right step one file and Up/Down one row. +pub(crate) fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool, grid_columns: Option, space: bool) -> Option { + // A focused card or button still leaves the arrows to the list; a text field, an open menu + // or a dialog doesn't. + let ctx = ui.ctx(); + if n == 0 || ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) || ctx.memory(|m| m.top_modal_layer().is_some()) { return None; } use egui::Key; @@ -633,6 +826,11 @@ fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool) -> Option { if i.consume_key(Modifiers::COMMAND, Key::A) { return Some(RowAction::SelectAll); } + // As Quick Look: the file the keyboard is on, large (the way there without a pointer). A + // focused button keeps its Space. + if any_selected && space && i.consume_key(Modifiers::NONE, Key::Space) { + return Some(RowAction::PreviewSelected); + } if any_selected { if i.consume_key(Modifiers::ALT, Key::ArrowUp) { return Some(RowAction::MoveUp); @@ -644,18 +842,89 @@ fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool) -> Option { return Some(RowAction::Remove); } } - for (down, key) in [(false, Key::ArrowUp), (true, Key::ArrowDown)] { - if i.consume_key(Modifiers::NONE, key) { - return Some(RowAction::Step { down, extend: false }); - } + let row = grid_columns.unwrap_or(1).max(1); + let mut keys = vec![(false, row, Key::ArrowUp), (true, row, Key::ArrowDown)]; + if grid_columns.is_some() { + keys.extend([(false, 1, Key::ArrowLeft), (true, 1, Key::ArrowRight)]); + } + for (down, by, key) in keys { + // Shift first: a plain arrow also matches with Shift held (egui ignores an extra + // Shift), which would step instead of extending. if i.consume_key(Modifiers::SHIFT, key) { - return Some(RowAction::Step { down, extend: true }); + return Some(RowAction::Step { down, by, extend: true }); + } + if i.consume_key(Modifiers::NONE, key) { + return Some(RowAction::Step { down, by, extend: false }); } } None }) } +/// ⌘+ (or ⌘=), ⌘− and ⌘0, while no text field, menu or dialog has the keyboard: the grid zoom +/// they ask for, from `zoom`. +fn zoom_keys(ui: &egui::Ui, zoom: f32) -> Option { + use crate::combine_grid::zoom_step; + use egui::{Key, KeyboardShortcut}; + let ctx = ui.ctx(); + if ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) || ctx.memory(|m| m.top_modal_layer().is_some()) { + return None; + } + // All of them are taken (several can arrive in one slow frame), so none is left for egui to + // zoom the whole window with. + let (larger, smaller, reset) = ctx.input_mut(|i| { + let mut pressed = |k| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, k)); + (pressed(Key::Plus) | pressed(Key::Equals), pressed(Key::Minus), pressed(Key::Num0)) + }); + match (larger, smaller, reset) { + (_, _, true) => Some(1.0), + (true, false, _) => Some(zoom_step(zoom, true)), + (false, true, _) => Some(zoom_step(zoom, false)), + _ => None, + } +} + +/// The grid's card size, at the right of the toolbar (right to left: larger, the percentage, the +/// slider, smaller), as the page grid's. +fn zoom_controls(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, edge: f32) { + use crate::combine_grid::{ZOOM_RANGE, zoom_step}; + let zoom = app.combine_zoom; + // Two buttons, the percentage, the slider and the spaces between: on a line of their own when + // they don't fit beside the rest. + let needed = 30.0 + 44.0 + 96.0 + 30.0 + 4.0 * ui.spacing().item_spacing.x + 8.0; + if edge - ui.cursor().min.x < needed { + ui.end_row(); + } + // Right-aligned on the line, up to its end. + let line = Rect::from_min_max(ui.cursor().min, egui::pos2(edge, ui.cursor().min.y + 30.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(line).layout(Layout::right_to_left(Align::Center)), |ui| { + let larger = ui.add_enabled_ui(zoom < *ZOOM_RANGE.end(), |ui| icons::button(ui, "zoom-in", 30.0, false, tl!("Larger pages"))).inner; + if larger.clicked() { + app.combine_tab.zoom_request = Some(zoom_step(zoom, true)); + } + 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() { + app.combine_tab.zoom_request = Some(1.0); + } + let mut value = zoom; + ui.spacing_mut().slider_width = 96.0; + let slider = ui.add(egui::Slider::new(&mut value, ZOOM_RANGE).show_value(false)); + // Read out as a percentage, as shown beside it. + slider.widget_info(|| egui::WidgetInfo::slider(true, f64::from(value * 100.0).round(), tl!("Page size"))); + app.combine_tab.zoom_slider = Some(slider.id); + if slider.on_hover_text(tl!("Page size")).changed() { + app.combine_tab.zoom_request = Some(value); + } + let smaller = ui.add_enabled_ui(zoom > *ZOOM_RANGE.start(), |ui| icons::button(ui, "zoom-out", 30.0, false, tl!("Smaller pages"))).inner; + if smaller.clicked() { + app.combine_tab.zoom_request = Some(zoom_step(zoom, false)); + } + }); +} + fn empty_state(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, files_hovering: bool) { ui.vertical_centered(|ui| { ui.add_space((ui.available_height() / 2.0 - 70.0).max(16.0)); @@ -1092,6 +1361,37 @@ fn natural(a: &str, b: &str) -> Ordering { } } +/// Where an arrow key goes from `at` among `n` files, `by` at a time (1 in the list; a grid +/// row's length for Up/Down in the grid). It stays put on the first row going up and on the last +/// row going down; a short last row is reached from the row above at its last file. +pub(crate) fn step_to(at: usize, n: usize, by: usize, down: bool) -> usize { + let by = by.max(1); + let last = n.saturating_sub(1); + let at = at.min(last); + if down { + if at >= last / by * by { at } else { at.saturating_add(by).min(last) } + } else if at < by { + at + } else { + at - by + } +} + +/// The order after moving the files `moving` (keeping their order) to `gap` in `ids` as it is +/// (0 = before the first, `ids.len()` = after the last); `None` when nothing would change. +pub(crate) fn moved_order(ids: &[u64], moving: &BTreeSet, gap: usize) -> Option> { + let gap = gap.min(ids.len()); + // The gap counted among the files that stay. + let before = ids.get(..gap).map_or(0, |s| s.iter().filter(|id| moving.contains(id)).count()); + let (taken, mut rest): (Vec, Vec) = ids.iter().partition(|id| moving.contains(id)); + if taken.is_empty() { + return None; + } + let at = gap.saturating_sub(before).min(rest.len()); + rest.splice(at..at, taken); + (rest != ids).then_some(rest) +} + /// Six dots: the drag handle. fn grip(ui: &egui::Ui, rect: Rect, colour: Color32) { for dx in [-2.5, 2.5] { @@ -1167,6 +1467,7 @@ impl PdfCraftApp { self.combine_tab.selected = s.selected; self.combine_tab.sort = s.sort; self.combine_tab.range_edit = None; + self.combine_tab.revision = self.combine_tab.revision.wrapping_add(1); // A Pages field with the keyboard would otherwise keep typing into the restored list // without an undo step. if let Some(ctx) = &self.ctx { @@ -1175,6 +1476,7 @@ impl PdfCraftApp { } fn combine_push_undo(&mut self, before: Snapshot) { + self.combine_tab.revision = self.combine_tab.revision.wrapping_add(1); let undo = &mut self.combine_tab.undo; undo.push(before); if undo.len() > HISTORY { @@ -1230,7 +1532,19 @@ impl PdfCraftApp { self.combine_tab.sort = Some((key, ascending)); } - fn combine_apply(&mut self, action: RowAction) { + /// Move `moving` to `gap`, as one undo step; a move that changes nothing leaves the list, + /// its sort and the history alone. + fn combine_move_to_gap(&mut self, moving: BTreeSet, gap: usize) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let Some(order) = moved_order(&ids, &moving, gap) else { return }; + self.combine_record(); + let rank: std::collections::HashMap = order.iter().enumerate().map(|(i, id)| (*id, i)).collect(); + self.combine_draft.sort_by_key(|f| rank.get(&f.id).copied().unwrap_or(usize::MAX)); + self.combine_tab.selected = moving.into_iter().filter(|id| rank.contains_key(id)).collect(); + self.combine_tab.sort = None; + } + + pub(crate) fn combine_apply(&mut self, action: RowAction) { let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); let n = ids.len(); let index_of = |id: Option| id.and_then(|id| ids.iter().position(|x| *x == id)); @@ -1259,10 +1573,9 @@ impl PdfCraftApp { RowAction::SelectAll => { tab.selected = ids.iter().copied().collect(); } - RowAction::Step { down, extend } => { + RowAction::Step { down, by, extend } => { let next = match index_of(tab.cursor) { - Some(c) if down => (c + 1).min(n.saturating_sub(1)), - Some(c) => c.saturating_sub(1), + Some(c) => step_to(c, n, by, down), None if down => 0, None => n.saturating_sub(1), }; @@ -1275,6 +1588,7 @@ impl PdfCraftApp { tab.anchor = Some(id); } tab.cursor = Some(id); + tab.reveal = Some(id); } RowAction::MoveUp | RowAction::MoveDown => { let up = matches!(action, RowAction::MoveUp); @@ -1313,21 +1627,52 @@ impl PdfCraftApp { self.combine_tab.selected = next.into_iter().collect(); (self.combine_tab.anchor, self.combine_tab.cursor) = (next, next); } + RowAction::RemoveFile(id) => { + if !ids.contains(&id) { + return; + } + self.combine_record(); + self.combine_draft.retain(|f| f.id != id); + // The rest of the selection stays as it was. + self.combine_tab.selected.remove(&id); + if self.combine_tab.anchor == Some(id) { + self.combine_tab.anchor = None; + } + if self.combine_tab.cursor == Some(id) { + self.combine_tab.cursor = None; + } + } + RowAction::Preview(id) => { + if ids.contains(&id) { + self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None }); + } + } + RowAction::PreviewSelected => { + let id = tab.cursor.filter(|c| tab.selected.contains(c)).or_else(|| ids.iter().copied().find(|id| tab.selected.contains(id))); + // Only what its card would offer a magnifier for (unlocked, readable, measured). + let shows = |id: u64| self.combine_draft.iter().any(|f| f.id == id && f.lock.is_none() && f.problem.is_none() && !f.sizes.is_empty()); + if let Some(id) = id.filter(|id| shows(*id)) { + self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None }); + } + } RowAction::Drop { from, to } => { let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; // Dragging a selected file takes the whole selection along. let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; + // Dropped on the selection itself: nothing to do. if moving.contains(&target) { return; } - self.combine_record(); - let list = &mut self.combine_draft; - let (taken, mut rest): (Vec<_>, Vec<_>) = list.drain(..).partition(|f| moving.contains(&f.id)); - let at = rest.iter().position(|f| f.id == target).map_or(rest.len(), |p| if from < to { p + 1 } else { p }); - rest.splice(at..at, taken); - *list = rest; - self.combine_tab.selected = moving; - self.combine_tab.sort = None; + // Below the row when dragged down, above it when dragged up. + self.combine_move_to_gap(moving, if from < to { to + 1 } else { to }); + } + RowAction::DropAt { file, revision, gap } => { + // The list changed under the drag (e.g. undo): the gap no longer means the same. + if revision != tab.revision || !ids.contains(&file) { + return; + } + let moving: BTreeSet = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) }; + self.combine_move_to_gap(moving, gap); } RowAction::Unlock(row, at) => { let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); @@ -1356,8 +1701,11 @@ impl PdfCraftApp { /// Close the Combine files tab and forget its list; the last document shows, if any. pub fn close_combine_tab(&mut self) { let showing = self.combine_showing(); - self.combine_tab = CombineTab::default(); + // (Revisions keep counting: a drag begun before can never match a list made after.) + let revision = self.combine_tab.revision.wrapping_add(1); + self.combine_tab = CombineTab { revision, ..CombineTab::default() }; self.combine_draft.clear(); + self.combine_thumbs.clear(); if showing { self.active = self.views.len().checked_sub(1); } @@ -1394,6 +1742,8 @@ impl PdfCraftApp { problem: None, lock: None, password: None, + sizes: Arc::from(Vec::new()), + auth: 0, notes: Vec::new(), origin_note: note, checked: None, @@ -1525,3 +1875,71 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A one-page PDF that opens with "pw". + fn locked() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut doc = pdfcraft_cos::Document::open(Arc::new(out)).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "pw", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() + } + + #[test] + fn every_read_of_a_file_is_new_even_after_undo() { + // Regression: the read counter lived in the undo history, so unlock → undo → unlock (with + // another password) could repeat a value and keep a thumbnail of the other read. + let mut app = PdfCraftApp::new(); + app.stage_combine(vec![("secret.pdf".into(), locked())]); + let added = app.combine_draft[0].auth; + assert!(app.combine_unlock_rows(&[0], "pw")); + let first = app.combine_draft[0].auth; + app.combine_history_step(true); + assert_eq!(app.combine_draft[0].auth, added, "undo brings back the file as it was read"); + assert!(app.combine_unlock_rows(&[0], "owner")); + let second = app.combine_draft[0].auth; + assert!(second != first && second != added, "added {added}, unlocked {first}, unlocked again {second}"); + } + + #[test] + fn moves_by_rank_never_lose_or_repeat_a_file() { + let mut app = PdfCraftApp::new(); + app.stage_combine((0..6).map(|i| (format!("f{i}.pdf"), locked())).collect()); + let mut ids: Vec = app.combine_draft.iter().map(|f| f.id).collect(); + for (moving, gap) in [(vec![0usize, 2, 4], 6), (vec![5], 0), (vec![1, 3], 3)] { + let set: BTreeSet = moving.iter().filter_map(|i| ids.get(*i).copied()).collect(); + app.combine_move_to_gap(set, gap); + let mut now: Vec = app.combine_draft.iter().map(|f| f.id).collect(); + ids = now.clone(); + now.sort_unstable(); + assert_eq!(now, (0..6).collect::>(), "every file once"); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c237889f52..7ee7257329 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -566,6 +566,16 @@ impl Host for crate::PdfCraftApp { "selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })), "comment_composer_open": v.comments.composer.is_some(), })), + // The Combine files tab: how its files show, and each grid thumbnail's state. + "combine": { + "showing": self.combine_showing(), + "view": self.combine_view.as_str(), + "zoom": self.combine_zoom, + "files": self.combine_draft.iter().map(|f| f.name.clone()).collect::>(), + "selected": self.combine_selection(), + "thumbnails": self.combine_thumbnails().iter().map(|t| format!("{t:?}")).collect::>(), + "preview": self.combine_preview().map(|(file, page, render)| json!({ "file": file, "page": page + 1, "render": format!("{render:?}") })), + }, "quick_tool": match self.quick_tool { crate::QuickTool::Measure(t) => format!("measure-{}", t.name()), crate::QuickTool::Select => "select".to_string(), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ed0f3945c0..1e377a63bb 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2178,3 +2178,10 @@ key Esc Esc key Home Home key End End key Space مسافة + Grid view عرض الشبكة + List view عرض القائمة + Files are combined in the order shown. Drag them to change it. تُدمج الملفات بالترتيب المعروض. اسحبها لتغيير الترتيب. + Preview unavailable: {e} المعاينة غير متاحة: {e} + Pages: {k} of {n} الصفحات: {k} من {n} + Check the pages تحقق من الصفحات + Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d25d29e7b9..5407bf51cf 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2118,3 +2118,10 @@ key Esc Esc key Home Home key End End key Space Интервал + Grid view Изглед мрежа + List view Изглед списък + Files are combined in the order shown. Drag them to change it. Файловете се обединяват в показания ред. Плъзнете ги, за да го промените. + Preview unavailable: {e} Няма преглед: {e} + Pages: {k} of {n} Страници: {k} от {n} + Check the pages Проверете страниците + Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 58720ac2bb..5cdb657eba 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2156,3 +2156,10 @@ key Esc Esc key Home Pos1 key End Ende key Space Leertaste + Grid view Rasteransicht + List view Listenansicht + Files are combined in the order shown. Drag them to change it. Dateien werden in der gezeigten Reihenfolge kombiniert. Zieh sie, um sie zu ändern. + Preview unavailable: {e} Keine Vorschau: {e} + Pages: {k} of {n} Seiten: {k} von {n} + Check the pages Prüfe die Seiten + Switch to List view to change the pages it takes. Wechsle zur Listenansicht, um die übernommenen Seiten zu ändern. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 13bcd06f6d..6bd65002c8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2072,3 +2072,10 @@ key Esc Esc key Home Inicio key End Fin key Space Espacio + Grid view Vista de cuadrícula + List view Vista de lista + Files are combined in the order shown. Drag them to change it. Los archivos se combinan en el orden mostrado. Arrástralos para cambiarlo. + Preview unavailable: {e} Vista previa no disponible: {e} + Pages: {k} of {n} Páginas: {k} de {n} + Check the pages Revisa las páginas + Switch to List view to change the pages it takes. Cambia a la vista de lista para cambiar las páginas que se incluyen. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4c293b5e84..1788c97b51 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2156,3 +2156,10 @@ key Esc Échap key Home Début key End Fin key Space Espace + Grid view Vue en grille + List view Vue en liste + Files are combined in the order shown. Drag them to change it. Les fichiers sont combinés dans l'ordre affiché. Faites-les glisser pour le modifier. + Preview unavailable: {e} Aperçu indisponible : {e} + Pages: {k} of {n} Pages : {k} sur {n} + Check the pages Vérifiez les pages + Switch to List view to change the pages it takes. Passez à la vue en liste pour modifier les pages prises. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index db059d5866..33efda92e7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2154,3 +2154,10 @@ key Esc Esc key Home Home key End End key Space Szóköz + Grid view Rácsnézet + List view Listanézet + Files are combined in the order shown. Drag them to change it. A fájlok a látható sorrendben egyesülnek. Húzd őket a sorrend módosításához. + Preview unavailable: {e} Nincs előnézet: {e} + Pages: {k} of {n} Oldalak: {k} / {n} + Check the pages Ellenőrizd az oldalakat + Switch to List view to change the pages it takes. Válts listanézetre, ha módosítanád, mely oldalak kerüljenek bele. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 852c0eb8c2..e14577a331 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2165,3 +2165,10 @@ key Esc Esc key Home Home key End Fine key Space Spazio + Grid view Vista griglia + List view Vista elenco + Files are combined in the order shown. Drag them to change it. I file vengono combinati nell'ordine mostrato. Trascinali per cambiarlo. + Preview unavailable: {e} Anteprima non disponibile: {e} + Pages: {k} of {n} Pagine: {k} di {n} + Check the pages Controlla le pagine + Switch to List view to change the pages it takes. Passa alla vista elenco per cambiare le pagine incluse. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4ab9e223fd..a557604a12 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2156,3 +2156,10 @@ key Esc Esc key Home Home key End End key Space スペース + Grid view グリッド表示 + List view リスト表示 + Files are combined in the order shown. Drag them to change it. ファイルは表示されている順に結合されます。ドラッグして順番を変更できます。 + Preview unavailable: {e} プレビューを表示できません: {e} + Pages: {k} of {n} ページ: {k}/{n} + Check the pages ページ指定を確認してください + Switch to List view to change the pages it takes. 使用するページを変更するには、リスト表示に切り替えてください。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 699976349f..3c8e506308 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2157,3 +2157,10 @@ key Esc Esc key Home Home key End End key Space Espaço + Grid view Exibição em grade + List view Exibição em lista + Files are combined in the order shown. Drag them to change it. Os arquivos são combinados na ordem mostrada. Arraste-os para mudá-la. + Preview unavailable: {e} Pré-visualização indisponível: {e} + Pages: {k} of {n} Páginas: {k} de {n} + Check the pages Verifique as páginas + Switch to List view to change the pages it takes. Mude para a exibição em lista para alterar as páginas incluídas. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 10d34a859c..57e2923c5e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2069,3 +2069,10 @@ key Esc Esc key Home Home key End End key Space Пробел + Grid view Вид сеткой + List view Вид списком + Files are combined in the order shown. Drag them to change it. Файлы объединяются в показанном порядке. Перетащите их, чтобы изменить его. + Preview unavailable: {e} Предпросмотр недоступен: {e} + Pages: {k} of {n} Страницы: {k} из {n} + Check the pages Проверьте страницы + Switch to List view to change the pages it takes. Переключитесь на вид списком, чтобы изменить, какие страницы взять. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index ab197fdc81..6e34758eca 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2084,3 +2084,13 @@ key Esc Esc key Home Home key End End key Space స్పేస్ + Grid view గ్రిడ్ వీక్షణ + List view జాబితా వీక్షణ + Files are combined in the order shown. Drag them to change it. ఫైల్‌లు చూపిన క్రమంలో కలపబడతాయి. క్రమాన్ని మార్చడానికి వాటిని లాగండి. + Preview unavailable: {e} ప్రివ్యూ అందుబాటులో లేదు: {e} + Pages: {k} of {n} పేజీలు: {n}లో {k} + Check the pages పేజీలను తనిఖీ చేయండి + Switch to List view to change the pages it takes. ఇది తీసుకునే పేజీలను మార్చడానికి జాబితా వీక్షణకు మారండి. + Larger pages పెద్ద పేజీలు + Smaller pages చిన్న పేజీలు + Reset page size పేజీ పరిమాణాన్ని రీసెట్ చేయి diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5fc970b9ae..fa19857269 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2179,3 +2179,10 @@ key Esc Esc key Home Home key End End key Space Пробіл + Grid view Вигляд сіткою + List view Вигляд списком + Files are combined in the order shown. Drag them to change it. Файли об’єднуються в показаному порядку. Перетягніть їх, щоб змінити його. + Preview unavailable: {e} Попередній перегляд недоступний: {e} + Pages: {k} of {n} Сторінки: {k} із {n} + Check the pages Перевірте сторінки + Switch to List view to change the pages it takes. Перейдіть до вигляду списком, щоб змінити, які сторінки взяти. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9cdfc5b8fe..fd515a5782 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2120,3 +2120,10 @@ key Esc Esc key Home Home key End End key Space 空格 + Grid view 网格视图 + List view 列表视图 + Files are combined in the order shown. Drag them to change it. 文件按显示的顺序合并。拖动可更改顺序。 + Preview unavailable: {e} 无法预览:{e} + Pages: {k} of {n} 页面:{k}/{n} + Check the pages 请检查页码 + Switch to List view to change the pages it takes. 切换到列表视图以更改要使用的页面。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 520bd3eceb..42f2d3955c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2042,3 +2042,10 @@ key Esc Esc key Home Home key End End key Space 空格 + Grid view 格狀檢視 + List view 清單檢視 + Files are combined in the order shown. Drag them to change it. 檔案將按顯示的順序合併。拖曳可變更順序。 + Preview unavailable: {e} 無法預覽:{e} + Pages: {k} of {n} 頁面:{k}/{n} + Check the pages 請檢查頁碼 + Switch to List view to change the pages it takes. 切換到清單檢視以變更要使用的頁面。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4182d3d49..039103ecef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,8 +26,10 @@ mod a11y_ui; mod actions_ui; pub mod canvas; mod chrome; +mod combine_grid; +pub use combine_grid::ThumbState as CombineThumb; mod combine_ui; -pub use combine_ui::{Columns as CombineColumns, Lock as CombineLock, SortKey}; +pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey}; mod commands; mod comment_props; pub mod comments; @@ -516,6 +518,13 @@ pub struct PdfCraftApp { pub combine_tab: combine_ui::CombineTab, /// The Combine files table's column order and widths (kept in the settings). pub combine_columns: combine_ui::Columns, + /// Combine files as thumbnails or as the table (kept in the settings). + pub combine_view: combine_ui::CombineView, + /// How large the Combine grid draws its cards (1.0 = the usual; kept in the settings). Set + /// with [`PdfCraftApp::set_combine_zoom`]. + pub combine_zoom: f32, + /// The Combine grid's thumbnails: rendered off the UI thread, a few at a time. + pub(crate) combine_thumbs: combine_grid::Thumbs, /// Images waiting for the resolution choice (released on cancel). pub image_import: Option, /// The custom stamp library, and the stamp being created. @@ -764,6 +773,9 @@ impl PdfCraftApp { combine_draft: Vec::new(), combine_tab: Default::default(), combine_columns: Default::default(), + combine_view: Default::default(), + combine_zoom: 1.0, + combine_thumbs: Default::default(), image_import: None, custom_stamps: Vec::new(), stamp_draft: Default::default(), @@ -1373,6 +1385,8 @@ impl PdfCraftApp { "javascript": self.session.javascript(), "actions": actions_ui::encode(&self.custom_actions), "combine_columns": self.combine_columns.to_json(), + "combine_view": self.combine_view.as_str(), + "combine_zoom": self.combine_zoom, "comments_panel_closed": self.comments_panel_closed, }) .to_string() @@ -1382,6 +1396,12 @@ impl PdfCraftApp { pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]); + if let Some(view) = v["combine_view"].as_str().and_then(combine_ui::CombineView::parse) { + self.combine_view = view; + } + if let Some(zoom) = v["combine_zoom"].as_f64() { + self.set_combine_zoom(zoom as f32); + } if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) { // Only keep entries whose files still exist. #[cfg(not(target_arch = "wasm32"))] @@ -1715,6 +1735,18 @@ impl PdfCraftApp { v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1))); v.comments.reveal = true; } + // `--combine-view list`: Combine files as thumbnails (grid) or as the table (list). + ("combine-view", _) => { + self.combine_view = combine_ui::CombineView::parse(value).ok_or("combine-view must be grid or list")?; + } + // `--combine-zoom 150`: the size of the cards in the Combine grid, in percent. + ("combine-zoom", _) => { + let percent = value.trim_end_matches('%').parse::().map_err(|e| e.to_string())?; + if !percent.is_finite() || !combine_grid::ZOOM_RANGE.contains(&(percent / 100.0)) { + return Err("combine-zoom must be between 60 and 200".into()); + } + self.set_combine_zoom(percent / 100.0); + } (k, None) if ["page", "zoom", "layout", "cover", "organize", "grid-zoom", "fields", "find", "rotate", "select", "notice", "comment"] .contains(&k) => @@ -1743,7 +1775,8 @@ impl PdfCraftApp { return; } self.registry_shortcuts(ctx); - if self.full_screen && ctx.input(|i| i.key_pressed(Key::Escape)) { + // (Esc closes a Combine file shown large first.) + if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) { self.set_full_screen(ctx, false); } if let Some(i) = self.active { @@ -2018,6 +2051,8 @@ impl eframe::App for PdfCraftApp { impl PdfCraftApp { fn finish_render_frame(&mut self, ctx: &egui::Context) { + // The Combine grid's thumbnails, whichever tab shows (renders under way still finish). + self.combine_thumbs_frame(ctx); // Retire hidden documents before admitting the visible document's textures, so the // three cache limits apply to the application, regardless of how many tabs are open. for (i, view) in self.views.iter_mut().enumerate() { diff --git a/crates/ui-egui/tests/combine_grid.rs b/crates/ui-egui/tests/combine_grid.rs new file mode 100644 index 0000000000..e7cf9d1ec7 --- /dev/null +++ b/crates/ui-egui/tests/combine_grid.rs @@ -0,0 +1,1289 @@ +//! Headless UI tests for the Combine files grid: a thumbnail card per file, selected, moved and +//! undone like the list's rows; thumbnails rendered lazily, a few at a time, and never stale. + +use std::time::Duration; + +use egui::{Key, Modifiers, Pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; +use pdfcraft_ui_egui::{CombineThumb, CombineView, FilePurpose, PdfCraftApp}; + +/// An `n`-page document with a proper xref table; page `i` shows "Page i+1". +fn fixture(n: usize) -> Vec { + fixture_sized(n, 200.0, 300.0) +} + +/// The same, with pages of `w` × `h` points. +fn fixture_sized(n: usize, w: f32, h: f32) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 {w} {h}] >>", kids.join(" "))); + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A one-page document whose page takes a while to draw (tens of thousands of shapes), so a +/// thumbnail render is still under way when a test acts. +fn heavy() -> Vec { + let mut body = String::from("0.2 0.4 0.8 rg\n"); + for i in 0..60_000 { + body.push_str(&format!("{} {} 1 1 re f\n", i % 200, (i / 200) % 300)); + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A two-page fixture that opens with `user` and allows everything with `owner`. +fn protected(user: &str, owner: &str) -> Vec { + protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner) +} + +fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str) -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: user, + owner_password: owner, + permissions: -1, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +/// The Combine tab with these files (name, bytes), in its default (grid) view. +fn grid_of(files: Vec<(&str, Vec)>, size: egui::Vec2) -> Harness<'static, PdfCraftApp> { + grid_after(files, size, 4) +} + +/// The same, after only `steps` frames (1: the first thumbnails have only just been asked for). +fn grid_after(files: Vec<(&str, Vec)>, size: egui::Vec2, steps: usize) -> Harness<'static, PdfCraftApp> { + let files: Vec<(String, Vec)> = files.into_iter().map(|(n, b)| (n.to_string(), b)).collect(); + let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.use_files(FilePurpose::Combine, files); + app + }); + h.run_steps(steps); + h +} + +/// The grid on the frame its first thumbnails start rendering (a render started in one frame +/// is collected in a later one, so there always is such a frame). +fn grid_after_one(files: Vec<(&str, Vec)>, size: egui::Vec2) -> Harness<'static, PdfCraftApp> { + let mut h = grid_after(files, size, 0); + for _ in 0..20 { + h.run_steps(1); + if h.state().combine_thumbnails_rendering() > 0 { + break; + } + } + h +} + +fn grid_of_pages(names: &[(&str, usize)]) -> Harness<'static, PdfCraftApp> { + grid_of(names.iter().map(|(n, p)| (*n, fixture(*p))).collect(), vec2(1400.0, 900.0)) +} + +fn names(h: &Harness<'static, PdfCraftApp>) -> Vec { + h.state().combine_draft.iter().map(|f| f.name.clone()).collect() +} + +/// Run frames until `done`, giving render threads time; whether it happened. +fn settle(h: &mut Harness<'static, PdfCraftApp>, done: impl Fn(&PdfCraftApp) -> bool) -> bool { + for _ in 0..400 { + h.run_steps(1); + if done(h.state()) { + return true; + } + std::thread::sleep(Duration::from_millis(10)); + } + false +} + +fn ready(t: &CombineThumb) -> bool { + matches!(t, CombineThumb::Ready { .. }) +} + +/// Press, move in steps and release, as a user drags. +fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=6 { + h.hover_at(from + (to - from) * (k as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(4); +} + +/// Where a file's card is. +fn card(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { + h.get_by_label(name).rect() +} + +fn texts_of(app: &PdfCraftApp, tab: usize) -> Vec { + let doc = app.session.get(app.views[tab].id).unwrap(); + let mut r = PageRenderer::new(doc.bytes.clone(), Default::default()); + (0..r.page_count()) + .map(|p| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() + }) + .collect() +} + +#[test] +fn combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 2)]); + assert_eq!(h.state().combine_view, CombineView::Grid); + h.get_by_label_contains("Files are combined in the order shown"); + assert!(h.query_by_label("File name").is_none(), "no table headings in the grid"); + // The totals stay on screen below the grid. + let totals = h.get_by_label_contains("2 files · 3 pages").rect(); + assert!(totals.bottom() <= 900.0, "the totals line is visible: {totals:?}"); + h.get_by_label("List view").click(); + h.run_steps(2); + assert_eq!(h.state().combine_view, CombineView::List); + h.get_by_label("File name"); + // Kept in the settings, and read back. + let saved = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + assert_eq!(fresh.combine_view, CombineView::Grid); + fresh.restore(&saved); + assert_eq!(fresh.combine_view, CombineView::List); + // A damaged setting keeps the default. + let mut damaged = PdfCraftApp::new(); + damaged.restore(r#"{"combine_view": "tiles"}"#); + assert_eq!(damaged.combine_view, CombineView::Grid); + // Scriptable, with a clear refusal for anything else. + let app = h.state_mut(); + app.set_option("combine-view", "grid").unwrap(); + assert_eq!(app.combine_view, CombineView::Grid); + assert_eq!(app.set_option("combine-view", "tiles"), Err("combine-view must be grid or list".into())); +} + +#[test] +fn cards_select_like_the_list_and_the_selection_survives_switching_views() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1), ("e.pdf", 1)]); + h.get_by_label("b.pdf").click(); + h.run_steps(1); + h.get_by_label("d.pdf").click_modifiers(Modifiers::SHIFT); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1, 2, 3]); + h.get_by_label("c.pdf").click_modifiers(Modifiers::COMMAND); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1, 3], "Ctrl/⌘ toggles one card"); + h.get_by_label("2 selected"); + assert!(h.get_by_label("b.pdf").accesskit_node().is_selected() == Some(true), "cards say they are selected"); + h.get_by_label("List view").click(); + h.run_steps(2); + assert_eq!(h.state().combine_selection(), [1, 3]); + h.get_by_label("Grid view").click(); + h.run_steps(2); + assert_eq!(h.state().combine_selection(), [1, 3]); + // The toolbar acts on it in the grid too. + h.get_by_label("Remove 2 files").click(); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf", "c.pdf", "e.pdf"]); +} + +#[test] +fn dragging_a_card_moves_it_and_undo_puts_it_back() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); + // Sorted first: a move by hand ends the sort, as in the list. + h.state_mut().combine_tab.sort = Some((pdfcraft_ui_egui::SortKey::Name, true)); + let (a, c) = (card(&h, "a.pdf"), card(&h, "c.pdf")); + drag(&mut h, a.center(), c.center() + vec2(c.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["b.pdf", "c.pdf", "a.pdf", "d.pdf"]); + assert_eq!(h.state().combine_selection(), [2], "the moved card is selected"); + assert_eq!(h.state().combine_tab.sort, None); + // The list shows the same order. + h.get_by_label("List view").click(); + h.run_steps(2); + let rows: Vec = ["b.pdf", "c.pdf", "a.pdf", "d.pdf"].iter().map(|n| h.get_by_label(n).rect().top()).collect(); + assert!(rows.windows(2).all(|w| w[0] < w[1]), "the table lists them in the same order: {rows:?}"); + h.get_by_label("Grid view").click(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"]); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(2); + assert_eq!(names(&h), ["b.pdf", "c.pdf", "a.pdf", "d.pdf"]); +} + +#[test] +fn a_dragged_selection_moves_together_to_the_first_and_last_gaps() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1), ("e.pdf", 1)]); + // A selection with a gap in it. + h.state_mut().select_combine_rows(&[1, 3]); + h.run_steps(1); + let (b, e) = (card(&h, "b.pdf"), card(&h, "e.pdf")); + drag(&mut h, b.center(), e.center() + vec2(e.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["a.pdf", "c.pdf", "e.pdf", "b.pdf", "d.pdf"], "to the end, in their order"); + assert_eq!(h.state().combine_selection(), [3, 4]); + // And back to the very start. + let (d, a) = (card(&h, "d.pdf"), card(&h, "a.pdf")); + drag(&mut h, d.center(), a.center() - vec2(a.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["b.pdf", "d.pdf", "a.pdf", "c.pdf", "e.pdf"]); + // A file that isn't selected moves alone, and becomes the selection. + let (c, b) = (card(&h, "c.pdf"), card(&h, "b.pdf")); + drag(&mut h, c.center(), b.center() - vec2(b.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["c.pdf", "b.pdf", "d.pdf", "a.pdf", "e.pdf"]); + assert_eq!(h.state().combine_selection(), [0]); +} + +#[test] +fn a_drop_that_changes_nothing_or_lands_outside_the_grid_leaves_no_undo_step() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); + // The same drag gesture, to a real gap, does move (and is undone). + let (b, c) = (card(&h, "b.pdf"), card(&h, "c.pdf")); + drag(&mut h, b.center(), c.center() + vec2(c.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["a.pdf", "c.pdf", "b.pdf"]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); + h.state_mut().combine_tab.sort = Some((pdfcraft_ui_egui::SortKey::Name, true)); + let b = card(&h, "b.pdf"); + // Onto its own place. + drag(&mut h, b.center(), b.center() + vec2(b.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); + // Onto the toolbar, outside the grid. + let toolbar = h.get_by_label("Grid view").rect().center(); + drag(&mut h, b.center(), toolbar); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); + assert!(h.state().combine_tab.sort.is_some(), "a move that didn't happen keeps the sort"); + // The only step is still the adding of the files: one undo empties the list. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert!(names(&h).is_empty()); + assert!(h.get_by_label("Undo").accesskit_node().is_disabled()); +} + +#[test] +fn arrow_keys_move_by_card_and_by_row_and_shift_extends() { + let files: Vec<(String, usize)> = (0..9).map(|i| (format!("f{i}.pdf"), 1)).collect(); + let named: Vec<(&str, usize)> = files.iter().map(|(n, p)| (n.as_str(), *p)).collect(); + // Narrow enough for several rows. + let mut h = grid_of(named.iter().map(|(n, p)| (*n, fixture(*p))).collect(), vec2(760.0, 1200.0)); + let top = card(&h, "f0.pdf").top(); + let cols = (0..9).filter(|i| (card(&h, &format!("f{i}.pdf")).top() - top).abs() < 1.0).count(); + assert!((2..9).contains(&cols), "several cards per row and several rows ({cols} per row)"); + h.get_by_label("f0.pdf").click(); + h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1]); + h.key_press(Key::ArrowDown); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1 + cols], "down one row, same column"); + h.key_press(Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1]); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), (1..=1 + cols).collect::>(), "Shift extends from the anchor"); + // On the first row, Up stays. + h.get_by_label("f0.pdf").click(); + h.run_steps(1); + h.key_press(Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [0]); + // Alt+arrows still move the selection. + h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown); + h.run_steps(1); + assert_eq!(names(&h).first().map(String::as_str), Some("f1.pdf")); +} + +#[test] +fn shift_arrows_extend_the_selection_in_the_list_too() { + // Regression: a plain arrow matched with Shift held, so Shift+Up/Down only stepped. + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); + h.state_mut().set_option("combine-view", "list").unwrap(); + h.run_steps(2); + h.get_by_label("b.pdf").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown); + h.run_steps(1); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1, 2, 3]); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1, 2]); + h.key_press(Key::ArrowUp); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1], "a plain arrow still steps"); +} + +#[test] +fn thumbnails_show_the_first_page_each_file_takes() { + let mut h = grid_of_pages(&[("three.pdf", 3), ("one.pdf", 1)]); + h.state_mut().combine_draft[0].range = "3, 1".into(); + assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(ready)), "{:?}", h.state().combine_thumbnails()); + let thumbs = h.state().combine_thumbnails(); + assert!(matches!(thumbs[0], CombineThumb::Ready { page: 2, .. }), "\"3, 1\" starts with page 3: {:?}", thumbs[0]); + assert!(matches!(thumbs[1], CombineThumb::Ready { page: 0, .. })); + let CombineThumb::Ready { width, height, .. } = thumbs[1] else { unreachable!() }; + assert!(width > 0 && height > 0 && width <= 768 && height <= 768, "{width}×{height}"); + // A new range is a new thumbnail, never the old page. + h.state_mut().combine_draft[0].range = "2".into(); + assert!(settle(&mut h, |app| matches!(app.combine_thumbnails()[0], CombineThumb::Ready { page: 1, .. }))); + // A range that can't be used shows no page at all. + h.state_mut().combine_draft[0].range = "9".into(); + h.run_steps(2); + assert_eq!(h.state().combine_thumbnails()[0], CombineThumb::None); +} + +#[test] +fn a_locked_file_shows_no_page_until_unlocked_and_undo_locks_it_again() { + let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); + assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[0]))); + assert_eq!(h.state().combine_thumbnails()[1], CombineThumb::None, "nothing is drawn of a locked file"); + assert!(h.state_mut().combine_unlock_rows(&[1], "pw")); + assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[1])), "{:?}", h.state().combine_thumbnails()); + // Undo brings back the locked file: its unlocked thumbnail goes with the password. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert!(h.state().combine_draft[1].lock.is_some()); + assert_eq!(h.state().combine_thumbnails()[1], CombineThumb::None); + // Unlocked again (a new branch of the history): drawn again, from this unlock. + assert!(h.state_mut().combine_unlock_rows(&[1], "pw")); + assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[1])), "{:?}", h.state().combine_thumbnails()); +} + +#[test] +fn many_files_render_only_what_is_in_view_a_few_at_a_time() { + // The first few are slow to draw, so renders are still under way while the frames are watched + // (small pages can all be drawn before the first look). + let files: Vec<(String, Vec)> = (0..200).map(|i| (format!("scan{i:03}.pdf"), if i < 6 { heavy() } else { fixture(1) })).collect(); + let mut h = grid_after(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1200.0, 800.0), 0); + let mut most = 0; + let mut done = false; + for _ in 0..800 { + h.run_steps(1); + // Every render thread counts, including those of finished renders not yet exited. + most = most.max(h.state_mut().combine_thumbnail_threads()); + let app = h.state(); + if app.combine_thumbnails().first().is_some_and(ready) && app.combine_thumbnails_rendering() == 0 { + done = true; + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(done, "the first cards render"); + assert!(most > 0, "renders were seen under way"); + assert!(most <= 3, "never more than three render threads at once ({most})"); + let thumbs = h.state().combine_thumbnails(); + let drawn = thumbs.iter().filter(|t| ready(t)).count(); + assert!(drawn > 0 && drawn < 60, "only the cards in view (and a row either side) are rendered: {drawn} of 200"); + assert_eq!(thumbs.last(), Some(&CombineThumb::None), "the last card, far out of view, isn't rendered"); +} + +#[test] +fn removing_a_file_while_it_renders_and_closing_the_tab_leave_nothing_behind() { + let files: Vec<(String, Vec)> = (0..12).map(|i| (format!("f{i}.pdf"), fixture(2))).collect(); + let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + // Remove files straight away, while their thumbnails may still be rendering. + h.state_mut().select_combine_rows(&[0, 1, 2]); + h.key_press(Key::Delete); + assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnails().iter().take(4).all(ready))); + assert_eq!(h.state().combine_thumbnails().len(), 9); + // Undo brings them back, and their thumbnails follow. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + assert!(settle(&mut h, |app| app.combine_draft.len() == 12 && app.combine_thumbnails().iter().take(3).all(ready))); + // Closing the tab forgets everything. + h.state_mut().select_combine_rows(&[0]); + h.key_press(Key::Delete); + h.run_steps(1); + h.state_mut().close_combine_tab(); + h.run_steps(2); + assert!(h.state().combine_thumbnails().is_empty()); + assert_eq!(h.state().combine_thumbnails_rendering(), 0); + assert_eq!(h.state().combine_thumbnail_textures(), 0, "no thumbnail texture is kept"); + assert!(settle(&mut h, |app| app.combine_thumbnail_threads_exited()), "every render thread exits"); +} + +#[test] +fn switching_to_the_list_lets_renders_under_way_finish() { + let files: Vec<(String, Vec)> = (0..6).map(|i| (format!("f{i}.pdf"), heavy())).collect(); + let mut h = grid_after_one(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + assert!(h.state().combine_thumbnails_rendering() > 0, "renders are under way"); + // Straight to the list, while the grid's first thumbnails are still rendering. + h.state_mut().set_option("combine-view", "list").unwrap(); + assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0), "renders finish and let go of their threads"); + for _ in 0..20 { + h.run_steps(1); + assert_eq!(h.state().combine_thumbnails_rendering(), 0, "nothing new starts in the list"); + } + assert!(h.state().combine_thumbnails().contains(&CombineThumb::None), "the rest wait for the grid"); + assert!(settle(&mut h, |app| app.combine_thumbnail_threads_exited()), "and their threads exit"); + // Back in the grid, the rest follow. + h.get_by_label("Grid view").click(); + assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(ready))); +} + +#[test] +fn combine_follows_the_order_shown_in_the_grid() { + let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 1), ("three.pdf", 1)]); + let (three, one) = (card(&h, "three.pdf"), card(&h, "one.pdf")); + drag(&mut h, three.center(), one.center() - vec2(one.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["three.pdf", "one.pdf", "two.pdf"]); + h.get_by_label("Combine").click(); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1, "the result opens"); + assert_eq!(texts_of(app, 0), ["Page 1", "Page 1", "Page 2", "Page 1"]); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::>(), ["three", "one", "two"]); +} + +#[test] +fn long_and_unusual_names_are_shortened_on_the_card_but_kept_whole() { + let long = "Überweisungsbestätigung für das dritte Quartal 2026 – Endfassung (unterschrieben).pdf"; + let mut h = grid_of(vec![(long, fixture(1)), ("日本語のファイル名.pdf", fixture(1)), ("a&b 'd'.pdf", fixture(1))], vec2(1400.0, 900.0)); + // The card's label is the whole name; only the drawing is shortened. + h.get_by_label(long); + h.get_by_label("日本語のファイル名.pdf"); + h.get_by_label("a&b 'd'.pdf"); + assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(ready))); +} + +#[test] +fn long_thin_pages_get_thumbnails_within_the_size_limits() { + // Regression: the scale came from the rounded width alone, so a strip 1 pt wide and 999 pt + // long asked for a 9×8192 raster, over the GPU texture limit (a panic in load_texture). + let files = vec![ + ("strip.pdf", fixture_sized(1, 1.0, 999.0)), + ("tall.pdf", fixture_sized(1, 10.0, 3000.0)), + ("wide.pdf", fixture_sized(1, 3000.0, 10.0)), + ("big.pdf", fixture_sized(1, 14400.0, 14400.0)), + ]; + let mut h = grid_of(files, vec2(1400.0, 900.0)); + assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(|t| *t != CombineThumb::None && *t != CombineThumb::Pending))); + for t in h.state().combine_thumbnails() { + match t { + CombineThumb::Ready { width, height, .. } => assert!(width <= 768 && height <= 768, "{width}×{height}"), + other => panic!("each strip gets a thumbnail: {other:?}"), + } + } +} + +#[test] +fn a_bad_page_range_on_a_card_says_where_to_fix_it() { + let mut h = grid_of_pages(&[("a.pdf", 3), ("b.pdf", 1)]); + h.state_mut().combine_draft[0].range = "9".into(); + h.run_steps(2); + let a = card(&h, "a.pdf"); + h.hover_at(a.center()); + for _ in 0..60 { + h.run_steps(1); + if h.query_by_label_contains("Switch to List view to change the pages it takes").is_some() { + break; + } + } + h.get_by_label_contains("Switch to List view to change the pages it takes"); +} + +#[test] +fn a_file_whose_pages_cant_be_measured_says_there_is_no_preview() { + // An RC4 owner password opens the file for combining but not for reading it here, so the + // page's size is unknown and nothing is drawn. Regression: the card was a blank white page. + // The fixture must keep the size unknown: if the inspector learns RC4 owner passwords, this + // needs another file it can't measure (the check below says so rather than pass vacuously). + let mut h = + grid_of(vec![("one.pdf", fixture(1)), ("old.pdf", protected_with(pdfcraft_cos::Algorithm::Rc4_128, "pw", "owner"))], vec2(1400.0, 900.0)); + assert!(h.state_mut().combine_unlock_rows(&[1], "owner")); + assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[0]))); + assert!(h.state().combine_draft[1].lock.is_none(), "unlocked for combining"); + assert_eq!(h.state().combine_thumbnails()[1], CombineThumb::None, "nothing rendered (is the page size still unknown?)"); + assert_eq!(h.state().combine_thumbnails_rendering(), 0); + // Screen readers hear it too, not only in the tooltip. + let description = |h: &Harness<'static, PdfCraftApp>, name: &str| h.get_by_label(name).accesskit_node().description().unwrap_or_default(); + assert!(description(&h, "old.pdf").contains("No preview"), "{}", description(&h, "old.pdf")); + assert_eq!(description(&h, "one.pdf"), "1 page"); + let tip_of = |h: &mut Harness<'static, PdfCraftApp>, name: &str, text: &str| { + let at = card(h, name).center(); + h.hover_at(at); + (0..60).any(|_| { + h.run_steps(1); + h.query_by_label_contains(text).is_some() + }) + }; + assert!(tip_of(&mut h, "old.pdf", "No preview"), "the card says why it shows no page"); + h.hover_at(egui::pos2(5.0, 890.0)); + h.run_steps(3); + assert!(!tip_of(&mut h, "one.pdf", "No preview"), "a card with its page doesn't"); +} + +#[test] +fn thumbnails_under_way_finish_on_home_and_in_a_document_tab() { + // Regression: they were only collected while the Combine page was drawn. + let files: Vec<(String, Vec)> = (0..6).map(|i| (format!("f{i}.pdf"), heavy())).collect(); + let mut h = grid_after_one(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + assert!(h.state().combine_thumbnails_rendering() > 0, "renders are under way"); + // Home. + h.state_mut().combine_tab.focused = false; + h.run_steps(1); + assert!(!h.state().combine_showing()); + assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnail_threads_exited())); + // A document tab: the same. + h.state_mut().open_combine_tab(); + h.run_steps(1); + h.state_mut().open_bytes("doc.pdf", None, fixture(1)).unwrap(); + h.run_steps(1); + assert!(h.state().active.is_some()); + assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnail_threads_exited())); +} + +#[test] +fn a_focused_card_still_takes_the_arrow_keys() { + // Regression: the keys were ignored whenever any widget, a card included, had focus. + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); + h.get_by_label("a.pdf").click(); + h.run_steps(1); + h.get_by_label("a.pdf").focus(); + h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1]); +} + +#[test] +fn a_drag_is_dropped_when_the_list_changes_under_it() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); + h.state_mut().select_combine_rows(&[3]); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); + // A drag begins; undo brings d.pdf back while it is under way; then the card is dropped. + let (a, c) = (card(&h, "a.pdf"), card(&h, "c.pdf")); + let to = c.center() + vec2(c.width() * 0.3, 0.0); + h.hover_at(a.center()); + h.run_steps(1); + h.drag_at(a.center()); + h.run_steps(1); + for k in 1..=3 { + h.hover_at(a.center() + (to - a.center()) * (k as f32 / 6.0)); + h.run_steps(1); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(1); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"]); + h.hover_at(to); + h.run_steps(1); + h.drop_at(to); + h.run_steps(3); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"], "the gap it was aimed at no longer means the same: nothing moves"); +} + +#[test] +fn a_card_dragged_to_the_bottom_edge_scrolls_the_grid_and_still_drops() { + let files: Vec<(String, Vec)> = (0..30).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); + let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(820.0, 860.0)); + let from = card(&h, "f00.pdf").center(); + let totals = h.get_by_label_contains("30 files").rect(); + let edge = egui::pos2(from.x, totals.top() - 30.0); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(edge); + // Held at the edge, the grid scrolls; the card dragged leaves the rows drawn. + for _ in 0..80 { + h.run_steps(1); + } + assert!(h.query_by_label("f00.pdf").is_none(), "scrolled away from the first row"); + // A card wholly in view (rows just outside it are drawn too, to scroll smoothly). + let top = h.get_by_label("Grid view").rect().bottom() + 40.0; + let (target_name, target) = (1..30) + .map(|i| format!("f{i:02}.pdf")) + .find_map(|n| h.query_by_label(&n).map(|c| c.rect()).filter(|r| r.top() > top && r.bottom() < totals.top() - 20.0).map(|r| (n, r))) + .expect("a card in view"); + let to = target.center() - vec2(target.width() * 0.3, 0.0); + h.hover_at(to); + h.run_steps(1); + h.drop_at(to); + h.run_steps(3); + let order = names(&h); + let at = order.iter().position(|n| n == "f00.pdf").unwrap(); + assert!(at > 0, "moved from the start: {order:?}"); + assert_eq!(order.get(at + 1), Some(&target_name), "dropped before {target_name}: {order:?}"); +} + +#[test] +fn a_locked_card_offers_unlock_on_a_right_click() { + let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); + h.get_by_label("secret.pdf").click_secondary(); + h.run_steps(2); + // The menu's item comes after the toolbar's button of the same name. + h.get_all_by_label("Unlock…").last().unwrap().click(); + h.run_steps(2); + h.get_by_label("Unlock secret.pdf"); + assert_eq!(h.state().combine_selection(), [1], "the card right-clicked is the one unlocked"); +} + +fn disabled(h: &Harness<'static, PdfCraftApp>, label: &str) -> bool { + h.get_by_label(label).accesskit_node().is_disabled() +} + +/// Ctrl/⌘ with the wheel at `at` (a positive `dy` zooms in). +fn ctrl_wheel(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, dy: f32) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::COMMAND, + }); + h.run_steps(3); +} + +#[test] +fn the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 2)]); + assert_eq!(h.state().combine_zoom, 1.0); + let before = card(&h, "a.pdf"); + h.get_by_label("Larger pages").click(); + h.run_steps(3); + assert_eq!(h.state().combine_zoom, 1.25); + let after = card(&h, "a.pdf"); + assert!(after.width() > before.width() && after.height() > before.height(), "{before:?} → {after:?}"); + // Larger until it can't go further: 200%, and the button says so. + for _ in 0..10 { + if disabled(&h, "Larger pages") { + break; + } + h.get_by_label("Larger pages").click(); + h.run_steps(2); + } + assert_eq!(h.state().combine_zoom, 2.0); + assert!(disabled(&h, "Larger pages")); + // The percentage resets it. + h.get_by_label("Reset page size").click(); + h.run_steps(2); + assert_eq!(h.state().combine_zoom, 1.0); + for _ in 0..10 { + if disabled(&h, "Smaller pages") { + break; + } + h.get_by_label("Smaller pages").click(); + h.run_steps(2); + } + assert_eq!(h.state().combine_zoom, 0.6); + let smallest = card(&h, "a.pdf"); + assert!(smallest.width() < before.width() && smallest.height() < before.height(), "{smallest:?}"); + // Kept in the settings, and read back; a damaged setting is clamped or ignored. + let saved = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&saved); + assert_eq!(fresh.combine_zoom, 0.6); + fresh.restore(r#"{"combine_zoom": 99}"#); + assert_eq!(fresh.combine_zoom, 2.0); + fresh.restore(r#"{"combine_zoom": "big"}"#); + assert_eq!(fresh.combine_zoom, 2.0); + // Scriptable in percent, with a clear refusal outside the range. + let app = h.state_mut(); + app.set_option("combine-zoom", "150%").unwrap(); + assert_eq!(app.combine_zoom, 1.5); + for bad in ["59", "201", "big", "NaN", "inf", ""] { + assert!(app.set_option("combine-zoom", bad).is_err(), "{bad:?}"); + } + assert_eq!(app.combine_zoom, 1.5); + // The list has no card size. + h.get_by_label("List view").click(); + h.run_steps(2); + assert!(h.query_by_label("Larger pages").is_none() && h.query_by_label("Page size").is_none()); +} + +#[test] +fn the_slider_sizes_the_cards_from_smallest_to_largest() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1)]); + let slider = h.get_by_label("Page size").rect(); + drag(&mut h, slider.center(), slider.right_center() + vec2(40.0, 0.0)); + assert_eq!(h.state().combine_zoom, 2.0); + let slider = h.get_by_label("Page size").rect(); + drag(&mut h, slider.center(), slider.left_center() - vec2(40.0, 0.0)); + assert_eq!(h.state().combine_zoom, 0.6); + // The cards are still there and in order. + assert_eq!(names(&h), ["a.pdf", "b.pdf"]); + assert!(card(&h, "a.pdf").left() < card(&h, "b.pdf").left()); +} + +#[test] +fn ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Equals); + h.run_steps(3); + assert_eq!(h.state().combine_zoom, 1.25); + h.key_press_modifiers(Modifiers::COMMAND, Key::Plus); + h.run_steps(3); + assert_eq!(h.state().combine_zoom, 1.5); + h.key_press_modifiers(Modifiers::COMMAND, Key::Minus); + h.run_steps(3); + assert_eq!(h.state().combine_zoom, 1.25); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); + h.run_steps(3); + assert_eq!(h.state().combine_zoom, 1.0); + assert_eq!(h.ctx.zoom_factor(), 1.0, "the window's text keeps its size"); + // Ctrl/⌘ with the wheel over the grid: in, then out. + let at = card(&h, "b.pdf").center(); + ctrl_wheel(&mut h, at, 120.0); + let zoomed = h.state().combine_zoom; + assert!(zoomed > 1.0, "{zoomed}"); + let at = card(&h, "b.pdf").center(); + ctrl_wheel(&mut h, at, -240.0); + assert!(h.state().combine_zoom < zoomed, "{}", h.state().combine_zoom); + // Not over the grid (the toolbar): nothing. + let now = h.state().combine_zoom; + let toolbar = h.get_by_label("Grid view").rect().center(); + ctrl_wheel(&mut h, toolbar, 120.0); + assert_eq!(h.state().combine_zoom, now); + // Nor in the list. + h.get_by_label("List view").click(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::Equals); + h.run_steps(2); + assert_eq!(h.state().combine_zoom, now); +} + +#[test] +fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank() { + let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); + let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + // Scroll down a little, so the grid isn't simply at its top. + let first = card(&h, "f00.pdf"); + h.hover_at(first.center()); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(6); + // A card fully in view, below the toolbar, with its thumbnail drawn. + let toolbar = h.get_by_label("Grid view").rect().bottom(); + let pick = files + .iter() + .map(|(n, _)| n.as_str()) + .find(|n| h.query_by_label(n).is_some_and(|c| c.rect().top() > toolbar + 80.0 && c.rect().bottom() < 820.0)) + .expect("a card in view") + .to_string(); + let i = names(&h).iter().position(|n| *n == pick).unwrap(); + assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[i]))); + let CombineThumb::Ready { height: small, .. } = h.state().combine_thumbnails()[i] else { unreachable!() }; + let before = card(&h, &pick); + h.hover_at(before.center()); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::COMMAND, + }); + // The wheel zooms smoothly over several frames; in every one the card stays put. + let mut zooms = Vec::new(); + for frame in 0..12 { + h.run_steps(1); + zooms.push(h.state().combine_zoom); + let now = card(&h, &pick); + assert!((now.top() - before.top()).abs() < 2.0, "frame {frame}: the card under the pointer moved: {before:?} → {now:?}"); + } + assert!(h.state().combine_zoom > 1.0, "{zooms:?}"); + // Its thumbnail never went blank: the smaller one stands in until the sharper one arrives. + for _ in 0..400 { + h.run_steps(1); + match h.state().combine_thumbnails()[i] { + CombineThumb::Ready { height, .. } if height > small => break, + CombineThumb::Ready { .. } => {} + ref other => panic!("went blank: {other:?}"), + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(matches!(h.state().combine_thumbnails()[i], CombineThumb::Ready { height, .. } if height > small), "re-rendered larger"); +} + +#[test] +fn dragging_and_arrow_keys_work_at_the_smallest_and_largest_sizes() { + let mut columns = Vec::new(); + for zoom in ["60", "200"] { + let files: Vec<(String, Vec)> = (0..24).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); + let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + h.state_mut().set_option("combine-zoom", zoom).unwrap(); + h.run_steps(3); + let (a, c) = (card(&h, "f00.pdf"), card(&h, "f02.pdf")); + drag(&mut h, a.center(), c.center() + vec2(c.width() * 0.3, 0.0)); + assert_eq!(&names(&h)[..4], ["f01.pdf", "f02.pdf", "f00.pdf", "f03.pdf"], "at {zoom}%"); + // Down moves one row: as many files as fit across. + h.get_by_label("f01.pdf").click(); + h.run_steps(1); + h.key_press(Key::ArrowDown); + h.run_steps(2); + let row = h.state().combine_selection(); + assert_eq!(row.len(), 1); + assert!(row[0] > 1, "at {zoom}%: {row:?}"); + columns.push(row[0]); + } + assert!(columns[0] > columns[1], "more cards fit across when they are smaller: {columns:?}"); +} + +#[test] +fn zooming_quickly_never_runs_more_than_three_render_threads() { + let files: Vec<(String, Vec)> = (0..60).map(|i| (format!("f{i:02}.pdf"), if i < 6 { heavy() } else { fixture(1) })).collect(); + let mut h = grid_after(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0), 0); + let mut most = 0; + for k in 0..80 { + let key = if (k / 5) % 2 == 0 { Key::Equals } else { Key::Minus }; + h.key_press_modifiers(Modifiers::COMMAND, key); + h.run_steps(1); + let threads = h.state_mut().combine_thumbnail_threads(); + most = most.max(threads); + assert!(threads <= 3, "frame {k}: {threads} render threads"); + std::thread::sleep(Duration::from_millis(3)); + } + assert!(most > 0, "renders were seen under way"); + // And it settles: the cards in view all get their thumbnails. + assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnails().first().is_some_and(ready))); +} + +/// The grid with 40 one-page files, scrolled down by `dy`; and where the top of the grid is. +fn scrolled_grid(dy: f32) -> (Harness<'static, PdfCraftApp>, f32) { + let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); + let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + // Unscrolled, the first card sits a fixed space (12 points) below the top of the grid. + let top = card(&h, "f00.pdf").top() - 12.0; + let at = card(&h, "f00.pdf").center(); + h.hover_at(at); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -dy), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(8); + (h, top) +} + +/// The first card (in list order) still showing below `top`. +fn first_in_view(h: &Harness<'static, PdfCraftApp>, top: f32) -> String { + names(h).into_iter().find(|n| h.query_by_label(n).is_some_and(|c| c.rect().bottom() > top)).expect("a card in view") +} + +#[test] +fn zooming_from_the_toolbar_while_scrolled_keeps_the_top_cards_in_place() { + // Regression: with the pointer over the toolbar, a card drawn just above the view (under the + // toolbar) was taken as the one to keep in place, and the cards in view jumped. + for how in ["keys", "button", "slider"] { + let (mut h, top) = scrolled_grid(700.0); + let anchor = first_in_view(&h, top); + let before = card(&h, &anchor).top(); + let over_toolbar = egui::pos2(card(&h, &anchor).center().x, h.get_by_label("Grid view").rect().center().y); + h.hover_at(over_toolbar); + h.run_steps(1); + match how { + "keys" => h.key_press_modifiers(Modifiers::COMMAND, Key::Equals), + "button" => h.get_by_label("Larger pages").click(), + _ => { + let s = h.get_by_label("Page size").rect(); + drag(&mut h, s.center(), s.right_center() + vec2(40.0, 0.0)); + } + } + h.run_steps(3); + assert!(h.state().combine_zoom > 1.0, "{how}"); + let after = card(&h, &anchor).top(); + assert!((after - before).abs() < 2.0, "{how}: {anchor} moved from {before} to {after}"); + } +} + +#[test] +fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { + let (mut h, _) = scrolled_grid(20_000.0); + h.state_mut().set_option("combine-zoom", "200").unwrap(); + h.run_steps(2); + // Over the middle of the grid. + h.hover_at(egui::pos2(900.0, 600.0)); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -20_000.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(8); + let bottom = card(&h, "f39.pdf").bottom(); + h.get_by_label("Smaller pages").click(); + for frame in 0..6 { + h.run_steps(1); + let now = card(&h, "f39.pdf").bottom(); + assert!((now - bottom).abs() < 2.0, "frame {frame}: the last card left a gap below it ({bottom} → {now})"); + } + assert_eq!(h.state().combine_zoom, 1.75); +} + +#[test] +fn the_size_slider_takes_the_arrow_keys_when_it_has_the_keyboard() { + // Regression: the grid's arrow keys moved the selection instead. + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); + h.get_by_label("a.pdf").click(); + h.run_steps(1); + h.get_by_label("Page size").focus(); + h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(2); + assert!(h.state().combine_zoom > 1.0, "{}", h.state().combine_zoom); + assert_eq!(h.state().combine_selection(), [0], "the selection stays"); + let node = h.get_by_label("Page size"); + assert!(node.accesskit_node().numeric_value().is_some_and(|v| v > 100.0), "read out as a percentage"); +} + +#[test] +fn the_toolbar_fits_the_smallest_window() { + // 820 points is the narrowest the window goes. + let mut h = grid_after(vec![("a.pdf", fixture(1)), ("b.pdf", fixture(1))], vec2(820.0, 700.0), 4); + h.get_by_label("a.pdf").click(); + h.run_steps(1); + h.get_by_label("b.pdf").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + let controls: Vec = [ + "Add open documents", + "Undo", + "Redo", + "Grid view", + "List view", + "2 selected", + "Smaller pages", + "Page size", + "Reset page size", + "Larger pages", + ] + .iter() + .map(|l| h.get_by_label(l).rect()) + .collect(); + for (i, a) in controls.iter().enumerate() { + assert!(a.right() <= 820.0, "{i}: {a:?} is off the window"); + for b in controls.iter().skip(i + 1) { + assert!(!a.intersects(*b), "{a:?} overlaps {b:?}"); + } + } +} + +/// The pointer over a card (its centre, below the bar of actions at the top of its page). +fn hover_card(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + let at = card(h, name).center(); + h.hover_at(at); + h.run_steps(2); +} + +/// A primary click at a point, as a user makes one. +fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: Pos2) { + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(1); +} + +#[test] +fn hovering_a_card_shows_its_trash_and_magnifier_and_only_then() { + let mut h = grid_of(vec![("a.pdf", fixture(1)), ("b.pdf", fixture(3)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); + h.hover_at(egui::pos2(5.0, 890.0)); + h.run_steps(2); + assert!(h.query_by_label("Preview").is_none() && h.query_by_label("Remove a.pdf").is_none(), "nothing until a card is hovered"); + hover_card(&mut h, "a.pdf"); + h.get_by_label("Preview"); + h.get_by_label("Remove a.pdf"); + // Over the bar itself it stays (the pointer is still on the card). + let bar = h.get_by_label("Preview").rect().center(); + h.hover_at(bar); + h.run_steps(2); + h.get_by_label("Remove a.pdf"); + // Another card: its own. + hover_card(&mut h, "b.pdf"); + h.get_by_label("Remove b.pdf"); + assert!(h.query_by_label("Remove a.pdf").is_none()); + // A locked file has nothing to show large, but can still be removed. + hover_card(&mut h, "secret.pdf"); + h.get_by_label("Remove secret.pdf"); + assert!(h.query_by_label("Preview").is_none(), "no magnifier on a locked file"); +} + +#[test] +fn the_card_trash_removes_only_that_file_and_undo_brings_it_back() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); + h.get_by_label("b.pdf").click(); + h.run_steps(1); + h.get_by_label("c.pdf").click_modifiers(Modifiers::COMMAND); + h.run_steps(1); + // The trash of a card that isn't selected: that file only, the selection stays. + hover_card(&mut h, "a.pdf"); + h.get_by_label("Remove a.pdf").click(); + h.run_steps(2); + assert_eq!(names(&h), ["b.pdf", "c.pdf", "d.pdf"]); + assert_eq!(h.state().combine_selection(), [0, 1], "b and c stay selected"); + // The trash of a selected card: still that file only. + hover_card(&mut h, "b.pdf"); + h.get_all_by_label("Remove b.pdf").last().unwrap().click(); + h.run_steps(2); + assert_eq!(names(&h), ["c.pdf", "d.pdf"]); + assert_eq!(h.state().combine_selection(), [0], "c stays selected"); + // One undo step each. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(names(&h), ["b.pdf", "c.pdf", "d.pdf"]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"]); +} + +#[test] +fn the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4)]); + h.state_mut().combine_draft[1].range = "3, 1".into(); + h.get_by_label("a.pdf").click(); + h.run_steps(1); + assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[1]))); + let CombineThumb::Ready { width: card_width, .. } = h.state().combine_thumbnails()[1] else { unreachable!() }; + hover_card(&mut h, "b.pdf"); + h.get_by_label("Preview").click(); + h.run_steps(2); + // The card under it says nothing over it (its tooltip, its actions). + for _ in 0..150 { + h.run_steps(1); + } + assert!(h.query_by_label_contains("4 pages ·").is_none(), "no card tooltip over the preview"); + assert!(h.query_by_label("Remove b.pdf").is_none(), "no card actions over the preview"); + // The first page it adds: page 3. + let (name, page, _) = h.state().combine_preview().expect("shown large"); + assert_eq!((name.as_str(), page), ("b.pdf", 2)); + h.get_by_label("Page 1 of 2"); + assert!(disabled(&h, "Previous page")); + // Rendered larger than its card. + assert!( + settle(&mut h, |app| matches!(app.combine_preview(), Some((_, _, CombineThumb::Ready { width, .. })) if width > card_width)), + "{:?}", + h.state().combine_preview() + ); + // The arrow keys flip, and stop at the ends; the grid's selection doesn't move. + h.key_press(Key::ArrowRight); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "then page 1"); + h.get_by_label("Page 2 of 2"); + assert!(disabled(&h, "Next page")); + h.key_press(Key::ArrowRight); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0)); + h.get_by_label("Previous page").click(); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| p.1), Some(2)); + assert_eq!(h.state().combine_selection(), [0]); + // Esc closes it; so do the close button and a click outside. + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().combine_preview().is_none()); + for how in ["close", "outside"] { + hover_card(&mut h, "b.pdf"); + h.get_by_label("Preview").click(); + h.run_steps(2); + assert!(h.state().combine_preview().is_some(), "{how}"); + match how { + "close" => h.get_by_label("Close").click(), + _ => click_at(&mut h, egui::pos2(6.0, 6.0)), + } + h.run_steps(2); + assert!(h.state().combine_preview().is_none(), "{how}"); + } + assert_eq!(names(&h), ["a.pdf", "b.pdf"], "nothing else changed"); +} + +#[test] +fn a_right_click_on_a_card_offers_preview_and_remove() { + let mut h = grid_of_pages(&[("a.pdf", 2), ("b.pdf", 1)]); + h.get_by_label("a.pdf").click_secondary(); + h.run_steps(2); + // The menu's items come after the card's own buttons of the same name. + h.get_all_by_label("Preview").last().unwrap().click(); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("a.pdf".to_string(), 0))); + h.key_press(Key::Escape); + h.run_steps(2); + h.get_by_label("b.pdf").click_secondary(); + h.run_steps(2); + h.get_all_by_label("Remove b.pdf").last().unwrap().click(); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf"]); +} + +#[test] +fn the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads() { + let files: Vec<(String, Vec)> = (0..12).map(|i| (format!("f{i:02}.pdf"), heavy())).collect(); + let mut h = grid_after_one(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + hover_card(&mut h, "f03.pdf"); + h.get_by_label("Preview").click(); + let (mut most, mut rendered) = (0, false); + for frame in 0..60 { + h.run_steps(1); + let threads = h.state_mut().combine_thumbnail_threads(); + most = most.max(threads); + assert!(threads <= 3, "frame {frame}: {threads} render threads"); + // The large page itself is rendered (under way, or done) among them. + rendered |= matches!(h.state().combine_preview(), Some((_, _, CombineThumb::Pending | CombineThumb::Ready { .. }))); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(most > 0); + assert!(rendered, "the preview's own render ran: {:?}", h.state().combine_preview()); + // The file leaves the list (as undo of its adding would): the preview closes, nothing stays. + h.state_mut().combine_draft.retain(|f| f.name != "f03.pdf"); + h.run_steps(2); + assert!(h.state().combine_preview().is_none()); + assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnail_threads_exited())); +} + +#[test] +fn space_shows_the_selected_file_large_in_the_grid_and_the_list() { + // The way to the preview without a pointer (screen readers, the keyboard). + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 2), ("c.pdf", 1)]); + h.key_press(Key::Space); + h.run_steps(2); + assert!(h.state().combine_preview().is_none(), "nothing selected, nothing shown"); + h.get_by_label("a.pdf").click(); + h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(1); + h.key_press(Key::Space); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| p.0), Some("b.pdf".to_string()), "the file the keyboard is on"); + // While it shows, the grid's keys wait: an arrow flips its pages, Delete removes nothing. + h.key_press(Key::ArrowRight); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| p.1), Some(1)); + assert_eq!(names(&h).len(), 3); + assert_eq!(h.state().combine_selection(), [1]); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().combine_preview().is_none()); + // The list too. + h.get_by_label("List view").click(); + h.run_steps(2); + h.key_press(Key::Space); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| p.0), Some("b.pdf".to_string())); +} + +#[test] +fn flipping_quickly_ends_on_the_page_shown_and_leaving_the_tab_closes_the_preview() { + let mut h = grid_of_pages(&[("a.pdf", 6)]); + hover_card(&mut h, "a.pdf"); + h.get_by_label("Preview").click(); + h.run_steps(1); + // A page a frame, faster than they render. + for _ in 0..4 { + h.key_press(Key::ArrowRight); + h.run_steps(1); + } + assert_eq!(h.state().combine_preview().map(|p| p.1), Some(4)); + assert!( + settle(&mut h, |app| matches!(app.combine_preview(), Some((_, 4, CombineThumb::Ready { page: 4, .. })))), + "the render shown is of the page shown: {:?}", + h.state().combine_preview() + ); + // Home, then back: the preview is gone, not waiting there. + h.state_mut().combine_tab.focused = false; + h.run_steps(2); + assert!(h.state().combine_preview().is_none()); + h.state_mut().open_combine_tab(); + h.run_steps(2); + assert!(h.state().combine_preview().is_none()); + assert!(h.query_by_label("Page 5 of 6").is_none()); +} + +#[test] +fn space_on_a_focused_button_presses_it_and_previews_only_what_can_be_shown() { + // Regression: with a file selected, Space opened the preview whatever had the keyboard. + let mut h = + grid_of(vec![("a.pdf", fixture(2)), ("old.pdf", protected_with(pdfcraft_cos::Algorithm::Rc4_128, "pw", "owner"))], vec2(1400.0, 900.0)); + h.get_by_label("a.pdf").click(); + h.run_steps(1); + h.get_by_label("List view").focus(); + h.run_steps(1); + h.key_press(Key::Space); + h.run_steps(2); + assert_eq!(h.state().combine_view, CombineView::List, "the focused button was pressed"); + assert!(h.state().combine_preview().is_none()); + // A file whose pages can't be measured has nothing to show large (its card has no magnifier). + h.get_by_label("Grid view").click(); + h.run_steps(1); + assert!(h.state_mut().combine_unlock_rows(&[1], "owner")); + h.run_steps(2); + h.get_by_label("old.pdf").click(); + h.run_steps(1); + h.key_press(Key::Space); + h.run_steps(2); + assert!(h.state().combine_preview().is_none(), "nothing to show"); +} + +#[test] +fn a_drag_held_while_the_tab_closes_and_reopens_moves_nothing() { + // Regression: closing the tab started the list's revisions over, so a drag begun before could + // match a new list and move one of its files. + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); + let (a, c) = (card(&h, "a.pdf"), card(&h, "c.pdf")); + h.hover_at(a.center()); + h.run_steps(1); + h.drag_at(a.center()); + h.run_steps(1); + h.hover_at(a.center() + vec2(30.0, 0.0)); + h.run_steps(1); + // While held: the tab closes, and the same files come back. + h.state_mut().close_combine_tab(); + h.state_mut().use_files(FilePurpose::Combine, ["a.pdf", "b.pdf", "c.pdf"].iter().map(|n| (n.to_string(), fixture(1))).collect()); + h.run_steps(2); + let to = c.center() + vec2(c.width() * 0.3, 0.0); + h.hover_at(to); + h.run_steps(1); + h.drop_at(to); + h.run_steps(3); + assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"], "the old drag moves nothing in the new list"); +} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 99a5d9c00a..93c244affa 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -661,6 +661,7 @@ fn combining_files_opens_a_new_unsaved_tab() { #[test] fn combine_files_takes_chosen_pages_in_the_order_listed() { let mut h = harness(1, |app| { + app.set_option("combine-view", "list").unwrap(); app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(3)), ("two.pdf".into(), fixture(2))]); }); h.run_steps(3); @@ -727,6 +728,7 @@ fn closing_the_combine_tab_forgets_its_list() { #[test] fn combine_lists_size_and_warns_before_combining() { let mut h = harness(1, |app| { + app.set_option("combine-view", "list").unwrap(); app.use_files( pdfcraft_ui_egui::FilePurpose::Combine, vec![ @@ -792,9 +794,13 @@ fn combine_names(h: &Harness<'static, PdfCraftApp>) -> Vec { h.state().combine_draft.iter().map(|f| f.name.clone()).collect() } +/// Files in the Combine tab's list view (the table: headings, page ranges, row links). fn combine_of(names: &[(&str, usize)]) -> Harness<'static, PdfCraftApp> { let files: Vec<(String, Vec)> = names.iter().map(|(n, p)| (n.to_string(), fixture(*p))).collect(); - let mut h = harness(1, move |app| app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, files)); + let mut h = harness(1, move |app| { + app.set_option("combine-view", "list").unwrap(); + app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, files); + }); h.run_steps(3); h } @@ -887,6 +893,7 @@ fn undo_and_redo_restore_the_combine_list() { #[test] fn protected_files_that_can_be_combined_are_flagged() { let mut h = harness(1, |app| { + app.set_option("combine-view", "list").unwrap(); app.use_files( pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(1)), ("no-copy.pdf".into(), protected("", "owner", -1 ^ 16))], diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 760245bc3c..eabdac9858 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -511,6 +511,7 @@ fn pdfs_dropped_on_the_combine_tab_join_its_list() { app }); h.run_steps(3); + h.state_mut().set_option("combine-view", "list").unwrap(); h.state_mut().execute("page.combine"); h.run_steps(2); // An absolute path is read from disk, with its modified time. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 91ac26f1b3..0adb0b67a1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2951,7 +2951,19 @@ status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] evidence = ["crates/ui-egui/tests/editing.rs::column_headings_sort_the_list_and_flip_on_the_second_click", "crates/ui-egui/tests/editing.rs::ctrl_and_shift_clicks_select_several_files_that_move_and_go_together", "crates/ui-egui/tests/editing.rs::undo_and_redo_restore_the_combine_list", "crates/automation/tests/automation.rs::combine_extract_and_split"] -notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag; every change undoable. doc_combine combines in the order its paths are given." +notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag (rows in the list, cards in the grid); every change undoable. doc_combine combines in the order its paths are given." + +[[feature]] +id = "organize.combine-thumbnails" +title = "Thumbnail (grid) view of the files before combining" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = ["page.combine"] +tools = ["doc_combine"] +evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list"] +notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 200% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; kept in the settings, --combine-zoom 60..200), keeping the card under the pointer in place. Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 768 px; at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. Missing: expanding a file into its pages, and moving or removing single pages before combining (#611)." [[feature]] id = "organize.combine-options"