diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4d054998..6f51d34c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (260) +## In this repository (261) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -134,6 +134,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index fda61040..1733da08 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 = "5d982ffe0c946c4dc9930901a23e0a6221cff8ecd8481f704b06003719cd6fe0" +sha256 = "92b20307c6f461c4b057e9b2895122f876a2dd48b4101788f2cd47793df70499" [[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 = "eb612b49df91777bbb89625eb0257707a389720349b018fc954afe7ebfb02970" +sha256 = "c9b4aace4495fb998f3bb3aa233e20ce48f9d7fdf5ca35c0481b438881eddd62" [[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 = "9b6ef3dd77e51982a8e23eb4e4182c16c590553b060ab141283e851e93ee2041" +sha256 = "7bbf7cf6c65cde241a87c4b0c975cc21ed070c025b00f299deafcc3426e1bce4" [[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 = "7708e9b9a12d1896261e3e010addd61b4313c5126959f5b9d613d67ee469cc33" +sha256 = "210f0b8f4bff3f5b9cc22f240536bad68cd1a7de3958f2553dfb54d60555c07e" [[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 = "36bf3703711e7730a2b6995e6949f120bf0a4ff7ef6836c129d396d66ee61cac" +sha256 = "6eab259f84f0774a6cd816d04ce7eb16aa44a4004b25fbe2676ce9fef4fb386c" [[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 = "89e5a0f086d176a92addd6dfa327aa7e9ee619e6cab640a3353c07390c45575c" +sha256 = "f1e14339af91885fdf94d4f800ffee428c355d2368a3d8d56695638692021c44" [[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 = "1e24555846cacfc8e84ed6900e2a495ecf994bf7bd96bc01733b48507518d2a3" +sha256 = "01a6eff838ad9d34bac63c9c9228ac03c58826ea08e8c63cd26724da8ca00fd5" [[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 = "2772eb00b4ccdf378bf311ca82510202a4b9b6c948706378e3c319c8aa0713a2" +sha256 = "2daadafd5af3156e6dd269aed320525100a0a305de756c92b14e6078c14efb82" [[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 = "e273af2513c3bc8db051b7f80fedeaae36aaba53486e54f608fc227497d43a7f" +sha256 = "27e1f490a6e57e0b7e923d62451e934abcafc66944015d13b009e76a3c598863" [[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 = "4c044110ea4feef81e8561b510bd4bcf89beabc3e32a42b94265cf3d46692b72" +sha256 = "e913893a535c009df6de3909c1a2c7322293ccdc42ff97b962762d8d8d702a3d" [[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 = "d20714c060877abcea56c7d0f638a5bf1e31f698d606086d741985c03b10ff7a" +sha256 = "2109aafdefadde26f2a9da3842c08f1bf58cd920f932cb0d15e40b55f7f86b1b" [[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 = "361e015a2ebfb507d4c59f5b2450c9112fe0a7e3d4ad5ba77e88f33f473bdd75" +sha256 = "503ab86bef47616ead8f03db29bc3f629d31eaad7c4f708e2d5c1f4cda190ee8" [[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 = "48114bed18d577a6aa00dab59241ad26a22d369b41cc4f60cecd716e7921dbc4" +sha256 = "3576db171a132c4494d0bf37531cf0b817f527528e42e8323cad42bc516a9e90" [[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 = "e073dfb9aa269b5174b5b9e997629ffd988cc310afb461d6fa3185a9da050776" +sha256 = "32a866873b2ad4575dc2c25acba14b71d2065110239839948e6577c4a7fb3fb1" [[asset]] path = "assets/icons/accessibility.svg" @@ -1396,6 +1396,17 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "fe917d6144a9aed9a5297cb28addb9e582ac591a8f1c498a55aecea9b5c1783e" +[[asset]] +path = "assets/icons/minimize-2.svg" +title = "Lucide icon \"minimize-2\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "1150437d5a870321c59b7ebca2e1bc3f6227f0fa03dd10484ab384dfe5110739" + [[asset]] path = "assets/icons/minus.svg" title = "Lucide icon \"minus\"" diff --git a/assets/icons/minimize-2.svg b/assets/icons/minimize-2.svg new file mode 100644 index 00000000..f15d72cd --- /dev/null +++ b/assets/icons/minimize-2.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs index 49c70a6b..c1f368de 100644 --- a/crates/ui-egui/src/combine_grid.rs +++ b/crates/ui-egui/src/combine_grid.rs @@ -61,11 +61,19 @@ pub(crate) fn zoom_step(zoom: f32, larger: bool) -> f32 { } /// 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 +/// A thumbnail's place: the file (by id) and the page (0-based, in the file). A file's card and +/// the card of its first page when expanded show the same thumbnail. +type Slot = (u64, usize); +/// The page shown large (a card's magnifier) is kept beside the thumbnails under this slot, so it /// shares their threads, their limits and their checks. -const PREVIEW: u64 = u64::MAX; +const PREVIEW: Slot = (u64::MAX, 0); /// The longest side of the page shown large, in pixels. const PREVIEW_SIDE: f32 = 2048.0; +/// A file whose render is done keeps its document open this long (seconds), so the next page of +/// the same file renders without reading the whole file again. +const KEEP_OPEN: f64 = 2.0; +/// The most pages one file can be spread out into (each is a card, laid out every frame). +pub(crate) const MAX_EXPAND: usize = 2000; /// 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 @@ -120,15 +128,25 @@ struct Entry { } struct Job { - file: u64, + slot: Slot, key: Key, pool: RenderPool, tag: u64, } +/// A pool whose render is done, with its document still open: kept a moment for the next page of +/// the same file, read the same way. +struct Open { + source: usize, + auth: u64, + pool: RenderPool, + /// When its render finished (seconds). + since: f64, +} + /// A thumbnail a card needs this frame. struct Want { - file: u64, + slot: Slot, key: Key, bytes: Arc>, password: Option>, @@ -139,8 +157,10 @@ struct Want { /// The grid's thumbnails and the renders under way. #[derive(Default)] pub(crate) struct Thumbs { - entries: HashMap, + entries: HashMap, jobs: Vec, + /// Pools done rendering, their documents still open (their threads count). + open: Vec, /// Threads of pools let go of, until they exit. retiring: Vec, frame: u64, @@ -168,20 +188,24 @@ impl Thumbs { for job in self.jobs.drain(..) { self.retiring.push(job.pool.retire()); } + for open in self.open.drain(..) { + self.retiring.push(open.pool.retire()); + } } - /// Render threads that exist now (real threads, not pools): those of pools at work and of - /// pools let go of. + /// Render threads that exist now (real threads, not pools): those of pools at work, of pools + /// keeping a document open, 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) + let open: usize = self.open.iter().map(|o| o.pool.threads()).sum(); + working.saturating_add(open).saturating_add(retiring) } - fn look(&mut self, file: u64, key: Key) -> Look { + fn look(&mut self, slot: Slot, key: Key) -> Look { let frame = self.frame; - match self.entries.get_mut(&file) { + match self.entries.get_mut(&slot) { Some(e) if e.key == key => { e.seen = frame; match (&e.tex, &e.error) { @@ -203,8 +227,8 @@ impl Thumbs { } } - fn state(&self, file: u64) -> ThumbState { - if let Some(e) = self.entries.get(&file) { + fn state(&self, slot: Slot) -> ThumbState { + if let Some(e) = self.entries.get(&slot) { if let Some(tex) = &e.tex { let [w, h] = tex.size(); return ThumbState::Ready { @@ -217,19 +241,26 @@ impl Thumbs { return ThumbState::Failed(err.clone()); } } - if self.jobs.iter().any(|j| j.file == file) { ThumbState::Pending } else { ThumbState::None } + if self.jobs.iter().any(|j| j.slot == slot) { 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) { + /// renders, start those the grid asked for and keep the textures within budget. `current` + /// says what a thumbnail must match now (looked up, so the cost follows the files and the + /// thumbnails kept, not the pages of an expanded file). + fn finish(&mut self, ctx: &egui::Context, current: &dyn Fn(Slot) -> Option) { + let now = ctx.input(|i| i.time); // 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))); + self.entries.retain(|slot, e| current(*slot).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))); + let (keep, gone): (Vec, Vec) = self.jobs.drain(..).partition(|job| current(job.slot).is_some_and(|k| k.same_page(&job.key))); self.jobs = keep; self.retiring.extend(gone.into_iter().map(|job| job.pool.retire())); + // Documents kept open past their time are closed. + let (keep, gone): (Vec, Vec) = self.open.drain(..).partition(|o| now - o.since < KEEP_OPEN); + self.open = keep; + self.retiring.extend(gone.into_iter().map(|o| o.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. @@ -259,6 +290,8 @@ impl Thumbs { } let job = self.jobs.swap_remove(i); taken += 1; + // A pool that rendered (rather than failed or hung) can keep its document open. + let healthy = answer.as_ref().is_some_and(|out| out.error.is_none()); 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()), @@ -266,10 +299,10 @@ impl Thumbs { Some(e) => failed(e), None => match texture( ctx, - job.file, + job.slot, [out.width, out.height], out.rgba, - if job.file == PREVIEW { gpu_side.min(PREVIEW_SIDE as usize) } else { max_side }, + if job.slot == PREVIEW { gpu_side.min(PREVIEW_SIDE as usize) } else { max_side }, ) { Ok((tex, bytes)) => { uploaded = uploaded.saturating_add(bytes); @@ -279,15 +312,20 @@ impl Thumbs { }, }, }; - 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()); + self.entries.insert(job.slot, entry); + // Its document stays open a moment for the file's next page (its thread, idle, still + // counts); otherwise its thread (idle, or finishing a render the watchdog gave up on) + // counts until it exits. + if healthy && job.pool.threads() <= 1 && self.open.len() < MAX_THREADS { + self.open.push(Open { source: job.key.source, auth: job.key.auth, pool: job.pool, since: now }); + } else { + self.retiring.push(job.pool.retire()); + } } - // New renders: the cards in view first, in grid order; one per file at a time. + // New renders: the cards in view first, in grid order; one per thumbnail at a time. let mut wants = std::mem::take(&mut self.wants); - wants.sort_by_key(|w| (w.file != PREVIEW, !w.visible)); + wants.sort_by_key(|w| (w.slot != 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.) @@ -295,14 +333,37 @@ impl Thumbs { 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) + // per thumbnail at a time; and a want made earlier this frame may have just been + // answered. + if current(w.slot).is_none_or(|k| !k.same_page(&w.key)) + || self.jobs.iter().any(|j| j.slot == w.slot) + || self.entries.get(&w.slot).is_some_and(|e| e.key == w.key) { continue; } + // The same file, read the same way, still open from a render just done: no new read of + // the file (and its thread is already counted). On the web, not while another waits. + let inline_waiting = self.jobs.iter().any(|j| j.pool.is_inline()); + if let Some(k) = + self.open.iter().position(|o| o.source == w.key.source && o.auth == w.key.auth && !(o.pool.is_inline() && inline_waiting)) + { + let pool = self.open.swap_remove(k).pool; + self.next_tag = self.next_tag.wrapping_add(1); + let tag = self.next_tag; + pool.set_queue(vec![RenderRequest { page: w.key.page, scale: w.scale, tag, ..Default::default() }]); + if pool.is_inline() { + threads = MAX_THREADS; + } + self.jobs.push(Job { slot: w.slot, key: w.key, pool, tag }); + continue; + } if threads >= MAX_THREADS { + // A document kept open for another file gives its thread up: it exits, and this + // starts then. + if let Some(k) = self.open.iter().enumerate().min_by(|(_, a), (_, b)| a.since.total_cmp(&b.since)).map(|(k, _)| k) { + let open = self.open.swap_remove(k); + self.retiring.push(open.pool.retire()); + } waiting = true; break; } @@ -316,25 +377,25 @@ impl Thumbs { // 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 }); + self.jobs.push(Job { slot: w.slot, 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 + let mut old: Vec<(u64, Slot, 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 { + for (_, slot, bytes) in old { if total <= TEXTURE_BUDGET { break; } - self.entries.remove(&file); + self.entries.remove(&slot); total = total.saturating_sub(bytes); } } @@ -348,6 +409,9 @@ impl Thumbs { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } else if waiting { ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } else if !self.open.is_empty() { + // To close the documents kept open when their time is up. + ctx.request_repaint_after(std::time::Duration::from_secs_f64(KEEP_OPEN)); } self.frame = self.frame.wrapping_add(1); } @@ -357,7 +421,7 @@ impl Thumbs { /// larger than a thumbnail can be (or than the GPU accepts), is refused, never uploaded. fn texture( ctx: &egui::Context, - file: u64, + slot: Slot, size: [u32; 2], pixels: pdfcraft_render::Pixels, max_side: usize, @@ -371,7 +435,7 @@ fn texture( 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)) + Ok((ctx.load_texture(format!("combine-thumb-{}-{}", slot.0, slot.1), image, egui::TextureOptions::LINEAR), bytes)) } /// `name` shortened in the middle to what `fits`, keeping its extension ("Pilot Boat Ad….pdf"). @@ -416,6 +480,79 @@ fn columns_in(width: f32, cell: Vec2) -> usize { ((width - 16.0) / cell.x).floor().max(1.0) as usize } +/// What one place in the grid shows: a file's card, or (the file expanded) one of the pages it +/// adds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Cell { + /// The file, by its place in the list. + pub(crate) file: usize, + /// For a page: its place among the pages the file adds, the page (0-based, in the file) and + /// how many it adds. + pub(crate) page: Option<(usize, usize, usize)>, +} + +/// The pages an expanded file shows: in its range's order, and sorted (to look one up), for the +/// range and page count they were worked out for. +#[derive(Clone, Debug)] +pub(crate) struct PageList { + of: (String, usize), + pub(crate) order: Arc<[usize]>, + sorted: Arc<[usize]>, +} + +/// The pages an expanded file shows, worked out again only when its range or page count +/// changes; `None` when it can't be expanded (locked, unreadable, a bad range, a single page, or +/// more than [`MAX_EXPAND`]). +pub(crate) fn pages_shown(cache: &mut HashMap, f: &CombineFile) -> Option { + if f.lock.is_some() || f.problem.is_some() { + return None; + } + let list = match cache.get(&f.id) { + Some(list) if list.of.0 == f.range && list.of.1 == f.pages => list.clone(), + _ => { + let order = f.pages_taken().ok()?; + let mut sorted = order.clone(); + sorted.sort_unstable(); + let list = PageList { of: (f.range.clone(), f.pages), order: Arc::from(order), sorted: Arc::from(sorted) }; + cache.insert(f.id, list.clone()); + list + } + }; + (list.order.len() > 1 && list.order.len() <= MAX_EXPAND).then_some(list) +} + +/// The grid's places, in order: each file's card, or its pages when it is expanded. +fn layout(app: &mut PdfCraftApp) -> Vec { + let tab = &mut app.combine_tab; + let mut cells = Vec::with_capacity(app.combine_draft.len()); + for (file, f) in app.combine_draft.iter().enumerate() { + match tab.expanded.contains(&f.id).then(|| pages_shown(&mut tab.page_lists, f)).flatten() { + Some(list) => { + let of = list.order.len(); + cells.extend(list.order.iter().enumerate().map(|(nth, &page)| Cell { file, page: Some((nth, page, of)) })); + } + None => cells.push(Cell { file, page: None }), + } + } + cells +} + +/// The file gap (0 = before the first file, n = after the last) a drop before place `at` means: a +/// drop inside an expanded file's pages goes to the nearer end of that file. +fn file_gap(cells: &[Cell], at: usize, files: usize) -> usize { + match cells.get(at) { + None => files, + Some(Cell { file, page: Some((nth, _, of)) }) if *nth > 0 => { + if nth.saturating_mul(2) < *of { + *file + } else { + file.saturating_add(1) + } + } + Some(c) => c.file, + } +} + /// 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(); @@ -432,6 +569,15 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: *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 places = layout(app); + // Each file's first place (for the keys and to scroll to it). + let mut first_place = vec![0usize; n]; + for (at, c) in places.iter().enumerate().rev() { + if let Some(slot) = first_place.get_mut(c.file) { + *slot = at; + } + } + app.combine_tab.grid_places = places.iter().map(|c| c.file).collect(); let reveal = app.combine_tab.reveal.take(); let fit = paper_box(app.combine_zoom); let cell = fit + AROUND; @@ -469,7 +615,7 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: width = ui.available_width(); let cols = columns_in(width, cell); *columns = cols; - let rows = n.div_ceil(cols); + let rows = places.len().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| { @@ -477,44 +623,72 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: 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); + ui.scroll_to_rect(cell_rect(first_place.get(i).copied().unwrap_or(0)), 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 first = row.saturating_mul(cols); + let in_row = places.get(first..first.saturating_add(cols).min(places.len())).unwrap_or_default(); + // An expanded file's pages on this row share a tinted band, so they read as one file. + let mut run = 0; + while run < in_row.len() { + let Some(start) = in_row.get(run).copied() else { break }; + let mut end = run; + while in_row.get(end.saturating_add(1)).is_some_and(|c| c.file == start.file && c.page.is_some()) { + end = end.saturating_add(1); + } + if start.page.is_some() { + let band = cell_rect(first.saturating_add(run)).union(cell_rect(first.saturating_add(end))).shrink2(vec2(2.0, 4.0)); + ui.painter().rect_filled(band, CornerRadius::same(10), t.accent_soft.gamma_multiply(0.45)); + } + run = end.saturating_add(1); + } + for (col, place) in in_row.iter().enumerate() { + let i = first.saturating_add(col); + let Some(f) = files.get_mut(place.file) else { break }; let c = cell_rect(i); cells.push((i, c)); let card = Card { - i, + i: place.file, + page: place.page, 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), + check: checks.get(place.file).cloned().unwrap_or(Ok(f.pages)), + selected: selected.get(place.file).copied().unwrap_or(false), + twice: twice.get(place.file).copied().unwrap_or(false), revision, ppp, fit, }; - let card_id = ui.id().with(("combine-card", f.id)); + let card_id = card.id(ui, f); 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); + focused_card = Some((card_id, f.id, place.page.map_or(0, |p| p.0))); } } } - // While a card is dragged: the gap it would go to, drawn as a bar, and how many go. + // While a card is dragged: the file 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 gap = crate::canvas::drop_gap(&cells, p).map(|at| file_gap(&places, at, n)); + // Drawn at the end of the file before the gap or the start of the one after (an + // expanded file's pages are never split), whichever is nearer the pointer. + if let Some(gap) = gap { + let before = gap + .checked_sub(1) + .and_then(|f| first_place.get(f.saturating_add(1)).map(|next| next.saturating_sub(1)).or(places.len().checked_sub(1))); + let after = first_place.get(gap).copied(); + let ends = [before.map(|at| cell_rect(at).right() - 2.0).zip(before), after.map(|at| cell_rect(at).left() + 2.0).zip(after)]; + let nearest = ends.into_iter().flatten().min_by(|(xa, a), (xb, b)| { + let da = pos2(*xa, cell_rect(*a).center().y).distance_sq(p); + let db = pos2(*xb, cell_rect(*b).center().y).distance_sq(p); + da.total_cmp(&db) + }); + if let Some((x, at)) = nearest { + let r = cell_rect(at); + 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() @@ -532,7 +706,8 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: } }); - app.combine_tab.card_focus = focused_card; + app.combine_tab.card_focus = focused_card.map(|(id, ..)| id); + app.combine_tab.card_focus_at = focused_card.map(|(_, file, nth)| (file, nth)); // Pinch, or Ctrl/⌘ with the wheel, over the grid zooms it. let pinch = ctx.input(|i| i.zoom_delta()); @@ -549,25 +724,28 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: 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. + // first in view changes as the cards reflow, and following it would drift. A card is + // kept by its file and its place among the file's pages. 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))); + let gesture = app.combine_tab.zoom_anchor.filter(|a| now - a.3 < 0.5).and_then(|(id, nth, above, _)| { + let file = app.combine_draft.iter().position(|f| f.id == id)?; + Some((first_place.get(file)?.saturating_add(nth).min(places.len().saturating_sub(1)), 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))); + app.combine_tab.zoom_anchor = anchor.and_then(|(i, above)| { + let place = places.get(i)?; + Some((app.combine_draft.get(place.file)?.id, place.page.map_or(0, |p| p.0), 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 content = TOP + places.len().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 @@ -585,7 +763,11 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: /// One card's place and what it shows. struct Card { + /// The file, by its place in the list. i: usize, + /// A page card (the file expanded): its place among the pages the file adds, the page and how + /// many it adds. `None` for the file's own card. + page: Option<(usize, usize, usize)>, rect: Rect, check: Result, selected: bool, @@ -597,16 +779,34 @@ struct Card { } impl Card { + fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { + match self.page { + None => ui.id().with(("combine-card", f.id)), + Some((nth, ..)) => ui.id().with(("combine-page", f.id, nth)), + } + } + 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 id = self.id(ui, f); let resp = ui.interact(c.shrink(4.0), id, Sense::click_and_drag()); let name = f.name.clone(); + // A page card: "Page 3", and the file it belongs to. + let page_label = self.page.map(|(_, p, _)| crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.saturating_add(1).to_string())])); + let label = match &page_label { + Some(page) => format!("{page} · {name}"), + None => 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())]) }; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &label)); + let pages = match self.page { + // For screen readers: which page of how many. + Some((_, p, _)) => crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &p.saturating_add(1).to_string()), ("n", &f.pages.to_string())]), + None if f.pages == 1 => tl!("1 page").to_string(), + None => crate::i18n::fmt(tl!("{n} pages"), &[("n", &f.pages.to_string())]), + }; let painter = ui.painter_at(c); // Selected and hovered cards. @@ -618,16 +818,19 @@ impl Card { } // The page, fitted in the paper box (top-aligned, room above for the stack). - let page = f.first_page(); + let page = match self.page { + Some((_, p, _)) => Ok(p), + None => 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. + // A file's card that adds more than one page: sheets behind it. let takes = self.check.as_ref().map_or(f.pages, |k| *k); - if takes > 1 { + if self.page.is_none() && takes > 1 { for d in [6.0, 3.0] { let sheet = paper.translate(vec2(d, -d)); painter.rect_filled(sheet, CornerRadius::ZERO, Color32::WHITE); @@ -661,7 +864,7 @@ impl Card { 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 look = thumbs.look((f.id, p), key); let mut want = false; match look { Look::Ready(tex) => { @@ -677,7 +880,7 @@ impl Card { } if want && scale.is_finite() && scale > 0.0 { thumbs.wants.push(Want { - file: f.id, + slot: (f.id, p), key, bytes: f.bytes.clone(), password: f.password().map(Arc::from), @@ -702,22 +905,29 @@ impl Card { }); 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; + // A warning that doesn't stop it being combined: a badge on the file's card. + let warned = self.page.is_none() && (!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(); + // The name, shortened in the middle to fit, and the pages taken when a range is set; on a + // page card, the page first and the file's name below it, smaller. 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() { + let max_w = c.width() - 20.0; + let (font, colour, y) = match &page_label { + Some(page) => { + painter.text(pos2(c.center().x, name_y), Align2::CENTER_CENTER, page, theme::semibold(12.5), t.text); + (theme::regular(11.5), t.text_muted, name_y + 17.0) + } + None => (theme::medium(12.5), t.text, name_y), + }; + let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), colour).size().x <= max_w; + let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned(); + painter.text(pos2(c.center().x, y), Align2::CENTER_CENTER, shown, font, colour); + let note = if self.page.is_none() && !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)), @@ -733,30 +943,52 @@ impl Card { // 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(); + // A file that adds more pages than one can be spread out into them. + let expandable = self.page.is_none() && takes > 1 && takes <= MAX_EXPAND && f.lock.is_none() && f.problem.is_none() && self.check.is_ok(); + // Shown large: the file from its first page, or a page card's page. + let preview = match self.page { + Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), + None => RowAction::Preview(f.id), + }; + // The card's own actions, in this order: expand (or, on a page, collapse), remove, show + // large. Page cards can't be removed one by one (yet): no trash on them. + let mut buttons: Vec<(&str, String, RowAction)> = Vec::new(); + if expandable { + buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); + } + if self.page.is_some() { + buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); + } else { + buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); + } + if previewable { + buttons.push(("search", tl!("Preview").to_owned(), preview)); + } // 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 }; + let count = buttons.len() as f32; // 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)); + let bar = Rect::from_center_size(pos2(paper.center().x, y), vec2(count * 26.0 + (count - 1.0).max(0.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)); + for (icon, label, act) in &buttons { + if icons::button(&mut bar_ui, icon, 26.0, false, label).clicked() { + action = Some(act.clone()); + } } } - // 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())); + // The full name and everything known about the file, on hover (on a page card, which page). + let mut tip = match &page_label { + Some(page) => format!("{}\n{page}", f.name), + None => 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); @@ -766,28 +998,26 @@ impl Card { tip.push_str(tl!("Added more than once")); } // Page ranges are typed in the list. - if self.check.is_err() { + if self.page.is_none() && 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). + // The same actions on a right click, show large first (and Unlock… for a locked file). let at = resp.rect.left_bottom(); - let (file, locked) = (f.id, f.lock.is_some()); + let locked = 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(); + for (icon, label, act) in buttons.iter().rev() { + // (Remove names its file, as the trash button does.) + if item(ui, icon, label) { + action = Some(act.clone()); + ui.close(); + } } if locked && item(ui, "lock-open", tl!("Unlock…")) { action = Some(RowAction::Unlock(Some(self.i), at)); @@ -900,13 +1130,13 @@ pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { } 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()); + let card = thumbs.entries.get(&(f.id, page)).filter(|e| 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, + slot: PREVIEW, key, bytes: f.bytes.clone(), password: f.password().map(Arc::from), @@ -964,32 +1194,63 @@ impl PdfCraftApp { /// 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(); + // What each thumbnail must match now (cards out of view included): each file's first page, + // and an expanded file's pages (looked up in its sorted list, never listed one by one). + struct Shown { + source: usize, + auth: u64, + first: usize, + sorted: Option>, + } + let mut shown: HashMap = HashMap::with_capacity(self.combine_draft.len()); + let tab = &mut self.combine_tab; + for f in self.combine_draft.iter_mut() { + let Ok(first) = f.first_page() else { continue }; + let sorted = tab.expanded.contains(&f.id).then(|| pages_shown(&mut tab.page_lists, f)).flatten().map(|l| l.sorted); + shown.insert(f.id, Shown { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, first, sorted }); + } // 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] }); - } + let preview = self.combine_tab.preview.as_ref().and_then(|p| { + let page = p.page?; + let f = self.combine_draft.iter().find(|f| f.id == p.file)?; + Some(Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px: [0, 0] }) + }); + let current = |slot: Slot| -> Option { + if slot == PREVIEW { + return preview; + } + let (id, page) = slot; + let s = shown.get(&id)?; + (page == s.first || s.sorted.as_ref().is_some_and(|p| p.binary_search(&page).is_ok())).then_some(Key { + source: s.source, + auth: s.auth, + page, + px: [0, 0], + }) + }; self.combine_thumbs.finish(ctx, ¤t); } + /// The files shown as their pages in the grid, by place in the list (tests and the control + /// channel). + pub fn combine_expanded(&self) -> Vec { + self.combine_draft.iter().enumerate().filter(|(_, f)| self.combine_tab.expanded.contains(&f.id)).map(|(i, _)| i).collect() + } + + /// An expanded file's page cards: each page (0-based, in the file) and its thumbnail; empty + /// for a file shown as its card (tests and the control channel). + pub fn combine_page_thumbnails(&self, file: usize) -> Vec<(usize, ThumbState)> { + let Some(f) = self.combine_draft.get(file).filter(|f| self.combine_tab.expanded.contains(&f.id)) else { return Vec::new() }; + let pages = f.pages_taken().unwrap_or_default(); + pages.into_iter().map(|p| (p, self.combine_thumbs.state((f.id, p)))).collect() + } + /// 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() + self.combine_draft.iter().map(|f| f.first_taken().map_or(ThumbState::None, |p| self.combine_thumbs.state((f.id, p)))).collect() } /// Thumbnail renders under way (tests: never more than a few). @@ -1148,16 +1409,16 @@ mod tests { } fn want(file: u64, bytes: &Arc>) -> Want { - Want { file, key: key_of(bytes), bytes: bytes.clone(), password: None, scale: 0.15, visible: true } + Want { slot: (file, 0), 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() + fn listing(files: &[(u64, &Arc>)]) -> HashMap { + files.iter().map(|(f, b)| ((*f, 0), 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)); + 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(), &|slot| current.get(&slot).copied())); // 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) @@ -1176,7 +1437,7 @@ mod tests { 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()) { + if thumbs.entries.get(&(1, 0)).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; } @@ -1217,11 +1478,11 @@ mod tests { // 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 }); + thumbs.entries.insert((i as u64, 0), 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(); + let current: HashMap = files.iter().enumerate().map(|(i, b)| ((i as u64, 0), key_of(b))).collect(); frame(&ctx, &mut thumbs, ¤t); - let mut kept: Vec = thumbs.entries.keys().copied().collect(); + let mut kept: Vec = thumbs.entries.keys().map(|k| k.0).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"); } @@ -1268,6 +1529,41 @@ mod tests { assert!(thumbs.entries.values().filter(|e| e.tex.is_some()).count() == 6, "all six drawn, one heavy step per frame"); } + #[test] + fn the_next_page_of_a_file_reuses_its_open_document_until_it_closes() { + // Regression: each page of an expanded file read (parsed) the whole file again. + let ctx = egui::Context::default(); + let mut thumbs = Thumbs::default(); + let bytes = pdf(3, 200, 300); + let first = key_of(&bytes); + let second = Key { page: 1, ..first }; + let current: HashMap = [((1, 0), first), ((1, 1), second)].into_iter().collect(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let drawn = |thumbs: &Thumbs, slot: Slot| thumbs.entries.get(&slot).is_some_and(|e| e.tex.is_some()); + while !drawn(&thumbs, (1, 0)) { + assert!(std::time::Instant::now() < deadline, "page 1 is drawn"); + thumbs.wants.push(want(1, &bytes)); + frame(&ctx, &mut thumbs, ¤t); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(thumbs.open.len(), 1, "its document stays open a moment"); + // Page 2 of the same file, read the same way: that open document renders it. + thumbs.wants.push(Want { slot: (1, 1), key: second, ..want(1, &bytes) }); + frame(&ctx, &mut thumbs, ¤t); + assert!(thumbs.open.is_empty() && thumbs.jobs.len() == 1, "reused, not a new pool: {} open, {} jobs", thumbs.open.len(), thumbs.jobs.len()); + while !drawn(&thumbs, (1, 1)) { + assert!(std::time::Instant::now() < deadline, "page 2 is drawn"); + frame(&ctx, &mut thumbs, ¤t); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(thumbs.threads() <= MAX_THREADS); + // Unused, it closes after a while, and its thread goes. + while thumbs.threads() > 0 || !thumbs.open.is_empty() { + assert!(std::time::Instant::now() < deadline, "the open document closes and its thread exits"); + frame(&ctx, &mut thumbs, ¤t); + } + } + #[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 @@ -1276,12 +1572,12 @@ mod tests { 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 }); + thumbs.entries.insert((1, 0), 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"); + assert!(matches!(thumbs.look((1, 0), 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))); + assert!(matches!(thumbs.look((1, 0), reread), Look::Pending(None))); } #[test] @@ -1293,14 +1589,14 @@ mod tests { 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 }); + thumbs.jobs.push(Job { slot: (1, 0), 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.entries.get(&(1, 0)).is_some_and(|e| e.tex.is_none() && e.error.is_some()), "the silent one failed"); + assert!(thumbs.entries.get(&(2, 0)).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 2588b63a..de3086e2 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -5,7 +5,7 @@ //! column heading; PDFs dropped on the tab join the list. Every change to the list can be undone. use std::cmp::Ordering; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use std::time::SystemTime; @@ -189,14 +189,25 @@ pub struct CombineTab { 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)>, + /// The card kept in place through a zoom gesture: its file, its place among the file's pages + /// (0 for the file's own card), how far below the top of the grid it was, and when the grid + /// last zoomed (seconds). + pub(crate) zoom_anchor: Option<(u64, usize, f32, f64)>, + /// Files shown as their pages in the grid (a view, not an edit: no undo step). + pub(crate) expanded: BTreeSet, + /// The pages each expanded file shows, worked out again only when its range changes. + pub(crate) page_lists: HashMap, + /// The file (by place in the list) at each place of the grid as last drawn: Up and Down step + /// a row of cards, page cards included. + pub(crate) grid_places: Vec, /// 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, + /// That card's file and its place among the file's pages (0 for the file's own card): Space + /// on a page card shows that page. + pub(crate) card_focus_at: Option<(u64, usize)>, } /// The pages a range takes, and the range and page count they were worked out for. @@ -392,6 +403,14 @@ impl CombineFile { self.checked_range().map(|(_, first)| first) } + /// The first page the range takes, without updating the cache (read-only callers). + pub(crate) fn first_taken(&self) -> Option { + match &self.checked { + Some((range, result)) if *range == self.range => result.as_ref().ok().map(|(_, first)| *first), + _ => self.pages_taken().ok()?.first().copied(), + } + } + /// 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() { @@ -439,6 +458,7 @@ impl CombineFile { } } +#[derive(Clone)] pub(crate) enum RowAction { /// A click on row `i` (Ctrl/⌘ toggles it, Shift selects up to it). Click(usize, Modifiers), @@ -459,6 +479,13 @@ pub(crate) enum RowAction { Preview(u64), /// Space: show the file the keyboard is on (or the first selected) large. PreviewSelected, + /// A page card's magnifier: show its file large, from that page (its place among the pages + /// the file adds). + PreviewAt(u64, usize), + /// Show one file as its pages in the grid (`true`), or as its card again. + Expand(u64, bool), + /// Every file that adds more than one page, as its pages (`true`), or all as cards again. + ExpandAll(bool), /// Row `from` (and the rest of the selection, if it is selected) dropped on row `to`. Drop { from: usize, @@ -495,6 +522,16 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let n = app.combine_draft.len(); let ids: Vec = app.combine_draft.iter().map(|f| f.id).collect(); app.combine_tab.selected.retain(|id| ids.contains(id)); + // A file stays expanded only while it can show as its pages (not locked again by undo, nor + // edited down to one page or a bad range): it doesn't come back expanded by itself. + { + let tab = &mut app.combine_tab; + let draft = &app.combine_draft; + let lists = &mut tab.page_lists; + tab.expanded.retain(|id| draft.iter().find(|f| f.id == *id).is_some_and(|f| crate::combine_grid::pages_shown(lists, f).is_some())); + let expanded = &tab.expanded; + tab.page_lists.retain(|id, _| expanded.contains(id)); + } 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); @@ -610,6 +647,16 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{n} selected"), &[("n", &count.to_string())])).color(t.text_muted)); } if grid && n > 0 { + // Every file as its pages, or all as cards again. + group(ui, &t, edge, 2.0 * 30.0 + 6.0); + let can_expand = app.combine_draft.iter_mut().any(|f| !app.combine_tab.expanded.contains(&f.id) && expandable(f)); + if ui.add_enabled_ui(can_expand, |ui| icons::button(ui, "maximize-2", 30.0, false, tl!("Expand all"))).inner.clicked() { + events.action = Some(RowAction::ExpandAll(true)); + } + let can_collapse = !app.combine_tab.expanded.is_empty(); + if ui.add_enabled_ui(can_collapse, |ui| icons::button(ui, "minimize-2", 30.0, false, tl!("Collapse all"))).inner.clicked() { + events.action = Some(RowAction::ExpandAll(false)); + } zoom_controls(app, ui, &t, edge); } }); @@ -795,6 +842,11 @@ fn tool(ui: &mut egui::Ui, enabled: bool, icon: &str, label: &str, disabled_why: ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, label)).inner.on_disabled_hover_text(disabled_why) } +/// Whether a file can be shown as its pages: readable, unlocked, and adding more than one. +fn expandable(f: &mut CombineFile) -> bool { + f.lock.is_none() && f.problem.is_none() && f.selection().is_ok_and(|k| k > 1 && k <= crate::combine_grid::MAX_EXPAND) +} + /// Why Combine can't run yet, if so. fn blocker(files: &[CombineFile], checks: &[Result]) -> Option { if files.len() < 2 { @@ -1574,7 +1626,15 @@ impl PdfCraftApp { tab.selected = ids.iter().copied().collect(); } RowAction::Step { down, by, extend } => { + // With files shown as their pages, Up and Down go a row of cards (from the last + // of a file's cards down, from its first up) to the file there. + let places = &tab.grid_places; + let rows_of_cards = by > 1 && places.len() > n; let next = match index_of(tab.cursor) { + Some(c) if rows_of_cards => { + let from = if down { places.iter().rposition(|f| *f == c) } else { places.iter().position(|f| *f == c) }; + from.and_then(|at| places.get(step_to(at, places.len(), by, down)).copied()).unwrap_or(c) + } Some(c) => step_to(c, n, by, down), None if down => 0, None => n.saturating_sub(1), @@ -1647,12 +1707,37 @@ impl PdfCraftApp { self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None }); } } + RowAction::PreviewAt(id, at) => { + if ids.contains(&id) { + self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None }); + } + } + RowAction::Expand(id, on) => { + if on { + if self.combine_draft.iter_mut().any(|f| f.id == id && expandable(f)) { + self.combine_tab.expanded.insert(id); + } + } else { + self.combine_tab.expanded.remove(&id); + // Its card may be far from where its pages were: brought into view. + self.combine_tab.reveal = Some(id); + } + } + RowAction::ExpandAll(on) => { + self.combine_tab.expanded = + if on { self.combine_draft.iter_mut().filter_map(|f| expandable(f).then_some(f.id)).collect() } else { BTreeSet::new() }; + if !on { + self.combine_tab.reveal = self.combine_tab.cursor; + } + } 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 }); + // From the page card that has the keyboard, if it's one of this file's. + let at = self.combine_tab.card_focus_at.filter(|(file, _)| *file == id).map_or(0, |(_, nth)| nth); + self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None }); } } RowAction::Drop { from, to } => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7ee72573..67af8064 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -574,6 +574,7 @@ impl Host for crate::PdfCraftApp { "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::>(), + "expanded": self.combine_expanded().iter().filter_map(|i| self.combine_draft.get(*i).map(|f| f.name.clone())).collect::>(), "preview": self.combine_preview().map(|(file, page, render)| json!({ "file": file, "page": page + 1, "render": format!("{render:?}") })), }, "quick_tool": match self.quick_tool { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1e377a63..630b6f25 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2185,3 +2185,5 @@ key Space مسافة Pages: {k} of {n} الصفحات: {k} من {n} Check the pages تحقق من الصفحات Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها. + Expand all توسيع الكل + Collapse all طي الكل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 5407bf51..ed805955 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2125,3 +2125,5 @@ key Space Интервал Pages: {k} of {n} Страници: {k} от {n} Check the pages Проверете страниците Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат. + Expand all Разгъни всички + Collapse all Свий всички diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5cdb657e..c010e601 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2163,3 +2163,5 @@ key Space Leertaste 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. + Expand all Alle ausklappen + Collapse all Alle einklappen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6bd65002..d9aefa6f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2079,3 +2079,5 @@ key Space Espacio 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. + Expand all Expandir todo + Collapse all Contraer todo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1788c97b..6fcd00ab 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2163,3 +2163,5 @@ key Space Espace 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. + Expand all Tout développer + Collapse all Tout réduire diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 33efda92..6da6ba9b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2161,3 +2161,5 @@ key Space Szóköz 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. + Expand all Összes kibontása + Collapse all Összes összecsukása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e14577a3..cbad9d09 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2172,3 +2172,5 @@ key Space Spazio 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. + Expand all Espandi tutto + Collapse all Comprimi tutto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a557604a..8c5db03b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2163,3 +2163,5 @@ key Space スペース Pages: {k} of {n} ページ: {k}/{n} Check the pages ページ指定を確認してください Switch to List view to change the pages it takes. 使用するページを変更するには、リスト表示に切り替えてください。 + Expand all すべて展開 + Collapse all すべて折りたたむ diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3c8e5063..53302ac4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2164,3 +2164,5 @@ key Space Espaço 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. + Expand all Expandir tudo + Collapse all Recolher tudo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 57e2923c..8bb05402 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2076,3 +2076,5 @@ key Space Пробел Pages: {k} of {n} Страницы: {k} из {n} Check the pages Проверьте страницы Switch to List view to change the pages it takes. Переключитесь на вид списком, чтобы изменить, какие страницы взять. + Expand all Развернуть все + Collapse all Свернуть все diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 6e34758e..92f137f6 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2094,3 +2094,5 @@ key Space స్పేస్ Larger pages పెద్ద పేజీలు Smaller pages చిన్న పేజీలు Reset page size పేజీ పరిమాణాన్ని రీసెట్ చేయి + Expand all అన్నీ విస్తరించు + Collapse all అన్నీ ముడుచు diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fa198572..fe288b55 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2186,3 +2186,5 @@ key Space Пробіл Pages: {k} of {n} Сторінки: {k} із {n} Check the pages Перевірте сторінки Switch to List view to change the pages it takes. Перейдіть до вигляду списком, щоб змінити, які сторінки взяти. + Expand all Розгорнути всі + Collapse all Згорнути всі diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fd515a57..2f52ed31 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2127,3 +2127,5 @@ key Space 空格 Pages: {k} of {n} 页面:{k}/{n} Check the pages 请检查页码 Switch to List view to change the pages it takes. 切换到列表视图以更改要使用的页面。 + Expand all 全部展开 + Collapse all 全部折叠 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 42f2d395..15ac4ee8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2049,3 +2049,5 @@ key Space 空格 Pages: {k} of {n} 頁面:{k}/{n} Check the pages 請檢查頁碼 Switch to List view to change the pages it takes. 切換到清單檢視以變更要使用的頁面。 + Expand all 全部展開 + Collapse all 全部摺疊 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index fbb39d3c..7341b577 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -116,6 +116,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("messages-square", include_bytes!("../../../assets/icons/messages-square.svg")), ("mic", include_bytes!("../../../assets/icons/mic.svg")), ("minimize", include_bytes!("../../../assets/icons/minimize.svg")), + ("minimize-2", include_bytes!("../../../assets/icons/minimize-2.svg")), ("minus", include_bytes!("../../../assets/icons/minus.svg")), ("moon", include_bytes!("../../../assets/icons/moon.svg")), ("mouse-pointer-2", include_bytes!("../../../assets/icons/mouse-pointer-2.svg")), diff --git a/crates/ui-egui/tests/combine_grid.rs b/crates/ui-egui/tests/combine_grid.rs index e7cf9d1e..43748fd8 100644 --- a/crates/ui-egui/tests/combine_grid.rs +++ b/crates/ui-egui/tests/combine_grid.rs @@ -1287,3 +1287,283 @@ fn a_drag_held_while_the_tab_closes_and_reopens_moves_nothing() { h.run_steps(3); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"], "the old drag moves nothing in the new list"); } + +// ---- Stage 3: a file shown as its pages ---- + +/// The label of a page card: "Page 3 · b.pdf". +fn page_card(page: usize, name: &str) -> String { + format!("Page {page} · {name}") +} + +/// Expand a file from its card's hover bar. +fn expand(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + hover_card(h, name); + h.get_by_label("Expand").click(); + h.run_steps(2); +} + +#[test] +fn expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4)]); + h.state_mut().combine_draft[1].range = "3, 1, 4".into(); + h.run_steps(2); + // A single page has nothing to expand. + hover_card(&mut h, "a.pdf"); + assert!(h.query_by_label("Expand").is_none()); + expand(&mut h, "b.pdf"); + assert_eq!(h.state().combine_expanded(), [1]); + assert!(h.query_by_label("b.pdf").is_none(), "the file's card gives way to its pages"); + // The pages it adds, in the range's order, each saying which page of which file. + let xs: Vec = [3, 1, 4].iter().map(|p| card(&h, &page_card(*p, "b.pdf")).left()).collect(); + assert!(xs.windows(2).all(|w| w[0] < w[1]), "pages 3, 1, 4 in that order: {xs:?}"); + assert!(h.query_by_label(&page_card(2, "b.pdf")).is_none(), "page 2 isn't added, so isn't shown"); + // Each renders its own page. + assert!(settle(&mut h, |app| { + let pages = app.combine_page_thumbnails(1); + pages.len() == 3 && pages.iter().all(|(p, t)| matches!(t, CombineThumb::Ready { page, .. } if page == p)) + })); + // A page card collapses the file again. + hover_card(&mut h, &page_card(1, "b.pdf")); + h.get_by_label("Collapse").click(); + h.run_steps(2); + assert!(h.state().combine_expanded().is_empty()); + h.get_by_label("b.pdf"); +} + +#[test] +fn expand_all_and_collapse_all_from_the_toolbar_and_a_right_click() { + let mut h = grid_of( + vec![("a.pdf", fixture(1)), ("b.pdf", fixture(3)), ("c.pdf", fixture(2)), ("secret.pdf", protected("pw", "owner"))], + vec2(1400.0, 900.0), + ); + assert!(disabled(&h, "Collapse all"), "nothing to collapse yet"); + h.get_by_label("Expand all").click(); + h.run_steps(2); + assert_eq!(h.state().combine_expanded(), [1, 2], "every file adding more than one page; not a single page, not a locked file"); + assert!(disabled(&h, "Expand all")); + h.get_by_label("Collapse all").click(); + h.run_steps(2); + assert!(h.state().combine_expanded().is_empty()); + // On a right click too (the pointer then off the card, so its hover bar is gone and the + // label is the menu's alone). + h.get_by_label("c.pdf").click_secondary(); + h.run_steps(2); + h.hover_at(egui::pos2(5.0, 890.0)); + h.run_steps(2); + h.get_by_label("Expand").click(); + h.run_steps(2); + assert_eq!(h.state().combine_expanded(), [2]); + h.get_by_label(&page_card(2, "c.pdf")).click_secondary(); + h.run_steps(2); + h.hover_at(egui::pos2(5.0, 890.0)); + h.run_steps(2); + h.get_by_label("Collapse").click(); + h.run_steps(2); + assert!(h.state().combine_expanded().is_empty()); +} + +#[test] +fn expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 3)]); + let undo = disabled(&h, "Undo"); + expand(&mut h, "b.pdf"); + assert_eq!(disabled(&h, "Undo"), undo, "not an edit: the undo history is as it was"); + assert!(disabled(&h, "Redo")); + // Undo undoes what came before (the files' adding), not the expanding. + let before: Vec = names(&h); + assert_eq!(before, ["a.pdf", "b.pdf"]); + h.get_by_label("List view").click(); + h.run_steps(2); + h.get_by_label("Grid view").click(); + h.run_steps(2); + h.get_by_label("Larger pages").click(); + h.run_steps(2); + assert_eq!(h.state().combine_expanded(), [1], "still shown as its pages"); + h.get_by_label(&page_card(3, "b.pdf")); + // Its range edited down to one page: its card again (nothing to spread out), and it stays a + // card when the range grows again (no expanding by itself). + h.state_mut().combine_draft[1].range = "2".into(); + h.run_steps(2); + h.get_by_label("b.pdf"); + assert!(h.state().combine_expanded().is_empty(), "no longer expanded"); + assert!(disabled(&h, "Collapse all")); + h.state_mut().combine_draft[1].range = String::new(); + h.run_steps(2); + h.get_by_label("b.pdf"); + assert!(h.state().combine_expanded().is_empty()); + expand(&mut h, "b.pdf"); + h.get_by_label(&page_card(2, "b.pdf")); + // The file removed: forgotten (undo brings the file back as its card). + h.get_by_label(&page_card(1, "b.pdf")).click(); + h.run_steps(1); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf"]); + assert!(h.state().combine_expanded().is_empty()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(names(&h), ["a.pdf", "b.pdf"]); + h.get_by_label("b.pdf"); +} + +#[test] +fn a_page_selects_its_file_and_the_arrows_step_rows_of_cards() { + // a, then b's 6 pages, then eight more files: rows of cards below b's last page whatever + // the window's width. + let mut files = vec![("a.pdf".to_string(), fixture(1)), ("b.pdf".to_string(), fixture(6))]; + files.extend((0..8).map(|i| (format!("f{i}.pdf"), fixture(1)))); + let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1000.0, 1100.0)); + expand(&mut h, "b.pdf"); + let top = card(&h, "a.pdf").top(); + let cols = ["a.pdf".to_string(), page_card(1, "b.pdf"), page_card(2, "b.pdf"), page_card(3, "b.pdf"), page_card(4, "b.pdf")] + .iter() + .filter(|n| (card(&h, n).top() - top).abs() < 1.0) + .count(); + assert!((2..5).contains(&cols), "a row of cards is shorter than a + b's pages ({cols})"); + h.get_by_label(&page_card(2, "b.pdf")).click(); + h.run_steps(2); + assert_eq!(h.state().combine_selection(), [1], "a page selects its file"); + assert!(h.get_by_label(&page_card(5, "b.pdf")).accesskit_node().is_selected() == Some(true), "all its pages show selected"); + // Down goes a row of cards from b's last page, to the file below it; Up comes back to b. + let last = card(&h, &page_card(6, "b.pdf")); + h.key_press(Key::ArrowDown); + h.run_steps(2); + let below = h.state().combine_selection(); + assert_eq!(below.len(), 1); + let name = names(&h)[below[0]].clone(); + assert!(below[0] > 1, "a file after b: {name}"); + let r = card(&h, &name); + assert!((r.left() - last.left()).abs() < 1.0 && r.top() > last.top(), "the card right below b's last page: {r:?} vs {last:?}"); + h.key_press(Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().combine_selection(), [1]); + h.key_press(Key::ArrowLeft); + h.run_steps(2); + assert_eq!(h.state().combine_selection(), [0], "left and right step by file"); +} + +#[test] +fn dragging_a_page_moves_its_whole_file_and_drops_go_to_a_file_edge() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4), ("c.pdf", 1)]); + expand(&mut h, "b.pdf"); + // A page of b dragged after c: all of b moves. + let (p2, c) = (card(&h, &page_card(2, "b.pdf")), card(&h, "c.pdf")); + drag(&mut h, p2.center(), c.center() + vec2(c.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["a.pdf", "c.pdf", "b.pdf"]); + // a dropped among b's pages: before b when nearer its start, after it when nearer its end. + let (a, early) = (card(&h, "a.pdf"), card(&h, &page_card(2, "b.pdf"))); + drag(&mut h, a.center(), early.center() - vec2(early.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["c.pdf", "a.pdf", "b.pdf"], "page 2 of 4: the start of b"); + let (a, late) = (card(&h, "a.pdf"), card(&h, &page_card(4, "b.pdf"))); + drag(&mut h, a.center(), late.center() - vec2(late.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["c.pdf", "b.pdf", "a.pdf"], "page 4 of 4: the end of b"); +} + +#[test] +fn a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads() { + let mut h = grid_of(vec![("long.pdf", fixture(300)), ("b.pdf", fixture(1))], vec2(1400.0, 900.0)); + h.get_by_label("Expand all").click(); + let mut most = 0; + for _ in 0..200 { + h.run_steps(1); + let threads = h.state_mut().combine_thumbnail_threads(); + most = most.max(threads); + assert!(threads <= 3, "{threads} render threads"); + if h.state().combine_thumbnails_rendering() == 0 && h.state().combine_page_thumbnails(0).first().is_some_and(|(_, t)| ready(t)) { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let pages = h.state().combine_page_thumbnails(0); + assert_eq!(pages.len(), 300); + let drawn = pages.iter().filter(|(_, t)| ready(t)).count(); + assert!(drawn > 0 && drawn < 60, "only the pages in view (and a row either side): {drawn} of 300"); + assert!(h.query_by_label(&page_card(300, "long.pdf")).is_none(), "the last page, far out of view, isn't even laid out as a widget"); +} + +#[test] +fn a_page_cards_magnifier_opens_the_preview_on_that_page() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 5)]); + // Pages 3, 1, 4: page 4 is the third the file adds. + h.state_mut().combine_draft[1].range = "3, 1, 4".into(); + h.run_steps(2); + expand(&mut h, "b.pdf"); + hover_card(&mut h, &page_card(4, "b.pdf")); + h.get_by_label("Preview").click(); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("b.pdf".to_string(), 3))); + h.get_by_label("Page 3 of 3"); + // Page cards have no trash (removing single pages comes later); nothing is selected yet, so + // no "Remove b.pdf" anywhere. + h.key_press(Key::Escape); + h.run_steps(2); + hover_card(&mut h, &page_card(4, "b.pdf")); + h.get_by_label("Collapse"); + assert!(h.query_by_label("Remove b.pdf").is_none()); + // Space on a page card that has the keyboard: that page too. + h.get_by_label(&page_card(1, "b.pdf")).click(); + h.run_steps(2); + h.get_by_label(&page_card(1, "b.pdf")).focus(); + h.run_steps(2); + h.key_press(Key::Space); + h.run_steps(2); + assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "page 1, the second the file adds"); + h.get_by_label("Page 2 of 3"); +} + +#[test] +fn combining_with_files_expanded_gives_the_same_document() { + let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 3)]); + h.state_mut().combine_draft[1].range = "3, 1".into(); + h.get_by_label("Expand all").click(); + h.run_steps(2); + assert_eq!(h.state().combine_expanded(), [0, 1]); + 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 2", "Page 3", "Page 1"]); +} + +#[test] +fn collapsing_brings_the_files_card_into_view() { + // Many files after it, so the grid stays long once it collapses (it would otherwise scroll + // back by itself). + let mut files = vec![("long.pdf".to_string(), fixture(60))]; + files.extend((0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1)))); + let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); + expand(&mut h, "long.pdf"); + // Down to its last pages. + h.hover_at(egui::pos2(900.0, 600.0)); + for _ in 0..80 { + if h.query_by_label(&page_card(60, "long.pdf")).is_some_and(|c| c.rect().top() > 300.0 && c.rect().bottom() < 880.0) { + break; + } + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -120.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(3); + } + hover_card(&mut h, &page_card(60, "long.pdf")); + h.get_by_label("Collapse").click(); + for _ in 0..20 { + h.run_steps(1); + } + let r = card(&h, "long.pdf"); + let toolbar = h.get_by_label("Grid view").rect().bottom(); + assert!(r.bottom() > toolbar && r.top() < 900.0, "the file's card is in view: {r:?}"); +} + +#[test] +fn a_file_too_long_to_spread_out_offers_no_expand() { + // 2001 pages: more cards than are laid out comfortably every frame. + let mut h = grid_of(vec![("huge.pdf", fixture(2001)), ("b.pdf", fixture(3))], vec2(1400.0, 900.0)); + hover_card(&mut h, "huge.pdf"); + assert!(h.query_by_label("Expand").is_none()); + h.get_by_label("Expand all").click(); + h.run_steps(2); + assert_eq!(h.state().combine_expanded(), [1], "only the file that can be"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0adb0b67..8d19d4da 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2962,8 +2962,8 @@ 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)." +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", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_its_whole_file_and_drops_go_to_a_file_edge", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_selects_its_file_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes"] +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. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a page selects its file, Up/Down step rows of cards, dragging a page moves its whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. Missing: moving or removing single pages before combining (#611)." [[feature]] id = "organize.combine-options"