From ec671e754ff3fe7af75adf8ab0b98aca4550ba58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Proch=C3=A1zka?= <1665677+jprochazk@users.noreply.github.com> Date: Wed, 6 Sep 2023 10:51:51 +0200 Subject: [PATCH] Managed texture loading (#3297) * add types from proposal * add load methods on `egui::Context` * implement loaders from proposal in `egui_extras` * impl `From` for `SizeHint` * re-export `SizeHint` from `egui` root * rework `svg` example to use new managed `Image` * split loaders into separate files + add logging * add `log_trace` * clean up `RetainedImage` from `svg` example * refactor ehttp loader response to bytes mapping * remove spammy trace * load images even without extension * fix lints * remove unused imports * use `Image2` in `download_image` * use `visuals.error_fg_color` in `Image2` error state * update lockfile * use `Arc` in `ImageData` + add `forget` API * add `ui.image2` * add byte size query api * use iterators to sum loader byte sizes * add static image loading * use static image in `svg` example * small refactor of `Image2::ui` texture loading code * add `ImageFit` to size images properly * remove println calls * add bad image load to `download_image` example * add loader file extension support tests * fix lint errors in `loaders` * remove unused `poll-promise` dependency * add some docs to `Image2` * add some docs to `egui_extras::loaders::install` * explain `loaders::install` in examples * fix lint * upgrade `ehttp` to `0.3` for some crates * Remove some unused dependencies * Remove unnecessary context clone * Turn on the `log` create feature of egui_extras in all examples * rename `forget` and document it * derive `Debug` on `SizeHint` Co-authored-by: Emil Ernerfeldt * round when converting SizeHint from vec2 Co-authored-by: Emil Ernerfeldt * add `load` module docs * docstring `add_loader` methods * expose + document `load_include_bytes` * cache texture handles in `DefaultTextureLoader` * add `image2` doctest + further document `Image2` * use `Default` for default `Image2` options * update `image2` doc comment * mention immediate-mode safety * more fit calculation into inherent impl * add hover text on spinner * add `all-loaders` feature * clarify `egui_extras::loaders::install` behavior * explain how to enable image formats * properly format `uri` * use `thread::Builder` instead of `spawn` * use eq op instead of `matches` * inline `From>` for `ImageData` * allow non-`'static` bytes + `forget` in `DefaultTextureLoader` * sort features * change `ehttp` feature to `http` * update `Image2` docs * refactor loader cache type --------- Co-authored-by: Emil Ernerfeldt --- Cargo.lock | 9 +- crates/egui/src/context.rs | 155 +++++++ crates/egui/src/lib.rs | 2 + crates/egui/src/load.rs | 417 ++++++++++++++++++ crates/egui/src/ui.rs | 19 + crates/egui/src/widgets/image.rs | 221 +++++++++- crates/egui/src/widgets/mod.rs | 2 +- crates/egui_demo_app/Cargo.toml | 6 +- crates/egui_demo_lib/Cargo.toml | 4 +- crates/egui_extras/Cargo.toml | 10 +- crates/egui_extras/src/lib.rs | 29 ++ crates/egui_extras/src/loaders.rs | 58 +++ .../egui_extras/src/loaders/ehttp_loader.rs | 107 +++++ crates/egui_extras/src/loaders/file_loader.rs | 84 ++++ .../egui_extras/src/loaders/image_loader.rs | 84 ++++ crates/egui_extras/src/loaders/svg_loader.rs | 92 ++++ crates/epaint/src/image.rs | 10 +- crates/epaint/src/texture_handle.rs | 5 + examples/download_image/Cargo.toml | 8 +- examples/download_image/src/main.rs | 63 +-- examples/retained_image/Cargo.toml | 2 +- examples/screenshot/Cargo.toml | 1 - examples/svg/Cargo.toml | 2 +- examples/svg/src/main.rs | 32 +- 24 files changed, 1343 insertions(+), 79 deletions(-) create mode 100644 crates/egui/src/load.rs create mode 100644 crates/egui_extras/src/loaders.rs create mode 100644 crates/egui_extras/src/loaders/ehttp_loader.rs create mode 100644 crates/egui_extras/src/loaders/file_loader.rs create mode 100644 crates/egui_extras/src/loaders/image_loader.rs create mode 100644 crates/egui_extras/src/loaders/svg_loader.rs diff --git a/Cargo.lock b/Cargo.lock index 047903a51..a877ba759 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,10 +1089,8 @@ version = "0.1.0" dependencies = [ "eframe", "egui_extras", - "ehttp", "env_logger", "image", - "poll-promise", ] [[package]] @@ -1263,6 +1261,7 @@ dependencies = [ "chrono", "document-features", "egui", + "ehttp", "image", "log", "puffin", @@ -1302,10 +1301,11 @@ dependencies = [ [[package]] name = "ehttp" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80b69a6f9168b96c0ae04763bec27a8b06b34343c334dd2703a4ec21f0f5e110" +checksum = "31e4525e883dd283d12b755ab3ad71d7c8dea2ee8e8a062b9f4c4f84637ed681" dependencies = [ + "document-features", "js-sys", "ureq", "wasm-bindgen", @@ -3166,7 +3166,6 @@ dependencies = [ "eframe", "env_logger", "image", - "itertools", ] [[package]] diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 8e47f8d61..f4ccef9b5 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -166,6 +166,8 @@ struct ContextImpl { is_accesskit_enabled: bool, #[cfg(feature = "accesskit")] accesskit_node_classes: accesskit::NodeClassSet, + + loaders: load::Loaders, } impl ContextImpl { @@ -1901,6 +1903,159 @@ impl Context { } } +/// ## Image loading +impl Context { + /// Associate some static bytes with a `uri`. + /// + /// The same `uri` may be passed to [`Ui::image2`] later to load the bytes as an image. + pub fn include_static_bytes(&self, uri: &'static str, bytes: &'static [u8]) { + self.read(|ctx| ctx.loaders.include.insert_static(uri, bytes)); + } + + /// Associate some bytes with a `uri`. + /// + /// The same `uri` may be passed to [`Ui::image2`] later to load the bytes as an image. + pub fn include_bytes(&self, uri: &'static str, bytes: impl Into>) { + self.read(|ctx| ctx.loaders.include.insert_shared(uri, bytes)); + } + + /// Append an entry onto the chain of bytes loaders. + /// + /// See [`load`] for more information. + pub fn add_bytes_loader(&self, loader: Arc) { + self.write(|ctx| ctx.loaders.bytes.push(loader)); + } + + /// Append an entry onto the chain of image loaders. + /// + /// See [`load`] for more information. + pub fn add_image_loader(&self, loader: Arc) { + self.write(|ctx| ctx.loaders.image.push(loader)); + } + + /// Append an entry onto the chain of texture loaders. + /// + /// See [`load`] for more information. + pub fn add_texture_loader(&self, loader: Arc) { + self.write(|ctx| ctx.loaders.texture.push(loader)); + } + + /// Release all memory and textures related to the given image URI. + /// + /// If you attempt to load the image again, it will be reloaded from scratch. + pub fn forget_image(&self, uri: &str) { + self.write(|ctx| { + use crate::load::BytesLoader as _; + + ctx.loaders.include.forget(uri); + + for loader in &ctx.loaders.bytes { + loader.forget(uri); + } + + for loader in &ctx.loaders.image { + loader.forget(uri); + } + + for loader in &ctx.loaders.texture { + loader.forget(uri); + } + }); + } + + /// Try loading the bytes from the given uri using any available bytes loaders. + /// + /// Loaders are expected to cache results, so that this call is immediate-mode safe. + /// + /// This calls the loaders one by one in the order in which they were registered. + /// If a loader returns [`LoadError::NotSupported`][not_supported], + /// then the next loader is called. This process repeats until all loaders have + /// been exhausted, at which point this returns [`LoadError::NotSupported`][not_supported]. + /// + /// # Errors + /// This may fail with: + /// - [`LoadError::NotSupported`][not_supported] if none of the registered loaders support loading the given `uri`. + /// - [`LoadError::Custom`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// + /// [not_supported]: crate::load::LoadError::NotSupported + /// [custom]: crate::load::LoadError::Custom + pub fn try_load_bytes(&self, uri: &str) -> load::BytesLoadResult { + self.read(|this| { + for loader in &this.loaders.bytes { + match loader.load(self, uri) { + Err(load::LoadError::NotSupported) => continue, + result => return result, + } + } + + Err(load::LoadError::NotSupported) + }) + } + + /// Try loading the image from the given uri using any available image loaders. + /// + /// Loaders are expected to cache results, so that this call is immediate-mode safe. + /// + /// This calls the loaders one by one in the order in which they were registered. + /// If a loader returns [`LoadError::NotSupported`][not_supported], + /// then the next loader is called. This process repeats until all loaders have + /// been exhausted, at which point this returns [`LoadError::NotSupported`][not_supported]. + /// + /// # Errors + /// This may fail with: + /// - [`LoadError::NotSupported`][not_supported] if none of the registered loaders support loading the given `uri`. + /// - [`LoadError::Custom`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// + /// [not_supported]: crate::load::LoadError::NotSupported + /// [custom]: crate::load::LoadError::Custom + pub fn try_load_image(&self, uri: &str, size_hint: load::SizeHint) -> load::ImageLoadResult { + self.read(|this| { + for loader in &this.loaders.image { + match loader.load(self, uri, size_hint) { + Err(load::LoadError::NotSupported) => continue, + result => return result, + } + } + + Err(load::LoadError::NotSupported) + }) + } + + /// Try loading the texture from the given uri using any available texture loaders. + /// + /// Loaders are expected to cache results, so that this call is immediate-mode safe. + /// + /// This calls the loaders one by one in the order in which they were registered. + /// If a loader returns [`LoadError::NotSupported`][not_supported], + /// then the next loader is called. This process repeats until all loaders have + /// been exhausted, at which point this returns [`LoadError::NotSupported`][not_supported]. + /// + /// # Errors + /// This may fail with: + /// - [`LoadError::NotSupported`][not_supported] if none of the registered loaders support loading the given `uri`. + /// - [`LoadError::Custom`][custom] if one of the loaders _does_ support loading the `uri`, but the loading process failed. + /// + /// [not_supported]: crate::load::LoadError::NotSupported + /// [custom]: crate::load::LoadError::Custom + pub fn try_load_texture( + &self, + uri: &str, + texture_options: TextureOptions, + size_hint: load::SizeHint, + ) -> load::TextureLoadResult { + self.read(|this| { + for loader in &this.loaders.texture { + match loader.load(self, uri, texture_options, size_hint) { + Err(load::LoadError::NotSupported) => continue, + result => return result, + } + } + + Err(load::LoadError::NotSupported) + }) + } +} + #[test] fn context_impl_send_sync() { fn assert_send_sync() {} diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index f52eabc1a..590828ed4 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -314,6 +314,7 @@ mod input_state; pub mod introspection; pub mod layers; mod layout; +pub mod load; mod memory; pub mod menu; pub mod os; @@ -370,6 +371,7 @@ pub use { input_state::{InputState, MultiTouchInfo, PointerState}, layers::{LayerId, Order}, layout::*, + load::SizeHint, memory::{Memory, Options}, painter::Painter, response::{InnerResponse, Response}, diff --git a/crates/egui/src/load.rs b/crates/egui/src/load.rs new file mode 100644 index 000000000..3de8963bb --- /dev/null +++ b/crates/egui/src/load.rs @@ -0,0 +1,417 @@ +//! Types and traits related to image loading. +//! +//! If you just want to load some images, see [`egui_extras`](https://crates.io/crates/egui_extras/), +//! which contains reasonable default implementations of these traits. You can get started quickly +//! using [`egui_extras::loaders::install`](https://docs.rs/egui_extras/latest/egui_extras/loaders/fn.install.html). +//! +//! ## Loading process +//! +//! There are three kinds of loaders: +//! - [`BytesLoader`]: load the raw bytes of an image +//! - [`ImageLoader`]: decode the bytes into an array of colors +//! - [`TextureLoader`]: ask the backend to put an image onto the GPU +//! +//! The different kinds of loaders represent different layers in the loading process: +//! +//! ```text,ignore +//! ui.image2("file://image.png") +//! └► ctx.try_load_texture("file://image.png", ...) +//! └► TextureLoader::load("file://image.png", ...) +//! └► ctx.try_load_image("file://image.png", ...) +//! └► ImageLoader::load("file://image.png", ...) +//! └► ctx.try_load_bytes("file://image.png", ...) +//! └► BytesLoader::load("file://image.png", ...) +//! ``` +//! +//! As each layer attempts to load the URI, it first asks the layer below it +//! for the data it needs to do its job. But this is not a strict requirement, +//! an implementation could instead generate the data it needs! +//! +//! Loader trait implementations may be registered on a context with: +//! - [`Context::add_bytes_loader`] +//! - [`Context::add_image_loader`] +//! - [`Context::add_texture_loader`] +//! +//! There may be multiple loaders of the same kind registered at the same time. +//! The `try_load` methods on [`Context`] will attempt to call each loader one by one, +//! until one of them returns something other than [`LoadError::NotSupported`]. +//! +//! The loaders are stored in the context. This means they may hold state across frames, +//! which they can (and _should_) use to cache the results of the operations they perform. +//! +//! For example, a [`BytesLoader`] that loads file URIs (`file://image.png`) +//! would cache each file read. A [`TextureLoader`] would cache each combination +//! of `(URI, TextureOptions)`, and so on. +//! +//! Each URI will be passed through the loaders as a plain `&str`. +//! The loaders are free to derive as much meaning from the URI as they wish to. +//! For example, a loader may determine that it doesn't support loading a specific URI +//! if the protocol does not match what it expects. + +use crate::Context; +use ahash::HashMap; +use epaint::mutex::Mutex; +use epaint::TextureHandle; +use epaint::{textures::TextureOptions, ColorImage, TextureId, Vec2}; +use std::ops::Deref; +use std::{error::Error as StdError, fmt::Display, sync::Arc}; + +#[derive(Clone, Debug)] +pub enum LoadError { + /// This loader does not support this protocol or image format. + NotSupported, + + /// A custom error message (e.g. "File not found: foo.png"). + Custom(String), +} + +impl Display for LoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadError::NotSupported => f.write_str("not supported"), + LoadError::Custom(message) => f.write_str(message), + } + } +} + +impl StdError for LoadError {} + +pub type Result = std::result::Result; + +/// Given as a hint for image loading requests. +/// +/// Used mostly for rendering SVG:s to a good size. +/// +/// All variants will preserve the original aspect ratio. +/// +/// Similar to `usvg::FitTo`. +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SizeHint { + /// Keep original size. + #[default] + Original, + + /// Scale to width. + Width(u32), + + /// Scale to height. + Height(u32), + + /// Scale to size. + Size(u32, u32), +} + +impl From for SizeHint { + fn from(value: Vec2) -> Self { + Self::Size(value.x.round() as u32, value.y.round() as u32) + } +} + +// TODO: API for querying bytes caches in each loader + +pub type Size = [usize; 2]; + +#[derive(Clone)] +pub enum Bytes { + Static(&'static [u8]), + Shared(Arc<[u8]>), +} + +impl From<&'static [u8]> for Bytes { + #[inline] + fn from(value: &'static [u8]) -> Self { + Bytes::Static(value) + } +} + +impl From> for Bytes { + #[inline] + fn from(value: Arc<[u8]>) -> Self { + Bytes::Shared(value) + } +} + +impl AsRef<[u8]> for Bytes { + #[inline] + fn as_ref(&self) -> &[u8] { + match self { + Bytes::Static(bytes) => bytes, + Bytes::Shared(bytes) => bytes, + } + } +} + +impl Deref for Bytes { + type Target = [u8]; + + #[inline] + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +#[derive(Clone)] +pub enum BytesPoll { + /// Bytes are being loaded. + Pending { + /// Set if known (e.g. from a HTTP header, or by parsing the image file header). + size: Option, + }, + + /// Bytes are loaded. + Ready { + /// Set if known (e.g. from a HTTP header, or by parsing the image file header). + size: Option, + + /// File contents, e.g. the contents of a `.png`. + bytes: Bytes, + }, +} + +pub type BytesLoadResult = Result; + +pub trait BytesLoader { + /// Try loading the bytes from the given uri. + /// + /// Implementations should call `ctx.request_repaint` to wake up the ui + /// once the data is ready. + /// + /// The implementation should cache any result, so that calling this + /// is immediate-mode safe. + /// + /// # Errors + /// This may fail with: + /// - [`LoadError::NotSupported`] if the loader does not support loading `uri`. + /// - [`LoadError::Custom`] if the loading process failed. + fn load(&self, ctx: &Context, uri: &str) -> BytesLoadResult; + + /// Forget the given `uri`. + /// + /// If `uri` is cached, it should be evicted from cache, + /// so that it may be fully reloaded. + fn forget(&self, uri: &str); + + /// Implementations may use this to perform work at the end of a frame, + /// such as evicting unused entries from a cache. + fn end_frame(&self, frame_index: usize) { + let _ = frame_index; + } + + /// If the loader caches any data, this should return the size of that cache. + fn byte_size(&self) -> usize; +} + +#[derive(Clone)] +pub enum ImagePoll { + /// Image is loading. + Pending { + /// Set if known (e.g. from a HTTP header, or by parsing the image file header). + size: Option, + }, + + /// Image is loaded. + Ready { image: Arc }, +} + +pub type ImageLoadResult = Result; + +pub trait ImageLoader { + /// Try loading the image from the given uri. + /// + /// Implementations should call `ctx.request_repaint` to wake up the ui + /// once the image is ready. + /// + /// The implementation should cache any result, so that calling this + /// is immediate-mode safe. + /// + /// # Errors + /// This may fail with: + /// - [`LoadError::NotSupported`] if the loader does not support loading `uri`. + /// - [`LoadError::Custom`] if the loading process failed. + fn load(&self, ctx: &Context, uri: &str, size_hint: SizeHint) -> ImageLoadResult; + + /// Forget the given `uri`. + /// + /// If `uri` is cached, it should be evicted from cache, + /// so that it may be fully reloaded. + fn forget(&self, uri: &str); + + /// Implementations may use this to perform work at the end of a frame, + /// such as evicting unused entries from a cache. + fn end_frame(&self, frame_index: usize) { + let _ = frame_index; + } + + /// If the loader caches any data, this should return the size of that cache. + fn byte_size(&self) -> usize; +} + +/// A texture with a known size. +#[derive(Clone)] +pub struct SizedTexture { + pub id: TextureId, + pub size: Size, +} + +impl SizedTexture { + pub fn from_handle(handle: &TextureHandle) -> Self { + Self { + id: handle.id(), + size: handle.size(), + } + } +} + +#[derive(Clone)] +pub enum TexturePoll { + /// Texture is loading. + Pending { + /// Set if known (e.g. from a HTTP header, or by parsing the image file header). + size: Option, + }, + + /// Texture is loaded. + Ready { texture: SizedTexture }, +} + +pub type TextureLoadResult = Result; + +pub trait TextureLoader { + /// Try loading the texture from the given uri. + /// + /// Implementations should call `ctx.request_repaint` to wake up the ui + /// once the texture is ready. + /// + /// The implementation should cache any result, so that calling this + /// is immediate-mode safe. + /// + /// # Errors + /// This may fail with: + /// - [`LoadError::NotSupported`] if the loader does not support loading `uri`. + /// - [`LoadError::Custom`] if the loading process failed. + fn load( + &self, + ctx: &Context, + uri: &str, + texture_options: TextureOptions, + size_hint: SizeHint, + ) -> TextureLoadResult; + + /// Forget the given `uri`. + /// + /// If `uri` is cached, it should be evicted from cache, + /// so that it may be fully reloaded. + fn forget(&self, uri: &str); + + /// Implementations may use this to perform work at the end of a frame, + /// such as evicting unused entries from a cache. + fn end_frame(&self, frame_index: usize) { + let _ = frame_index; + } + + /// If the loader caches any data, this should return the size of that cache. + fn byte_size(&self) -> usize; +} + +#[derive(Default)] +pub(crate) struct DefaultBytesLoader { + cache: Mutex>, +} + +impl DefaultBytesLoader { + pub(crate) fn insert_static(&self, uri: &'static str, bytes: &'static [u8]) { + self.cache + .lock() + .entry(uri) + .or_insert_with(|| Bytes::Static(bytes)); + } + + pub(crate) fn insert_shared(&self, uri: &'static str, bytes: impl Into>) { + self.cache + .lock() + .entry(uri) + .or_insert_with(|| Bytes::Shared(bytes.into())); + } +} + +impl BytesLoader for DefaultBytesLoader { + fn load(&self, _: &Context, uri: &str) -> BytesLoadResult { + match self.cache.lock().get(uri).cloned() { + Some(bytes) => Ok(BytesPoll::Ready { size: None, bytes }), + None => Err(LoadError::NotSupported), + } + } + + fn forget(&self, uri: &str) { + let _ = self.cache.lock().remove(uri); + } + + fn byte_size(&self) -> usize { + self.cache.lock().values().map(|bytes| bytes.len()).sum() + } +} + +#[derive(Default)] +struct DefaultTextureLoader { + cache: Mutex>, +} + +impl TextureLoader for DefaultTextureLoader { + fn load( + &self, + ctx: &Context, + uri: &str, + texture_options: TextureOptions, + size_hint: SizeHint, + ) -> TextureLoadResult { + let mut cache = self.cache.lock(); + if let Some(handle) = cache.get(&(uri.into(), texture_options)) { + let texture = SizedTexture::from_handle(handle); + Ok(TexturePoll::Ready { texture }) + } else { + match ctx.try_load_image(uri, size_hint)? { + ImagePoll::Pending { size } => Ok(TexturePoll::Pending { size }), + ImagePoll::Ready { image } => { + let handle = ctx.load_texture(uri, image, texture_options); + let texture = SizedTexture::from_handle(&handle); + cache.insert((uri.into(), texture_options), handle); + Ok(TexturePoll::Ready { texture }) + } + } + } + } + + fn forget(&self, uri: &str) { + self.cache.lock().retain(|(u, _), _| u != uri); + } + + fn end_frame(&self, _: usize) {} + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|texture| texture.byte_size()) + .sum() + } +} + +pub(crate) struct Loaders { + pub include: Arc, + pub bytes: Vec>, + pub image: Vec>, + pub texture: Vec>, +} + +impl Default for Loaders { + fn default() -> Self { + let include = Arc::new(DefaultBytesLoader::default()); + Self { + bytes: vec![include.clone()], + image: Vec::new(), + // By default we only include `DefaultTextureLoader`. + texture: vec![Arc::new(DefaultTextureLoader::default())], + include, + } + } +} diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 6219fa556..c40ef5f51 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -1590,6 +1590,25 @@ impl Ui { pub fn image(&mut self, texture_id: impl Into, size: impl Into) -> Response { Image::new(texture_id, size).ui(self) } + + /// Show an image available at the given `uri`. + /// + /// ⚠ This will do nothing unless you install some image loaders first! + /// The easiest way to do this is via [`egui_extras::loaders::install`](https://docs.rs/egui_extras/latest/egui_extras/loaders/fn.install.html). + /// + /// The loaders handle caching image data, sampled textures, etc. across frames, so calling this is immediate-mode safe. + /// + /// ``` + /// # egui::__run_test_ui(|ui| { + /// ui.image2("file://ferris.svg"); + /// # }); + /// ``` + /// + /// See also [`crate::Image2`] and [`crate::ImageSource`]. + #[inline] + pub fn image2<'a>(&mut self, source: impl Into>) -> Response { + Image2::new(source.into()).ui(self) + } } /// # Colors diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 487af9d38..496ddaaf9 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,4 +1,7 @@ -use crate::*; +use std::sync::Arc; + +use crate::load::Bytes; +use crate::{load::SizeHint, load::TexturePoll, *}; use emath::Rot2; /// An widget to show an image of a given size. @@ -173,3 +176,219 @@ impl Widget for Image { response } } + +/// A widget which displays an image. +/// +/// There are three ways to construct this widget: +/// - [`Image2::from_uri`] +/// - [`Image2::from_bytes`] +/// - [`Image2::from_static_bytes`] +/// +/// In both cases the task of actually loading the image +/// is deferred to when the `Image2` is added to the [`Ui`]. +/// +/// See [`crate::load`] for more information. +pub struct Image2<'a> { + source: ImageSource<'a>, + texture_options: TextureOptions, + size_hint: SizeHint, + fit: ImageFit, + sense: Sense, +} + +#[derive(Default, Clone, Copy)] +enum ImageFit { + // TODO: options for aspect ratio + // TODO: other fit strategies + // FitToWidth, + // FitToHeight, + // FitToWidthExact(f32), + // FitToHeightExact(f32), + #[default] + ShrinkToFit, +} + +impl ImageFit { + pub fn calculate_final_size(&self, available_size: Vec2, image_size: Vec2) -> Vec2 { + let aspect_ratio = image_size.x / image_size.y; + // TODO: more image sizing options + match self { + // ImageFit::FitToWidth => todo!(), + // ImageFit::FitToHeight => todo!(), + // ImageFit::FitToWidthExact(_) => todo!(), + // ImageFit::FitToHeightExact(_) => todo!(), + ImageFit::ShrinkToFit => { + let width = if available_size.x < image_size.x { + available_size.x + } else { + image_size.x + }; + let height = if available_size.y < image_size.y { + available_size.y + } else { + image_size.y + }; + if width < height { + Vec2::new(width, width / aspect_ratio) + } else { + Vec2::new(height * aspect_ratio, height) + } + } + } + } +} + +/// This type tells the [`Ui`] how to load the image. +pub enum ImageSource<'a> { + /// Load the image from a URI. + /// + /// This could be a `file://` url, `http://` url, or a `bare` identifier. + /// How the URI will be turned into a texture for rendering purposes is + /// up to the registered loaders to handle. + /// + /// See [`crate::load`] for more information. + Uri(&'a str), + + /// Load the image from some raw bytes. + /// + /// The [`Bytes`] may be: + /// - `'static`, obtained from `include_bytes!` or similar + /// - Anything that can be converted to `Arc<[u8]>` + /// + /// This instructs the [`Ui`] to cache the raw bytes, which are then further processed by any registered loaders. + /// + /// See [`crate::load`] for more information. + Bytes(&'static str, Bytes), +} + +impl<'a> From<&'a str> for ImageSource<'a> { + #[inline] + fn from(value: &'a str) -> Self { + Self::Uri(value) + } +} + +impl> From<(&'static str, T)> for ImageSource<'static> { + #[inline] + fn from((uri, bytes): (&'static str, T)) -> Self { + Self::Bytes(uri, bytes.into()) + } +} + +impl<'a> Image2<'a> { + /// Load the image from some source. + pub fn new(source: ImageSource<'a>) -> Self { + Self { + source, + texture_options: Default::default(), + size_hint: Default::default(), + fit: Default::default(), + sense: Sense::hover(), + } + } + + /// Load the image from a URI. + /// + /// See [`ImageSource::Uri`]. + pub fn from_uri(uri: &'a str) -> Self { + Self { + source: ImageSource::Uri(uri), + texture_options: Default::default(), + size_hint: Default::default(), + fit: Default::default(), + sense: Sense::hover(), + } + } + + /// Load the image from some raw `'static` bytes. + /// + /// For example, you can use this to load an image from bytes obtained via [`include_bytes`]. + /// + /// See [`ImageSource::Bytes`]. + pub fn from_static_bytes(name: &'static str, bytes: &'static [u8]) -> Self { + Self { + source: ImageSource::Bytes(name, Bytes::Static(bytes)), + texture_options: Default::default(), + size_hint: Default::default(), + fit: Default::default(), + sense: Sense::hover(), + } + } + + /// Load the image from some raw bytes. + /// + /// See [`ImageSource::Bytes`]. + pub fn from_bytes(name: &'static str, bytes: impl Into>) -> Self { + Self { + source: ImageSource::Bytes(name, Bytes::Shared(bytes.into())), + texture_options: Default::default(), + size_hint: Default::default(), + fit: Default::default(), + sense: Sense::hover(), + } + } + + /// Texture options used when creating the texture. + #[inline] + pub fn texture_options(mut self, texture_options: TextureOptions) -> Self { + self.texture_options = texture_options; + self + } + + /// Size hint used when creating the texture. + #[inline] + pub fn size_hint(mut self, size_hint: impl Into) -> Self { + self.size_hint = size_hint.into(); + self + } + + /// Make the image respond to clicks and/or drags. + #[inline] + pub fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } +} + +impl<'a> Widget for Image2<'a> { + fn ui(self, ui: &mut Ui) -> Response { + let uri = match self.source { + ImageSource::Uri(uri) => uri, + ImageSource::Bytes(uri, bytes) => { + match bytes { + Bytes::Static(bytes) => ui.ctx().include_static_bytes(uri, bytes), + Bytes::Shared(bytes) => ui.ctx().include_bytes(uri, bytes), + } + uri + } + }; + + match ui + .ctx() + .try_load_texture(uri, self.texture_options, self.size_hint) + { + Ok(TexturePoll::Ready { texture }) => { + let final_size = self.fit.calculate_final_size( + ui.available_size(), + Vec2::new(texture.size[0] as f32, texture.size[1] as f32), + ); + + let (rect, response) = ui.allocate_exact_size(final_size, self.sense); + + let mut mesh = Mesh::with_texture(texture.id); + mesh.add_rect_with_uv( + rect, + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + Color32::WHITE, + ); + ui.painter().add(Shape::mesh(mesh)); + + response + } + Ok(TexturePoll::Pending { .. }) => { + ui.spinner().on_hover_text(format!("Loading {uri:?}…")) + } + Err(err) => ui.colored_label(ui.visuals().error_fg_color, err.to_string()), + } + } +} diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index 921848f89..c1193dba6 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -22,7 +22,7 @@ pub mod text_edit; pub use button::*; pub use drag_value::DragValue; pub use hyperlink::*; -pub use image::Image; +pub use image::{Image, Image2, ImageSource}; pub use label::*; pub use progress_bar::ProgressBar; pub use selected_label::SelectableLabel; diff --git a/crates/egui_demo_app/Cargo.toml b/crates/egui_demo_app/Cargo.toml index 0a9a2814a..ef35b1ccb 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -45,10 +45,12 @@ log = { version = "0.4", features = ["std"] } # Optional dependencies: bytemuck = { version = "1.7.1", optional = true } -egui_extras = { version = "0.22.0", optional = true, path = "../egui_extras" } +egui_extras = { version = "0.22.0", optional = true, path = "../egui_extras", features = [ + "log", +] } # feature "http": -ehttp = { version = "0.2.0", optional = true } +ehttp = { version = "0.3.0", optional = true } image = { version = "0.24", optional = true, default-features = false, features = [ "jpeg", "png", diff --git a/crates/egui_demo_lib/Cargo.toml b/crates/egui_demo_lib/Cargo.toml index 961896e45..3fd6a6939 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -33,7 +33,9 @@ syntax_highlighting = ["syntect"] [dependencies] egui = { version = "0.22.0", path = "../egui", default-features = false } -egui_extras = { version = "0.22.0", path = "../egui_extras" } +egui_extras = { version = "0.22.0", path = "../egui_extras", features = [ + "log", +] } egui_plot = { version = "0.22.0", path = "../egui_plot" } enum-map = { version = "2", features = ["serde"] } log = { version = "0.4", features = ["std"] } diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 7622d1898..25ec2acda 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -26,12 +26,18 @@ all-features = true [features] default = [] +## Shorthand for enabling `svg`, `image`, and `ehttp`. +all-loaders = ["svg", "image", "http"] + ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] ## Log warnings using [`log`](https://docs.rs/log) crate. log = ["dep:log", "egui/log"] +## Add support for loading images via HTTP. +http = ["dep:ehttp"] + ## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate. ## ## Only enabled on native, because of the low resolution (1ms) of clocks in browsers. @@ -40,7 +46,6 @@ puffin = ["dep:puffin", "egui/puffin"] ## Support loading svg images. svg = ["resvg", "tiny-skia", "usvg"] - [dependencies] egui = { version = "0.22.0", path = "../egui", default-features = false } @@ -76,3 +81,6 @@ puffin = { version = "0.16", optional = true } resvg = { version = "0.28", optional = true, default-features = false } tiny-skia = { version = "0.8", optional = true, default-features = false } # must be updated in lock-step with resvg usvg = { version = "0.28", optional = true, default-features = false } + +# http feature +ehttp = { version = "0.3.0", optional = true, default-features = false } diff --git a/crates/egui_extras/src/lib.rs b/crates/egui_extras/src/lib.rs index 29475c14d..6ae107452 100644 --- a/crates/egui_extras/src/lib.rs +++ b/crates/egui_extras/src/lib.rs @@ -15,6 +15,7 @@ mod datepicker; pub mod image; mod layout; +pub mod loaders; mod sizing; mod strip; mod table; @@ -85,3 +86,31 @@ macro_rules! log_or_panic { }}; } pub(crate) use log_or_panic; + +#[allow(unused_macros)] +macro_rules! log_warn { + ($fmt: literal) => {$crate::log_warn!($fmt,)}; + ($fmt: literal, $($arg: tt)*) => {{ + #[cfg(feature = "log")] + log::warn!($fmt, $($arg)*); + + #[cfg(not(feature = "log"))] + println!( + concat!("egui_extras: warning: ", $fmt), $($arg)* + ) + }}; +} + +#[allow(unused_imports)] +pub(crate) use log_warn; + +#[allow(unused_macros)] +macro_rules! log_trace { + ($fmt: literal) => {$crate::log_trace!($fmt,)}; + ($fmt: literal, $($arg: tt)*) => {{ + #[cfg(feature = "log")] + log::trace!($fmt, $($arg)*); + }}; +} +#[allow(unused_imports)] +pub(crate) use log_trace; diff --git a/crates/egui_extras/src/loaders.rs b/crates/egui_extras/src/loaders.rs new file mode 100644 index 000000000..717d4ddc7 --- /dev/null +++ b/crates/egui_extras/src/loaders.rs @@ -0,0 +1,58 @@ +// TODO: automatic cache eviction + +/// Installs the default set of loaders: +/// - `file` loader on non-Wasm targets +/// - `http` loader (with the `ehttp` feature) +/// - `image` loader (with the `image` feature) +/// - `svg` loader with the `svg` feature +/// +/// ⚠ This will do nothing and you won't see any images unless you enable some features! +/// If you just want to be able to load `file://` and `http://` images, enable the `all-loaders` feature. +/// +/// ⚠ The supported set of image formats is configured by adding the [`image`](https://crates.io/crates/image) +/// crate as your direct dependency, and enabling features on it: +/// +/// ```toml,ignore +/// image = { version = "0.24", features = ["jpeg", "png"] } +/// ``` +/// +/// See [`egui::load`] for more information about how loaders work. +pub fn install(ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + ctx.add_bytes_loader(std::sync::Arc::new(self::file_loader::FileLoader::default())); + + #[cfg(feature = "http")] + ctx.add_bytes_loader(std::sync::Arc::new( + self::ehttp_loader::EhttpLoader::default(), + )); + + #[cfg(feature = "image")] + ctx.add_image_loader(std::sync::Arc::new( + self::image_loader::ImageCrateLoader::default(), + )); + + #[cfg(feature = "svg")] + ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); + + #[cfg(all( + target_arch = "wasm32", + not(feature = "http"), + not(feature = "image"), + not(feature = "svg") + ))] + crate::log_warn!("`loaders::install` was called, but no loaders are enabled"); + + let _ = ctx; +} + +#[cfg(not(target_arch = "wasm32"))] +mod file_loader; + +#[cfg(feature = "http")] +mod ehttp_loader; + +#[cfg(feature = "image")] +mod image_loader; + +#[cfg(feature = "svg")] +mod svg_loader; diff --git a/crates/egui_extras/src/loaders/ehttp_loader.rs b/crates/egui_extras/src/loaders/ehttp_loader.rs new file mode 100644 index 000000000..9467eafd5 --- /dev/null +++ b/crates/egui_extras/src/loaders/ehttp_loader.rs @@ -0,0 +1,107 @@ +use egui::{ + ahash::HashMap, + load::{Bytes, BytesLoadResult, BytesLoader, BytesPoll, LoadError}, + mutex::Mutex, +}; +use std::{sync::Arc, task::Poll}; + +type Entry = Poll, String>>; + +#[derive(Default)] +pub struct EhttpLoader { + cache: Arc>>, +} + +const PROTOCOLS: &[&str] = &["http://", "https://"]; + +fn starts_with_one_of(s: &str, prefixes: &[&str]) -> bool { + prefixes.iter().any(|prefix| s.starts_with(prefix)) +} + +fn get_image_bytes( + uri: &str, + response: Result, +) -> Result, String> { + let response = response?; + if !response.ok { + match response.text() { + Some(response_text) => { + return Err(format!( + "failed to load {uri:?}: {} {} {response_text}", + response.status, response.status_text + )) + } + None => { + return Err(format!( + "failed to load {uri:?}: {} {}", + response.status, response.status_text + )) + } + } + } + + let Some(content_type) = response.content_type() else { + return Err(format!("failed to load {uri:?}: no content-type header found")); + }; + if !content_type.starts_with("image/") { + return Err(format!("failed to load {uri:?}: expected content-type starting with \"image/\", found {content_type:?}")); + } + + Ok(response.bytes.into()) +} + +impl BytesLoader for EhttpLoader { + fn load(&self, ctx: &egui::Context, uri: &str) -> BytesLoadResult { + if !starts_with_one_of(uri, PROTOCOLS) { + return Err(LoadError::NotSupported); + } + + let mut cache = self.cache.lock(); + if let Some(entry) = cache.get(uri).cloned() { + match entry { + Poll::Ready(Ok(bytes)) => Ok(BytesPoll::Ready { + size: None, + bytes: Bytes::Shared(bytes), + }), + Poll::Ready(Err(err)) => Err(LoadError::Custom(err)), + Poll::Pending => Ok(BytesPoll::Pending { size: None }), + } + } else { + crate::log_trace!("started loading {uri:?}"); + + let uri = uri.to_owned(); + cache.insert(uri.clone(), Poll::Pending); + drop(cache); + + ehttp::fetch(ehttp::Request::get(uri.clone()), { + let ctx = ctx.clone(); + let cache = self.cache.clone(); + move |response| { + let result = get_image_bytes(&uri, response); + crate::log_trace!("finished loading {uri:?}"); + let prev = cache.lock().insert(uri, Poll::Ready(result)); + assert!(matches!(prev, Some(Poll::Pending))); + ctx.request_repaint(); + } + }); + + Ok(BytesPoll::Pending { size: None }) + } + } + + fn forget(&self, uri: &str) { + let _ = self.cache.lock().remove(uri); + } + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|entry| match entry { + Poll::Ready(Ok(bytes)) => bytes.len(), + Poll::Ready(Err(err)) => err.len(), + _ => 0, + }) + .sum() + } +} diff --git a/crates/egui_extras/src/loaders/file_loader.rs b/crates/egui_extras/src/loaders/file_loader.rs new file mode 100644 index 000000000..533c3dc95 --- /dev/null +++ b/crates/egui_extras/src/loaders/file_loader.rs @@ -0,0 +1,84 @@ +use egui::{ + ahash::HashMap, + load::{Bytes, BytesLoadResult, BytesLoader, BytesPoll, LoadError}, + mutex::Mutex, +}; +use std::{sync::Arc, task::Poll, thread}; + +type Entry = Poll, String>>; + +#[derive(Default)] +pub struct FileLoader { + /// Cache for loaded files + cache: Arc>>, +} + +const PROTOCOL: &str = "file://"; + +impl BytesLoader for FileLoader { + fn load(&self, ctx: &egui::Context, uri: &str) -> BytesLoadResult { + // File loader only supports the `file` protocol. + let Some(path) = uri.strip_prefix(PROTOCOL) else { + return Err(LoadError::NotSupported); + }; + + let mut cache = self.cache.lock(); + if let Some(entry) = cache.get(path).cloned() { + // `path` has either begun loading, is loaded, or has failed to load. + match entry { + Poll::Ready(Ok(bytes)) => Ok(BytesPoll::Ready { + size: None, + bytes: Bytes::Shared(bytes), + }), + Poll::Ready(Err(err)) => Err(LoadError::Custom(err)), + Poll::Pending => Ok(BytesPoll::Pending { size: None }), + } + } else { + crate::log_trace!("started loading {uri:?}"); + // We need to load the file at `path`. + + // Set the file to `pending` until we finish loading it. + let path = path.to_owned(); + cache.insert(path.clone(), Poll::Pending); + drop(cache); + + // Spawn a thread to read the file, so that we don't block the render for too long. + thread::Builder::new() + .name(format!("egui_extras::FileLoader::load({uri:?})")) + .spawn({ + let ctx = ctx.clone(); + let cache = self.cache.clone(); + let uri = uri.to_owned(); + move || { + let result = match std::fs::read(&path) { + Ok(bytes) => Ok(bytes.into()), + Err(err) => Err(err.to_string()), + }; + let prev = cache.lock().insert(path, Poll::Ready(result)); + assert!(matches!(prev, Some(Poll::Pending))); + ctx.request_repaint(); + crate::log_trace!("finished loading {uri:?}"); + } + }) + .expect("failed to spawn thread"); + + Ok(BytesPoll::Pending { size: None }) + } + } + + fn forget(&self, uri: &str) { + let _ = self.cache.lock().remove(uri); + } + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|entry| match entry { + Poll::Ready(Ok(bytes)) => bytes.len(), + Poll::Ready(Err(err)) => err.len(), + _ => 0, + }) + .sum() + } +} diff --git a/crates/egui_extras/src/loaders/image_loader.rs b/crates/egui_extras/src/loaders/image_loader.rs new file mode 100644 index 000000000..2dbc1f8bd --- /dev/null +++ b/crates/egui_extras/src/loaders/image_loader.rs @@ -0,0 +1,84 @@ +use egui::{ + ahash::HashMap, + load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, + mutex::Mutex, + ColorImage, +}; +use std::{mem::size_of, path::Path, sync::Arc}; + +type Entry = Result, String>; + +#[derive(Default)] +pub struct ImageCrateLoader { + cache: Mutex>, +} + +fn is_supported(uri: &str) -> bool { + let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { + // `true` because if there's no extension, assume that we support it + return true + }; + + ext != "svg" +} + +impl ImageLoader for ImageCrateLoader { + fn load(&self, ctx: &egui::Context, uri: &str, _: SizeHint) -> ImageLoadResult { + if !is_supported(uri) { + return Err(LoadError::NotSupported); + } + + let mut cache = self.cache.lock(); + if let Some(entry) = cache.get(uri).cloned() { + match entry { + Ok(image) => Ok(ImagePoll::Ready { image }), + Err(err) => Err(LoadError::Custom(err)), + } + } else { + match ctx.try_load_bytes(uri) { + Ok(BytesPoll::Ready { bytes, .. }) => { + crate::log_trace!("started loading {uri:?}"); + let result = crate::image::load_image_bytes(&bytes).map(Arc::new); + crate::log_trace!("finished loading {uri:?}"); + cache.insert(uri.into(), result.clone()); + match result { + Ok(image) => Ok(ImagePoll::Ready { image }), + Err(err) => Err(LoadError::Custom(err)), + } + } + Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), + Err(err) => Err(err), + } + } + } + + fn forget(&self, uri: &str) { + let _ = self.cache.lock().remove(uri); + } + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|result| match result { + Ok(image) => image.pixels.len() * size_of::(), + Err(err) => err.len(), + }) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_support() { + assert!(is_supported("https://test.png")); + assert!(is_supported("test.jpeg")); + assert!(is_supported("http://test.gif")); + assert!(is_supported("test.webp")); + assert!(is_supported("file://test")); + assert!(!is_supported("test.svg")); + } +} diff --git a/crates/egui_extras/src/loaders/svg_loader.rs b/crates/egui_extras/src/loaders/svg_loader.rs new file mode 100644 index 000000000..71f233803 --- /dev/null +++ b/crates/egui_extras/src/loaders/svg_loader.rs @@ -0,0 +1,92 @@ +use egui::{ + ahash::HashMap, + load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, + mutex::Mutex, + ColorImage, +}; +use std::{mem::size_of, path::Path, sync::Arc}; + +type Entry = Result, String>; + +#[derive(Default)] +pub struct SvgLoader { + cache: Mutex>, +} + +fn is_supported(uri: &str) -> bool { + let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { return false }; + + ext == "svg" +} + +impl ImageLoader for SvgLoader { + fn load(&self, ctx: &egui::Context, uri: &str, size_hint: SizeHint) -> ImageLoadResult { + if !is_supported(uri) { + return Err(LoadError::NotSupported); + } + + let uri = uri.to_owned(); + + let mut cache = self.cache.lock(); + // We can't avoid the `uri` clone here without unsafe code. + if let Some(entry) = cache.get(&(uri.clone(), size_hint)).cloned() { + match entry { + Ok(image) => Ok(ImagePoll::Ready { image }), + Err(err) => Err(LoadError::Custom(err)), + } + } else { + match ctx.try_load_bytes(&uri) { + Ok(BytesPoll::Ready { bytes, .. }) => { + crate::log_trace!("started loading {uri:?}"); + let fit_to = match size_hint { + SizeHint::Original => usvg::FitTo::Original, + SizeHint::Width(w) => usvg::FitTo::Width(w), + SizeHint::Height(h) => usvg::FitTo::Height(h), + SizeHint::Size(w, h) => usvg::FitTo::Size(w, h), + }; + let result = + crate::image::load_svg_bytes_with_size(&bytes, fit_to).map(Arc::new); + crate::log_trace!("finished loading {uri:?}"); + cache.insert((uri, size_hint), result.clone()); + match result { + Ok(image) => Ok(ImagePoll::Ready { image }), + Err(err) => Err(LoadError::Custom(err)), + } + } + Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), + Err(err) => Err(err), + } + } + } + + fn forget(&self, uri: &str) { + self.cache.lock().retain(|(u, _), _| u != uri); + } + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|result| match result { + Ok(image) => image.pixels.len() * size_of::(), + Err(err) => err.len(), + }) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_support() { + // inverse of same test in `image_loader.rs` + assert!(!is_supported("https://test.png")); + assert!(!is_supported("test.jpeg")); + assert!(!is_supported("http://test.gif")); + assert!(!is_supported("test.webp")); + assert!(!is_supported("file://test")); + assert!(is_supported("test.svg")); + } +} diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 7ca33b3ac..dca86f38c 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -1,4 +1,5 @@ use crate::{textures::TextureOptions, Color32}; +use std::sync::Arc; /// An image stored in RAM. /// @@ -11,7 +12,7 @@ use crate::{textures::TextureOptions, Color32}; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum ImageData { /// RGBA image. - Color(ColorImage), + Color(Arc), /// Used for the font texture. Font(FontImage), @@ -226,6 +227,13 @@ impl std::ops::IndexMut<(usize, usize)> for ColorImage { impl From for ImageData { #[inline(always)] fn from(image: ColorImage) -> Self { + Self::Color(Arc::new(image)) + } +} + +impl From> for ImageData { + #[inline] + fn from(image: Arc) -> Self { Self::Color(image) } } diff --git a/crates/epaint/src/texture_handle.rs b/crates/epaint/src/texture_handle.rs index d612b8873..24d7179b9 100644 --- a/crates/epaint/src/texture_handle.rs +++ b/crates/epaint/src/texture_handle.rs @@ -95,6 +95,11 @@ impl TextureHandle { crate::Vec2::new(w as f32, h as f32) } + /// `width x height x bytes_per_pixel` + pub fn byte_size(&self) -> usize { + self.tex_mngr.read().meta(self.id).unwrap().bytes_used() + } + /// width / height pub fn aspect_ratio(&self) -> f32 { let [w, h] = self.size(); diff --git a/examples/download_image/Cargo.toml b/examples/download_image/Cargo.toml index 055810e6d..630b7ef68 100644 --- a/examples/download_image/Cargo.toml +++ b/examples/download_image/Cargo.toml @@ -12,8 +12,10 @@ publish = false eframe = { path = "../../crates/eframe", features = [ "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO ] } -egui_extras = { path = "../../crates/egui_extras", features = ["image"] } -ehttp = "0.2" +egui_extras = { path = "../../crates/egui_extras", features = [ + "http", + "image", + "log", +] } env_logger = "0.10" image = { version = "0.24", default-features = false, features = ["jpeg"] } -poll-promise = "0.2" diff --git a/examples/download_image/src/main.rs b/examples/download_image/src/main.rs index 9a48c8951..610c39d1f 100644 --- a/examples/download_image/src/main.rs +++ b/examples/download_image/src/main.rs @@ -1,8 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release use eframe::egui; -use egui_extras::RetainedImage; -use poll_promise::Promise; fn main() -> Result<(), eframe::Error> { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). @@ -10,55 +8,34 @@ fn main() -> Result<(), eframe::Error> { eframe::run_native( "Download and show an image with eframe/egui", options, - Box::new(|_cc| Box::::default()), + Box::new(|cc| { + // Without the following call, the `Image2` created below + // will simply output `not supported` error messages. + egui_extras::loaders::install(&cc.egui_ctx); + Box::new(MyApp) + }), ) } #[derive(Default)] -struct MyApp { - /// `None` when download hasn't started yet. - promise: Option>>, -} +struct MyApp; impl eframe::App for MyApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::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 (sender, promise) = Promise::new(); - let request = ehttp::Request::get("https://picsum.photos/seed/1.759706314/1024"); - ehttp::fetch(request, move |response| { - let image = response.and_then(parse_response); - sender.send(image); // send the results back to the UI thread. - ctx.request_repaint(); // wake up UI thread - }); - promise - }); + egui::CentralPanel::default().show(ctx, |ui| { + let width = ui.available_width(); + let half_height = ui.available_height() / 2.0; - egui::CentralPanel::default().show(ctx, |ui| match promise.ready() { - None => { - ui.spinner(); // still loading - } - Some(Err(err)) => { - ui.colored_label(ui.visuals().error_fg_color, err); // something went wrong - } - Some(Ok(image)) => { - image.show_max_size(ui, ui.available_size()); - } + ui.allocate_ui(egui::Vec2::new(width, half_height), |ui| { + ui.add(egui::Image2::from_uri( + "https://picsum.photos/seed/1.759706314/1024", + )) + }); + ui.allocate_ui(egui::Vec2::new(width, half_height), |ui| { + ui.add(egui::Image2::from_uri( + "https://this-is-hopefully-not-a-real-website.rs/image.png", + )) + }); }); } } - -#[allow(clippy::needless_pass_by_value)] -fn parse_response(response: ehttp::Response) -> Result { - let content_type = response.content_type().unwrap_or_default(); - if content_type.starts_with("image/") { - RetainedImage::from_image_bytes(&response.url, &response.bytes) - } else { - Err(format!( - "Expected image, found content-type {content_type:?}" - )) - } -} diff --git a/examples/retained_image/Cargo.toml b/examples/retained_image/Cargo.toml index 4f4858976..50cf0e5bb 100644 --- a/examples/retained_image/Cargo.toml +++ b/examples/retained_image/Cargo.toml @@ -12,6 +12,6 @@ publish = false eframe = { path = "../../crates/eframe", features = [ "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO ] } -egui_extras = { path = "../../crates/egui_extras", features = ["image"] } +egui_extras = { path = "../../crates/egui_extras", features = ["image", "log"] } env_logger = "0.10" image = { version = "0.24", default-features = false, features = ["png"] } diff --git a/examples/screenshot/Cargo.toml b/examples/screenshot/Cargo.toml index e6517be09..fdec95537 100644 --- a/examples/screenshot/Cargo.toml +++ b/examples/screenshot/Cargo.toml @@ -17,5 +17,4 @@ eframe = { path = "../../crates/eframe", features = [ "wgpu", ] } env_logger = "0.10" -itertools = "0.10.3" image = { version = "0.24", default-features = false, features = ["png"] } diff --git a/examples/svg/Cargo.toml b/examples/svg/Cargo.toml index d461f6877..62333a09a 100644 --- a/examples/svg/Cargo.toml +++ b/examples/svg/Cargo.toml @@ -12,5 +12,5 @@ publish = false eframe = { path = "../../crates/eframe", features = [ "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO ] } -egui_extras = { path = "../../crates/egui_extras", features = ["svg"] } +egui_extras = { path = "../../crates/egui_extras", features = ["log", "svg"] } env_logger = "0.10" diff --git a/examples/svg/src/main.rs b/examples/svg/src/main.rs index 5629f8035..bba91d059 100644 --- a/examples/svg/src/main.rs +++ b/examples/svg/src/main.rs @@ -15,26 +15,16 @@ fn main() -> Result<(), eframe::Error> { eframe::run_native( "svg example", options, - Box::new(|_cc| Box::::default()), + Box::new(|cc| { + // Without the following call, the `Image2` created below + // will simply output a `not supported` error message. + egui_extras::loaders::install(&cc.egui_ctx); + Box::new(MyApp) + }), ) } -struct MyApp { - svg_image: egui_extras::RetainedImage, -} - -impl Default for MyApp { - fn default() -> Self { - Self { - svg_image: egui_extras::RetainedImage::from_svg_bytes_with_size( - "rustacean-flat-happy.svg", - include_bytes!("rustacean-flat-happy.svg"), - egui_extras::image::FitTo::Original, - ) - .unwrap(), - } - } -} +struct MyApp; impl eframe::App for MyApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { @@ -45,7 +35,13 @@ impl eframe::App for MyApp { ui.separator(); let max_size = ui.available_size(); - self.svg_image.show_size(ui, max_size); + ui.add( + egui::Image2::from_static_bytes( + "ferris.svg", + include_bytes!("rustacean-flat-happy.svg"), + ) + .size_hint(max_size), + ); }); } }