diff --git a/Cargo.lock b/Cargo.lock index f8973c2e0..4e87754d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -783,8 +783,10 @@ dependencies = [ "egui_glium", "egui_glow", "egui_web", + "ehttp", "epi", "image", + "poll-promise", "rfd", ] @@ -832,6 +834,7 @@ dependencies = [ "enum-map", "epi", "image", + "poll-promise", "serde", "syntect", "unicode_names2", @@ -885,9 +888,9 @@ dependencies = [ [[package]] name = "ehttp" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b078e2305de4c998700ac152b3e7a358d7fbe77e15b3b1cd2c44a8b82176124f" +checksum = "80b69a6f9168b96c0ae04763bec27a8b06b34343c334dd2703a4ec21f0f5e110" dependencies = [ "js-sys", "ureq", @@ -913,9 +916,9 @@ dependencies = [ [[package]] name = "enum-map" -version = "1.1.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e893a7ba6116821058dec84a6fb14fb2a97cd8ce5fd0f85d5a4e760ecd7329d9" +checksum = "9ec3484df47a85c121b9d8fbf265ca7eedc26a5c4c341db7cf800876201c766f" dependencies = [ "enum-map-derive", "serde", @@ -923,9 +926,9 @@ dependencies = [ [[package]] name = "enum-map-derive" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84278eae0af6e34ff6c1db44c11634a694aafac559ff3080e4db4e4ac35907aa" +checksum = "8182c0d26a908f001a23adc388fcef7fde884fbaf668874126cd5a3c13ca299e" dependencies = [ "proc-macro2", "quote", @@ -1903,6 +1906,15 @@ dependencies = [ "miniz_oxide 0.3.7", ] +[[package]] +name = "poll-promise" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260817b339544e5b23d4bb66d4522d89dd64af88d16b6dcd7b2a2409ee2e095d" +dependencies = [ + "static_assertions", +] + [[package]] name = "proc-macro-crate" version = "1.1.0" @@ -2274,6 +2286,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.8.0" diff --git a/eframe/Cargo.toml b/eframe/Cargo.toml index a01d380a2..e282e330e 100644 --- a/eframe/Cargo.toml +++ b/eframe/Cargo.toml @@ -38,7 +38,9 @@ egui_glow = { version = "0.16.0", path = "../egui_glow", default-features = fals egui_web = { version = "0.16.0", path = "../egui_web", default-features = false, features = ["glow"] } [dev-dependencies] -image = { version = "0.23", default-features = false, features = ["png"] } +ehttp = "0.2" +image = { version = "0.23", default-features = false, features = ["jpeg", "png"] } +poll-promise = "0.1" rfd = "0.6" [features] diff --git a/eframe/examples/download_image.rs b/eframe/examples/download_image.rs new file mode 100644 index 000000000..14a173f7d --- /dev/null +++ b/eframe/examples/download_image.rs @@ -0,0 +1,82 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release + +use eframe::{egui, epi}; +use poll_promise::Promise; + +fn main() { + let options = eframe::NativeOptions::default(); + eframe::run_native(Box::new(MyApp::default()), options); +} + +#[derive(Default)] +struct MyApp { + /// `None` when download hasn't started yet. + promise: Option>>, +} + +impl epi::App for MyApp { + fn name(&self) -> &str { + "Download and show an image with eframe/egui" + } + + fn update(&mut self, ctx: &egui::Context, frame: &epi::Frame) { + let promise = self.promise.get_or_insert_with(|| { + // Begin download. + // We download the image using `ehttp`, a library that works both in WASM and on native. + // We use the `poll-promise` library to communicate with the UI thread. + let ctx = ctx.clone(); + let frame = frame.clone(); + let (sender, promise) = Promise::new(); + let request = ehttp::Request::get("https://picsum.photos/seed/1.759706314/1024"); + ehttp::fetch(request, move |response| { + frame.request_repaint(); // wake up UI thread + let texture = response.and_then(|response| parse_response(&ctx, response)); + sender.send(texture); // send the results back to the UI thread. + }); + promise + }); + + egui::CentralPanel::default().show(ctx, |ui| match promise.ready() { + None => { + ui.add(egui::Spinner::new()); // still loading + } + Some(Err(err)) => { + ui.colored_label(egui::Color32::RED, err); // something went wrong + } + Some(Ok(texture)) => { + let mut size = texture.size_vec2(); + size *= (ui.available_width() / size.x).min(1.0); + size *= (ui.available_height() / size.y).min(1.0); + ui.image(texture, size); + } + }); + } +} + +fn parse_response( + ctx: &egui::Context, + response: ehttp::Response, +) -> Result { + let content_type = response.content_type().unwrap_or_default(); + if content_type.starts_with("image/") { + let image = load_image(&response.bytes).map_err(|err| err.to_string())?; + Ok(ctx.load_texture("my-image", image)) + } else { + Err(format!( + "Expected image, found content-type {:?}", + content_type + )) + } +} + +fn load_image(image_data: &[u8]) -> Result { + use image::GenericImageView as _; + let image = image::load_from_memory(image_data)?; + let size = [image.width() as _, image.height() as _]; + let image_buffer = image.to_rgba8(); + let pixels = image_buffer.as_flat_samples(); + Ok(egui::ColorImage::from_rgba_unmultiplied( + size, + pixels.as_slice(), + )) +} diff --git a/egui/src/context.rs b/egui/src/context.rs index 85daee627..81e7494d4 100644 --- a/egui/src/context.rs +++ b/egui/src/context.rs @@ -1044,6 +1044,7 @@ impl Context { textures.len(), bytes as f64 * 1e-6 )); + let max_preview_size = Vec2::new(48.0, 32.0); ui.group(|ui| { ScrollArea::vertical() @@ -1053,14 +1054,24 @@ impl Context { ui.style_mut().override_text_style = Some(TextStyle::Monospace); Grid::new("textures") .striped(true) - .num_columns(3) + .num_columns(4) .spacing(Vec2::new(16.0, 2.0)) + .min_row_height(max_preview_size.y) .show(ui, |ui| { - for (_id, texture) in &textures { - let [w, h] = texture.size; + for (&texture_id, meta) in textures { + let [w, h] = meta.size; + + let mut size = Vec2::new(w as f32, h as f32); + size *= (max_preview_size.x / size.x).min(1.0); + size *= (max_preview_size.y / size.y).min(1.0); + ui.image(texture_id, size).on_hover_ui(|ui| { + // show full size on hover + ui.image(texture_id, Vec2::new(w as f32, h as f32)); + }); + ui.label(format!("{} x {}", w, h)); - ui.label(format!("{:.3} MB", texture.bytes_used() as f64 * 1e-6)); - ui.label(format!("{:?}", texture.name)); + ui.label(format!("{:.3} MB", meta.bytes_used() as f64 * 1e-6)); + ui.label(format!("{:?}", meta.name)); ui.end_row(); } }); diff --git a/egui_demo_lib/Cargo.toml b/egui_demo_lib/Cargo.toml index 1fed0df9c..1e9ade106 100644 --- a/egui_demo_lib/Cargo.toml +++ b/egui_demo_lib/Cargo.toml @@ -28,12 +28,13 @@ egui = { version = "0.16.0", path = "../egui", default-features = false } epi = { version = "0.16.0", path = "../epi" } chrono = { version = "0.4", features = ["js-sys", "wasmbind"], optional = true } -enum-map = { version = "1", features = ["serde"] } +enum-map = { version = "2", features = ["serde"] } unicode_names2 = { version = "0.4.0", default-features = false } # feature "http": -ehttp = { version = "0.1.0", optional = true } +ehttp = { version = "0.2.0", optional = true } image = { version = "0.23", default-features = false, features = ["jpeg", "png"], optional = true } +poll-promise = { version = "0.1", default-features = false, optional = true } # feature "syntax_highlighting": syntect = { version = "4", default-features = false, features = ["default-fancy"], optional = true } @@ -52,7 +53,7 @@ extra_debug_asserts = ["egui/extra_debug_asserts"] # Always enable additional checks. extra_asserts = ["egui/extra_asserts"] -http = ["ehttp", "image"] +http = ["ehttp", "image", "poll-promise"] persistence = ["egui/persistence", "epi/persistence", "serde"] serialize = ["egui/serialize", "serde"] syntax_highlighting = ["syntect"] diff --git a/egui_demo_lib/src/apps/http_app.rs b/egui_demo_lib/src/apps/http_app.rs index 7337d5d47..c3e53b113 100644 --- a/egui_demo_lib/src/apps/http_app.rs +++ b/egui_demo_lib/src/apps/http_app.rs @@ -1,4 +1,4 @@ -use std::sync::mpsc::Receiver; +use poll_promise::Promise; struct Resource { /// HTTP response @@ -7,7 +7,7 @@ struct Resource { text: Option, /// If set, the response was an image. - image: Option, + texture: Option, /// If set, the response was text with some supported syntax highlighting (e.g. ".rs" or ".md"). colored_text: Option, @@ -17,21 +17,21 @@ impl Resource { fn from_response(ctx: &egui::Context, response: ehttp::Response) -> Self { let content_type = response.content_type().unwrap_or_default(); let image = if content_type.starts_with("image/") { - load_image(&response.bytes).ok().map(|img| img.into()) + load_image(&response.bytes).ok() } else { None }; - let text = response.text(); + let texture = image.map(|image| ctx.load_texture(&response.url, image)); - let colored_text = text - .as_ref() - .and_then(|text| syntax_highlighting(ctx, &response, text)); + let text = response.text(); + let colored_text = text.and_then(|text| syntax_highlighting(ctx, &response, text)); + let text = text.map(|text| text.to_owned()); Self { response, text, - image, + texture, colored_text, } } @@ -42,22 +42,14 @@ pub struct HttpApp { url: String, #[cfg_attr(feature = "serde", serde(skip))] - in_progress: Option>>, - - #[cfg_attr(feature = "serde", serde(skip))] - result: Option>, - - #[cfg_attr(feature = "serde", serde(skip))] - tex_mngr: TexMngr, + promise: Option>>, } impl Default for HttpApp { fn default() -> Self { Self { url: "https://raw.githubusercontent.com/emilk/egui/master/README.md".to_owned(), - in_progress: Default::default(), - result: Default::default(), - tex_mngr: Default::default(), + promise: Default::default(), } } } @@ -68,14 +60,6 @@ impl epi::App for HttpApp { } fn update(&mut self, ctx: &egui::Context, frame: &epi::Frame) { - if let Some(receiver) = &mut self.in_progress { - // Are we there yet? - if let Ok(result) = receiver.try_recv() { - self.in_progress = None; - self.result = Some(result.map(|response| Resource::from_response(ctx, response))); - } - } - egui::TopBottomPanel::bottom("http_bottom").show(ctx, |ui| { let layout = egui::Layout::top_down(egui::Align::Center).with_main_justify(true); ui.allocate_ui_with_layout(ui.available_size(), layout, |ui| { @@ -94,33 +78,36 @@ impl epi::App for HttpApp { }); if trigger_fetch { - let request = ehttp::Request::get(&self.url); + let ctx = ctx.clone(); let frame = frame.clone(); - let (sender, receiver) = std::sync::mpsc::channel(); - self.in_progress = Some(receiver); - + let (sender, promise) = Promise::new(); + let request = ehttp::Request::get(&self.url); ehttp::fetch(request, move |response| { - sender.send(response).ok(); - frame.request_repaint(); + frame.request_repaint(); // wake up UI thread + let resource = response.map(|response| Resource::from_response(&ctx, response)); + sender.send(resource); }); + self.promise = Some(promise); } ui.separator(); - if self.in_progress.is_some() { - ui.label("Please wait…"); - } else if let Some(result) = &self.result { - match result { - Ok(resource) => { - ui_resource(ui, &mut self.tex_mngr, resource); - } - Err(error) => { - // This should only happen if the fetch API isn't available or something similar. - ui.colored_label( - egui::Color32::RED, - if error.is_empty() { "Error" } else { error }, - ); + if let Some(promise) = &self.promise { + if let Some(result) = promise.ready() { + match result { + Ok(resource) => { + ui_resource(ui, resource); + } + Err(error) => { + // This should only happen if the fetch API isn't available or something similar. + ui.colored_label( + egui::Color32::RED, + if error.is_empty() { "Error" } else { error }, + ); + } } + } else { + ui.add(egui::Spinner::new()); } } }); @@ -160,11 +147,11 @@ fn ui_url(ui: &mut egui::Ui, frame: &epi::Frame, url: &mut String) -> bool { trigger_fetch } -fn ui_resource(ui: &mut egui::Ui, tex_mngr: &mut TexMngr, resource: &Resource) { +fn ui_resource(ui: &mut egui::Ui, resource: &Resource) { let Resource { response, text, - image, + texture, colored_text, } = resource; @@ -211,8 +198,7 @@ fn ui_resource(ui: &mut egui::Ui, tex_mngr: &mut TexMngr, resource: &Resource) { ui.separator(); } - if let Some(image) = image { - let texture = tex_mngr.texture(ui.ctx(), &response.url, image); + if let Some(texture) = texture { let mut size = texture.size_vec2(); size *= (ui.available_width() / size.x).min(1.0); ui.image(texture, size); @@ -286,29 +272,6 @@ impl ColoredText { } // ---------------------------------------------------------------------------- -// Texture/image handling is very manual at the moment. - -/// Immediate mode texture manager that supports at most one texture at the time :) -#[derive(Default)] -struct TexMngr { - loaded_url: String, - texture: Option, -} - -impl TexMngr { - fn texture( - &mut self, - ctx: &egui::Context, - url: &str, - image: &egui::ImageData, - ) -> &egui::TextureHandle { - if self.loaded_url != url || self.texture.is_none() { - self.texture = Some(ctx.load_texture(url, image.clone())); - self.loaded_url = url.to_owned(); - } - self.texture.as_ref().unwrap() - } -} fn load_image(image_data: &[u8]) -> Result { use image::GenericImageView as _;