brep_app/plm/
thumbnail_view.rs1use super::{client::PlmClient, PlmFuture};
4use eframe::egui;
5use std::{collections::HashMap, io::Cursor, rc::Rc, task::{Context, Poll, Waker}};
6
7const MAX_ENTRIES: usize = 128;
8const MAX_PENDING: usize = 4;
9const MAX_BYTES: usize = 1024 * 1024;
10pub const SIDE: f32 = 32.0;
11
12fn version(url: &str) -> Option<&str> {
15 let (path, hash) = url.split_once("?v=")?;
16 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
17 return None;
18 }
19 let segments: Vec<_> = path.split('/').collect();
20 let id = |s: &str| !s.is_empty() && super::identity::unsegment(s).is_some_and(|raw| super::identity::segment(&raw) == s);
21 match segments.as_slice() {
22 ["", "api", "parts", part, "thumbnail"] if id(part) => Some(hash),
23 ["", "api", "parts", part, "revisions", revision, "thumbnail"] if id(part) && id(revision) => Some(hash),
24 _ => None,
25 }
26}
27
28
29pub fn fetch(client: Rc<PlmClient>, url: String) -> PlmFuture<Option<Vec<u8>>> {
30 Box::pin(async move {
31 let expected = version(&url).ok_or("invalid thumbnail URL")?;
32 let response = client.call("GET", &url, None).await.map_err(|e| e.to_string())?;
33 if response.status == 204 {
34 return Ok(None);
35 }
36 let etag = response.headers.iter().find(|(name, _)| name.eq_ignore_ascii_case("etag"));
39 if etag.map(|(_, value)| value.trim_matches('"')) != Some(expected) {
40 return Ok(None);
41 }
42 if response.body.len() > MAX_BYTES {
43 return Err("thumbnail exceeds the size limit".into());
44 }
45 Ok(Some(response.body))
46 })
47}
48
49fn decode(bytes: &[u8]) -> Option<egui::ColorImage> {
50 if bytes.len() > MAX_BYTES {
51 return None;
52 }
53 let mut reader = image::ImageReader::with_format(Cursor::new(bytes), image::ImageFormat::Png);
54 let mut limits = image::Limits::default();
55 limits.max_image_width = Some(1024);
56 limits.max_image_height = Some(1024);
57 limits.max_alloc = Some(16 * 1024 * 1024);
58 reader.limits(limits);
59 let image = reader.decode().ok()?.thumbnail(64, 64).to_rgba8();
60 Some(egui::ColorImage::from_rgba_unmultiplied(
61 [image.width() as usize, image.height() as usize], image.as_raw(),
62 ))
63}
64
65struct Entry {
66 pending: Option<PlmFuture<Option<Vec<u8>>>>,
67 image: Option<egui::ColorImage>,
68 texture: Option<egui::TextureHandle>,
69 used: u64,
70}
71
72#[derive(Default)]
73pub struct ThumbnailCache {
74 entries: HashMap<String, Entry>,
75 clock: u64,
76}
77
78impl ThumbnailCache {
79 pub fn clear(&mut self) {
80 self.entries.clear();
81 }
82
83 pub fn busy(&self) -> bool {
84 self.entries.values().any(|e| e.pending.is_some())
85 }
86
87 pub fn ready(&self, url: &str) -> bool {
88 self.entries.get(url).is_some_and(|e| e.image.is_some() || e.texture.is_some())
89 }
90
91 pub fn poll(&mut self, waker: &Waker) {
93 for entry in self.entries.values_mut() {
94 if let Some(future) = &mut entry.pending {
95 if let Poll::Ready(answer) = future.as_mut().poll(&mut Context::from_waker(waker)) {
96 entry.pending = None;
97 entry.image = answer.ok().flatten().and_then(|bytes| decode(&bytes));
98 }
99 }
100 }
101 }
102
103 fn request(&mut self, url: &str, fetch: impl FnOnce(&str) -> PlmFuture<Option<Vec<u8>>>) {
104 self.clock = self.clock.wrapping_add(1);
105 if let Some(entry) = self.entries.get_mut(url) {
106 entry.used = self.clock;
107 return;
108 }
109 if version(url).is_none() || self.entries.values().filter(|e| e.pending.is_some()).count() >= MAX_PENDING {
110 return;
111 }
112 if self.entries.len() >= MAX_ENTRIES {
113 let oldest = self.entries.iter().filter(|(_, e)| e.pending.is_none())
114 .min_by_key(|(_, e)| e.used).map(|(url, _)| url.clone());
115 let Some(oldest) = oldest else { return };
116 self.entries.remove(&oldest);
117 }
118 self.entries.insert(url.to_string(), Entry {
119 pending: Some(fetch(url)), image: None, texture: None, used: self.clock,
120 });
121 }
122
123 pub fn show(&mut self, ui: &mut egui::Ui, url: &str, fetch: impl FnOnce(&str) -> PlmFuture<Option<Vec<u8>>>) -> egui::Response {
125 let (rect, response) = ui.allocate_exact_size(egui::Vec2::splat(SIDE), egui::Sense::click());
126 if !ui.is_rect_visible(rect) {
127 return response;
128 }
129 if !url.is_empty() {
130 self.request(url, fetch);
131 }
132 if let Some(entry) = self.entries.get_mut(url) {
133 if let Some(image) = entry.image.take() {
134 entry.texture = Some(ui.ctx().load_texture(format!("plm-thumbnail:{url}"), image, egui::TextureOptions::LINEAR));
135 }
136 if let Some(texture) = &entry.texture {
137 let size = texture.size_vec2();
138 let size = size * (SIDE / size.x.max(size.y));
139 ui.painter().image(texture.id(), egui::Rect::from_center_size(rect.center(), size),
140 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
141 return response;
142 }
143 }
144 ui.painter().rect_filled(rect.shrink(2.0), 3.0, ui.visuals().faint_bg_color);
145 ui.painter().rect_stroke(rect.shrink(2.0), 3.0, ui.visuals().widgets.noninteractive.bg_stroke, egui::StrokeKind::Inside);
146 if self.busy() {
147 ui.ctx().request_repaint();
148 }
149 response
150 }
151}