diff --git a/Cargo.lock b/Cargo.lock index a877ba75..00dcba23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1083,16 +1083,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" -[[package]] -name = "download_image" -version = "0.1.0" -dependencies = [ - "eframe", - "egui_extras", - "env_logger", - "image", -] - [[package]] name = "dyn-clonable" version = "0.9.0" @@ -1231,6 +1221,7 @@ dependencies = [ "image", "log", "poll-promise", + "rfd", "serde", "wasm-bindgen", "wasm-bindgen-futures", @@ -1264,6 +1255,7 @@ dependencies = [ "ehttp", "image", "log", + "mime_guess", "puffin", "resvg", "serde", @@ -2026,6 +2018,16 @@ dependencies = [ "png", ] +[[package]] +name = "images" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_extras", + "env_logger", + "image", +] + [[package]] name = "imagesize" version = "0.10.1" @@ -2310,6 +2312,22 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2998,16 +3016,6 @@ dependencies = [ "usvg", ] -[[package]] -name = "retained_image" -version = "0.1.0" -dependencies = [ - "eframe", - "egui_extras", - "env_logger", - "image", -] - [[package]] name = "rfd" version = "0.11.4" @@ -3408,15 +3416,6 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" -[[package]] -name = "svg" -version = "0.1.0" -dependencies = [ - "eframe", - "egui_extras", - "env_logger", -] - [[package]] name = "svgtypes" version = "0.8.2" @@ -3747,6 +3746,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.13" diff --git a/crates/egui/assets/ferris.png b/crates/egui/assets/ferris.png new file mode 100644 index 00000000..8741baa1 Binary files /dev/null and b/crates/egui/assets/ferris.png differ diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 554d36f5..c3211315 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -2,9 +2,11 @@ use std::sync::Arc; +use crate::load::Bytes; +use crate::load::SizedTexture; use crate::{ animation_manager::AnimationManager, data::output::PlatformOutput, frame_state::FrameState, - input_state::*, layers::GraphicLayers, memory::Options, os::OperatingSystem, + input_state::*, layers::GraphicLayers, load::Loaders, memory::Options, os::OperatingSystem, output::FullOutput, util::IdTypeMap, TextureHandle, *, }; use epaint::{mutex::*, stats::*, text::Fonts, TessellationOptions, *}; @@ -167,7 +169,7 @@ struct ContextImpl { #[cfg(feature = "accesskit")] accesskit_node_classes: accesskit::NodeClassSet, - loaders: load::Loaders, + loaders: Arc, } impl ContextImpl { @@ -1143,7 +1145,7 @@ impl Context { /// }); /// /// // Show the image: - /// ui.image(texture, texture.size_vec2()); + /// ui.raw_image((texture.id(), texture.size_vec2())); /// } /// } /// ``` @@ -1689,14 +1691,15 @@ impl Context { let mut size = vec2(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 larger on hover - let max_size = 0.5 * ui.ctx().screen_rect().size(); - let mut size = vec2(w as f32, h as f32); - size *= max_size.x / size.x.max(max_size.x); - size *= max_size.y / size.y.max(max_size.y); - ui.image(texture_id, size); - }); + ui.raw_image(SizedTexture::new(texture_id, size)) + .on_hover_ui(|ui| { + // show larger on hover + let max_size = 0.5 * ui.ctx().screen_rect().size(); + let mut size = vec2(w as f32, h as f32); + size *= max_size.x / size.x.max(max_size.x); + size *= max_size.y / size.y.max(max_size.y); + ui.raw_image(SizedTexture::new(texture_id, size)); + }); ui.label(format!("{w} x {h}")); ui.label(format!("{:.3} MB", meta.bytes_used() as f64 * 1e-6)); @@ -1907,60 +1910,90 @@ impl Context { 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)); + /// The same `uri` may be passed to [`Ui::image`] later to load the bytes as an image. + pub fn include_bytes(&self, uri: &'static str, bytes: impl Into) { + self.loaders().include.insert(uri, bytes.into()); } - /// 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)); + /// Returns `true` if the chain of bytes, image, or texture loaders + /// contains a loader with the given `id`. + pub fn is_loader_installed(&self, id: &str) -> bool { + let loaders = self.loaders(); + + let in_bytes = loaders.bytes.lock().iter().any(|loader| loader.id() == id); + let in_image = loaders.image.lock().iter().any(|loader| loader.id() == id); + let in_texture = loaders + .texture + .lock() + .iter() + .any(|loader| loader.id() == id); + + in_bytes || in_image || in_texture } /// 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)); + self.loaders().bytes.lock().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)); + self.loaders().image.lock().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)); + self.loaders().texture.lock().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 _; + use load::BytesLoader as _; - ctx.loaders.include.forget(uri); + crate::profile_function!(); - for loader in &ctx.loaders.bytes { - loader.forget(uri); - } + let loaders = self.loaders(); - for loader in &ctx.loaders.image { - loader.forget(uri); - } + loaders.include.forget(uri); + for loader in loaders.bytes.lock().iter() { + loader.forget(uri); + } + for loader in loaders.image.lock().iter() { + loader.forget(uri); + } + for loader in loaders.texture.lock().iter() { + loader.forget(uri); + } + } - for loader in &ctx.loaders.texture { - loader.forget(uri); - } - }); + /// Release all memory and textures related to images used in [`Ui::image`] or [`Image`]. + /// + /// If you attempt to load any images again, they will be reloaded from scratch. + pub fn forget_all_images(&self) { + use load::BytesLoader as _; + + crate::profile_function!(); + + let loaders = self.loaders(); + + loaders.include.forget_all(); + for loader in loaders.bytes.lock().iter() { + loader.forget_all(); + } + for loader in loaders.image.lock().iter() { + loader.forget_all(); + } + for loader in loaders.texture.lock().iter() { + loader.forget_all(); + } } /// Try loading the bytes from the given uri using any available bytes loaders. @@ -1977,11 +2010,14 @@ impl Context { /// - [`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. /// + /// ⚠ May deadlock if called from within a `BytesLoader`! + /// /// [not_supported]: crate::load::LoadError::NotSupported /// [custom]: crate::load::LoadError::Custom pub fn try_load_bytes(&self, uri: &str) -> load::BytesLoadResult { - let loaders = self.loaders(); - for loader in &loaders.bytes { + crate::profile_function!(); + + for loader in self.loaders().bytes.lock().iter() { match loader.load(self, uri) { Err(load::LoadError::NotSupported) => continue, result => return result, @@ -2005,11 +2041,14 @@ impl Context { /// - [`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. /// + /// ⚠ May deadlock if called from within an `ImageLoader`! + /// /// [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 { - let loaders = self.loaders(); - for loader in &loaders.image { + crate::profile_function!(); + + for loader in self.loaders().image.lock().iter() { match loader.load(self, uri, size_hint) { Err(load::LoadError::NotSupported) => continue, result => return result, @@ -2033,6 +2072,8 @@ impl Context { /// - [`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. /// + /// ⚠ May deadlock if called from within a `TextureLoader`! + /// /// [not_supported]: crate::load::LoadError::NotSupported /// [custom]: crate::load::LoadError::Custom pub fn try_load_texture( @@ -2041,9 +2082,9 @@ impl Context { texture_options: TextureOptions, size_hint: load::SizeHint, ) -> load::TextureLoadResult { - let loaders = self.loaders(); + crate::profile_function!(); - for loader in &loaders.texture { + for loader in self.loaders().texture.lock().iter() { match loader.load(self, uri, texture_options, size_hint) { Err(load::LoadError::NotSupported) => continue, result => return result, @@ -2053,9 +2094,9 @@ impl Context { Err(load::LoadError::NotSupported) } - fn loaders(&self) -> load::Loaders { + fn loaders(&self) -> Arc { crate::profile_function!(); - self.read(|this| this.loaders.clone()) // TODO(emilk): something less slow + self.read(|this| this.loaders.clone()) } } diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 8730b450..65f0aab7 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -84,7 +84,7 @@ //! ui.separator(); //! //! # let my_image = egui::TextureId::default(); -//! ui.image(my_image, [640.0, 480.0]); +//! ui.raw_image((my_image, egui::Vec2::new(640.0, 480.0))); //! //! ui.collapsing("Click to see what is hidden!", |ui| { //! ui.label("Not much, as it turns out"); @@ -424,6 +424,28 @@ pub fn warn_if_debug_build(ui: &mut crate::Ui) { // ---------------------------------------------------------------------------- +/// Include an image in the binary. +/// +/// This is a wrapper over `include_bytes!`, and behaves in the same way. +/// +/// It produces an [`ImageSource`] which can be used directly in [`Ui::image`] or [`Image::new`]: +/// +/// ``` +/// # egui::__run_test_ui(|ui| { +/// ui.image(egui::include_image!("../assets/ferris.png")); +/// ui.add( +/// egui::Image::new(egui::include_image!("../assets/ferris.png")) +/// .rounding(egui::Rounding::same(6.0)) +/// ); +/// # }); +/// ``` +#[macro_export] +macro_rules! include_image { + ($path: literal) => { + $crate::ImageSource::Bytes($path, $crate::load::Bytes::Static(include_bytes!($path))) + }; +} + /// Create a [`Hyperlink`](crate::Hyperlink) to the current [`file!()`] (and line) on Github /// /// ``` diff --git a/crates/egui/src/load.rs b/crates/egui/src/load.rs index 21886571..a325fc00 100644 --- a/crates/egui/src/load.rs +++ b/crates/egui/src/load.rs @@ -1,8 +1,12 @@ -//! Types and traits related to image loading. +//! # 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). +//! If you just want to display some images, [`egui_extras`](https://crates.io/crates/egui_extras/) +//! will get you up and running quickly with its reasonable default implementations of the traits described below. +//! +//! 1. Add [`egui_extras`](https://crates.io/crates/egui_extras/) as a dependency with the `all-loaders` feature. +//! 2. Add a call to [`egui_extras::loaders::install`](https://docs.rs/egui_extras/latest/egui_extras/loaders/fn.install.html) +//! in your app's setup code. +//! 3. Use [`Ui::image`][`crate::ui::Ui::image`] with some [`ImageSource`][`crate::ImageSource`]. //! //! ## Loading process //! @@ -14,13 +18,13 @@ //! 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", ...) +//! ui.image("file://image.png") +//! └► Context::try_load_texture +//! └► TextureLoader::load +//! └► Context::try_load_image +//! └► ImageLoader::load +//! └► Context::try_load_bytes +//! └► BytesLoader::load //! ``` //! //! As each layer attempts to load the URI, it first asks the layer below it @@ -51,11 +55,15 @@ use crate::Context; use ahash::HashMap; use epaint::mutex::Mutex; +use epaint::util::FloatOrd; +use epaint::util::OrderedFloat; use epaint::TextureHandle; use epaint::{textures::TextureOptions, ColorImage, TextureId, Vec2}; +use std::fmt::Debug; use std::ops::Deref; use std::{error::Error as StdError, fmt::Display, sync::Arc}; +/// Represents a failed attempt at loading an image. #[derive(Clone, Debug)] pub enum LoadError { /// This loader does not support this protocol or image format. @@ -85,11 +93,10 @@ pub type Result = std::result::Result; /// All variants will preserve the original aspect ratio. /// /// Similar to `usvg::FitTo`. -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum SizeHint { - /// Keep original size. - #[default] - Original, + /// Scale original size by some factor. + Scale(OrderedFloat), /// Scale to width. Width(u32), @@ -101,22 +108,36 @@ pub enum SizeHint { Size(u32, u32), } +impl Default for SizeHint { + fn default() -> Self { + Self::Scale(1.0.ord()) + } +} + 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]; - +/// Represents a byte buffer. +/// +/// This is essentially `Cow<'static, [u8]>` but with the `Owned` variant being an `Arc`. #[derive(Clone)] pub enum Bytes { Static(&'static [u8]), Shared(Arc<[u8]>), } +impl Debug for Bytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Static(arg0) => f.debug_tuple("Static").field(&arg0.len()).finish(), + Self::Shared(arg0) => f.debug_tuple("Shared").field(&arg0.len()).finish(), + } + } +} + impl From<&'static [u8]> for Bytes { #[inline] fn from(value: &'static [u8]) -> Self { @@ -124,6 +145,13 @@ impl From<&'static [u8]> for Bytes { } } +impl From<&'static [u8; N]> for Bytes { + #[inline] + fn from(value: &'static [u8; N]) -> Self { + Bytes::Static(value) + } +} + impl From> for Bytes { #[inline] fn from(value: Arc<[u8]>) -> Self { @@ -131,6 +159,13 @@ impl From> for Bytes { } } +impl From> for Bytes { + #[inline] + fn from(value: Vec) -> Self { + Bytes::Shared(value.into()) + } +} + impl AsRef<[u8]> for Bytes { #[inline] fn as_ref(&self) -> &[u8] { @@ -150,27 +185,59 @@ impl Deref for Bytes { } } +/// Represents bytes which are currently being loaded. +/// +/// This is similar to [`std::task::Poll`], but the `Pending` variant +/// contains an optional `size`, which may be used during layout to +/// pre-allocate space the image. #[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, + size: Option, }, /// Bytes are loaded. Ready { /// Set if known (e.g. from a HTTP header, or by parsing the image file header). - size: Option, + size: Option, /// File contents, e.g. the contents of a `.png`. bytes: Bytes, + + /// Mime type of the content, e.g. `image/png`. + /// + /// Set if known (e.g. from `Content-Type` HTTP header). + mime: Option, }, } +/// Used to get a unique ID when implementing one of the loader traits: [`BytesLoader::id`], [`ImageLoader::id`], and [`TextureLoader::id`]. +/// +/// This just expands to `module_path!()` concatenated with the given type name. +#[macro_export] +macro_rules! generate_loader_id { + ($ty:ident) => { + concat!(module_path!(), "::", stringify!($ty)) + }; +} +pub use crate::generate_loader_id; + pub type BytesLoadResult = Result; +/// Represents a loader capable of loading raw unstructured bytes. +/// +/// It should also provide any subsequent loaders a hint for what the bytes may +/// represent using [`BytesPoll::Ready::mime`], if it can be inferred. +/// +/// Implementations are expected to cache at least each `URI`. pub trait BytesLoader { + /// Unique ID of this loader. + /// + /// To reduce the chance of collisions, use [`generate_loader_id`] for this. + fn id(&self) -> &str; + /// Try loading the bytes from the given uri. /// /// Implementations should call `ctx.request_repaint` to wake up the ui @@ -191,6 +258,12 @@ pub trait BytesLoader { /// so that it may be fully reloaded. fn forget(&self, uri: &str); + /// Forget all URIs ever given to this loader. + /// + /// If the loader caches any URIs, the entire cache should be cleared, + /// so that all of them may be fully reloaded. + fn forget_all(&self); + /// 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) { @@ -201,12 +274,17 @@ pub trait BytesLoader { fn byte_size(&self) -> usize; } +/// Represents an image which is currently being loaded. +/// +/// This is similar to [`std::task::Poll`], but the `Pending` variant +/// contains an optional `size`, which may be used during layout to +/// pre-allocate space the image. #[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, + size: Option, }, /// Image is loaded. @@ -215,7 +293,18 @@ pub enum ImagePoll { pub type ImageLoadResult = Result; +/// Represents a loader capable of loading a raw image. +/// +/// Implementations are expected to cache at least each `URI`. pub trait ImageLoader { + /// Unique ID of this loader. + /// + /// To reduce the chance of collisions, include `module_path!()` as part of this ID. + /// + /// For example: `concat!(module_path!(), "::MyLoader")` + /// for `my_crate::my_loader::MyLoader`. + fn id(&self) -> &str; + /// Try loading the image from the given uri. /// /// Implementations should call `ctx.request_repaint` to wake up the ui @@ -236,6 +325,12 @@ pub trait ImageLoader { /// so that it may be fully reloaded. fn forget(&self, uri: &str); + /// Forget all URIs ever given to this loader. + /// + /// If the loader caches any URIs, the entire cache should be cleared, + /// so that all of them may be fully reloaded. + fn forget_all(&self); + /// 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) { @@ -247,27 +342,49 @@ pub trait ImageLoader { } /// A texture with a known size. -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct SizedTexture { pub id: TextureId, - pub size: Size, + pub size: Vec2, } impl SizedTexture { + /// Create a [`SizedTexture`] from a texture `id` with a specific `size`. + pub fn new(id: impl Into, size: impl Into) -> Self { + Self { + id: id.into(), + size: size.into(), + } + } + + /// Fetch the [id][`SizedTexture::id`] and [size][`SizedTexture::size`] from a [`TextureHandle`]. pub fn from_handle(handle: &TextureHandle) -> Self { + let size = handle.size(); Self { id: handle.id(), - size: handle.size(), + size: Vec2::new(size[0] as f32, size[1] as f32), } } } +impl From<(TextureId, Vec2)> for SizedTexture { + #[inline] + fn from((id, size): (TextureId, Vec2)) -> Self { + SizedTexture { id, size } + } +} + +/// Represents a texture is currently being loaded. +/// +/// This is similar to [`std::task::Poll`], but the `Pending` variant +/// contains an optional `size`, which may be used during layout to +/// pre-allocate space the image. #[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, + size: Option, }, /// Texture is loaded. @@ -276,7 +393,18 @@ pub enum TexturePoll { pub type TextureLoadResult = Result; +/// Represents a loader capable of loading a full texture. +/// +/// Implementations are expected to cache each combination of `(URI, TextureOptions)`. pub trait TextureLoader { + /// Unique ID of this loader. + /// + /// To reduce the chance of collisions, include `module_path!()` as part of this ID. + /// + /// For example: `concat!(module_path!(), "::MyLoader")` + /// for `my_crate::my_loader::MyLoader`. + fn id(&self) -> &str; + /// Try loading the texture from the given uri. /// /// Implementations should call `ctx.request_repaint` to wake up the ui @@ -303,6 +431,12 @@ pub trait TextureLoader { /// so that it may be fully reloaded. fn forget(&self, uri: &str); + /// Forget all URIs ever given to this loader. + /// + /// If the loader caches any URIs, the entire cache should be cleared, + /// so that all of them may be fully reloaded. + fn forget_all(&self); + /// 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) { @@ -319,25 +453,23 @@ pub(crate) struct DefaultBytesLoader { } 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())); + pub(crate) fn insert(&self, uri: &'static str, bytes: impl Into) { + self.cache.lock().entry(uri).or_insert_with(|| bytes.into()); } } impl BytesLoader for DefaultBytesLoader { + fn id(&self) -> &str { + generate_loader_id!(DefaultBytesLoader) + } + fn load(&self, _: &Context, uri: &str) -> BytesLoadResult { match self.cache.lock().get(uri).cloned() { - Some(bytes) => Ok(BytesPoll::Ready { size: None, bytes }), + Some(bytes) => Ok(BytesPoll::Ready { + size: None, + bytes, + mime: None, + }), None => Err(LoadError::NotSupported), } } @@ -346,6 +478,10 @@ impl BytesLoader for DefaultBytesLoader { let _ = self.cache.lock().remove(uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn byte_size(&self) -> usize { self.cache.lock().values().map(|bytes| bytes.len()).sum() } @@ -357,6 +493,10 @@ struct DefaultTextureLoader { } impl TextureLoader for DefaultTextureLoader { + fn id(&self) -> &str { + generate_loader_id!(DefaultTextureLoader) + } + fn load( &self, ctx: &Context, @@ -385,6 +525,10 @@ impl TextureLoader for DefaultTextureLoader { self.cache.lock().retain(|(u, _), _| u != uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn end_frame(&self, _: usize) {} fn byte_size(&self) -> usize { @@ -396,22 +540,26 @@ impl TextureLoader for DefaultTextureLoader { } } +type BytesLoaderImpl = Arc; +type ImageLoaderImpl = Arc; +type TextureLoaderImpl = Arc; + #[derive(Clone)] pub(crate) struct Loaders { pub include: Arc, - pub bytes: Vec>, - pub image: Vec>, - pub texture: Vec>, + pub bytes: Mutex>, + pub image: Mutex>, + pub texture: Mutex>, } impl Default for Loaders { fn default() -> Self { let include = Arc::new(DefaultBytesLoader::default()); Self { - bytes: vec![include.clone()], - image: Vec::new(), + bytes: Mutex::new(vec![include.clone()]), + image: Mutex::new(Vec::new()), // By default we only include `DefaultTextureLoader`. - texture: vec![Arc::new(DefaultTextureLoader::default())], + texture: Mutex::new(vec![Arc::new(DefaultTextureLoader::default())]), include, } } diff --git a/crates/egui/src/menu.rs b/crates/egui/src/menu.rs index 6c8d8f9b..ab92f063 100644 --- a/crates/egui/src/menu.rs +++ b/crates/egui/src/menu.rs @@ -111,7 +111,7 @@ pub fn menu_button( /// Returns `None` if the menu is not open. pub fn menu_image_button( ui: &mut Ui, - image_button: ImageButton, + image_button: ImageButton<'_>, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse> { stationary_menu_image_impl(ui, image_button, Box::new(add_contents)) @@ -201,7 +201,7 @@ fn stationary_menu_impl<'c, R>( /// Responds to primary clicks. fn stationary_menu_image_impl<'c, R>( ui: &mut Ui, - image_button: ImageButton, + image_button: ImageButton<'_>, add_contents: Box R + 'c>, ) -> InnerResponse> { let bar_id = ui.id(); diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index c40ef5f5..5b342b20 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use epaint::mutex::RwLock; +use crate::load::SizedTexture; use crate::{ containers::*, ecolor::*, epaint::text::Fonts, layout::*, menu::MenuState, placer::Placer, util::IdTypeMap, widgets::*, *, @@ -1558,39 +1559,6 @@ impl Ui { response } - /// Show an image here with the given size. - /// - /// In order to display an image you must first acquire a [`TextureHandle`]. - /// This is best done with [`egui_extras::RetainedImage`](https://docs.rs/egui_extras/latest/egui_extras/image/struct.RetainedImage.html) or [`Context::load_texture`]. - /// - /// ``` - /// struct MyImage { - /// texture: Option, - /// } - /// - /// impl MyImage { - /// fn ui(&mut self, ui: &mut egui::Ui) { - /// let texture: &egui::TextureHandle = self.texture.get_or_insert_with(|| { - /// // Load the texture only once. - /// ui.ctx().load_texture( - /// "my-image", - /// egui::ColorImage::example(), - /// Default::default() - /// ) - /// }); - /// - /// // Show the image: - /// ui.image(texture, texture.size_vec2()); - /// } - /// } - /// ``` - /// - /// See also [`crate::Image`] and [`crate::ImageButton`]. - #[inline] - 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! @@ -1600,14 +1568,61 @@ impl Ui { /// /// ``` /// # egui::__run_test_ui(|ui| { - /// ui.image2("file://ferris.svg"); + /// ui.image("https://picsum.photos/480"); + /// ui.image("file://assets/ferris.png"); + /// ui.image(egui::include_image!("../assets/ferris.png")); + /// ui.add( + /// egui::Image::new(egui::include_image!("../assets/ferris.png")) + /// .rounding(egui::Rounding::same(6.0)) + /// ); /// # }); /// ``` /// - /// See also [`crate::Image2`] and [`crate::ImageSource`]. + /// Note: Prefer `include_image` as a source if you're loading an image + /// from a file with a statically known path, unless you really want to + /// load it at runtime instead! + /// + /// See also [`crate::Image`], [`crate::ImageSource`] and [`Self::raw_image`]. #[inline] - pub fn image2<'a>(&mut self, source: impl Into>) -> Response { - Image2::new(source.into()).ui(self) + pub fn image<'a>(&mut self, source: impl Into>) -> Response { + Image::new(source.into()).ui(self) + } + + /// Show an image created from a sized texture. + /// + /// You may use this method over [`Ui::image`] if you already have a [`TextureHandle`] + /// or a [`SizedTexture`]. + /// + /// ``` + /// # egui::__run_test_ui(|ui| { + /// struct MyImage { + /// texture: Option, + /// } + /// + /// impl MyImage { + /// fn ui(&mut self, ui: &mut egui::Ui) { + /// let texture = self + /// .texture + /// .get_or_insert_with(|| { + /// // Load the texture only once. + /// ui.ctx().load_texture( + /// "my-image", + /// egui::ColorImage::example(), + /// Default::default() + /// ) + /// }); + /// + /// // Show the image: + /// ui.raw_image((texture.id(), texture.size_vec2())); + /// } + /// } + /// # }); + /// ``` + /// + /// See also [`crate::RawImage`]. + #[inline] + pub fn raw_image(&mut self, texture: impl Into) -> Response { + RawImage::new(texture).ui(self) } } @@ -2206,15 +2221,9 @@ impl Ui { /// If called from within a menu this will instead create a button for a sub-menu. /// /// ```ignore - /// use egui_extras; + /// let img = egui::include_image!("../assets/ferris.png"); /// - /// let img = egui_extras::RetainedImage::from_svg_bytes_with_size( - /// "rss", - /// include_bytes!("rss.svg"), - /// egui_extras::image::FitTo::Size(24, 24), - /// ); - /// - /// ui.menu_image_button(img.texture_id(ctx), img.size_vec2(), |ui| { + /// ui.menu_image_button(img, |ui| { /// ui.menu_button("My sub-menu", |ui| { /// if ui.button("Close the menu").clicked() { /// ui.close_menu(); @@ -2225,16 +2234,15 @@ impl Ui { /// /// See also: [`Self::close_menu`] and [`Response::context_menu`]. #[inline] - pub fn menu_image_button( + pub fn menu_image_button<'a, R>( &mut self, - texture_id: TextureId, - image_size: impl Into, + image_source: impl Into>, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse> { if let Some(menu_state) = self.menu_state.clone() { menu::submenu_button(self, menu_state, String::new(), add_contents) } else { - menu::menu_image_button(self, ImageButton::new(texture_id, image_size), add_contents) + menu::menu_image_button(self, ImageButton::new(image_source), add_contents) } } } diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index fd3f4ecc..38353b14 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -1,3 +1,5 @@ +use crate::load::SizedTexture; +use crate::load::TexturePoll; use crate::*; /// Clickable button with text. @@ -32,7 +34,7 @@ pub struct Button { frame: Option, min_size: Vec2, rounding: Option, - image: Option, + image: Option, } impl Button { @@ -60,7 +62,10 @@ impl Button { text: impl Into, ) -> Self { Self { - image: Some(widgets::Image::new(texture_id, image_size)), + image: Some(widgets::RawImage::new(SizedTexture { + id: texture_id, + size: image_size.into(), + })), ..Self::new(text) } } @@ -161,7 +166,7 @@ impl Widget for Button { } let mut text_wrap_width = ui.available_width() - 2.0 * button_padding.x; - if let Some(image) = image { + if let Some(image) = &image { text_wrap_width -= image.size().x + ui.spacing().icon_spacing; } if !shortcut_text.is_empty() { @@ -173,7 +178,7 @@ impl Widget for Button { .then(|| shortcut_text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Button)); let mut desired_size = text.size(); - if let Some(image) = image { + if let Some(image) = &image { desired_size.x += image.size().x + ui.spacing().icon_spacing; desired_size.y = desired_size.y.max(image.size().y); } @@ -201,7 +206,7 @@ impl Widget for Button { .rect(rect.expand(visuals.expansion), rounding, fill, stroke); } - let text_pos = if let Some(image) = image { + let text_pos = if let Some(image) = &image { let icon_spacing = ui.spacing().icon_spacing; pos2( rect.min.x + button_padding.x + image.size().x + icon_spacing, @@ -226,7 +231,7 @@ impl Widget for Button { ); } - if let Some(image) = image { + if let Some(image) = &image { let image_rect = Rect::from_min_size( pos2( rect.min.x + button_padding.x, @@ -468,17 +473,17 @@ impl Widget for RadioButton { /// A clickable image within a frame. #[must_use = "You should put this widget in an ui with `ui.add(widget);`"] #[derive(Clone, Debug)] -pub struct ImageButton { - image: widgets::Image, +pub struct ImageButton<'a> { + image: Image<'a>, sense: Sense, frame: bool, selected: bool, } -impl ImageButton { - pub fn new(texture_id: impl Into, size: impl Into) -> Self { +impl<'a> ImageButton<'a> { + pub fn new(source: impl Into>) -> Self { Self { - image: widgets::Image::new(texture_id, size), + image: Image::new(source.into()), sense: Sense::click(), frame: true, selected: false, @@ -515,29 +520,21 @@ impl ImageButton { self.sense = sense; self } -} -impl Widget for ImageButton { - fn ui(self, ui: &mut Ui) -> Response { - let Self { - image, - sense, - frame, - selected, - } = self; - - let padding = if frame { + fn show(&self, ui: &mut Ui, texture: &SizedTexture) -> Response { + let padding = if self.frame { // so we can see that it is a button: Vec2::splat(ui.spacing().button_padding.x) } else { Vec2::ZERO }; - let padded_size = image.size() + 2.0 * padding; - let (rect, response) = ui.allocate_exact_size(padded_size, sense); + + let padded_size = texture.size + 2.0 * padding; + let (rect, response) = ui.allocate_exact_size(padded_size, self.sense); response.widget_info(|| WidgetInfo::new(WidgetType::ImageButton)); if ui.is_rect_visible(rect) { - let (expansion, rounding, fill, stroke) = if selected { + let (expansion, rounding, fill, stroke) = if self.selected { let selection = ui.visuals().selection; ( Vec2::ZERO, @@ -545,7 +542,7 @@ impl Widget for ImageButton { selection.bg_fill, selection.stroke, ) - } else if frame { + } else if self.frame { let visuals = ui.style().interact(&response); let expansion = Vec2::splat(visuals.expansion); ( @@ -558,17 +555,19 @@ impl Widget for ImageButton { Default::default() }; - let image = image.rounding(rounding); // apply rounding to the image - // Draw frame background (for transparent images): ui.painter() .rect_filled(rect.expand2(expansion), rounding, fill); let image_rect = ui .layout() - .align_size_within_rect(image.size(), rect.shrink2(padding)); + .align_size_within_rect(texture.size, rect.shrink2(padding)); // let image_rect = image_rect.expand2(expansion); // can make it blurry, so let's not - image.paint_at(ui, image_rect); + let image_options = ImageOptions { + rounding, + ..Default::default() + }; // apply rounding to the image + crate::widgets::image::paint_image_at(ui, image_rect, &image_options, texture); // Draw frame outline: ui.painter() @@ -578,3 +577,17 @@ impl Widget for ImageButton { response } } + +impl<'a> Widget for ImageButton<'a> { + fn ui(self, ui: &mut Ui) -> Response { + match self.image.load(ui) { + Ok(TexturePoll::Ready { texture }) => self.show(ui, &texture), + Ok(TexturePoll::Pending { .. }) => ui + .spinner() + .on_hover_text(format!("Loading {:?}…", self.image.uri())), + Err(err) => ui + .colored_label(ui.visuals().error_fg_color, "⚠") + .on_hover_text(err.to_string()), + } + } +} diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 496ddaaf..cc98559d 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,90 +1,176 @@ -use std::sync::Arc; +use std::borrow::Cow; -use crate::load::Bytes; -use crate::{load::SizeHint, load::TexturePoll, *}; +use crate::load::TextureLoadResult; +use crate::{ + load::{Bytes, SizeHint, SizedTexture, TexturePoll}, + *, +}; use emath::Rot2; +use epaint::{util::FloatOrd, RectShape}; -/// An widget to show an image of a given size. +/// A widget which displays an image. /// -/// In order to display an image you must first acquire a [`TextureHandle`]. -/// This is best done with [`egui_extras::RetainedImage`](https://docs.rs/egui_extras/latest/egui_extras/image/struct.RetainedImage.html) or [`Context::load_texture`]. +/// The task of actually loading the image is deferred to when the `Image` is added to the [`Ui`], +/// and how it is loaded depends on the provided [`ImageSource`]: /// -/// ``` -/// struct MyImage { -/// texture: Option, -/// } +/// - [`ImageSource::Uri`] will load the image using the [asynchronous loading process][`load`]. +/// - [`ImageSource::Bytes`] will also load the image using the [asynchronous loading process][`load`], but with lower latency. +/// - [`ImageSource::Texture`] will use the provided texture. /// -/// impl MyImage { -/// fn ui(&mut self, ui: &mut egui::Ui) { -/// let texture: &egui::TextureHandle = self.texture.get_or_insert_with(|| { -/// // Load the texture only once. -/// ui.ctx().load_texture( -/// "my-image", -/// egui::ColorImage::example(), -/// Default::default() -/// ) -/// }); +/// To use a texture you already have with a simpler API, consider using [`RawImage`]. /// -/// // Show the image: -/// ui.add(egui::Image::new(texture, texture.size_vec2())); -/// -/// // Shorter version: -/// ui.image(texture, texture.size_vec2()); -/// } -/// } -/// ``` -/// -/// Se also [`crate::Ui::image`] and [`crate::ImageButton`]. +/// See [`load`] for more information. #[must_use = "You should put this widget in an ui with `ui.add(widget);`"] -#[derive(Clone, Copy, Debug)] -pub struct Image { - texture_id: TextureId, - uv: Rect, - size: Vec2, - bg_fill: Color32, - tint: Color32, +#[derive(Debug, Clone)] +pub struct Image<'a> { + source: ImageSource<'a>, + texture_options: TextureOptions, + image_options: ImageOptions, sense: Sense, - rotation: Option<(Rot2, Vec2)>, - rounding: Rounding, + size: ImageSize, } -impl Image { - pub fn new(texture_id: impl Into, size: impl Into) -> Self { +impl<'a> Image<'a> { + /// Load the image from some source. + pub fn new(source: ImageSource<'a>) -> Self { Self { - texture_id: texture_id.into(), - uv: Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - size: size.into(), - bg_fill: Default::default(), - tint: Color32::WHITE, + source, + texture_options: Default::default(), + image_options: Default::default(), sense: Sense::hover(), - rotation: None, - rounding: Rounding::ZERO, + size: Default::default(), } } + /// Load the image from a URI. + /// + /// See [`ImageSource::Uri`]. + pub fn from_uri(uri: impl Into>) -> Self { + Self::new(ImageSource::Uri(uri.into())) + } + + /// Load the image from an existing texture. + /// + /// See [`ImageSource::Texture`]. + pub fn from_texture(texture: SizedTexture) -> Self { + Self::new(ImageSource::Texture(texture)) + } + + /// Load the image from some raw bytes. + /// + /// See [`ImageSource::Bytes`]. + pub fn from_bytes(uri: &'static str, bytes: impl Into) -> Self { + Self::new(ImageSource::Bytes(uri, bytes.into())) + } + + /// Texture options used when creating the texture. + #[inline] + pub fn texture_options(mut self, texture_options: TextureOptions) -> Self { + self.texture_options = texture_options; + self + } + + /// Set the max width of the image. + /// + /// No matter what the image is scaled to, it will never exceed this limit. + #[inline] + pub fn max_width(mut self, width: f32) -> Self { + match self.size.max_size.as_mut() { + Some(max_size) => max_size.x = width, + None => self.size.max_size = Some(Vec2::new(width, f32::INFINITY)), + } + self + } + + /// Set the max height of the image. + /// + /// No matter what the image is scaled to, it will never exceed this limit. + #[inline] + pub fn max_height(mut self, height: f32) -> Self { + match self.size.max_size.as_mut() { + Some(max_size) => max_size.y = height, + None => self.size.max_size = Some(Vec2::new(f32::INFINITY, height)), + } + self + } + + /// Set the max size of the image. + /// + /// No matter what the image is scaled to, it will never exceed this limit. + #[inline] + pub fn max_size(mut self, size: Option) -> Self { + self.size.max_size = size; + self + } + + /// Whether or not the [`ImageFit`] should maintain the image's original aspect ratio. + #[inline] + pub fn maintain_aspect_ratio(mut self, value: bool) -> Self { + self.size.maintain_aspect_ratio = value; + self + } + + /// Fit the image to its original size. + /// + /// This will cause the image to overflow if it is larger than the available space. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn fit_to_original_size(mut self, scale: Option) -> Self { + self.size.fit = ImageFit::Original(scale); + self + } + + /// Fit the image to an exact size. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn fit_to_exact_size(mut self, size: Vec2) -> Self { + self.size.fit = ImageFit::Exact(size); + self + } + + /// Fit the image to a fraction of the available space. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn fit_to_fraction(mut self, fraction: Vec2) -> Self { + self.size.fit = ImageFit::Fraction(fraction); + self + } + + /// Fit the image to 100% of its available size, shrinking it if necessary. + /// + /// This is a shorthand for [`Image::fit_to_fraction`] with `1.0` for both width and height. + /// + /// If [`Image::max_size`] is set, this is guaranteed to never exceed that limit. + #[inline] + pub fn shrink_to_fit(self) -> Self { + self.fit_to_fraction(Vec2::new(1.0, 1.0)) + } + + /// Make the image respond to clicks and/or drags. + #[inline] + pub fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + /// Select UV range. Default is (0,0) in top-left, (1,1) bottom right. pub fn uv(mut self, uv: impl Into) -> Self { - self.uv = uv.into(); + self.image_options.uv = uv.into(); self } /// A solid color to put behind the image. Useful for transparent images. pub fn bg_fill(mut self, bg_fill: impl Into) -> Self { - self.bg_fill = bg_fill.into(); + self.image_options.bg_fill = bg_fill.into(); self } /// Multiply image color with this. Default is WHITE (no tint). pub fn tint(mut self, tint: impl Into) -> Self { - self.tint = tint.into(); - self - } - - /// Make the image respond to clicks and/or drags. - /// - /// Consider using [`ImageButton`] instead, for an on-hover effect. - pub fn sense(mut self, sense: Sense) -> Self { - self.sense = sense; + self.image_options.tint = tint.into(); self } @@ -98,8 +184,8 @@ impl Image { /// Due to limitations in the current implementation, /// this will turn off rounding of the image. pub fn rotate(mut self, angle: f32, origin: Vec2) -> Self { - self.rotation = Some((Rot2::from_angle(angle), origin)); - self.rounding = Rounding::ZERO; // incompatible with rotation + self.image_options.rotation = Some((Rot2::from_angle(angle), origin)); + self.image_options.rounding = Rounding::ZERO; // incompatible with rotation self } @@ -110,144 +196,265 @@ impl Image { /// Due to limitations in the current implementation, /// this will turn off any rotation of the image. pub fn rounding(mut self, rounding: impl Into) -> Self { - self.rounding = rounding.into(); - if self.rounding != Rounding::ZERO { - self.rotation = None; // incompatible with rounding + self.image_options.rounding = rounding.into(); + if self.image_options.rounding != Rounding::ZERO { + self.image_options.rotation = None; // incompatible with rounding } self } } -impl Image { - pub fn size(&self) -> Vec2 { - self.size +impl<'a> Image<'a> { + /// Returns the size the image will occupy in the final UI. + pub fn calculate_size(&self, available_size: Vec2, image_size: Vec2) -> Vec2 { + self.size.get(available_size, image_size) } - pub fn paint_at(&self, ui: &mut Ui, rect: Rect) { - if ui.is_rect_visible(rect) { - use epaint::*; - let Self { - texture_id, - uv, - size, - bg_fill, - tint, - sense: _, - rotation, - rounding, - } = self; + pub fn size(&self) -> Option { + match &self.source { + ImageSource::Texture(texture) => Some(texture.size), + ImageSource::Uri(_) | ImageSource::Bytes(_, _) => None, + } + } - if *bg_fill != Default::default() { - let mut mesh = Mesh::default(); - mesh.add_colored_rect(rect, *bg_fill); - ui.painter().add(Shape::mesh(mesh)); - } + pub fn source(&self) -> &ImageSource<'a> { + &self.source + } - if let Some((rot, origin)) = rotation { - // TODO(emilk): implement this using `PathShape` (add texture support to it). - // This will also give us anti-aliasing of rotated images. - egui_assert!( - *rounding == Rounding::ZERO, - "Image had both rounding and rotation. Please pick only one" - ); + /// Get the `uri` that this image was constructed from. + /// + /// This will return `` for [`ImageSource::Texture`]. + pub fn uri(&self) -> &str { + match &self.source { + ImageSource::Bytes(uri, _) => uri, + ImageSource::Uri(uri) => uri, + // Note: texture source is never in "loading" state + ImageSource::Texture(_) => "", + } + } - let mut mesh = Mesh::with_texture(*texture_id); - mesh.add_rect_with_uv(rect, *uv, *tint); - mesh.rotate(*rot, rect.min + *origin * *size); - ui.painter().add(Shape::mesh(mesh)); - } else { - ui.painter().add(RectShape { - rect, - rounding: *rounding, - fill: *tint, - stroke: Stroke::NONE, - fill_texture_id: *texture_id, - uv: *uv, - }); + /// Load the image from its [`Image::source`], returning the resulting [`SizedTexture`]. + /// + /// # Errors + /// + /// May fail if they underlying [`Context::try_load_texture`] call fails. + pub fn load(&self, ui: &Ui) -> TextureLoadResult { + match self.source.clone() { + ImageSource::Texture(texture) => Ok(TexturePoll::Ready { texture }), + ImageSource::Uri(uri) => ui.ctx().try_load_texture( + uri.as_ref(), + self.texture_options, + self.size.hint(ui.available_size()), + ), + ImageSource::Bytes(uri, bytes) => { + ui.ctx().include_bytes(uri.as_ref(), bytes); + ui.ctx().try_load_texture( + uri.as_ref(), + self.texture_options, + self.size.hint(ui.available_size()), + ) } } } + + pub fn paint_at(&self, ui: &mut Ui, rect: Rect, texture: &SizedTexture) { + paint_image_at(ui, rect, &self.image_options, texture); + } } -impl Widget for Image { +impl<'a> Widget for Image<'a> { fn ui(self, ui: &mut Ui) -> Response { - let (rect, response) = ui.allocate_exact_size(self.size, self.sense); - self.paint_at(ui, rect); - response + match self.load(ui) { + Ok(TexturePoll::Ready { texture }) => { + let size = self.calculate_size(ui.available_size(), texture.size); + let (rect, response) = ui.allocate_exact_size(size, self.sense); + self.paint_at(ui, rect, &texture); + response + } + Ok(TexturePoll::Pending { size }) => match size { + Some(size) => { + let size = self.calculate_size(ui.available_size(), size); + ui.allocate_ui(size, |ui| { + ui.with_layout(Layout::centered_and_justified(Direction::TopDown), |ui| { + ui.spinner() + .on_hover_text(format!("Loading {:?}…", self.uri())) + }) + }) + .response + } + None => ui + .spinner() + .on_hover_text(format!("Loading {:?}…", self.uri())), + }, + Err(err) => ui + .colored_label(ui.visuals().error_fg_color, "⚠") + .on_hover_text(err.to_string()), + } } } -/// 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, +/// This type determines the constraints on how +/// the size of an image should be calculated. +#[derive(Debug, Clone, Copy)] +pub struct ImageSize { + /// Whether or not the final size should maintain the original aspect ratio. + /// + /// This setting is applied last. + /// + /// This defaults to `true`. + pub maintain_aspect_ratio: bool, + + /// Determines the maximum size of the image. + /// + /// Defaults to `None` + pub max_size: Option, + + /// Determines how the image should shrink/expand/stretch/etc. to fit within its allocated space. + /// + /// This setting is applied first. + /// + /// Defaults to `ImageFit::Fraction([1, 1])` + pub fit: ImageFit, } -#[derive(Default, Clone, Copy)] -enum ImageFit { - // TODO: options for aspect ratio - // TODO: other fit strategies - // FitToWidth, - // FitToHeight, - // FitToWidthExact(f32), - // FitToHeightExact(f32), - #[default] - ShrinkToFit, +/// This type determines how the image should try to fit within the UI. +/// +/// The final fit will be clamped to [`ImageSize::max_size`]. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ImageFit { + /// Fit the image to its original size, optionally scaling it by some factor. + Original(Option), + + /// Fit the image to a fraction of the available size. + Fraction(Vec2), + + /// Fit the image to an exact size. + Exact(Vec2), } -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) +impl ImageSize { + fn hint(&self, available_size: Vec2) -> SizeHint { + if self.maintain_aspect_ratio { + return SizeHint::Scale(1.0.ord()); + }; + + let fit = match self.fit { + ImageFit::Original(scale) => return SizeHint::Scale(scale.unwrap_or(1.0).ord()), + ImageFit::Fraction(fract) => available_size * fract, + ImageFit::Exact(size) => size, + }; + + let fit = match self.max_size { + Some(extent) => fit.min(extent), + None => fit, + }; + + // `inf` on an axis means "any value" + match (fit.x.is_finite(), fit.y.is_finite()) { + (true, true) => SizeHint::Size(fit.x.round() as u32, fit.y.round() as u32), + (true, false) => SizeHint::Width(fit.x.round() as u32), + (false, true) => SizeHint::Height(fit.y.round() as u32), + (false, false) => SizeHint::Scale(1.0.ord()), + } + } + + fn get(&self, available_size: Vec2, image_size: Vec2) -> Vec2 { + match self.fit { + ImageFit::Original(scale) => { + let image_size = image_size * scale.unwrap_or(1.0); + + if let Some(available_size) = self.max_size { + if image_size.x < available_size.x && image_size.y < available_size.y { + return image_size; + } + + if self.maintain_aspect_ratio { + let ratio_x = available_size.x / image_size.x; + let ratio_y = available_size.y / image_size.y; + let ratio = if ratio_x < ratio_y { ratio_x } else { ratio_y }; + let ratio = if ratio.is_infinite() { 1.0 } else { ratio }; + + return Vec2::new(image_size.x * ratio, image_size.y * ratio); + } else { + return image_size.min(available_size); + } } + + image_size + } + ImageFit::Fraction(fract) => { + let available_size = available_size * fract; + let available_size = match self.max_size { + Some(max_size) => available_size.min(max_size), + None => available_size, + }; + + if self.maintain_aspect_ratio { + let ratio_x = available_size.x / image_size.x; + let ratio_y = available_size.y / image_size.y; + let ratio = if ratio_x < ratio_y { ratio_x } else { ratio_y }; + let ratio = if ratio.is_infinite() { 1.0 } else { ratio }; + + return Vec2::new(image_size.x * ratio, image_size.y * ratio); + } + + available_size + } + ImageFit::Exact(size) => { + let available_size = size; + let available_size = match self.max_size { + Some(max_size) => available_size.min(max_size), + None => available_size, + }; + + if self.maintain_aspect_ratio { + let ratio_x = available_size.x / image_size.x; + let ratio_y = available_size.y / image_size.y; + let ratio = if ratio_x < ratio_y { ratio_x } else { ratio_y }; + let ratio = if ratio.is_infinite() { 1.0 } else { ratio }; + + return Vec2::new(image_size.x * ratio, image_size.y * ratio); + } + + available_size } } } } -/// This type tells the [`Ui`] how to load the image. +impl Default for ImageSize { + #[inline] + fn default() -> Self { + Self { + max_size: None, + fit: ImageFit::Fraction(Vec2::new(1.0, 1.0)), + maintain_aspect_ratio: true, + } + } +} + +/// This type tells the [`Ui`] how to load an image. +/// +/// This is used by [`Image::new`] and [`Ui::image`]. +#[derive(Debug, Clone)] pub enum ImageSource<'a> { /// Load the image from a URI. /// - /// This could be a `file://` url, `http://` url, or a `bare` identifier. + /// This could be a `file://` url, `http(s)?://` 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), + Uri(Cow<'a, str>), + + /// Load the image from an existing texture. + /// + /// The user is responsible for loading the texture, determining its size, + /// and allocating a [`TextureId`] for it. + /// + /// Note that a simpler API for this exists in [`RawImage`]. + Texture(SizedTexture), /// Load the image from some raw bytes. /// @@ -257,6 +464,8 @@ pub enum ImageSource<'a> { /// /// This instructs the [`Ui`] to cache the raw bytes, which are then further processed by any registered loaders. /// + /// See also [`include_image`] for an easy way to load and display static images. + /// /// See [`crate::load`] for more information. Bytes(&'static str, Bytes), } @@ -264,6 +473,33 @@ pub enum ImageSource<'a> { impl<'a> From<&'a str> for ImageSource<'a> { #[inline] fn from(value: &'a str) -> Self { + Self::Uri(value.into()) + } +} + +impl<'a> From<&'a String> for ImageSource<'a> { + #[inline] + fn from(value: &'a String) -> Self { + Self::Uri(value.as_str().into()) + } +} + +impl From for ImageSource<'static> { + fn from(value: String) -> Self { + Self::Uri(value.into()) + } +} + +impl<'a> From<&'a Cow<'a, str>> for ImageSource<'a> { + #[inline] + fn from(value: &'a Cow<'a, str>) -> Self { + Self::Uri(value.clone()) + } +} + +impl<'a> From> for ImageSource<'a> { + #[inline] + fn from(value: Cow<'a, str>) -> Self { Self::Uri(value) } } @@ -275,55 +511,29 @@ impl> From<(&'static str, T)> for ImageSource<'static> { } } -impl<'a> Image2<'a> { +impl> From for ImageSource<'static> { + fn from(value: T) -> Self { + Self::Texture(value.into()) + } +} + +/// A widget which displays a sized texture. +#[must_use = "You should put this widget in an ui with `ui.add(widget);`"] +#[derive(Debug, Clone)] +pub struct RawImage { + texture: SizedTexture, + texture_options: TextureOptions, + image_options: ImageOptions, + sense: Sense, +} + +impl RawImage { /// Load the image from some source. - pub fn new(source: ImageSource<'a>) -> Self { + pub fn new(texture: impl Into) -> Self { Self { - source, + texture: texture.into(), 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(), + image_options: Default::default(), sense: Sense::hover(), } } @@ -335,60 +545,164 @@ impl<'a> Image2<'a> { 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 } + + /// Select UV range. Default is (0,0) in top-left, (1,1) bottom right. + pub fn uv(mut self, uv: impl Into) -> Self { + self.image_options.uv = uv.into(); + self + } + + /// A solid color to put behind the image. Useful for transparent images. + pub fn bg_fill(mut self, bg_fill: impl Into) -> Self { + self.image_options.bg_fill = bg_fill.into(); + self + } + + /// Multiply image color with this. Default is WHITE (no tint). + pub fn tint(mut self, tint: impl Into) -> Self { + self.image_options.tint = tint.into(); + self + } + + /// Rotate the image about an origin by some angle + /// + /// Positive angle is clockwise. + /// Origin is a vector in normalized UV space ((0,0) in top-left, (1,1) bottom right). + /// + /// To rotate about the center you can pass `Vec2::splat(0.5)` as the origin. + /// + /// Due to limitations in the current implementation, + /// this will turn off rounding of the image. + pub fn rotate(mut self, angle: f32, origin: Vec2) -> Self { + self.image_options.rotation = Some((Rot2::from_angle(angle), origin)); + self.image_options.rounding = Rounding::ZERO; // incompatible with rotation + self + } + + /// Round the corners of the image. + /// + /// The default is no rounding ([`Rounding::ZERO`]). + /// + /// Due to limitations in the current implementation, + /// this will turn off any rotation of the image. + pub fn rounding(mut self, rounding: impl Into) -> Self { + self.image_options.rounding = rounding.into(); + if self.image_options.rounding != Rounding::ZERO { + self.image_options.rotation = None; // incompatible with rounding + } + self + } } -impl<'a> Widget for Image2<'a> { +impl RawImage { + /// Returns the [`TextureId`] of the texture from which this image was created. + pub fn texture_id(&self) -> TextureId { + self.texture.id + } + + /// Returns the size of the texture from which this image was created. + pub fn size(&self) -> Vec2 { + self.texture.size + } + + pub fn paint_at(&self, ui: &mut Ui, rect: Rect) { + paint_image_at(ui, rect, &self.image_options, &self.texture); + } +} + +impl Widget for RawImage { 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 - } - }; + let (rect, response) = ui.allocate_exact_size(self.size(), self.sense); + self.paint_at(ui, rect); + response + } +} - 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), - ); +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ImageOptions { + /// Select UV range. Default is (0,0) in top-left, (1,1) bottom right. + pub uv: Rect, - let (rect, response) = ui.allocate_exact_size(final_size, self.sense); + /// A solid color to put behind the image. Useful for transparent images. + pub bg_fill: Color32, - 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)); + /// Multiply image color with this. Default is WHITE (no tint). + pub tint: Color32, - 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()), + /// Rotate the image about an origin by some angle + /// + /// Positive angle is clockwise. + /// Origin is a vector in normalized UV space ((0,0) in top-left, (1,1) bottom right). + /// + /// To rotate about the center you can pass `Vec2::splat(0.5)` as the origin. + /// + /// Due to limitations in the current implementation, + /// this will turn off rounding of the image. + pub rotation: Option<(Rot2, Vec2)>, + + /// Round the corners of the image. + /// + /// The default is no rounding ([`Rounding::ZERO`]). + /// + /// Due to limitations in the current implementation, + /// this will turn off any rotation of the image. + pub rounding: Rounding, +} + +impl Default for ImageOptions { + fn default() -> Self { + Self { + uv: Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + bg_fill: Default::default(), + tint: Color32::WHITE, + rotation: None, + rounding: Rounding::ZERO, + } + } +} + +/// Paint a `SizedTexture` as an image according to some `ImageOptions` at a given `rect`. +pub fn paint_image_at(ui: &mut Ui, rect: Rect, options: &ImageOptions, texture: &SizedTexture) { + if !ui.is_rect_visible(rect) { + return; + } + + if options.bg_fill != Default::default() { + let mut mesh = Mesh::default(); + mesh.add_colored_rect(rect, options.bg_fill); + ui.painter().add(Shape::mesh(mesh)); + } + + match options.rotation { + Some((rot, origin)) => { + // TODO(emilk): implement this using `PathShape` (add texture support to it). + // This will also give us anti-aliasing of rotated images. + egui_assert!( + options.rounding == Rounding::ZERO, + "Image had both rounding and rotation. Please pick only one" + ); + + let mut mesh = Mesh::with_texture(texture.id); + mesh.add_rect_with_uv(rect, options.uv, options.tint); + mesh.rotate(rot, rect.min + origin * rect.size()); + ui.painter().add(Shape::mesh(mesh)); + } + None => { + ui.painter().add(RectShape { + rect, + rounding: options.rounding, + fill: options.tint, + stroke: Stroke::NONE, + fill_texture_id: texture.id, + uv: options.uv, + }); } } } diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index c1193dba..708fcd84 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, Image2, ImageSource}; +pub use image::{Image, ImageFit, ImageOptions, ImageSize, ImageSource, RawImage}; 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 ef35b1cc..98953961 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -19,6 +19,7 @@ crate-type = ["cdylib", "rlib"] default = ["glow", "persistence"] http = ["ehttp", "image", "poll-promise", "egui_extras/image"] +image_viewer = ["image", "egui_extras/all-loaders", "rfd"] persistence = ["eframe/persistence", "egui/persistence", "serde"] web_screen_reader = ["eframe/web_screen_reader"] # experimental serde = ["dep:serde", "egui_demo_lib/serde", "egui/serde"] @@ -27,7 +28,6 @@ syntax_highlighting = ["egui_demo_lib/syntax_highlighting"] glow = ["eframe/glow"] wgpu = ["eframe/wgpu", "bytemuck"] - [dependencies] chrono = { version = "0.4", default-features = false, features = [ "js-sys", @@ -48,6 +48,7 @@ bytemuck = { version = "1.7.1", optional = true } egui_extras = { version = "0.22.0", optional = true, path = "../egui_extras", features = [ "log", ] } +rfd = { version = "0.11", optional = true } # feature "http": ehttp = { version = "0.3.0", optional = true } diff --git a/crates/egui_demo_app/src/apps/http_app.rs b/crates/egui_demo_app/src/apps/http_app.rs index e550c1db..33da4678 100644 --- a/crates/egui_demo_app/src/apps/http_app.rs +++ b/crates/egui_demo_app/src/apps/http_app.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use egui_extras::RetainedImage; use poll_promise::Promise; diff --git a/crates/egui_demo_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs new file mode 100644 index 00000000..8bae74f0 --- /dev/null +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -0,0 +1,217 @@ +use egui::emath::Rot2; +use egui::panel::Side; +use egui::panel::TopBottomSide; +use egui::ImageFit; +use egui::Slider; +use egui::Vec2; + +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ImageViewer { + current_uri: String, + uri_edit_text: String, + image_options: egui::ImageOptions, + chosen_fit: ChosenFit, + fit: ImageFit, + maintain_aspect_ratio: bool, + max_size: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +enum ChosenFit { + ExactSize, + Fraction, + OriginalSize, +} + +impl ChosenFit { + fn as_str(&self) -> &'static str { + match self { + ChosenFit::ExactSize => "exact size", + ChosenFit::Fraction => "fraction", + ChosenFit::OriginalSize => "original size", + } + } +} + +impl Default for ImageViewer { + fn default() -> Self { + Self { + current_uri: "https://picsum.photos/seed/1.759706314/1024".to_owned(), + uri_edit_text: "https://picsum.photos/seed/1.759706314/1024".to_owned(), + image_options: egui::ImageOptions::default(), + chosen_fit: ChosenFit::Fraction, + fit: ImageFit::Fraction(Vec2::splat(1.0)), + maintain_aspect_ratio: true, + max_size: None, + } + } +} + +impl eframe::App for ImageViewer { + fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { + egui::TopBottomPanel::new(TopBottomSide::Top, "url bar").show(ctx, |ui| { + ui.horizontal_centered(|ui| { + ui.label("URI:"); + ui.text_edit_singleline(&mut self.uri_edit_text); + if ui.small_button("✔").clicked() { + ctx.forget_image(&self.current_uri); + self.uri_edit_text = self.uri_edit_text.trim().to_owned(); + self.current_uri = self.uri_edit_text.clone(); + }; + + #[cfg(not(target_arch = "wasm32"))] + if ui.button("file…").clicked() { + if let Some(path) = rfd::FileDialog::new().pick_file() { + self.uri_edit_text = format!("file://{}", path.display()); + self.current_uri = self.uri_edit_text.clone(); + } + } + }); + }); + + egui::SidePanel::new(Side::Left, "controls").show(ctx, |ui| { + // uv + ui.label("UV"); + ui.add(Slider::new(&mut self.image_options.uv.min.x, 0.0..=1.0).text("min x")); + ui.add(Slider::new(&mut self.image_options.uv.min.y, 0.0..=1.0).text("min y")); + ui.add(Slider::new(&mut self.image_options.uv.max.x, 0.0..=1.0).text("max x")); + ui.add(Slider::new(&mut self.image_options.uv.max.y, 0.0..=1.0).text("max y")); + + // rotation + ui.add_space(2.0); + let had_rotation = self.image_options.rotation.is_some(); + let mut has_rotation = had_rotation; + ui.checkbox(&mut has_rotation, "Rotation"); + match (had_rotation, has_rotation) { + (true, false) => self.image_options.rotation = None, + (false, true) => { + self.image_options.rotation = + Some((Rot2::from_angle(0.0), Vec2::new(0.5, 0.5))); + } + (true, true) | (false, false) => {} + } + + if let Some((rot, origin)) = self.image_options.rotation.as_mut() { + let mut angle = rot.angle(); + + ui.label("angle"); + ui.drag_angle(&mut angle); + *rot = Rot2::from_angle(angle); + + ui.add(Slider::new(&mut origin.x, 0.0..=1.0).text("origin x")); + ui.add(Slider::new(&mut origin.y, 0.0..=1.0).text("origin y")); + } + + // bg_fill + ui.add_space(2.0); + ui.label("Background color"); + ui.color_edit_button_srgba(&mut self.image_options.bg_fill); + + // tint + ui.add_space(2.0); + ui.label("Tint"); + ui.color_edit_button_srgba(&mut self.image_options.tint); + + // fit + ui.add_space(10.0); + ui.label( + "The chosen fit will determine how the image tries to fill the available space", + ); + egui::ComboBox::from_label("Fit") + .selected_text(self.chosen_fit.as_str()) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.chosen_fit, + ChosenFit::ExactSize, + ChosenFit::ExactSize.as_str(), + ); + ui.selectable_value( + &mut self.chosen_fit, + ChosenFit::Fraction, + ChosenFit::Fraction.as_str(), + ); + ui.selectable_value( + &mut self.chosen_fit, + ChosenFit::OriginalSize, + ChosenFit::OriginalSize.as_str(), + ); + }); + + match self.chosen_fit { + ChosenFit::ExactSize => { + if !matches!(self.fit, ImageFit::Exact(_)) { + self.fit = ImageFit::Exact(Vec2::splat(128.0)); + } + let ImageFit::Exact(size) = &mut self.fit else { unreachable!() }; + ui.add(Slider::new(&mut size.x, 0.0..=2048.0).text("width")); + ui.add(Slider::new(&mut size.y, 0.0..=2048.0).text("height")); + } + ChosenFit::Fraction => { + if !matches!(self.fit, ImageFit::Fraction(_)) { + self.fit = ImageFit::Fraction(Vec2::splat(1.0)); + } + let ImageFit::Fraction(fract) = &mut self.fit else { unreachable!() }; + ui.add(Slider::new(&mut fract.x, 0.0..=1.0).text("width")); + ui.add(Slider::new(&mut fract.y, 0.0..=1.0).text("height")); + } + ChosenFit::OriginalSize => { + if !matches!(self.fit, ImageFit::Original(_)) { + self.fit = ImageFit::Original(Some(1.0)); + } + let ImageFit::Original(Some(scale)) = &mut self.fit else { unreachable!() }; + ui.add(Slider::new(scale, 0.1..=4.0).text("scale")); + } + } + + // max size + ui.add_space(5.0); + ui.label("The calculated size will not exceed the maximum size"); + let had_max_size = self.max_size.is_some(); + let mut has_max_size = had_max_size; + ui.checkbox(&mut has_max_size, "Max size"); + match (had_max_size, has_max_size) { + (true, false) => self.max_size = None, + (false, true) => { + self.max_size = Some(ui.available_size()); + } + (true, true) | (false, false) => {} + } + + if let Some(max_size) = self.max_size.as_mut() { + ui.add(Slider::new(&mut max_size.x, 0.0..=2048.0).text("width")); + ui.add(Slider::new(&mut max_size.y, 0.0..=2048.0).text("height")); + } + + // aspect ratio + ui.add_space(5.0); + ui.label("Aspect ratio is maintained by scaling both sides as necessary"); + ui.checkbox(&mut self.maintain_aspect_ratio, "Maintain aspect ratio"); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::new([true, true]).show(ui, |ui| { + let mut image = egui::Image::from_uri(&self.current_uri); + image = image.uv(self.image_options.uv); + image = image.bg_fill(self.image_options.bg_fill); + image = image.tint(self.image_options.tint); + let (angle, origin) = self + .image_options + .rotation + .map_or((0.0, Vec2::splat(0.5)), |(rot, origin)| { + (rot.angle(), origin) + }); + image = image.rotate(angle, origin); + match self.fit { + ImageFit::Original(scale) => image = image.fit_to_original_size(scale), + ImageFit::Fraction(fract) => image = image.fit_to_fraction(fract), + ImageFit::Exact(size) => image = image.fit_to_exact_size(size), + } + image = image.maintain_aspect_ratio(self.maintain_aspect_ratio); + image = image.max_size(self.max_size); + + ui.add_sized(ui.available_size(), image); + }); + }); + } +} diff --git a/crates/egui_demo_app/src/apps/mod.rs b/crates/egui_demo_app/src/apps/mod.rs index 1e28bbd6..33368bac 100644 --- a/crates/egui_demo_app/src/apps/mod.rs +++ b/crates/egui_demo_app/src/apps/mod.rs @@ -9,6 +9,12 @@ mod fractal_clock; #[cfg(feature = "http")] mod http_app; +#[cfg(feature = "image_viewer")] +mod image_viewer; + +#[cfg(feature = "image_viewer")] +pub use image_viewer::ImageViewer; + #[cfg(all(feature = "glow", not(feature = "wgpu")))] pub use custom3d_glow::Custom3d; diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 84581d56..c1a07e73 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -82,6 +82,8 @@ enum Anchor { EasyMarkEditor, #[cfg(feature = "http")] Http, + #[cfg(feature = "image_viewer")] + ImageViewer, Clock, #[cfg(any(feature = "glow", feature = "wgpu"))] Custom3d, @@ -142,6 +144,8 @@ pub struct State { easy_mark_editor: EasyMarkApp, #[cfg(feature = "http")] http: crate::apps::HttpApp, + #[cfg(feature = "image_viewer")] + image_viewer: crate::apps::ImageViewer, clock: FractalClockApp, color_test: ColorTestApp, @@ -161,6 +165,9 @@ pub struct WrapApp { impl WrapApp { pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { + #[cfg(feature = "image_viewer")] + egui_extras::loaders::install(&_cc.egui_ctx); + #[allow(unused_mut)] let mut slf = Self { state: State::default(), @@ -204,6 +211,12 @@ impl WrapApp { Anchor::Clock, &mut self.state.clock as &mut dyn eframe::App, ), + #[cfg(feature = "image_viewer")] + ( + "🖼️ Image Viewer", + Anchor::ImageViewer, + &mut self.state.image_viewer as &mut dyn eframe::App, + ), ]; #[cfg(any(feature = "glow", feature = "wgpu"))] diff --git a/crates/egui_demo_lib/src/color_test.rs b/crates/egui_demo_lib/src/color_test.rs index 9f209f9d..4dc01cc5 100644 --- a/crates/egui_demo_lib/src/color_test.rs +++ b/crates/egui_demo_lib/src/color_test.rs @@ -87,8 +87,12 @@ impl ColorTest { let tex = self.tex_mngr.get(ui.ctx(), &g); let texel_offset = 0.5 / (g.0.len() as f32); let uv = Rect::from_min_max(pos2(texel_offset, 0.0), pos2(1.0 - texel_offset, 1.0)); - ui.add(Image::new(tex, GRADIENT_SIZE).tint(vertex_color).uv(uv)) - .on_hover_text(format!("A texture that is {} texels wide", g.0.len())); + ui.add( + RawImage::new((tex.id(), GRADIENT_SIZE)) + .tint(vertex_color) + .uv(uv), + ) + .on_hover_text(format!("A texture that is {} texels wide", g.0.len())); ui.label("GPU result"); }); }); @@ -225,11 +229,15 @@ impl ColorTest { let tex = self.tex_mngr.get(ui.ctx(), gradient); let texel_offset = 0.5 / (gradient.0.len() as f32); let uv = Rect::from_min_max(pos2(texel_offset, 0.0), pos2(1.0 - texel_offset, 1.0)); - ui.add(Image::new(tex, GRADIENT_SIZE).bg_fill(bg_fill).uv(uv)) - .on_hover_text(format!( - "A texture that is {} texels wide", - gradient.0.len() - )); + ui.add( + RawImage::new((tex.id(), GRADIENT_SIZE)) + .bg_fill(bg_fill) + .uv(uv), + ) + .on_hover_text(format!( + "A texture that is {} texels wide", + gradient.0.len() + )); ui.label(label); }); } diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index 54c08350..152d58ac 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -209,11 +209,14 @@ impl WidgetGallery { let img_size = 16.0 * texture.size_vec2() / texture.size_vec2().y; ui.add(doc_link_label("Image", "Image")); - ui.image(texture, img_size); + ui.raw_image((texture.id(), img_size)); ui.end_row(); ui.add(doc_link_label("ImageButton", "ImageButton")); - if ui.add(egui::ImageButton::new(texture, img_size)).clicked() { + if ui + .add(egui::ImageButton::new((texture.id(), img_size))) + .clicked() + { *boolean = !*boolean; } ui.end_row(); diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 25ec2acd..cfbb845a 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -24,14 +24,17 @@ all-features = true [features] -default = [] +default = ["dep:mime_guess"] -## Shorthand for enabling `svg`, `image`, and `ehttp`. -all-loaders = ["svg", "image", "http"] +## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`). +all-loaders = ["svg", "image", "http", "file"] ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] +## Add support for loading images from `file://` URIs. +file = ["dep:mime_guess"] + ## Log warnings using [`log`](https://docs.rs/log) crate. log = ["dep:log", "egui/log"] @@ -75,6 +78,9 @@ image = { version = "0.24", optional = true, default-features = false } # feature "log" log = { version = "0.4", optional = true, features = ["std"] } +# file feature +mime_guess = { version = "2.0.4", optional = true, default-features = false } + puffin = { version = "0.16", optional = true } # svg feature diff --git a/crates/egui_extras/src/image.rs b/crates/egui_extras/src/image.rs index 9304f2b8..fcc30176 100644 --- a/crates/egui_extras/src/image.rs +++ b/crates/egui_extras/src/image.rs @@ -1,3 +1,5 @@ +#![allow(deprecated)] + use egui::{mutex::Mutex, TextureFilter, TextureOptions}; #[cfg(feature = "svg")] @@ -8,6 +10,9 @@ pub use usvg::FitTo; /// Load once, and save somewhere in your app state. /// /// Use the `svg` and `image` features to enable more constructors. +/// +/// ⚠ This type is deprecated: Consider using [`egui::Image`] instead. +#[deprecated = "consider using `egui::Image` instead"] pub struct RetainedImage { debug_name: String, @@ -186,7 +191,7 @@ impl RetainedImage { // We need to convert the SVG to a texture to display it: // Future improvement: tell backend to do mip-mapping of the image to // make it look smoother when downsized. - ui.image(self.texture_id(ui.ctx()), desired_size) + ui.raw_image((self.texture_id(ui.ctx()), desired_size)) } } diff --git a/crates/egui_extras/src/lib.rs b/crates/egui_extras/src/lib.rs index 6ae10745..3b020323 100644 --- a/crates/egui_extras/src/lib.rs +++ b/crates/egui_extras/src/lib.rs @@ -23,6 +23,7 @@ mod table; #[cfg(feature = "chrono")] pub use crate::datepicker::DatePickerButton; +#[allow(deprecated)] pub use crate::image::RetainedImage; pub(crate) use crate::layout::StripLayout; pub use crate::sizing::Size; @@ -63,6 +64,7 @@ pub(crate) use profiling_scopes::*; /// Log an error with either `log` or `eprintln` macro_rules! log_err { + ($fmt: literal) => {$crate::log_err!($fmt,)}; ($fmt: literal, $($arg: tt)*) => {{ #[cfg(feature = "log")] log::error!($fmt, $($arg)*); @@ -77,6 +79,7 @@ pub(crate) use log_err; /// Panic in debug builds, log otherwise. macro_rules! log_or_panic { + ($fmt: literal) => {$crate::log_or_panic!($fmt,)}; ($fmt: literal, $($arg: tt)*) => {{ if cfg!(debug_assertions) { panic!($fmt, $($arg)*); diff --git a/crates/egui_extras/src/loaders.rs b/crates/egui_extras/src/loaders.rs index 717d4ddc..59639167 100644 --- a/crates/egui_extras/src/loaders.rs +++ b/crates/egui_extras/src/loaders.rs @@ -1,41 +1,87 @@ // TODO: automatic cache eviction -/// Installs the default set of loaders: +/// Installs the default set of loaders. +/// /// - `file` loader on non-Wasm targets -/// - `http` loader (with the `ehttp` feature) +/// - `http` loader (with the `http` 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. +/// Calling this multiple times on the same [`egui::Context`] is safe. +/// It will never install duplicate loaders. /// -/// ⚠ The supported set of image formats is configured by adding the [`image`](https://crates.io/crates/image) +/// ⚠ 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://` URIs, 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"] } /// ``` /// +/// ⚠ You have to configure both the supported loaders in `egui_extras` _and_ the supported image formats +/// in `image` to get any output! +/// +/// ## Loader-specific information +/// +/// ⚠ The exact way bytes, images, and textures are loaded is subject to change, +/// but the supported protocols and file extensions are not. +/// +/// The `file` loader is a [`BytesLoader`][`egui::load::BytesLoader`]. +/// It will attempt to load `file://` URIs, and infer the content type from the extension. +/// The path will be passed to [`std::fs::read`] after trimming the `file://` prefix, +/// and is resolved the same way as with `std::fs::read(path)`: +/// - Relative paths are relative to the current working directory +/// - Absolute paths are left as is. +/// +/// The `http` loader is a [`BytesLoader`][`egui::load::BytesLoader`]. +/// It will attempt to load `http://` and `https://` URIs, and infer the content type from the `Content-Type` header. +/// +/// The `image` loader is an [`ImageLoader`][`egui::load::ImageLoader`]. +/// It will attempt to load any URI with any extension other than `svg`. It will also load any URI without an extension. +/// The content type specified by [`BytesPoll::Ready::mime`][`egui::load::BytesPoll::Ready::mime`] always takes precedence. +/// This means that even if the URI has a `png` extension, and the `png` image format is enabled, if the content type is +/// not one of the supported and enabled image formats, the loader will return [`LoadError::NotSupported`][`egui::load::LoadError::NotSupported`], +/// allowing a different loader to attempt to load the image. +/// +/// The `svg` loader is an [`ImageLoader`][`egui::load::ImageLoader`]. +/// It will attempt to load any URI with an `svg` extension. It will _not_ attempt to load a URI without an extension. +/// The content type specified by [`BytesPoll::Ready::mime`][`egui::load::BytesPoll::Ready::mime`] always takes precedence, +/// and must include `svg` for it to be considered supported. For example, `image/svg+xml` would be loaded by the `svg` loader. +/// /// 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(all(not(target_arch = "wasm32"), feature = "file"))] + if !ctx.is_loader_installed(self::file_loader::FileLoader::ID) { + ctx.add_bytes_loader(std::sync::Arc::new(self::file_loader::FileLoader::default())); + crate::log_trace!("installed FileLoader"); + } #[cfg(feature = "http")] - ctx.add_bytes_loader(std::sync::Arc::new( - self::ehttp_loader::EhttpLoader::default(), - )); + if !ctx.is_loader_installed(self::ehttp_loader::EhttpLoader::ID) { + ctx.add_bytes_loader(std::sync::Arc::new( + self::ehttp_loader::EhttpLoader::default(), + )); + crate::log_trace!("installed EhttpLoader"); + } #[cfg(feature = "image")] - ctx.add_image_loader(std::sync::Arc::new( - self::image_loader::ImageCrateLoader::default(), - )); + if !ctx.is_loader_installed(self::image_loader::ImageCrateLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new( + self::image_loader::ImageCrateLoader::default(), + )); + crate::log_trace!("installed ImageCrateLoader"); + } #[cfg(feature = "svg")] - ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); + if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); + crate::log_trace!("installed SvgLoader"); + } #[cfg(all( - target_arch = "wasm32", + any(target_arch = "wasm32", not(feature = "file")), not(feature = "http"), not(feature = "image"), not(feature = "svg") diff --git a/crates/egui_extras/src/loaders/ehttp_loader.rs b/crates/egui_extras/src/loaders/ehttp_loader.rs index 9467eafd..a8c435cc 100644 --- a/crates/egui_extras/src/loaders/ehttp_loader.rs +++ b/crates/egui_extras/src/loaders/ehttp_loader.rs @@ -5,52 +5,60 @@ use egui::{ }; use std::{sync::Arc, task::Poll}; -type Entry = Poll, String>>; +#[derive(Clone)] +struct File { + bytes: Arc<[u8]>, + mime: Option, +} + +impl File { + fn from_response(uri: &str, response: ehttp::Response) -> Result { + 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 mime = response.content_type().map(|v| v.to_owned()); + let bytes = response.bytes.into(); + + Ok(File { bytes, mime }) + } +} + +type Entry = Poll>; #[derive(Default)] pub struct EhttpLoader { cache: Arc>>, } +impl EhttpLoader { + pub const ID: &str = egui::generate_loader_id!(EhttpLoader); +} + 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 id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str) -> BytesLoadResult { if !starts_with_one_of(uri, PROTOCOLS) { return Err(LoadError::NotSupported); @@ -59,9 +67,10 @@ impl BytesLoader for EhttpLoader { let mut cache = self.cache.lock(); if let Some(entry) = cache.get(uri).cloned() { match entry { - Poll::Ready(Ok(bytes)) => Ok(BytesPoll::Ready { + Poll::Ready(Ok(file)) => Ok(BytesPoll::Ready { size: None, - bytes: Bytes::Shared(bytes), + bytes: Bytes::Shared(file.bytes), + mime: file.mime, }), Poll::Ready(Err(err)) => Err(LoadError::Custom(err)), Poll::Pending => Ok(BytesPoll::Pending { size: None }), @@ -77,7 +86,14 @@ impl BytesLoader for EhttpLoader { let ctx = ctx.clone(); let cache = self.cache.clone(); move |response| { - let result = get_image_bytes(&uri, response); + let result = match response { + Ok(response) => File::from_response(&uri, response), + Err(err) => { + // Log details; return summary + crate::log_err!("Failed to load {uri:?}: {err}"); + Err(format!("Failed to load {uri:?}")) + } + }; crate::log_trace!("finished loading {uri:?}"); let prev = cache.lock().insert(uri, Poll::Ready(result)); assert!(matches!(prev, Some(Poll::Pending))); @@ -93,12 +109,18 @@ impl BytesLoader for EhttpLoader { let _ = self.cache.lock().remove(uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn byte_size(&self) -> usize { self.cache .lock() .values() .map(|entry| match entry { - Poll::Ready(Ok(bytes)) => bytes.len(), + Poll::Ready(Ok(file)) => { + file.bytes.len() + file.mime.as_ref().map_or(0, |m| m.len()) + } Poll::Ready(Err(err)) => err.len(), _ => 0, }) diff --git a/crates/egui_extras/src/loaders/file_loader.rs b/crates/egui_extras/src/loaders/file_loader.rs index 533c3dc9..f04ce7c2 100644 --- a/crates/egui_extras/src/loaders/file_loader.rs +++ b/crates/egui_extras/src/loaders/file_loader.rs @@ -5,7 +5,13 @@ use egui::{ }; use std::{sync::Arc, task::Poll, thread}; -type Entry = Poll, String>>; +#[derive(Clone)] +struct File { + bytes: Arc<[u8]>, + mime: Option, +} + +type Entry = Poll>; #[derive(Default)] pub struct FileLoader { @@ -13,9 +19,17 @@ pub struct FileLoader { cache: Arc>>, } +impl FileLoader { + pub const ID: &str = egui::generate_loader_id!(FileLoader); +} + const PROTOCOL: &str = "file://"; impl BytesLoader for FileLoader { + fn id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str) -> BytesLoadResult { // File loader only supports the `file` protocol. let Some(path) = uri.strip_prefix(PROTOCOL) else { @@ -26,9 +40,10 @@ impl BytesLoader for FileLoader { 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 { + Poll::Ready(Ok(file)) => Ok(BytesPoll::Ready { size: None, - bytes: Bytes::Shared(bytes), + bytes: Bytes::Shared(file.bytes), + mime: file.mime, }), Poll::Ready(Err(err)) => Err(LoadError::Custom(err)), Poll::Pending => Ok(BytesPoll::Pending { size: None }), @@ -51,7 +66,12 @@ impl BytesLoader for FileLoader { let uri = uri.to_owned(); move || { let result = match std::fs::read(&path) { - Ok(bytes) => Ok(bytes.into()), + Ok(bytes) => Ok(File { + bytes: bytes.into(), + mime: mime_guess::from_path(&path) + .first_raw() + .map(|v| v.to_owned()), + }), Err(err) => Err(err.to_string()), }; let prev = cache.lock().insert(path, Poll::Ready(result)); @@ -70,12 +90,18 @@ impl BytesLoader for FileLoader { let _ = self.cache.lock().remove(uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn byte_size(&self) -> usize { self.cache .lock() .values() .map(|entry| match entry { - Poll::Ready(Ok(bytes)) => bytes.len(), + Poll::Ready(Ok(file)) => { + file.bytes.len() + file.mime.as_ref().map_or(0, |m| m.len()) + } Poll::Ready(Err(err)) => err.len(), _ => 0, }) diff --git a/crates/egui_extras/src/loaders/image_loader.rs b/crates/egui_extras/src/loaders/image_loader.rs index 2dbc1f8b..989efc63 100644 --- a/crates/egui_extras/src/loaders/image_loader.rs +++ b/crates/egui_extras/src/loaders/image_loader.rs @@ -13,7 +13,11 @@ pub struct ImageCrateLoader { cache: Mutex>, } -fn is_supported(uri: &str) -> bool { +impl ImageCrateLoader { + pub const ID: &str = egui::generate_loader_id!(ImageCrateLoader); +} + +fn is_supported_uri(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 @@ -22,9 +26,23 @@ fn is_supported(uri: &str) -> bool { ext != "svg" } +fn is_unsupported_mime(mime: &str) -> bool { + mime.contains("svg") +} + impl ImageLoader for ImageCrateLoader { + fn id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str, _: SizeHint) -> ImageLoadResult { - if !is_supported(uri) { + // three stages of guessing if we support loading the image: + // 1. URI extension + // 2. Mime from `BytesPoll::Ready` + // 3. image::guess_format + + // (1) + if !is_supported_uri(uri) { return Err(LoadError::NotSupported); } @@ -36,7 +54,14 @@ impl ImageLoader for ImageCrateLoader { } } else { match ctx.try_load_bytes(uri) { - Ok(BytesPoll::Ready { bytes, .. }) => { + Ok(BytesPoll::Ready { bytes, mime, .. }) => { + // (2 and 3) + if mime.as_deref().is_some_and(is_unsupported_mime) + || image::guess_format(&bytes).is_err() + { + return Err(LoadError::NotSupported); + } + crate::log_trace!("started loading {uri:?}"); let result = crate::image::load_image_bytes(&bytes).map(Arc::new); crate::log_trace!("finished loading {uri:?}"); @@ -56,6 +81,10 @@ impl ImageLoader for ImageCrateLoader { let _ = self.cache.lock().remove(uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn byte_size(&self) -> usize { self.cache .lock() @@ -74,11 +103,11 @@ mod tests { #[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")); + assert!(is_supported_uri("https://test.png")); + assert!(is_supported_uri("test.jpeg")); + assert!(is_supported_uri("http://test.gif")); + assert!(is_supported_uri("test.webp")); + assert!(is_supported_uri("file://test")); + assert!(!is_supported_uri("test.svg")); } } diff --git a/crates/egui_extras/src/loaders/svg_loader.rs b/crates/egui_extras/src/loaders/svg_loader.rs index 71f23380..ea98184f 100644 --- a/crates/egui_extras/src/loaders/svg_loader.rs +++ b/crates/egui_extras/src/loaders/svg_loader.rs @@ -13,6 +13,10 @@ pub struct SvgLoader { cache: Mutex>, } +impl SvgLoader { + pub const ID: &str = egui::generate_loader_id!(SvgLoader); +} + fn is_supported(uri: &str) -> bool { let Some(ext) = Path::new(uri).extension().and_then(|ext| ext.to_str()) else { return false }; @@ -20,6 +24,10 @@ fn is_supported(uri: &str) -> bool { } impl ImageLoader for SvgLoader { + fn id(&self) -> &str { + Self::ID + } + fn load(&self, ctx: &egui::Context, uri: &str, size_hint: SizeHint) -> ImageLoadResult { if !is_supported(uri) { return Err(LoadError::NotSupported); @@ -39,7 +47,7 @@ impl ImageLoader for SvgLoader { Ok(BytesPoll::Ready { bytes, .. }) => { crate::log_trace!("started loading {uri:?}"); let fit_to = match size_hint { - SizeHint::Original => usvg::FitTo::Original, + SizeHint::Scale(factor) => usvg::FitTo::Zoom(factor.into_inner()), SizeHint::Width(w) => usvg::FitTo::Width(w), SizeHint::Height(h) => usvg::FitTo::Height(h), SizeHint::Size(w, h) => usvg::FitTo::Size(w, h), @@ -63,6 +71,10 @@ impl ImageLoader for SvgLoader { self.cache.lock().retain(|(u, _), _| u != uri); } + fn forget_all(&self) { + self.cache.lock().clear(); + } + fn byte_size(&self) -> usize { self.cache .lock() diff --git a/crates/egui_plot/src/items/mod.rs b/crates/egui_plot/src/items/mod.rs index 820850f4..aefa6385 100644 --- a/crates/egui_plot/src/items/mod.rs +++ b/crates/egui_plot/src/items/mod.rs @@ -1234,7 +1234,7 @@ impl PlotItem for PlotImage { Rect::from_two_pos(left_top_screen, right_bottom_screen) }; let screen_rotation = -*rotation as f32; - Image::new(*texture_id, image_screen_rect.size()) + RawImage::new((*texture_id, image_screen_rect.size())) .bg_fill(*bg_fill) .tint(*tint) .uv(*uv) diff --git a/crates/epaint/src/texture_handle.rs b/crates/epaint/src/texture_handle.rs index 24d7179b..f4142d91 100644 --- a/crates/epaint/src/texture_handle.rs +++ b/crates/epaint/src/texture_handle.rs @@ -86,7 +86,10 @@ impl TextureHandle { /// width x height pub fn size(&self) -> [usize; 2] { - self.tex_mngr.read().meta(self.id).unwrap().size + self.tex_mngr + .read() + .meta(self.id) + .map_or([0, 0], |tex| tex.size) } /// width x height @@ -97,7 +100,10 @@ impl TextureHandle { /// `width x height x bytes_per_pixel` pub fn byte_size(&self) -> usize { - self.tex_mngr.read().meta(self.id).unwrap().bytes_used() + self.tex_mngr + .read() + .meta(self.id) + .map_or(0, |tex| tex.bytes_used()) } /// width / height @@ -108,7 +114,10 @@ impl TextureHandle { /// Debug-name. pub fn name(&self) -> String { - self.tex_mngr.read().meta(self.id).unwrap().name.clone() + self.tex_mngr + .read() + .meta(self.id) + .map_or_else(|| "".to_owned(), |tex| tex.name.clone()) } } diff --git a/crates/epaint/src/util/ordered_float.rs b/crates/epaint/src/util/ordered_float.rs index 1bef308c..0b305ca1 100644 --- a/crates/epaint/src/util/ordered_float.rs +++ b/crates/epaint/src/util/ordered_float.rs @@ -8,8 +8,16 @@ use std::hash::{Hash, Hasher}; /// Possible types for `T` are `f32` and `f64`. /// /// See also [`FloatOrd`]. +#[derive(Clone, Copy)] pub struct OrderedFloat(T); +impl OrderedFloat { + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + impl Eq for OrderedFloat {} impl PartialEq for OrderedFloat { diff --git a/examples/download_image/README.md b/examples/download_image/README.md deleted file mode 100644 index 936e1e05..00000000 --- a/examples/download_image/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Example how to download and show an image with eframe/egui. - -```sh -cargo run -p download_image -``` - -![](screenshot.png) diff --git a/examples/download_image/screenshot.png b/examples/download_image/screenshot.png deleted file mode 100644 index 919c4544..00000000 Binary files a/examples/download_image/screenshot.png and /dev/null differ diff --git a/examples/download_image/src/main.rs b/examples/download_image/src/main.rs deleted file mode 100644 index 610c39d1..00000000 --- a/examples/download_image/src/main.rs +++ /dev/null @@ -1,41 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release - -use eframe::egui; - -fn main() -> Result<(), eframe::Error> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - let options = eframe::NativeOptions::default(); - eframe::run_native( - "Download and show an image with eframe/egui", - options, - 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; - -impl eframe::App for MyApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - let width = ui.available_width(); - let half_height = ui.available_height() / 2.0; - - 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", - )) - }); - }); - } -} diff --git a/examples/download_image/Cargo.toml b/examples/images/Cargo.toml similarity index 60% rename from examples/download_image/Cargo.toml rename to examples/images/Cargo.toml index 630b7ef6..85f68479 100644 --- a/examples/download_image/Cargo.toml +++ b/examples/images/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "download_image" +name = "images" version = "0.1.0" -authors = ["Emil Ernerfeldt "] +authors = ["Jan Procházka "] license = "MIT OR Apache-2.0" edition = "2021" rust-version = "1.70" @@ -10,12 +10,14 @@ publish = false [dependencies] eframe = { path = "../../crates/eframe", features = [ - "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO ] } egui_extras = { path = "../../crates/egui_extras", features = [ - "http", - "image", - "log", + "all-loaders", + "log", ] } env_logger = "0.10" -image = { version = "0.24", default-features = false, features = ["jpeg"] } +image = { version = "0.24", default-features = false, features = [ + "jpeg", + "png", +] } diff --git a/examples/images/README.md b/examples/images/README.md new file mode 100644 index 00000000..02f5007a --- /dev/null +++ b/examples/images/README.md @@ -0,0 +1,7 @@ +Example showing how to show images with eframe/egui. + +```sh +cargo run -p images +``` + +![](screenshot.png) diff --git a/examples/images/screenshot.png b/examples/images/screenshot.png new file mode 100644 index 00000000..b9d550b3 Binary files /dev/null and b/examples/images/screenshot.png differ diff --git a/examples/svg/src/rustacean-flat-happy.svg b/examples/images/src/ferris.svg similarity index 100% rename from examples/svg/src/rustacean-flat-happy.svg rename to examples/images/src/ferris.svg diff --git a/examples/images/src/main.rs b/examples/images/src/main.rs new file mode 100644 index 00000000..193ab37c --- /dev/null +++ b/examples/images/src/main.rs @@ -0,0 +1,41 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release + +use eframe::egui; +use eframe::epaint::vec2; + +fn main() -> Result<(), eframe::Error> { + env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). + let options = eframe::NativeOptions { + drag_and_drop_support: true, + ..Default::default() + }; + eframe::run_native( + "Image Viewer", + options, + Box::new(|cc| { + // The following call is needed to load images when using `ui.image` and `egui::Image`: + egui_extras::loaders::install(&cc.egui_ctx); + Box::::default() + }), + ) +} + +#[derive(Default)] +struct MyApp {} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::new([true, true]).show(ui, |ui| { + ui.add( + egui::Image::new(egui::include_image!("ferris.svg")) + .fit_to_fraction(vec2(1.0, 0.5)), + ); + ui.add( + egui::Image::new("https://picsum.photos/seed/1.759706314/1024".into()) + .rounding(egui::Rounding::same(10.0)), + ); + }); + }); + } +} diff --git a/examples/retained_image/Cargo.toml b/examples/retained_image/Cargo.toml deleted file mode 100644 index 50cf0e5b..00000000 --- a/examples/retained_image/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "retained_image" -version = "0.1.0" -authors = ["Emil Ernerfeldt "] -license = "MIT OR Apache-2.0" -edition = "2021" -rust-version = "1.70" -publish = false - - -[dependencies] -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", "log"] } -env_logger = "0.10" -image = { version = "0.24", default-features = false, features = ["png"] } diff --git a/examples/retained_image/README.md b/examples/retained_image/README.md deleted file mode 100644 index 122a7481..00000000 --- a/examples/retained_image/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Example how to show an image with eframe/egui. - -```sh -cargo run -p retained_image -``` - -![](screenshot.png) diff --git a/examples/retained_image/screenshot.png b/examples/retained_image/screenshot.png deleted file mode 100644 index 9e2234e4..00000000 Binary files a/examples/retained_image/screenshot.png and /dev/null differ diff --git a/examples/retained_image/src/crab.png b/examples/retained_image/src/crab.png deleted file mode 100644 index 781b2199..00000000 Binary files a/examples/retained_image/src/crab.png and /dev/null differ diff --git a/examples/retained_image/src/main.rs b/examples/retained_image/src/main.rs deleted file mode 100644 index f96fb964..00000000 --- a/examples/retained_image/src/main.rs +++ /dev/null @@ -1,84 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release - -use eframe::egui; -use egui_extras::RetainedImage; - -fn main() -> Result<(), eframe::Error> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - let options = eframe::NativeOptions { - initial_window_size: Some(egui::vec2(400.0, 1000.0)), - ..Default::default() - }; - eframe::run_native( - "Show an image with eframe/egui", - options, - Box::new(|_cc| Box::::default()), - ) -} - -struct MyApp { - image: RetainedImage, - rounding: f32, - tint: egui::Color32, -} - -impl Default for MyApp { - fn default() -> Self { - Self { - // crab image is CC0, found on https://stocksnap.io/search/crab - image: RetainedImage::from_image_bytes("crab.png", include_bytes!("crab.png")).unwrap(), - rounding: 32.0, - tint: egui::Color32::from_rgb(100, 200, 200), - } - } -} - -impl eframe::App for MyApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let Self { - image, - rounding, - tint, - } = self; - - egui::CentralPanel::default().show(ctx, |ui| { - ui.heading("This is an image:"); - image.show(ui); - - ui.add_space(32.0); - - ui.heading("This is a tinted image with rounded corners:"); - ui.add( - egui::Image::new(image.texture_id(ctx), image.size_vec2()) - .tint(*tint) - .rounding(*rounding), - ); - - ui.horizontal(|ui| { - ui.label("Tint:"); - egui::color_picker::color_edit_button_srgba( - ui, - tint, - egui::color_picker::Alpha::BlendOrAdditive, - ); - - ui.add_space(16.0); - - ui.label("Rounding:"); - ui.add( - egui::DragValue::new(rounding) - .speed(1.0) - .clamp_range(0.0..=0.5 * image.size_vec2().min_elem()), - ); - }); - - ui.add_space(32.0); - - ui.heading("This is an image you can click:"); - ui.add(egui::ImageButton::new( - image.texture_id(ctx), - image.size_vec2(), - )); - }); - } -} diff --git a/examples/screenshot/src/main.rs b/examples/screenshot/src/main.rs index 4dff5326..9533a425 100644 --- a/examples/screenshot/src/main.rs +++ b/examples/screenshot/src/main.rs @@ -63,7 +63,7 @@ impl eframe::App for MyApp { }); if let Some(texture) = self.texture.as_ref() { - ui.image(texture, ui.available_size()); + ui.raw_image((texture.id(), ui.available_size())); } else { ui.spinner(); } diff --git a/examples/svg/Cargo.toml b/examples/svg/Cargo.toml deleted file mode 100644 index 62333a09..00000000 --- a/examples/svg/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "svg" -version = "0.1.0" -authors = ["Emil Ernerfeldt "] -license = "MIT OR Apache-2.0" -edition = "2021" -rust-version = "1.70" -publish = false - - -[dependencies] -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 = ["log", "svg"] } -env_logger = "0.10" diff --git a/examples/svg/README.md b/examples/svg/README.md deleted file mode 100644 index c171c172..00000000 --- a/examples/svg/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Example how to show an SVG image. - -```sh -cargo run -p svg -``` - -![](screenshot.png) diff --git a/examples/svg/screenshot.png b/examples/svg/screenshot.png deleted file mode 100644 index 1a9664a8..00000000 Binary files a/examples/svg/screenshot.png and /dev/null differ diff --git a/examples/svg/src/main.rs b/examples/svg/src/main.rs deleted file mode 100644 index bba91d05..00000000 --- a/examples/svg/src/main.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! A good way of displaying an SVG image in egui. -//! -//! Requires the dependency `egui_extras` with the `svg` feature. - -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release - -use eframe::egui; - -fn main() -> Result<(), eframe::Error> { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - let options = eframe::NativeOptions { - initial_window_size: Some(egui::vec2(1000.0, 700.0)), - ..Default::default() - }; - eframe::run_native( - "svg example", - options, - 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; - -impl eframe::App for MyApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - ui.heading("SVG example"); - ui.label("The SVG is rasterized and displayed as a texture."); - - ui.separator(); - - let max_size = ui.available_size(); - ui.add( - egui::Image2::from_static_bytes( - "ferris.svg", - include_bytes!("rustacean-flat-happy.svg"), - ) - .size_hint(max_size), - ); - }); - } -} diff --git a/examples/svg/src/rust-logo-license.txt b/examples/svg/src/rust-logo-license.txt deleted file mode 100644 index 7efaf759..00000000 --- a/examples/svg/src/rust-logo-license.txt +++ /dev/null @@ -1 +0,0 @@ -Rust logo by Mozilla, from https://github.com/rust-lang/rust-artwork