images.rs (8903B)
1 use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint, TextureHandle}; 2 use image::imageops::FilterType; 3 use notedeck::ImageCache; 4 use notedeck::Result; 5 use poll_promise::Promise; 6 use std::path; 7 use tokio::fs; 8 9 //pub type ImageCacheKey = String; 10 //pub type ImageCacheValue = Promise<Result<TextureHandle>>; 11 //pub type ImageCache = HashMap<String, ImageCacheValue>; 12 13 // NOTE(jb55): chatgpt wrote this because I was too dumb to 14 pub fn aspect_fill( 15 ui: &mut egui::Ui, 16 sense: Sense, 17 texture_id: egui::TextureId, 18 aspect_ratio: f32, 19 ) -> egui::Response { 20 let frame = ui.available_rect_before_wrap(); // Get the available frame space in the current layout 21 let frame_ratio = frame.width() / frame.height(); 22 23 let (width, height) = if frame_ratio > aspect_ratio { 24 // Frame is wider than the content 25 (frame.width(), frame.width() / aspect_ratio) 26 } else { 27 // Frame is taller than the content 28 (frame.height() * aspect_ratio, frame.height()) 29 }; 30 31 let content_rect = Rect::from_min_size( 32 frame.min 33 + egui::vec2( 34 (frame.width() - width) / 2.0, 35 (frame.height() - height) / 2.0, 36 ), 37 egui::vec2(width, height), 38 ); 39 40 // Set the clipping rectangle to the frame 41 //let clip_rect = ui.clip_rect(); // Preserve the original clipping rectangle 42 //ui.set_clip_rect(frame); 43 44 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); 45 46 let (response, painter) = ui.allocate_painter(ui.available_size(), sense); 47 48 // Draw the texture within the calculated rect, potentially clipping it 49 painter.rect_filled(content_rect, 0.0, ui.ctx().style().visuals.window_fill()); 50 painter.image(texture_id, content_rect, uv, Color32::WHITE); 51 52 // Restore the original clipping rectangle 53 //ui.set_clip_rect(clip_rect); 54 response 55 } 56 57 pub fn round_image(image: &mut ColorImage) { 58 #[cfg(feature = "profiling")] 59 puffin::profile_function!(); 60 61 // The radius to the edge of of the avatar circle 62 let edge_radius = image.size[0] as f32 / 2.0; 63 let edge_radius_squared = edge_radius * edge_radius; 64 65 for (pixnum, pixel) in image.pixels.iter_mut().enumerate() { 66 // y coordinate 67 let uy = pixnum / image.size[0]; 68 let y = uy as f32; 69 let y_offset = edge_radius - y; 70 71 // x coordinate 72 let ux = pixnum % image.size[0]; 73 let x = ux as f32; 74 let x_offset = edge_radius - x; 75 76 // The radius to this pixel (may be inside or outside the circle) 77 let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset; 78 79 // If inside of the avatar circle 80 if pixel_radius_squared <= edge_radius_squared { 81 // squareroot to find how many pixels we are from the edge 82 let pixel_radius: f32 = pixel_radius_squared.sqrt(); 83 let distance = edge_radius - pixel_radius; 84 85 // If we are within 1 pixel of the edge, we should fade, to 86 // antialias the edge of the circle. 1 pixel from the edge should 87 // be 100% of the original color, and right on the edge should be 88 // 0% of the original color. 89 if distance <= 1.0 { 90 *pixel = Color32::from_rgba_premultiplied( 91 (pixel.r() as f32 * distance) as u8, 92 (pixel.g() as f32 * distance) as u8, 93 (pixel.b() as f32 * distance) as u8, 94 (pixel.a() as f32 * distance) as u8, 95 ); 96 } 97 } else { 98 // Outside of the avatar circle 99 *pixel = Color32::TRANSPARENT; 100 } 101 } 102 } 103 104 fn process_pfp_bitmap(imgtyp: ImageType, image: &mut image::DynamicImage) -> ColorImage { 105 #[cfg(feature = "profiling")] 106 puffin::profile_function!(); 107 108 match imgtyp { 109 ImageType::Content(w, h) => { 110 let image = image.resize(w, h, FilterType::CatmullRom); // DynamicImage 111 let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) 112 let color_image = ColorImage::from_rgba_unmultiplied( 113 [ 114 image_buffer.width() as usize, 115 image_buffer.height() as usize, 116 ], 117 image_buffer.as_flat_samples().as_slice(), 118 ); 119 color_image 120 } 121 ImageType::Profile(size) => { 122 // Crop square 123 let smaller = image.width().min(image.height()); 124 125 if image.width() > smaller { 126 let excess = image.width() - smaller; 127 *image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); 128 } else if image.height() > smaller { 129 let excess = image.height() - smaller; 130 *image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); 131 } 132 let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage 133 let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) 134 let mut color_image = ColorImage::from_rgba_unmultiplied( 135 [ 136 image_buffer.width() as usize, 137 image_buffer.height() as usize, 138 ], 139 image_buffer.as_flat_samples().as_slice(), 140 ); 141 round_image(&mut color_image); 142 color_image 143 } 144 } 145 } 146 147 fn parse_img_response(response: ehttp::Response, imgtyp: ImageType) -> Result<ColorImage> { 148 #[cfg(feature = "profiling")] 149 puffin::profile_function!(); 150 151 let content_type = response.content_type().unwrap_or_default(); 152 let size_hint = match imgtyp { 153 ImageType::Profile(size) => SizeHint::Size(size, size), 154 ImageType::Content(w, h) => SizeHint::Size(w, h), 155 }; 156 157 if content_type.starts_with("image/svg") { 158 #[cfg(feature = "profiling")] 159 puffin::profile_scope!("load_svg"); 160 161 let mut color_image = 162 egui_extras::image::load_svg_bytes_with_size(&response.bytes, Some(size_hint))?; 163 round_image(&mut color_image); 164 Ok(color_image) 165 } else if content_type.starts_with("image/") { 166 #[cfg(feature = "profiling")] 167 puffin::profile_scope!("load_from_memory"); 168 let mut dyn_image = image::load_from_memory(&response.bytes)?; 169 Ok(process_pfp_bitmap(imgtyp, &mut dyn_image)) 170 } else { 171 Err(format!("Expected image, found content-type {:?}", content_type).into()) 172 } 173 } 174 175 fn fetch_img_from_disk( 176 ctx: &egui::Context, 177 url: &str, 178 path: &path::Path, 179 ) -> Promise<Result<TextureHandle>> { 180 let ctx = ctx.clone(); 181 let url = url.to_owned(); 182 let path = path.to_owned(); 183 Promise::spawn_async(async move { 184 let data = fs::read(path).await?; 185 let image_buffer = image::load_from_memory(&data).map_err(notedeck::Error::Image)?; 186 187 // TODO: remove unwrap here 188 let flat_samples = image_buffer.as_flat_samples_u8().unwrap(); 189 let img = ColorImage::from_rgba_unmultiplied( 190 [ 191 image_buffer.width() as usize, 192 image_buffer.height() as usize, 193 ], 194 flat_samples.as_slice(), 195 ); 196 197 Ok(ctx.load_texture(&url, img, Default::default())) 198 }) 199 } 200 201 /// Controls type-specific handling 202 #[derive(Debug, Clone, Copy)] 203 pub enum ImageType { 204 /// Profile Image (size) 205 Profile(u32), 206 /// Content Image (width, height) 207 Content(u32, u32), 208 } 209 210 pub fn fetch_img( 211 img_cache: &ImageCache, 212 ctx: &egui::Context, 213 url: &str, 214 imgtyp: ImageType, 215 ) -> Promise<Result<TextureHandle>> { 216 let key = ImageCache::key(url); 217 let path = img_cache.cache_dir.join(key); 218 219 if path.exists() { 220 fetch_img_from_disk(ctx, url, &path) 221 } else { 222 fetch_img_from_net(&img_cache.cache_dir, ctx, url, imgtyp) 223 } 224 225 // TODO: fetch image from local cache 226 } 227 228 fn fetch_img_from_net( 229 cache_path: &path::Path, 230 ctx: &egui::Context, 231 url: &str, 232 imgtyp: ImageType, 233 ) -> Promise<Result<TextureHandle>> { 234 let (sender, promise) = Promise::new(); 235 let request = ehttp::Request::get(url); 236 let ctx = ctx.clone(); 237 let cloned_url = url.to_owned(); 238 let cache_path = cache_path.to_owned(); 239 ehttp::fetch(request, move |response| { 240 let handle = response 241 .map_err(notedeck::Error::Generic) 242 .and_then(|resp| parse_img_response(resp, imgtyp)) 243 .map(|img| { 244 let texture_handle = ctx.load_texture(&cloned_url, img.clone(), Default::default()); 245 246 // write to disk 247 std::thread::spawn(move || ImageCache::write(&cache_path, &cloned_url, img)); 248 249 texture_handle 250 }); 251 252 sender.send(handle); // send the results back to the UI thread. 253 ctx.request_repaint(); 254 }); 255 256 promise 257 }