diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs index 548a65000..fab14b8dd 100644 --- a/crates/ui-egui/src/combine_grid.rs +++ b/crates/ui-egui/src/combine_grid.rs @@ -25,24 +25,16 @@ use crate::combine_ui::{CombineFile, ERROR, Lock, RowAction, WARNING}; use crate::icons; use crate::theme::{self, Tokens}; -/// Render threads for thumbnails at once, counting those of retired pools until they exit. -const MAX_THREADS: usize = 3; -/// Results taken per frame, and the bytes they may upload. -const UPLOADS_PER_FRAME: usize = 4; -const UPLOAD_BYTES_PER_FRAME: usize = 8 << 20; -/// Thumbnail textures kept, in bytes. Those out of view go first, longest unseen first; those in -/// view are kept even over budget (at most a screenful of thumbnails of at most [`MAX_SIDE`]). -const TEXTURE_BUDGET: usize = 32 << 20; -/// The longest side of a thumbnail, in pixels. -/// (Room for a Letter page at 500% on a screen of 1.5 pixels per point; sharper screens upscale a -/// little at the largest sizes.) This only binds near the top of the zoom range: a card below it -/// asks for less than this and costs no more than it did. Near the top one thumbnail is a few -/// megabytes, so [`TEXTURE_BUDGET`] and [`UPLOAD_BYTES_PER_FRAME`] keep the cache at a screenful -/// plus one frame's uploads — and at the top of the range a screenful is a card or two, because a -/// card is wider than most windows. -const MAX_SIDE: f32 = 1536.0; -/// Thumbnail sizes round up to this many pixels, so a slightly different layout reuses them. -const SIZE_STEP: u32 = 16; +mod card; +mod thumbs; + +use card::Card; +pub(crate) use card::preview; +pub use thumbs::ThumbState; +pub(crate) use thumbs::Thumbs; +#[cfg(test)] +use thumbs::{Entry, Job, Look, MAX_SIDE, MAX_THREADS, Want}; +use thumbs::{Key, PREVIEW, Slot}; /// The box a page is fitted in at 100%, and the room the card adds around it (for the hover bar /// and the stack above it, the name and a note below), which doesn't scale. const PAPER: Vec2 = vec2(132.0, 172.0); @@ -65,17 +57,6 @@ pub(crate) fn zoom_step(zoom: f32, larger: bool) -> f32 { } /// Space above the first row of cards. const TOP: f32 = 12.0; -/// 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: 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. @@ -84,20 +65,6 @@ const FALLBACK_PAGE: (f32, f32) = (612.0, 792.0); /// keeps its contrast on white where the dark theme's doesn't. const ON_PAPER: Color32 = Color32::from_rgb(0x5E, 0x5E, 0x66); -/// What the grid has for a file (tests and the control channel). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ThumbState { - /// Nothing yet: not in view, or nothing to draw (locked, unreadable, a bad page range, a page - /// whose size is unknown). - None, - /// Being rendered. - Pending, - /// A thumbnail of page `page` (0-based), `width` × `height` pixels. - Ready { page: usize, width: u32, height: u32 }, - /// The page couldn't be drawn. - Failed(String), -} - /// A card being dragged: which file, and the list as it was (see [`RowAction::DropAt`]). #[derive(Clone, Copy)] struct GridDrag { @@ -107,343 +74,6 @@ struct GridDrag { page: Option<(u64, usize)>, } -/// Everything a thumbnail depends on: the file's bytes, how it was read (its password), the page -/// and the size. A cached thumbnail is used only while all of them still match. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct Key { - /// The address of the file's shared bytes: the same file, even after undo brings it back. - source: usize, - auth: u64, - page: usize, - px: [u32; 2], -} - -impl Key { - fn same_page(&self, other: &Key) -> bool { - (self.source, self.auth, self.page) == (other.source, other.auth, other.page) - } -} - -struct Entry { - key: Key, - tex: Option, - error: Option, - /// The frame the card last showed it. - seen: u64, - bytes: usize, -} - -struct Job { - 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 { - slot: Slot, - key: Key, - bytes: Arc>, - password: Option>, - scale: f32, - visible: bool, -} - -/// The grid's thumbnails and the renders under way. -#[derive(Default)] -pub(crate) struct Thumbs { - entries: HashMap, - jobs: Vec, - /// 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, - next_tag: u64, - wants: Vec, - /// Render on the UI thread, as the web build does (tests). - #[cfg(test)] - inline: bool, -} - -/// What a card can draw now. -enum Look { - Ready(egui::TextureId), - Failed(String), - /// Not ready; an older thumbnail of the same page may stand in. - Pending(Option), -} - -impl Thumbs { - /// Forget every thumbnail (the tab closed). Renders under way are let go of: told to stop at - /// their next drawing operator, and counted until their threads exit. - pub(crate) fn clear(&mut self) { - self.entries.clear(); - self.wants.clear(); - for job in self.jobs.drain(..) { - self.retiring.push(job.pool.retire()); - } - 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, 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(); - let open: usize = self.open.iter().map(|o| o.pool.threads()).sum(); - working.saturating_add(open).saturating_add(retiring) - } - - fn look(&mut self, slot: Slot, key: Key) -> Look { - let frame = self.frame; - match self.entries.get_mut(&slot) { - Some(e) if e.key == key => { - e.seen = frame; - match (&e.tex, &e.error) { - (Some(tex), _) => Look::Ready(tex.id()), - (None, Some(err)) => Look::Failed(err.clone()), - (None, None) => Look::Pending(None), - } - } - // Another size of the same page stands in until this one arrives. A page that failed - // isn't tried again at another size: it would fail (or hang) the same way, each zoom. - Some(e) if e.key.same_page(&key) => { - e.seen = frame; - match &e.error { - Some(err) if e.tex.is_none() => Look::Failed(err.clone()), - _ => Look::Pending(e.tex.as_ref().map(TextureHandle::id)), - } - } - _ => Look::Pending(None), - } - } - - fn state(&self, 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 { - page: e.key.page, - width: u32::try_from(w).unwrap_or(u32::MAX), - height: u32::try_from(h).unwrap_or(u32::MAX), - }; - } - if let Some(err) = &e.error { - return ThumbState::Failed(err.clone()); - } - } - if self.jobs.iter().any(|j| j.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. `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(|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(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. - let gpu_side = ctx.input(|i| i.max_texture_side); - let max_side = gpu_side.min(2 * MAX_SIDE as usize); - let (mut taken, mut uploaded, mut inline_used) = (0usize, 0usize, false); - let mut i = 0; - while i < self.jobs.len() { - if taken >= UPLOADS_PER_FRAME || uploaded >= UPLOAD_BYTES_PER_FRAME { - break; - } - let Some(job) = self.jobs.get(i) else { break }; - if job.pool.is_inline() { - if inline_used { - i += 1; - continue; - } - inline_used = true; - } - // Not done yet, or not this job's answer (cannot happen with one request per pool): - // keep waiting. A pool without threads answers on the call that renders, so there no - // answer is one that never comes: the thumbnail fails rather than hold up the rest. - let answer = job.pool.try_recv().filter(|out| out.request.tag == job.tag); - if answer.is_none() && !job.pool.is_inline() { - i += 1; - continue; - } - let job = self.jobs.swap_remove(i); - taken += 1; - // 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()), - Some(out) => match out.error { - Some(e) => failed(e), - None => match texture( - ctx, - job.slot, - [out.width, out.height], - out.rgba, - if job.slot == PREVIEW { gpu_side.min(PREVIEW_SIDE as usize) } else { max_side }, - ) { - Ok((tex, bytes)) => { - uploaded = uploaded.saturating_add(bytes); - Entry { key: job.key, tex: Some(tex), error: None, seen: self.frame, bytes } - } - Err(e) => failed(e), - }, - }, - }; - self.entries.insert(job.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 thumbnail at a time. - let mut wants = std::mem::take(&mut self.wants); - 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.) - let mut threads = if inline_used || self.jobs.iter().any(|j| j.pool.is_inline()) { MAX_THREADS } else { self.threads() }; - let mut waiting = false; - for w in wants { - // Wanted for the list as it is now (not changed by this frame's action); one render - // per 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; - } - let config = RenderConfig { password: w.password, ..Default::default() }; - #[cfg(test)] - let pool = if self.inline { RenderPool::new_inline(w.bytes, config) } else { RenderPool::new(w.bytes, 1, config).without_replacements() }; - #[cfg(not(test))] - let pool = RenderPool::new(w.bytes, 1, config).without_replacements(); - self.next_tag = self.next_tag.wrapping_add(1); - let tag = self.next_tag; - // The request stays in the pool's queue (it is never replaced) until its answer. - pool.set_queue(vec![RenderRequest { page: w.key.page, scale: w.scale, tag, ..Default::default() }]); - threads = if pool.is_inline() { MAX_THREADS } else { threads.saturating_add(1) }; - self.jobs.push(Job { 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, 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 (_, slot, bytes) in old { - if total <= TEXTURE_BUDGET { - break; - } - self.entries.remove(&slot); - total = total.saturating_sub(bytes); - } - } - - // The cards were drawn before these results arrived: draw them again. Keep polling while - // renders are under way, and while a thumbnail waits for a thread to exit (not when - // nothing waits: a thread exiting changes nothing on screen). - if taken > 0 || self.jobs.iter().any(|j| j.pool.is_inline()) { - ctx.request_repaint(); - } else if !self.jobs.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } else if waiting { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } 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); - } -} - -/// A rendered thumbnail as a texture, and its size in bytes. A raster that doesn't add up, or is -/// larger than a thumbnail can be (or than the GPU accepts), is refused, never uploaded. -fn texture( - ctx: &egui::Context, - slot: Slot, - size: [u32; 2], - pixels: pdfcraft_render::Pixels, - max_side: usize, -) -> Result<(TextureHandle, usize), String> { - let [w, h] = size.map(|v| v as usize); - if w == 0 || h == 0 || w > max_side || h > max_side { - return Err(format!("the thumbnail came back {w}×{h} pixels")); - } - let bytes = w.checked_mul(h).and_then(|p| p.checked_mul(4)).ok_or("the thumbnail is too large")?; - if pixels.len() != bytes { - return Err("the thumbnail came back incomplete".to_owned()); - } - let image = crate::canvas::texture_image([w, h], pixels); - Ok((ctx.load_texture(format!("combine-thumb-{}-{}", slot.0, slot.1), image, egui::TextureOptions::LINEAR), bytes)) -} - /// `name` shortened in the middle to what `fits`, keeping its extension ("Pilot Boat Ad….pdf"). pub(crate) fn fit_name(name: &str, fits: impl Fn(&str) -> bool) -> String { if fits(name) { @@ -816,425 +446,6 @@ fn pending_offset_id() -> egui::Id { egui::Id::unique("pdfcraft-combine-grid-offset") } -/// 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, - twice: bool, - revision: u64, - ppp: f32, - /// The box the page is fitted in (the zoom). - fit: Vec2, -} - -impl Card { - fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { - match self.page { - None => ui.scope_id().with(("combine-card", f.id)), - Some((nth, ..)) => ui.scope_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 = 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::Role::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. - if self.selected || resp.hovered() { - painter.rect_filled(c.shrink(6.0), CornerRadius::same(8), if self.selected { t.accent_soft } else { t.hover }); - } - if self.selected { - painter.rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - } - - // The page, fitted in the paper box (top-aligned, room above for the stack). - let page = 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); - // 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 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); - painter.rect_stroke(sheet, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); - } - } - painter.rect_filled(paper.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); - painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); - - // What goes on the paper: the thumbnail, or why there isn't one. - let mut why: Option<(&str, Color32, String)> = None; - if let Some(lock) = f.lock { - let text = match lock { - Lock::Open => tl!("Password-protected"), - Lock::Permissions => tl!("Its security settings don't allow copying pages"), - }; - why = Some(("lock", ON_PAPER, text.to_owned())); - } else if let Some(p) = &f.problem { - why = Some(("circle-x", ERROR, p.clone())); - } else { - match page { - Err(e) => why = Some(("circle-x", ERROR, e)), - // The pages couldn't be counted, or the page's size is unknown (some passwords - // open a file for combining but not for reading it here): nothing is drawn (the - // engine decides at Combine), and the card says so rather than look blank. - Ok(p) if p >= f.pages || known.is_none() => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), - Ok(p) => { - // Pixels per point: the size the paper is drawn at, on both sides, and never a - // side longer than a thumbnail's (a long, thin page fits by its long side). - let (w, h) = size_pt; - let scale = (size.x * self.ppp / w).min(size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); - let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); - let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; - let look = thumbs.look((f.group, p), key); - let mut want = false; - match look { - Look::Ready(tex) => { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), - Look::Pending(stand_in) => { - if let Some(tex) = stand_in { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - want = true; - } - } - if want && scale.is_finite() && scale > 0.0 { - thumbs.wants.push(Want { - slot: (f.group, p), - key, - bytes: f.bytes.clone(), - password: f.password().map(Arc::from), - scale, - visible: ui.is_rect_visible(c), - }); - } - } - } - } - if let Some((icon, colour, _)) = &why { - icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(28.0)), icon, 28.0, *colour); - } - // For screen readers: the pages, and why no page is shown (otherwise only in the tooltip). - let description = match &why { - Some((_, _, reason)) => format!("{pages}. {reason}"), - None => pages.clone(), - }; - ui.ctx().accesskit_node_builder(id, |node| { - node.set_selected(self.selected); - node.set_description(description); - }); - painter.rect_stroke(paper, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); - - // A warning that doesn't stop it being combined: a badge on the 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; on a - // page card, the page first and the file's name below it, smaller. - let name_y = top + self.fit.y + 16.0; - 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| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), colour).size().x <= max_w; - let shown = fit_name(&f.name, fits); - // A file name is file/path chrome: always left to right, so an Arabic name ends in `.pdf`. - let name_galley = crate::bidi::layout_ltr(ui, shown, font, colour); - painter.galley(pos2(c.center().x - name_galley.size().x / 2.0, y - name_galley.size().y / 2.0), name_galley, 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)), - } - } else { - None - }; - if let Some((text, colour)) = note { - painter.text(pos2(c.center().x, name_y + 17.0), Align2::CENTER_CENTER, text, theme::regular(11.5), colour); - } - - // On hover, the card's own actions along the top of the page (not while a card is - // dragged): remove this file only, and show it large when there is a page to show. Drawn - // over the card, so a click on one doesn't select or move it. - let previewable = why.is_none(); - // 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 (the file, - // or the page), show large. - 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 let Some((nth, ..)) = self.page { - buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); - buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); - } 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 = 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).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; - 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 (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); - } - if self.twice { - tip.push('\n'); - tip.push_str(tl!("Added more than once")); - } - // Page ranges are typed in the list. - 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, show large first (and Unlock… for a locked file). - let at = resp.rect.left_bottom(); - 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() - }; - 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)); - ui.close(); - } - }); - if resp.drag_started() { - egui::DragAndDrop::set_payload(ui.ctx(), GridDrag { file: f.id, revision: self.revision, page: self.page.map(|(nth, ..)| (f.id, nth)) }); - } - if resp.clicked() { - let mods = resp.ctx.input(|i| i.modifiers); - action = Some(match self.page { - Some((nth, ..)) => RowAction::ClickPage(f.id, nth, mods), - None => RowAction::Click(self.i, mods), - }); - } - action - } -} - -/// The file a card's magnifier opened, shown large over the page: one of the pages it adds at a -/// time, flipped with the buttons or the Left and Right keys; Esc, the close button or a click -/// outside closes it. It closes by itself when the file leaves the list or has nothing to show. -pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { - // The command palette goes over everything: the preview waits under it (drawn again after). - if app.palette_open { - return; - } - let Some(p) = app.combine_tab.preview.as_mut() else { return }; - let Some(f) = app.combine_draft.iter().find(|f| f.id == p.file).filter(|f| f.lock.is_none() && f.problem.is_none()) else { - app.combine_tab.preview = None; - return; - }; - // The pages it adds, worked out again only when its range changes. - let pages = match &p.pages { - Some(((range, count), pages)) if *range == f.range && *count == f.pages => pages.clone(), - _ => match f.pages_taken() { - Ok(pages) => { - let pages: Arc<[usize]> = Arc::from(pages); - p.pages = Some(((f.range.clone(), f.pages), pages.clone())); - pages - } - Err(_) => { - app.combine_tab.preview = None; - return; - } - }, - }; - let n = pages.len(); - p.at = p.at.min(n.saturating_sub(1)); - let Some(&page) = pages.get(p.at) else { - app.combine_tab.preview = None; - return; - }; - p.page = Some(page); - let at = p.at; - // Flipped with the keys (the grid's arrows wait while this shows), unless a text field has - // them. - let typing = ctx.text_edit_focused(); - let (back, on) = ctx.input_mut(|i| { - for k in [egui::Key::Plus, egui::Key::Equals, egui::Key::Minus, egui::Key::Num0] { - i.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, k)); - } - if typing { - return (false, false); - } - ( - i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) | i.consume_key(egui::Modifiers::NONE, egui::Key::PageUp), - i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) | i.consume_key(egui::Modifiers::NONE, egui::Key::PageDown), - ) - }); - let (mut step, mut close) = (i64::from(on) - i64::from(back), false); - let screen = ctx.content_rect(); - let width = (screen.width() * 0.86).max(320.0); - let body_h = (screen.height() * 0.86 - 110.0).max(200.0); - let ppp = ctx.pixels_per_point(); - // No larger than the GPU takes, or the render would be refused. - let longest = ctx.input(|i| i.max_texture_side as f32).min(PREVIEW_SIDE); - let thumbs = &mut app.combine_thumbs; - let modal = egui::Modal::new(egui::Id::unique("combine-preview")).show(ctx, |ui| { - ui.set_width(width); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(f.name.clone()).font(theme::semibold(15.0))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "x", 30.0, false, tl!("Close")).clicked() { - close = true; - } - let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &(at + 1).to_string()), ("n", &n.to_string())]); - ui.label(egui::RichText::new(of).color(t.text_muted)); - }); - }); - ui.add_space(8.0); - // The page, as large as fits. - let (area, _) = ui.allocate_exact_size(vec2(width, body_h), Sense::hover()); - let known = f.sizes.get(page).copied().filter(|(w, h)| paper_size(*w, *h, area.size()).is_some()); - let (w, h) = known.unwrap_or(FALLBACK_PAGE); - let size = paper_size(w, h, area.size() - vec2(8.0, 8.0)).unwrap_or(area.size()); - let paper = Rect::from_center_size(area.center(), size); - let painter = ui.painter_at(area); - painter.rect_filled(paper.translate(vec2(0.0, 2.0)), CornerRadius::same(1), t.page_shadow); - painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); - let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - if known.is_some() { - let scale = (size.x * ppp / w).min(size.y * ppp / h).min(longest / w.max(h)); - let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); - let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px }; - match thumbs.look(PREVIEW, key) { - Look::Ready(tex) => { - painter.image(tex, paper, uv, Color32::WHITE); - } - Look::Failed(e) => { - let text = crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]); - painter.text(paper.center(), Align2::CENTER_CENTER, text, theme::regular(13.0), ON_PAPER); - } - Look::Pending(stand_in) => { - // A smaller render of this page stands in: the last size shown, or its card's. - let card = thumbs.entries.get(&(f.group, 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 { - slot: PREVIEW, - key, - bytes: f.bytes.clone(), - password: f.password().map(Arc::from), - scale, - visible: true, - }); - } - } - } - } else { - icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(36.0)), "eye-off", 36.0, ON_PAPER); - } - painter.rect_stroke(paper, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); - ui.add_space(8.0); - // Previous and next, centred below. - ui.horizontal(|ui| { - ui.add_space((width - 2.0 * 30.0 - 6.0) / 2.0); - if ui.add_enabled_ui(at > 0, |ui| icons::button(ui, "chevron-left", 30.0, false, tl!("Previous page"))).inner.clicked() { - step = -1; - } - if ui.add_enabled_ui(at + 1 < n, |ui| icons::button(ui, "chevron-right", 30.0, false, tl!("Next page"))).inner.clicked() { - step = 1; - } - }); - }); - if close || modal.should_close() { - app.combine_tab.preview = None; - } else if let Some(p) = app.combine_tab.preview.as_mut() { - p.at = match step { - s if s < 0 => p.at.saturating_sub(1), - s if s > 0 => (p.at + 1).min(n.saturating_sub(1)), - _ => p.at, - }; - } -} - impl PdfCraftApp { /// The file shown large, the page on screen (0-based, in the file) and its render, while it /// shows (tests and the control channel). diff --git a/crates/ui-egui/src/combine_grid/card.rs b/crates/ui-egui/src/combine_grid/card.rs new file mode 100644 index 000000000..63cf14c5e --- /dev/null +++ b/crates/ui-egui/src/combine_grid/card.rs @@ -0,0 +1,421 @@ +use super::thumbs::{Key, Look, MAX_SIDE, PREVIEW, PREVIEW_SIDE, SIZE_STEP, Thumbs, Want}; +use super::*; + +/// One card's place and what it shows. +pub(super) struct Card { + /// The file, by its place in the list. + pub(super) 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. + pub(super) page: Option<(usize, usize, usize)>, + pub(super) rect: Rect, + pub(super) check: Result, + pub(super) selected: bool, + pub(super) twice: bool, + pub(super) revision: u64, + pub(super) ppp: f32, + /// The box the page is fitted in (the zoom). + pub(super) fit: Vec2, +} + +impl Card { + pub(super) fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { + match self.page { + None => ui.scope_id().with(("combine-card", f.id)), + Some((nth, ..)) => ui.scope_id().with(("combine-page", f.id, nth)), + } + } + + pub(super) 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 = 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::Role::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. + if self.selected || resp.hovered() { + painter.rect_filled(c.shrink(6.0), CornerRadius::same(8), if self.selected { t.accent_soft } else { t.hover }); + } + if self.selected { + painter.rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + + // The page, fitted in the paper box (top-aligned, room above for the stack). + let page = 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); + // 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 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); + painter.rect_stroke(sheet, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + } + } + painter.rect_filled(paper.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); + + // What goes on the paper: the thumbnail, or why there isn't one. + let mut why: Option<(&str, Color32, String)> = None; + if let Some(lock) = f.lock { + let text = match lock { + Lock::Open => tl!("Password-protected"), + Lock::Permissions => tl!("Its security settings don't allow copying pages"), + }; + why = Some(("lock", ON_PAPER, text.to_owned())); + } else if let Some(p) = &f.problem { + why = Some(("circle-x", ERROR, p.clone())); + } else { + match page { + Err(e) => why = Some(("circle-x", ERROR, e)), + // The pages couldn't be counted, or the page's size is unknown (some passwords + // open a file for combining but not for reading it here): nothing is drawn (the + // engine decides at Combine), and the card says so rather than look blank. + Ok(p) if p >= f.pages || known.is_none() => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), + Ok(p) => { + // Pixels per point: the size the paper is drawn at, on both sides, and never a + // side longer than a thumbnail's (a long, thin page fits by its long side). + let (w, h) = size_pt; + let scale = (size.x * self.ppp / w).min(size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); + let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); + let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; + let look = thumbs.look((f.group, p), key); + let mut want = false; + match look { + Look::Ready(tex) => { + painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), + Look::Pending(stand_in) => { + if let Some(tex) = stand_in { + painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + want = true; + } + } + if want && scale.is_finite() && scale > 0.0 { + thumbs.wants.push(Want { + slot: (f.group, p), + key, + bytes: f.bytes.clone(), + password: f.password().map(Arc::from), + scale, + visible: ui.is_rect_visible(c), + }); + } + } + } + } + if let Some((icon, colour, _)) = &why { + icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(28.0)), icon, 28.0, *colour); + } + // For screen readers: the pages, and why no page is shown (otherwise only in the tooltip). + let description = match &why { + Some((_, _, reason)) => format!("{pages}. {reason}"), + None => pages.clone(), + }; + ui.ctx().accesskit_node_builder(id, |node| { + node.set_selected(self.selected); + node.set_description(description); + }); + painter.rect_stroke(paper, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + + // A warning that doesn't stop it being combined: a badge on the 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; on a + // page card, the page first and the file's name below it, smaller. + let name_y = top + self.fit.y + 16.0; + 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| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), colour).size().x <= max_w; + let shown = fit_name(&f.name, fits); + // A file name is file/path chrome: always left to right, so an Arabic name ends in `.pdf`. + let name_galley = crate::bidi::layout_ltr(ui, shown, font, colour); + painter.galley(pos2(c.center().x - name_galley.size().x / 2.0, y - name_galley.size().y / 2.0), name_galley, 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)), + } + } else { + None + }; + if let Some((text, colour)) = note { + painter.text(pos2(c.center().x, name_y + 17.0), Align2::CENTER_CENTER, text, theme::regular(11.5), colour); + } + + // On hover, the card's own actions along the top of the page (not while a card is + // dragged): remove this file only, and show it large when there is a page to show. Drawn + // over the card, so a click on one doesn't select or move it. + let previewable = why.is_none(); + // 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 (the file, + // or the page), show large. + 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 let Some((nth, ..)) = self.page { + buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); + buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); + } 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 = 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).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; + 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 (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); + } + if self.twice { + tip.push('\n'); + tip.push_str(tl!("Added more than once")); + } + // Page ranges are typed in the list. + 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, show large first (and Unlock… for a locked file). + let at = resp.rect.left_bottom(); + 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() + }; + 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)); + ui.close(); + } + }); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), GridDrag { file: f.id, revision: self.revision, page: self.page.map(|(nth, ..)| (f.id, nth)) }); + } + if resp.clicked() { + let mods = resp.ctx.input(|i| i.modifiers); + action = Some(match self.page { + Some((nth, ..)) => RowAction::ClickPage(f.id, nth, mods), + None => RowAction::Click(self.i, mods), + }); + } + action + } +} + +/// The file a card's magnifier opened, shown large over the page: one of the pages it adds at a +/// time, flipped with the buttons or the Left and Right keys; Esc, the close button or a click +/// outside closes it. It closes by itself when the file leaves the list or has nothing to show. +pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { + // The command palette goes over everything: the preview waits under it (drawn again after). + if app.palette_open { + return; + } + let Some(p) = app.combine_tab.preview.as_mut() else { return }; + let Some(f) = app.combine_draft.iter().find(|f| f.id == p.file).filter(|f| f.lock.is_none() && f.problem.is_none()) else { + app.combine_tab.preview = None; + return; + }; + // The pages it adds, worked out again only when its range changes. + let pages = match &p.pages { + Some(((range, count), pages)) if *range == f.range && *count == f.pages => pages.clone(), + _ => match f.pages_taken() { + Ok(pages) => { + let pages: Arc<[usize]> = Arc::from(pages); + p.pages = Some(((f.range.clone(), f.pages), pages.clone())); + pages + } + Err(_) => { + app.combine_tab.preview = None; + return; + } + }, + }; + let n = pages.len(); + p.at = p.at.min(n.saturating_sub(1)); + let Some(&page) = pages.get(p.at) else { + app.combine_tab.preview = None; + return; + }; + p.page = Some(page); + let at = p.at; + // Flipped with the keys (the grid's arrows wait while this shows), unless a text field has + // them. + let typing = ctx.text_edit_focused(); + let (back, on) = ctx.input_mut(|i| { + for k in [egui::Key::Plus, egui::Key::Equals, egui::Key::Minus, egui::Key::Num0] { + i.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, k)); + } + if typing { + return (false, false); + } + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) | i.consume_key(egui::Modifiers::NONE, egui::Key::PageUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) | i.consume_key(egui::Modifiers::NONE, egui::Key::PageDown), + ) + }); + let (mut step, mut close) = (i64::from(on) - i64::from(back), false); + let screen = ctx.content_rect(); + let width = (screen.width() * 0.86).max(320.0); + let body_h = (screen.height() * 0.86 - 110.0).max(200.0); + let ppp = ctx.pixels_per_point(); + // No larger than the GPU takes, or the render would be refused. + let longest = ctx.input(|i| i.max_texture_side as f32).min(PREVIEW_SIDE); + let thumbs = &mut app.combine_thumbs; + let modal = egui::Modal::new(egui::Id::unique("combine-preview")).show(ctx, |ui| { + ui.set_width(width); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(f.name.clone()).font(theme::semibold(15.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "x", 30.0, false, tl!("Close")).clicked() { + close = true; + } + let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &(at + 1).to_string()), ("n", &n.to_string())]); + ui.label(egui::RichText::new(of).color(t.text_muted)); + }); + }); + ui.add_space(8.0); + // The page, as large as fits. + let (area, _) = ui.allocate_exact_size(vec2(width, body_h), Sense::hover()); + let known = f.sizes.get(page).copied().filter(|(w, h)| paper_size(*w, *h, area.size()).is_some()); + let (w, h) = known.unwrap_or(FALLBACK_PAGE); + let size = paper_size(w, h, area.size() - vec2(8.0, 8.0)).unwrap_or(area.size()); + let paper = Rect::from_center_size(area.center(), size); + let painter = ui.painter_at(area); + painter.rect_filled(paper.translate(vec2(0.0, 2.0)), CornerRadius::same(1), t.page_shadow); + painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + if known.is_some() { + let scale = (size.x * ppp / w).min(size.y * ppp / h).min(longest / w.max(h)); + let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); + let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px }; + match thumbs.look(PREVIEW, key) { + Look::Ready(tex) => { + painter.image(tex, paper, uv, Color32::WHITE); + } + Look::Failed(e) => { + let text = crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]); + painter.text(paper.center(), Align2::CENTER_CENTER, text, theme::regular(13.0), ON_PAPER); + } + Look::Pending(stand_in) => { + // A smaller render of this page stands in: the last size shown, or its card's. + let card = thumbs.entries.get(&(f.group, 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 { + slot: PREVIEW, + key, + bytes: f.bytes.clone(), + password: f.password().map(Arc::from), + scale, + visible: true, + }); + } + } + } + } else { + icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(36.0)), "eye-off", 36.0, ON_PAPER); + } + painter.rect_stroke(paper, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + ui.add_space(8.0); + // Previous and next, centred below. + ui.horizontal(|ui| { + ui.add_space((width - 2.0 * 30.0 - 6.0) / 2.0); + if ui.add_enabled_ui(at > 0, |ui| icons::button(ui, "chevron-left", 30.0, false, tl!("Previous page"))).inner.clicked() { + step = -1; + } + if ui.add_enabled_ui(at + 1 < n, |ui| icons::button(ui, "chevron-right", 30.0, false, tl!("Next page"))).inner.clicked() { + step = 1; + } + }); + }); + if close || modal.should_close() { + app.combine_tab.preview = None; + } else if let Some(p) = app.combine_tab.preview.as_mut() { + p.at = match step { + s if s < 0 => p.at.saturating_sub(1), + s if s > 0 => (p.at + 1).min(n.saturating_sub(1)), + _ => p.at, + }; + } +} diff --git a/crates/ui-egui/src/combine_grid/thumbs.rs b/crates/ui-egui/src/combine_grid/thumbs.rs new file mode 100644 index 000000000..5494cdc00 --- /dev/null +++ b/crates/ui-egui/src/combine_grid/thumbs.rs @@ -0,0 +1,391 @@ +use super::*; + +/// Render threads for thumbnails at once, counting those of retired pools until they exit. +pub(super) const MAX_THREADS: usize = 3; + +/// Results taken per frame, and the bytes they may upload. +const UPLOADS_PER_FRAME: usize = 4; + +const UPLOAD_BYTES_PER_FRAME: usize = 8 << 20; + +/// Thumbnail textures kept, in bytes. Those out of view go first, longest unseen first; those in +/// view are kept even over budget (at most a screenful of thumbnails of at most [`MAX_SIDE`]). +const TEXTURE_BUDGET: usize = 32 << 20; + +/// The longest side of a thumbnail, in pixels. +/// (Room for a Letter page at 500% on a screen of 1.5 pixels per point; sharper screens upscale a +/// little at the largest sizes.) This only binds near the top of the zoom range: a card below it +/// asks for less than this and costs no more than it did. Near the top one thumbnail is a few +/// megabytes, so [`TEXTURE_BUDGET`] and [`UPLOAD_BYTES_PER_FRAME`] keep the cache at a screenful +/// plus one frame's uploads — and at the top of the range a screenful is a card or two, because a +/// card is wider than most windows. +pub(super) const MAX_SIDE: f32 = 1536.0; + +/// Thumbnail sizes round up to this many pixels, so a slightly different layout reuses them. +pub(super) const SIZE_STEP: u32 = 16; + +/// 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. +pub(super) 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. +pub(super) const PREVIEW: Slot = (u64::MAX, 0); + +/// The longest side of the page shown large, in pixels. +pub(super) 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; + +/// What the grid has for a file (tests and the control channel). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ThumbState { + /// Nothing yet: not in view, or nothing to draw (locked, unreadable, a bad page range, a page + /// whose size is unknown). + None, + /// Being rendered. + Pending, + /// A thumbnail of page `page` (0-based), `width` × `height` pixels. + Ready { page: usize, width: u32, height: u32 }, + /// The page couldn't be drawn. + Failed(String), +} + +/// Everything a thumbnail depends on: the file's bytes, how it was read (its password), the page +/// and the size. A cached thumbnail is used only while all of them still match. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Key { + /// The address of the file's shared bytes: the same file, even after undo brings it back. + pub(super) source: usize, + pub(super) auth: u64, + pub(super) page: usize, + pub(super) px: [u32; 2], +} + +impl Key { + fn same_page(&self, other: &Key) -> bool { + (self.source, self.auth, self.page) == (other.source, other.auth, other.page) + } +} + +pub(super) struct Entry { + pub(super) key: Key, + pub(super) tex: Option, + pub(super) error: Option, + /// The frame the card last showed it. + pub(super) seen: u64, + pub(super) bytes: usize, +} + +pub(super) struct Job { + pub(super) slot: Slot, + pub(super) key: Key, + pub(super) pool: RenderPool, + pub(super) 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. +pub(super) struct Open { + source: usize, + auth: u64, + pool: RenderPool, + /// When its render finished (seconds). + since: f64, +} + +/// A thumbnail a card needs this frame. +pub(super) struct Want { + pub(super) slot: Slot, + pub(super) key: Key, + pub(super) bytes: Arc>, + pub(super) password: Option>, + pub(super) scale: f32, + pub(super) visible: bool, +} + +/// The grid's thumbnails and the renders under way. +#[derive(Default)] +pub(crate) struct Thumbs { + pub(super) entries: HashMap, + pub(super) jobs: Vec, + /// Pools done rendering, their documents still open (their threads count). + pub(super) open: Vec, + /// Threads of pools let go of, until they exit. + pub(super) retiring: Vec, + pub(super) frame: u64, + pub(super) next_tag: u64, + pub(super) wants: Vec, + /// Render on the UI thread, as the web build does (tests). + #[cfg(test)] + pub(super) inline: bool, +} + +/// What a card can draw now. +pub(super) enum Look { + Ready(egui::TextureId), + Failed(String), + /// Not ready; an older thumbnail of the same page may stand in. + Pending(Option), +} + +impl Thumbs { + /// Forget every thumbnail (the tab closed). Renders under way are let go of: told to stop at + /// their next drawing operator, and counted until their threads exit. + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.wants.clear(); + for job in self.jobs.drain(..) { + self.retiring.push(job.pool.retire()); + } + 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, of pools + /// keeping a document open, and of pools let go of. + pub(super) 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(); + let open: usize = self.open.iter().map(|o| o.pool.threads()).sum(); + working.saturating_add(open).saturating_add(retiring) + } + + pub(super) fn look(&mut self, slot: Slot, key: Key) -> Look { + let frame = self.frame; + match self.entries.get_mut(&slot) { + Some(e) if e.key == key => { + e.seen = frame; + match (&e.tex, &e.error) { + (Some(tex), _) => Look::Ready(tex.id()), + (None, Some(err)) => Look::Failed(err.clone()), + (None, None) => Look::Pending(None), + } + } + // Another size of the same page stands in until this one arrives. A page that failed + // isn't tried again at another size: it would fail (or hang) the same way, each zoom. + Some(e) if e.key.same_page(&key) => { + e.seen = frame; + match &e.error { + Some(err) if e.tex.is_none() => Look::Failed(err.clone()), + _ => Look::Pending(e.tex.as_ref().map(TextureHandle::id)), + } + } + _ => Look::Pending(None), + } + } + + pub(super) 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 { + page: e.key.page, + width: u32::try_from(w).unwrap_or(u32::MAX), + height: u32::try_from(h).unwrap_or(u32::MAX), + }; + } + if let Some(err) = &e.error { + return ThumbState::Failed(err.clone()); + } + } + if self.jobs.iter().any(|j| j.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. `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). + pub(super) 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(|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(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. + let gpu_side = ctx.input(|i| i.max_texture_side); + let max_side = gpu_side.min(2 * MAX_SIDE as usize); + let (mut taken, mut uploaded, mut inline_used) = (0usize, 0usize, false); + let mut i = 0; + while i < self.jobs.len() { + if taken >= UPLOADS_PER_FRAME || uploaded >= UPLOAD_BYTES_PER_FRAME { + break; + } + let Some(job) = self.jobs.get(i) else { break }; + if job.pool.is_inline() { + if inline_used { + i += 1; + continue; + } + inline_used = true; + } + // Not done yet, or not this job's answer (cannot happen with one request per pool): + // keep waiting. A pool without threads answers on the call that renders, so there no + // answer is one that never comes: the thumbnail fails rather than hold up the rest. + let answer = job.pool.try_recv().filter(|out| out.request.tag == job.tag); + if answer.is_none() && !job.pool.is_inline() { + i += 1; + continue; + } + let job = self.jobs.swap_remove(i); + taken += 1; + // 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()), + Some(out) => match out.error { + Some(e) => failed(e), + None => match texture( + ctx, + job.slot, + [out.width, out.height], + out.rgba, + if job.slot == PREVIEW { gpu_side.min(PREVIEW_SIDE as usize) } else { max_side }, + ) { + Ok((tex, bytes)) => { + uploaded = uploaded.saturating_add(bytes); + Entry { key: job.key, tex: Some(tex), error: None, seen: self.frame, bytes } + } + Err(e) => failed(e), + }, + }, + }; + self.entries.insert(job.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 thumbnail at a time. + let mut wants = std::mem::take(&mut self.wants); + 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.) + let mut threads = if inline_used || self.jobs.iter().any(|j| j.pool.is_inline()) { MAX_THREADS } else { self.threads() }; + let mut waiting = false; + for w in wants { + // Wanted for the list as it is now (not changed by this frame's action); one render + // per 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; + } + let config = RenderConfig { password: w.password, ..Default::default() }; + #[cfg(test)] + let pool = if self.inline { RenderPool::new_inline(w.bytes, config) } else { RenderPool::new(w.bytes, 1, config).without_replacements() }; + #[cfg(not(test))] + let pool = RenderPool::new(w.bytes, 1, config).without_replacements(); + self.next_tag = self.next_tag.wrapping_add(1); + let tag = self.next_tag; + // The request stays in the pool's queue (it is never replaced) until its answer. + pool.set_queue(vec![RenderRequest { page: w.key.page, scale: w.scale, tag, ..Default::default() }]); + threads = if pool.is_inline() { MAX_THREADS } else { threads.saturating_add(1) }; + self.jobs.push(Job { 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, 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 (_, slot, bytes) in old { + if total <= TEXTURE_BUDGET { + break; + } + self.entries.remove(&slot); + total = total.saturating_sub(bytes); + } + } + + // The cards were drawn before these results arrived: draw them again. Keep polling while + // renders are under way, and while a thumbnail waits for a thread to exit (not when + // nothing waits: a thread exiting changes nothing on screen). + if taken > 0 || self.jobs.iter().any(|j| j.pool.is_inline()) { + ctx.request_repaint(); + } else if !self.jobs.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } else if waiting { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } 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); + } +} + +/// A rendered thumbnail as a texture, and its size in bytes. A raster that doesn't add up, or is +/// larger than a thumbnail can be (or than the GPU accepts), is refused, never uploaded. +fn texture( + ctx: &egui::Context, + slot: Slot, + size: [u32; 2], + pixels: pdfcraft_render::Pixels, + max_side: usize, +) -> Result<(TextureHandle, usize), String> { + let [w, h] = size.map(|v| v as usize); + if w == 0 || h == 0 || w > max_side || h > max_side { + return Err(format!("the thumbnail came back {w}×{h} pixels")); + } + let bytes = w.checked_mul(h).and_then(|p| p.checked_mul(4)).ok_or("the thumbnail is too large")?; + if pixels.len() != bytes { + return Err("the thumbnail came back incomplete".to_owned()); + } + let image = crate::canvas::texture_image([w, h], pixels); + Ok((ctx.load_texture(format!("combine-thumb-{}-{}", slot.0, slot.1), image, egui::TextureOptions::LINEAR), bytes)) +}